From 38bfe5f1380791b545ac3a859021c716dbea2181 Mon Sep 17 00:00:00 2001 From: Cause Chung Date: Sat, 22 Aug 2026 20:27:06 -0400 Subject: [PATCH] fix(core): index modules project-wide so import gate covers modules outside sourceRoots The module index only scanned sourceRoots, so the import gate silently allowed importing internal files of modules outside those roots (e.g. sibling dirs when sourceRoots defaults to src/). Descriptor files are now discovered across the whole project while enforcement scope stays limited to sourceRoots. Also add the missing src/core/utils/index.ts interface and repoint imports that reached into its internal files. --- .devin-plugin/plugin.json | 2 +- README.ja.md | 2 +- README.md | 2 +- README.zh.md | 2 +- package-lock.json | 4 +- package.json | 2 +- src/bridges/claude/pre-tool-use.test.ts | 15 +++++++ src/bridges/devin/pre-tool-use.test.ts | 19 +++++++++ src/core/config.ts | 2 +- src/core/gates/run-gates.ts | 2 +- src/core/graph/module-index-builder.test.ts | 41 ++++++++++--------- src/core/graph/module-index-builder.ts | 15 ++----- src/core/utils/index.ts | 2 + .../module-interface-import-gating.test.ts | 2 +- 14 files changed, 71 insertions(+), 41 deletions(-) create mode 100644 src/core/utils/index.ts diff --git a/.devin-plugin/plugin.json b/.devin-plugin/plugin.json index 74960a1..cca5773 100644 --- a/.devin-plugin/plugin.json +++ b/.devin-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "module-gates", - "version": "1.1.3", + "version": "1.1.4", "description": "Enforce module boundary contracts for Devin CLI.", "author": { "name": "Cause Chung", diff --git a/README.ja.md b/README.ja.md index 31a9846..5595800 100644 --- a/README.ja.md +++ b/README.ja.md @@ -200,7 +200,7 @@ no-new-exports: [mod.rs] |-----------|-----------|------| | `moduleDescriptorFileName` | `MODULE.md` | モジュール記述子のファイル名(大文字小文字を無視) | | `moduleDescriptorReadonly` | `"off"` | `"file"` は記述子全体を読み取り専用にし、`"frontmatter"` は YAML frontmatter のみをロック(ボディの説明文は編集可能)、`"off"` は記述子の読み取り専用を無効にする。`true`/`false` も後方互換性のために受け入れられる。 | -| `sourceRoots` | `["src/"]` | 記述子ファイルをスキャンし、ゲートを強制するディレクトリ。単一のルートには文字列を、配列には複数のルート(例:マルチリポジトリで `["packages/app/src/", "packages/lib/src/"]`)を指定できる。`[""]` でプロジェクトルートからスキャンする。レガシーの単数形 `sourceRoot`(文字列)は引き続きサポートされる。 | +| `sourceRoots` | `["src/"]` | ゲートを強制するディレクトリ。記述子ファイルはこの設定に関係なくプロジェクト全体から検出される。単一のルートには文字列を、配列には複数のルート(例:マルチリポジトリで `["packages/app/src/", "packages/lib/src/"]`)を指定できる。 `[""]` でプロジェクトルートから強制する。レガシーの単数形 `sourceRoot`(文字列)は引き続きサポートされる。 | | `disableModuleInterfaceImportGate` | `false` | `true` の場合、インポートはモジュールインターフェースから強制されない。 | | `disableSystemPrompt` | `false` | `true` の場合、エージェントのシステムプロンプトへのモジュールゲートヒントの注入をスキップする。 | | `outputModuleProseOnBlock` | `false` | `true` の場合、違反モジュール記述子の説明文がブロックメッセージに追加され、エージェントが契約のコンテキストを確認できるにする。デフォルトでは無効で、エラーメッセージを簡潔に保つ。 | diff --git a/README.md b/README.md index d055275..f98c4a3 100644 --- a/README.md +++ b/README.md @@ -200,7 +200,7 @@ The canonical agent-independent location is `.module-gates/config.json` (the who |--------|---------|-------------| | `moduleDescriptorFileName` | `MODULE.md` | File name used for module descriptors (case-insensitive) | | `moduleDescriptorReadonly` | `"off"` | `"file"` makes the whole descriptor readonly; `"frontmatter"` locks only the YAML frontmatter (body prose stays editable); `"off"` disables descriptor readonly. `true`/`false` are also accepted for backward compatibility. | -| `sourceRoots` | `["src/"]` | Directories to scan for descriptor files and enforce gates. Pass a single string for one root, or an array for multiple roots (e.g. monorepos with `["packages/app/src/", "packages/lib/src/"]`). Use `[""]` to scan from the project root. Legacy singular `sourceRoot` (string) is still accepted. | +| `sourceRoots` | `["src/"]` | Directories where gates are enforced. Descriptor files are discovered across the whole project regardless of this setting. Pass a single string for one root, or an array for multiple roots (e.g. monorepos with `["packages/app/src/", "packages/lib/src/"]`). Use `[""]` to enforce from the project root. Legacy singular `sourceRoot` (string) is still accepted. | | `disableModuleInterfaceImportGate` | `false` | When `true`, imports will not be forced to be from module interface. | | `disableSystemPrompt` | `false` | When `true`, skip injecting the module-gates hint into the agent's system prompt. | | `outputModuleProseOnBlock` | `false` | When `true`, the violating module descriptor's prose is appended to the block message so the agent sees the contract context. Disabled by default to keep the error message concise. | diff --git a/README.zh.md b/README.zh.md index a3b135c..3367ac1 100644 --- a/README.zh.md +++ b/README.zh.md @@ -200,7 +200,7 @@ no-new-exports: [mod.rs] |------|--------|------| | `moduleDescriptorFileName` | `MODULE.md` | 用于模块描述符的文件名(不区分大小写) | | `moduleDescriptorReadonly` | `"off"` | `"file"` 使整个描述符只读;`"frontmatter"` 仅锁定 YAML frontmatter(主体说明文字仍可编辑);`"off"` 禁用描述符只读。`true`/`false` 也接受以保持向后兼容。 | -| `sourceRoots` | `["src/"]` | 扫描描述符文件和强制执行门控的目录。传递字符串表示单个根目录,或数组表示多个根目录(例如使用 `["packages/app/src/", "packages/lib/src/"]` 的多仓库项目)。使用 `[""]` 从项目根目录扫描。遗留的单数 `sourceRoot`(字符串)仍然接受。 | +| `sourceRoots` | `["src/"]` | 强制执行门控的目录。描述符文件的发现与该设置无关,始终覆盖整个项目。传递字符串表示单个根目录,或数组表示多个根目录(例如使用 `["packages/app/src/", "packages/lib/src/"]` 的多仓库项目)。使用 `[""]` 从项目根目录强制执行。遗留的单数 `sourceRoot`(字符串)仍然接受。 | | `disableModuleInterfaceImportGate` | `false` | 为 `true` 时,导入不会被强制为模块接口。 | | `disableSystemPrompt` | `false` | 为 `true` 时,跳过将模块门控提示注入代理的系统提示。 | | `outputModuleProseOnBlock` | `false` | 为 `true` 时,违规模块描述符的说明文字会附加到阻止消息中,使代理看到契约上下文。默认禁用以保持错误消息简洁。 | diff --git a/package-lock.json b/package-lock.json index a22f31d..aa75f11 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@cuzfrog/module-gates", - "version": "1.1.3", + "version": "1.1.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@cuzfrog/module-gates", - "version": "1.1.3", + "version": "1.1.4", "license": "MIT", "dependencies": { "jiti": "2.7.0", diff --git a/package.json b/package.json index edf880b..438041c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@cuzfrog/module-gates", - "version": "1.1.3", + "version": "1.1.4", "description": "Controls the entropy of the codebase by enforcing code module boundaries. Ships bridges for pi, Claude Code, and Devin CLI.", "keywords": [ "pi-package", diff --git a/src/bridges/claude/pre-tool-use.test.ts b/src/bridges/claude/pre-tool-use.test.ts index 4cc3030..96388eb 100644 --- a/src/bridges/claude/pre-tool-use.test.ts +++ b/src/bridges/claude/pre-tool-use.test.ts @@ -96,4 +96,19 @@ describe("pre-tool-use hook", () => { const r = runHook({ hook_event_name: "SessionStart", tool_name: "Read", tool_input: {} }); expect(r.status).toBe(0); }); + + it("exits 2 for import bypassing the interface of a module outside sourceRoots", () => { + const r = runHook({ + hook_event_name: "PreToolUse", + tool_name: "Write", + tool_input: { + file_path: "src/app.ts", + content: `import { fun1 } from "../other/fun1";\n\nexport function greet() { return 1; }\n`, + }, + cwd: FIXTURES, + }); + expect(r.status).toBe(2); + expect(r.stderr).toContain("fun1.ts"); + expect(r.stderr).toContain("other/"); + }); }); \ No newline at end of file diff --git a/src/bridges/devin/pre-tool-use.test.ts b/src/bridges/devin/pre-tool-use.test.ts index bd8f6f3..4a9bdd0 100644 --- a/src/bridges/devin/pre-tool-use.test.ts +++ b/src/bridges/devin/pre-tool-use.test.ts @@ -129,4 +129,23 @@ describe("pre-tool-use hook", () => { expect(parsed.decision).toBe("reject"); expect(parsed.reason).toContain("Readonly rule"); }); + + it("rejects import bypassing the interface of a module outside sourceRoots", () => { + const r = runHook( + { + hook_event_name: "PreToolUse", + tool_name: "write", + tool_input: { + file_path: "src/app.ts", + content: `import { fun1 } from "../other/fun1";\n\nexport function greet() { return 1; }\n`, + }, + }, + FIXTURES, + ); + expect(r.status).toBe(0); + const parsed = JSON.parse(r.stdout); + expect(parsed.decision).toBe("reject"); + expect(parsed.reason).toContain("fun1.ts"); + expect(parsed.reason).toContain("other/"); + }); }); diff --git a/src/core/config.ts b/src/core/config.ts index 422c845..be0f648 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -1,6 +1,6 @@ import * as fs from "node:fs"; import * as path from "node:path"; -import { parseJsonc } from "./utils/jsonc.ts"; +import { parseJsonc } from "./utils/index.ts"; export type ModuleGateConfig = { moduleDescriptorFileName: string; diff --git a/src/core/gates/run-gates.ts b/src/core/gates/run-gates.ts index 6656ead..e1adf39 100644 --- a/src/core/gates/run-gates.ts +++ b/src/core/gates/run-gates.ts @@ -1,5 +1,5 @@ import * as path from "node:path"; -import { parseFrontmatter } from "../utils/frontmatter.ts"; +import { parseFrontmatter } from "../utils/index.ts"; import type { ModuleIndex } from "../types.ts"; import type { ModuleGateConfig } from "../config.ts"; import { readFileSafe, applyEdits, isWithinSourceRoot, findOwningModule } from "../utils.ts"; diff --git a/src/core/graph/module-index-builder.test.ts b/src/core/graph/module-index-builder.test.ts index 15d1d80..004204a 100644 --- a/src/core/graph/module-index-builder.test.ts +++ b/src/core/graph/module-index-builder.test.ts @@ -9,13 +9,13 @@ vi.mock("node:fs", () => ({ readFileSync: vi.fn(), })); -vi.mock("../utils/frontmatter.ts", () => ({ +vi.mock("../utils/index.ts", () => ({ parseFrontmatter: vi.fn(), })); import { readdir } from "node:fs/promises"; import * as fs from "node:fs"; -import { parseFrontmatter } from "../utils/frontmatter.ts"; +import { parseFrontmatter } from "../utils/index.ts"; import { buildModuleIndex } from "./module-index-builder.ts"; import type { ModuleGateConfig } from "../config.ts"; @@ -281,17 +281,18 @@ describe("buildModuleIndex", () => { expect(index.contracts[0].modulePath).toBe("/project"); }); - it("scans only within sourceRoots", async () => { + it("scans the whole project regardless of sourceRoots", async () => { mockedReaddir.mockImplementation(async (dir: unknown) => { const d = dir as string; - if (d === "/project/src") return [makeDirent("module.md", false)] as Dirent[]; + if (d === "/project") return [makeDirent("lib", true)] as Dirent[]; + if (d === "/project/lib") return [makeDirent("module.md", false)] as Dirent[]; return [] as Dirent[]; }); mockedReadFileSync.mockReturnValue("content"); mockedParseFrontmatter.mockReturnValue({ frontmatter: {}, - body: "Src module.", + body: "Lib module.", }); const config: ModuleGateConfig = { @@ -305,14 +306,23 @@ describe("buildModuleIndex", () => { const { index } = await buildModuleIndex("/project", config); expect(index.contracts).toHaveLength(1); - expect(index.contracts[0].modulePath).toBe("/project/src"); + expect(index.contracts[0].modulePath).toBe("/project/lib"); }); - it("scans multiple sourceRoots and merges contracts", async () => { + it("skips node_modules and .git when scanning from the project root", async () => { mockedReaddir.mockImplementation(async (dir: unknown) => { const d = dir as string; - if (d === "/project/src") return [makeDirent("module.md", false)] as Dirent[]; + if (d === "/project") { + return [ + makeDirent("lib", true), + makeDirent("node_modules", true), + makeDirent(".git", true), + ] as Dirent[]; + } if (d === "/project/lib") return [makeDirent("module.md", false)] as Dirent[]; + if (d === "/project/node_modules") return [makeDirent("dep", true)] as Dirent[]; + if (d === "/project/node_modules/dep") return [makeDirent("module.md", false)] as Dirent[]; + if (d === "/project/.git") return [makeDirent("module.md", false)] as Dirent[]; return [] as Dirent[]; }); @@ -322,18 +332,9 @@ describe("buildModuleIndex", () => { body: "Module.", }); - const config: ModuleGateConfig = { - moduleDescriptorFileName: "module.md", - moduleDescriptorReadonly: "file", - sourceRoots: ["src/", "lib/"], - disableModuleInterfaceImportGate: false, - disableSystemPrompt: false, - outputModuleProseOnBlock: false, - }; - const { index } = await buildModuleIndex("/project", config); + const { index } = await buildModuleIndex("/project", defaultConfig); - expect(index.contracts).toHaveLength(2); - const paths = index.contracts.map((c) => c.modulePath).sort(); - expect(paths).toEqual(["/project/lib", "/project/src"]); + expect(index.contracts).toHaveLength(1); + expect(index.contracts[0].modulePath).toBe("/project/lib"); }); }); diff --git a/src/core/graph/module-index-builder.ts b/src/core/graph/module-index-builder.ts index 564bd84..151fdc9 100644 --- a/src/core/graph/module-index-builder.ts +++ b/src/core/graph/module-index-builder.ts @@ -1,7 +1,7 @@ import * as fs from "node:fs"; import * as path from "node:path"; import { readdir } from "node:fs/promises"; -import { parseFrontmatter } from "../utils/frontmatter.ts"; +import { parseFrontmatter } from "../utils/index.ts"; import type { Diagnostic, ModuleContract, ModuleFrontmatter, ModuleIndex } from "../types.ts"; import type { ModuleGateConfig } from "../config.ts"; import type { Dirent } from "node:fs"; @@ -15,22 +15,15 @@ export async function buildModuleIndex( cwd: string, config: ModuleGateConfig, ): Promise { - const scanRoots = resolveScanRoots(cwd, config.sourceRoots); + const scanRoot = path.resolve(cwd); - const moduleFilesList = await Promise.all( - scanRoots.map((root) => findModuleFiles(root, config.moduleDescriptorFileName)), - ); - const { contracts, diagnostics } = buildContracts(moduleFilesList.flat()); + const moduleFiles = await findModuleFiles(scanRoot, config.moduleDescriptorFileName); + const { contracts, diagnostics } = buildContracts(moduleFiles); const dirToModule = await buildDirToModuleMap(contracts); return { index: { contracts, dirToModule }, diagnostics }; } -function resolveScanRoots(cwd: string, sourceRoots: string[]): string[] { - if (sourceRoots.length === 0) return [path.resolve(cwd)]; - return sourceRoots.map((r) => path.resolve(cwd, r)); -} - function buildContracts( moduleFiles: string[], ): { contracts: ModuleContract[]; diagnostics: Diagnostic[] } { diff --git a/src/core/utils/index.ts b/src/core/utils/index.ts new file mode 100644 index 0000000..a2b7ec8 --- /dev/null +++ b/src/core/utils/index.ts @@ -0,0 +1,2 @@ +export { parseFrontmatter, stripFrontmatter } from "./frontmatter.ts"; +export { parseJsonc } from "./jsonc.ts"; diff --git a/test/behavior/module-interface-import-gating.test.ts b/test/behavior/module-interface-import-gating.test.ts index 5350f7f..53a7435 100644 --- a/test/behavior/module-interface-import-gating.test.ts +++ b/test/behavior/module-interface-import-gating.test.ts @@ -10,7 +10,7 @@ vi.mock("../../src/core/config.ts", () => ({ loadConfig: () => ({ moduleDescriptorFileName: "module.md", moduleDescriptorReadonly: "file", - sourceRoots: [""], + sourceRoots: ["src/"], disableModuleInterfaceImportGate: false, disableSystemPrompt: false, outputModuleProseOnBlock: false,