Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .devin-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion README.ja.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` の場合、違反モジュール記述子の説明文がブロックメッセージに追加され、エージェントが契約のコンテキストを確認できるにする。デフォルトでは無効で、エラーメッセージを簡潔に保つ。 |
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
2 changes: 1 addition & 1 deletion README.zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` 时,违规模块描述符的说明文字会附加到阻止消息中,使代理看到契约上下文。默认禁用以保持错误消息简洁。 |
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
15 changes: 15 additions & 0 deletions src/bridges/claude/pre-tool-use.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/");
});
});
19 changes: 19 additions & 0 deletions src/bridges/devin/pre-tool-use.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/");
});
});
2 changes: 1 addition & 1 deletion src/core/config.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
2 changes: 1 addition & 1 deletion src/core/gates/run-gates.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down
41 changes: 21 additions & 20 deletions src/core/graph/module-index-builder.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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 = {
Expand All @@ -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[];
});

Expand All @@ -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");
});
});
15 changes: 4 additions & 11 deletions src/core/graph/module-index-builder.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -15,22 +15,15 @@ export async function buildModuleIndex(
cwd: string,
config: ModuleGateConfig,
): Promise<ModuleIndexBuildResult> {
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[] } {
Expand Down
2 changes: 2 additions & 0 deletions src/core/utils/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { parseFrontmatter, stripFrontmatter } from "./frontmatter.ts";
export { parseJsonc } from "./jsonc.ts";
2 changes: 1 addition & 1 deletion test/behavior/module-interface-import-gating.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading