@cuzfrog/pi-module-gates 0.15.0 → 0.16.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # pi-module-gates - Constraints liberate, liberties constrain.
2
2
 
3
- Expirimental pi cli extension that controls the entropy of the codebase by enforcing code module boundaries.
3
+ Experimental pi cli extension that controls the entropy of the codebase by enforcing code module boundaries.
4
4
  It helps combat slop generation and code architecture degradation.
5
5
 
6
6
  ## Problem
@@ -134,6 +134,47 @@ Add a `module-gates` entry to `.pi/settings.json`:
134
134
 
135
135
  When no settings file exists or no `module-gates` key is present, defaults apply.
136
136
 
137
+ ## Claude Code Support
138
+
139
+ ### Install
140
+ Add the following to `.claude/settings.json` in the current project, pointing the `PreToolUse` hook at the installed binary.
141
+ ```json
142
+ {
143
+ "hooks": {
144
+ "PreToolUse": [
145
+ {
146
+ "matcher": "Edit|MultiEdit|Write",
147
+ "hooks": [
148
+ {
149
+ "type": "command",
150
+ "command": "bun ${CLAUDE_PROJECT_DIR}/node_modules/@cuzfrog/pi-module-gates/src/claude/pre-tool-use.ts",
151
+ "statusMessage": "Module gate checking edit..."
152
+ }
153
+ ]
154
+ }
155
+ ]
156
+ }
157
+ }
158
+ ```
159
+
160
+ If `pi-module-gates` is already installed in pi global dir, you can use below path instead:
161
+ ```
162
+ ~/.pi/agent/npm/node_modules/@cuzfrog/pi-module-gates/src/claude/pre-tool-use.ts
163
+ ```
164
+
165
+ ### System prompt
166
+ You need to add [system-prompt.md](src/context/system-prompt.template.md) manually to your context.
167
+
168
+ ### Configuration
169
+
170
+ Claude Code uses the same `.pi/settings.json#module-gates` block as the pi extension. See the Configuration section above.
171
+
172
+ ### Troubleshooting
173
+ Prompt:
174
+ ```
175
+ Check if PreToolUse hook `pi-module-gates` is triggered and runs expectedly.
176
+ ```
177
+
137
178
  ## License
138
179
 
139
180
  MIT
@@ -0,0 +1,80 @@
1
+ #!/usr/bin/env bun
2
+ // Bin entrypoint: executed by `bun` (see package.json#bin) and dispatches to
3
+ // src/cli/*.ts modules. Lives in .mjs for npm-install shebang compatibility on
4
+ // platforms that resolve `bin` to a file path directly; `bun` will load this as
5
+ // JavaScript since it has no TypeScript-specific syntax.
6
+ import { resolve, isAbsolute } from "node:path";
7
+ import { fileURLToPath } from "node:url";
8
+
9
+ const PKG_ROOT = resolve(fileURLToPath(new URL(".", import.meta.url)), "..");
10
+
11
+ function printUsage() {
12
+ process.stderr.write(`Usage: pi-module-gates <command> [...args]
13
+
14
+ Commands:
15
+ install-claude [--project-dir <dir>] Install Claude Code PreToolUse hooks into <dir>/.claude/settings.json
16
+ uninstall-claude [--project-dir <dir>] Remove Claude Code PreToolUse hooks from <dir>/.claude/settings.json
17
+
18
+ Environment:
19
+ CLAUDE_PROJECT_DIR Default --project-dir when running inside Claude Code.
20
+
21
+ Examples:
22
+ pi-module-gates install-claude
23
+ pi-module-gates install-claude --project-dir /path/to/project
24
+ pi-module-gates uninstall-claude
25
+ `);
26
+ }
27
+
28
+ function parseProjectDir(argv) {
29
+ let projectDir = process.env.CLAUDE_PROJECT_DIR ?? process.cwd();
30
+ for (let i = 0; i < argv.length; i++) {
31
+ if (argv[i] === "--project-dir" && i + 1 < argv.length) {
32
+ projectDir = argv[++i];
33
+ }
34
+ }
35
+ return isAbsolute(projectDir) ? projectDir : resolve(process.cwd(), projectDir);
36
+ }
37
+
38
+ async function main() {
39
+ const [cmd, ...rest] = process.argv.slice(2);
40
+ if (!cmd) {
41
+ printUsage();
42
+ process.exit(1);
43
+ }
44
+ if (cmd === "-h" || cmd === "--help") {
45
+ printUsage();
46
+ process.exit(0);
47
+ }
48
+
49
+ const projectDir = parseProjectDir(rest);
50
+
51
+ if (cmd === "install-claude") {
52
+ const mod = await import(resolve(PKG_ROOT, "src/cli/install-claude.ts"));
53
+ const result = mod.installClaude({ projectDir });
54
+ if (!result.ok) {
55
+ process.stderr.write(`${result.reason}\n`);
56
+ process.exit(1);
57
+ }
58
+ process.exit(0);
59
+ }
60
+
61
+ if (cmd === "uninstall-claude") {
62
+ const mod = await import(resolve(PKG_ROOT, "src/cli/uninstall-claude.ts"));
63
+ const result = mod.uninstallClaude({ projectDir });
64
+ if (!result.ok) {
65
+ process.stderr.write(`${result.reason}\n`);
66
+ process.exit(1);
67
+ }
68
+ process.exit(0);
69
+ }
70
+
71
+ process.stderr.write(`Unknown command: ${cmd}\n`);
72
+ printUsage();
73
+ process.exit(2);
74
+ }
75
+
76
+ main().catch((err) => {
77
+ const message = err instanceof Error ? err.message : String(err);
78
+ process.stderr.write(`[pi-module-gates] Unexpected error: ${message}\n`);
79
+ process.exit(1);
80
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cuzfrog/pi-module-gates",
3
- "version": "0.15.0",
3
+ "version": "0.16.3",
4
4
  "description": "pi extension that controls the entropy of the codebase by enforcing code module boundaries.",
5
5
  "keywords": [
6
6
  "pi-package"
@@ -10,6 +10,9 @@
10
10
  "check": "tsc --noEmit",
11
11
  "test": "vitest run"
12
12
  },
13
+ "bin": {
14
+ "pi-module-gates": "./bin/pi-module-gates.mjs"
15
+ },
13
16
  "pi": {
14
17
  "extensions": [
15
18
  "./src/index.ts"
@@ -18,12 +21,16 @@
18
21
  "./skills"
19
22
  ]
20
23
  },
24
+ "dependencies": {
25
+ "yaml": "2.8.1"
26
+ },
21
27
  "peerDependencies": {
22
28
  "@earendil-works/pi-coding-agent": "*"
23
29
  },
24
30
  "devDependencies": {
25
31
  "@earendil-works/pi-coding-agent": "0.79.8",
26
32
  "@types/node": "22.19.19",
33
+ "bun-types": "1.3.14",
27
34
  "typescript": "5.9.3",
28
35
  "vitest": "4.1.7"
29
36
  },
@@ -40,11 +47,12 @@
40
47
  "files": [
41
48
  "src/",
42
49
  "skills/",
50
+ "bin/",
43
51
  "README.md",
44
52
  "LICENSE"
45
53
  ],
46
54
  "engines": {
47
- "node": ">=18"
55
+ "bun": ">=1.3.0"
48
56
  },
49
57
  "publishConfig": {
50
58
  "access": "public",
@@ -0,0 +1,40 @@
1
+ import type { ModuleIndex } from "../types.ts";
2
+ import type { ModuleGateConfig } from "../config.ts";
3
+ import { loadConfig } from "../config.ts";
4
+ import { buildModuleIndex } from "../graph/index.ts";
5
+
6
+ export type IndexContext = {
7
+ cwd: string;
8
+ ui: { notify: (msg: string) => void };
9
+ };
10
+
11
+ export type LoadIndexResult = {
12
+ index: ModuleIndex;
13
+ config: ModuleGateConfig;
14
+ };
15
+
16
+ export async function loadIndexForHook(cwd: string): Promise<LoadIndexResult> {
17
+ const config = loadConfig(cwd);
18
+ const ctx: IndexContext = {
19
+ cwd,
20
+ ui: {
21
+ notify: (m) => process.stderr.write(`[Module Gate] ${m}\n`),
22
+ },
23
+ };
24
+
25
+ try {
26
+ const index = await buildModuleIndex(ctx, config);
27
+ return { index, config };
28
+ } catch (err) {
29
+ const message = err instanceof Error ? err.message : String(err);
30
+ process.stderr.write(`[Module Gate] index build failed: ${message}\n`);
31
+ return {
32
+ index: { contracts: [], dirToModule: new Map() },
33
+ config,
34
+ };
35
+ }
36
+ }
37
+
38
+ export function notifyNoContracts(ctx: IndexContext): void {
39
+ ctx.ui.notify("No module descriptor files found. Gates are not active.");
40
+ }
@@ -0,0 +1,99 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { applyEdits, readFileSafe } from "../utils.ts";
4
+ import { runGates, type GateEdit } from "../gates/run-gates.ts";
5
+ import { loadIndexForHook, notifyNoContracts } from "./index-loader.ts";
6
+ import "../gates/checkers/index.ts";
7
+
8
+ type HookEvent = {
9
+ hook_event_name?: string;
10
+ tool_name?: string;
11
+ tool_input?: {
12
+ file_path?: string;
13
+ old_string?: string;
14
+ new_string?: string;
15
+ edits?: { old_string?: string; new_string?: string }[];
16
+ content?: string;
17
+ };
18
+ cwd?: string;
19
+ };
20
+
21
+ const notifyCtx = (): { cwd: string; ui: { notify: (m: string) => void } } => ({
22
+ cwd: process.cwd(),
23
+ ui: {
24
+ notify: (m) => process.stderr.write(`[Module Gate] ${m}\n`),
25
+ },
26
+ });
27
+
28
+ async function main(): Promise<void> {
29
+ let raw: string;
30
+ try {
31
+ raw = fs.readFileSync(0, "utf-8");
32
+ } catch {
33
+ process.exit(0);
34
+ }
35
+
36
+ let event: HookEvent;
37
+ try {
38
+ event = JSON.parse(raw);
39
+ } catch {
40
+ process.stderr.write("[Module Gate] hook: invalid JSON input; allowing tool call.\n");
41
+ process.exit(0);
42
+ }
43
+
44
+ if (event.hook_event_name !== "PreToolUse") process.exit(0);
45
+ if (
46
+ event.tool_name !== "Edit" &&
47
+ event.tool_name !== "MultiEdit" &&
48
+ event.tool_name !== "Write"
49
+ ) {
50
+ process.exit(0);
51
+ }
52
+
53
+ const cwd: string = event.cwd ?? process.env.CLAUDE_PROJECT_DIR ?? process.cwd();
54
+ const toolInput = event.tool_input ?? {};
55
+ const filePath: string | undefined = toolInput.file_path;
56
+ if (!filePath) process.exit(0);
57
+
58
+ const absPath = path.resolve(cwd, filePath);
59
+ const before = readFileSafe(absPath);
60
+ let after: string;
61
+
62
+ if (event.tool_name === "Edit") {
63
+ after = applyEdits(before, [
64
+ { oldText: toolInput.old_string ?? "", newText: toolInput.new_string ?? "" },
65
+ ]);
66
+ } else if (event.tool_name === "MultiEdit") {
67
+ const edits: GateEdit[] = Array.isArray(toolInput.edits)
68
+ ? toolInput.edits.map((e) => ({
69
+ oldText: e.old_string ?? "",
70
+ newText: e.new_string ?? "",
71
+ }))
72
+ : [];
73
+ after = applyEdits(before, edits);
74
+ } else {
75
+ after = toolInput.content ?? "";
76
+ }
77
+
78
+ const { index, config } = await loadIndexForHook(cwd);
79
+
80
+ if (index.contracts.length === 0) {
81
+ notifyNoContracts(notifyCtx());
82
+ process.exit(0);
83
+ }
84
+
85
+ const result = runGates(filePath, [{ oldText: before, newText: after }], cwd, index, config, before);
86
+
87
+ if (result?.block) {
88
+ process.stderr.write(`${result.reason}\n`);
89
+ process.exit(2);
90
+ }
91
+
92
+ process.exit(0);
93
+ }
94
+
95
+ main().catch((err) => {
96
+ const message = err instanceof Error ? err.message : String(err);
97
+ process.stderr.write(`[Module Gate] hook internal error: ${message}\n`);
98
+ process.exit(0);
99
+ });
@@ -0,0 +1,91 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+
4
+ export const HOOK_MARKER = "@cuzfrog/pi-module-gates";
5
+ export const PRE_TOOL_USE_MATCHER = "Edit|MultiEdit|Write";
6
+
7
+ export type ClaudeHookCommand = {
8
+ type: "command";
9
+ command: string;
10
+ statusMessage?: string;
11
+ };
12
+
13
+ export type ClaudeHook = ClaudeHookCommand | {
14
+ type: string;
15
+ command?: string;
16
+ statusMessage?: string;
17
+ [key: string]: unknown;
18
+ };
19
+
20
+ export type HookMatcher = {
21
+ matcher: string;
22
+ hooks: ClaudeHook[];
23
+ };
24
+
25
+ export type ClaudeSettings = {
26
+ hooks?: Record<string, HookMatcher[]>;
27
+ [key: string]: unknown;
28
+ };
29
+
30
+ export function readSettings(projectDir: string): ClaudeSettings {
31
+ const settingsPath = path.join(projectDir, ".claude", "settings.json");
32
+ try {
33
+ const raw = fs.readFileSync(settingsPath, "utf-8");
34
+ const parsed = JSON.parse(raw);
35
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
36
+ return parsed as ClaudeSettings;
37
+ }
38
+ return {};
39
+ } catch {
40
+ return {};
41
+ }
42
+ }
43
+
44
+ export function buildPreToolUseEntry(): HookMatcher {
45
+ return {
46
+ matcher: PRE_TOOL_USE_MATCHER,
47
+ hooks: [
48
+ {
49
+ type: "command",
50
+ command: `bun \${CLAUDE_PROJECT_DIR}/node_modules/@cuzfrog/pi-module-gates/src/claude/pre-tool-use.ts`,
51
+ statusMessage: "Module gate checking edit...",
52
+ },
53
+ ],
54
+ };
55
+ }
56
+
57
+ export function upsertPreToolUse(settings: ClaudeSettings): ClaudeSettings {
58
+ const next: ClaudeSettings = JSON.parse(JSON.stringify(settings));
59
+ next.hooks = next.hooks ?? {};
60
+ const existing = next.hooks.PreToolUse ?? [];
61
+ next.hooks.PreToolUse = existing.filter(
62
+ (m) => !m.hooks.some((h) => typeof h.command === "string" && h.command.includes(HOOK_MARKER)),
63
+ );
64
+ next.hooks.PreToolUse.push(buildPreToolUseEntry());
65
+ return next;
66
+ }
67
+
68
+ export function removePreToolUse(settings: ClaudeSettings): ClaudeSettings {
69
+ const next: ClaudeSettings = JSON.parse(JSON.stringify(settings));
70
+ if (!next.hooks?.PreToolUse) return next;
71
+ const filtered = next.hooks.PreToolUse.filter(
72
+ (m) => !m.hooks.some((h) => typeof h.command === "string" && h.command.includes(HOOK_MARKER)),
73
+ );
74
+ if (filtered.length === 0) {
75
+ delete next.hooks.PreToolUse;
76
+ if (Object.keys(next.hooks).length === 0) {
77
+ delete next.hooks;
78
+ }
79
+ } else {
80
+ next.hooks.PreToolUse = filtered;
81
+ }
82
+ return next;
83
+ }
84
+
85
+ export function writeSettings(projectDir: string, settings: ClaudeSettings): string {
86
+ const claudeDir = path.join(projectDir, ".claude");
87
+ fs.mkdirSync(claudeDir, { recursive: true });
88
+ const target = path.join(claudeDir, "settings.json");
89
+ fs.writeFileSync(target, JSON.stringify(settings, null, 2) + "\n", "utf-8");
90
+ return target;
91
+ }
@@ -0,0 +1,54 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import {
4
+ readSettings,
5
+ upsertPreToolUse,
6
+ writeSettings,
7
+ HOOK_MARKER,
8
+ PRE_TOOL_USE_MATCHER,
9
+ } from "../claude/settings-writer.ts";
10
+
11
+ export type InstallClaudeOptions = {
12
+ projectDir: string;
13
+ };
14
+
15
+ export type InstallClaudeResult =
16
+ | { ok: true; written: string }
17
+ | { ok: false; reason: string };
18
+
19
+ export function installClaude(opts: InstallClaudeOptions): InstallClaudeResult {
20
+ const projectDir = path.resolve(opts.projectDir);
21
+ if (!fs.existsSync(projectDir) || !fs.statSync(projectDir).isDirectory()) {
22
+ return { ok: false, reason: `Project directory does not exist: ${projectDir}` };
23
+ }
24
+
25
+ const settingsPath = path.join(projectDir, ".pi", "settings.json");
26
+ if (!fs.existsSync(settingsPath)) {
27
+ process.stderr.write(
28
+ `[Module Gate] No .pi/settings.json found at ${settingsPath} — using defaults.\n`,
29
+ );
30
+ }
31
+
32
+ const settings = readSettings(projectDir);
33
+ const updated = upsertPreToolUse(settings);
34
+ const written = writeSettings(projectDir, updated);
35
+
36
+ const relPath = path.relative(projectDir, written) || written;
37
+ process.stdout.write(`Wrote ${relPath}\n\n`);
38
+ process.stdout.write("Hook entry inserted under hooks.PreToolUse:\n");
39
+ const matcher = updated.hooks?.PreToolUse?.find((m) =>
40
+ m.hooks.some((h) => typeof h.command === "string" && h.command.includes(HOOK_MARKER)),
41
+ );
42
+ if (matcher) {
43
+ process.stdout.write(` matcher: "${matcher.matcher}"\n`);
44
+ for (const h of matcher.hooks) {
45
+ if (typeof h.command === "string") {
46
+ process.stdout.write(` command: ${h.command}\n`);
47
+ }
48
+ if (h.statusMessage) process.stdout.write(` status: ${h.statusMessage}\n`);
49
+ }
50
+ }
51
+ process.stdout.write(`\nMatcher targets: ${PRE_TOOL_USE_MATCHER}\n`);
52
+
53
+ return { ok: true, written };
54
+ }
@@ -0,0 +1,40 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import {
4
+ readSettings,
5
+ removePreToolUse,
6
+ writeSettings,
7
+ HOOK_MARKER,
8
+ } from "../claude/settings-writer.ts";
9
+
10
+ export type UninstallClaudeOptions = {
11
+ projectDir: string;
12
+ };
13
+
14
+ export type UninstallClaudeResult =
15
+ | { ok: true; removed: boolean; written: string }
16
+ | { ok: false; reason: string };
17
+
18
+ export function uninstallClaude(opts: UninstallClaudeOptions): UninstallClaudeResult {
19
+ const projectDir = path.resolve(opts.projectDir);
20
+ const settingsPath = path.join(projectDir, ".claude", "settings.json");
21
+
22
+ if (!fs.existsSync(settingsPath)) {
23
+ process.stdout.write(`No .claude/settings.json found at ${settingsPath} — nothing to do.\n`);
24
+ return { ok: true, removed: false, written: settingsPath };
25
+ }
26
+
27
+ const before = readSettings(projectDir);
28
+ const beforeHadMarker = JSON.stringify(before).includes(HOOK_MARKER);
29
+ const after = removePreToolUse(before);
30
+ const afterHasMarker = JSON.stringify(after).includes(HOOK_MARKER);
31
+
32
+ if (beforeHadMarker && !afterHasMarker) {
33
+ writeSettings(projectDir, after);
34
+ process.stdout.write(`Removed pi-module-gates hooks from ${settingsPath}.\n`);
35
+ return { ok: true, removed: true, written: settingsPath };
36
+ }
37
+
38
+ process.stdout.write(`No pi-module-gates hooks found in ${settingsPath}.\n`);
39
+ return { ok: true, removed: false, written: settingsPath };
40
+ }
@@ -5,4 +5,5 @@ import "./go.ts";
5
5
  import "./kotlin.ts";
6
6
  import "./scala.ts";
7
7
 
8
- export { registerChecker, getChecker, ExportChecker } from "./registry.ts";
8
+ export { registerChecker, getChecker } from "./registry.ts";
9
+ export type { ExportChecker } from "./registry.ts";
@@ -0,0 +1,112 @@
1
+ import * as path from "node:path";
2
+ import { parseFrontmatter } from "../utils/frontmatter.ts";
3
+ import type { ModuleIndex } from "../types.ts";
4
+ import type { ModuleGateConfig } from "../config.ts";
5
+ import { readFileSafe, applyEdits, isWithinSourceRoot, findOwningModule } from "../utils.ts";
6
+ import {
7
+ checkReadonly,
8
+ checkFrozen,
9
+ checkExports,
10
+ checkModuleInterfaceImports,
11
+ } from "./index.ts";
12
+ import "./checkers/index.ts";
13
+
14
+ export type GateEdit = { oldText: string; newText: string };
15
+
16
+ export type GateDenial = { block: true; reason: string };
17
+
18
+ export function runGates(
19
+ filePath: string,
20
+ edits: GateEdit[],
21
+ cwd: string,
22
+ index: ModuleIndex,
23
+ config: ModuleGateConfig,
24
+ beforeOverride?: string,
25
+ ): GateDenial | undefined {
26
+ const absPath = path.resolve(cwd, filePath);
27
+
28
+ const before = beforeOverride ?? readFileSafe(absPath);
29
+ const after = applyEdits(before, edits);
30
+ const srcRoot = path.resolve(cwd, config.sourceRoot);
31
+
32
+ if (!isWithinSourceRoot(absPath, srcRoot)) return undefined;
33
+
34
+ const readonlyResult = checkReadonly(filePath, index, cwd, config.moduleDescriptorFileName);
35
+ if (readonlyResult.blocked) {
36
+ if (
37
+ config.moduleDescriptorReadonly === "frontmatter" &&
38
+ isDescriptorFile(absPath, config.moduleDescriptorFileName)
39
+ ) {
40
+ const fmBefore = extractFrontmatter(before);
41
+ const fmAfter = extractFrontmatter(after);
42
+ if (JSON.stringify(fmBefore) === JSON.stringify(fmAfter)) {
43
+ return undefined;
44
+ }
45
+ return {
46
+ block: true,
47
+ reason: formatDenial(
48
+ filePath,
49
+ `Readonly rule: frontmatter of ${config.moduleDescriptorFileName} is readonly`,
50
+ absPath,
51
+ index,
52
+ cwd,
53
+ config.moduleDescriptorFileName,
54
+ ),
55
+ };
56
+ }
57
+ return { block: true, reason: formatDenial(filePath, readonlyResult.reason, absPath, index, cwd, config.moduleDescriptorFileName) };
58
+ }
59
+
60
+ const frozenResult = checkFrozen(filePath, before, after, index, cwd, config.moduleDescriptorFileName);
61
+ if (frozenResult.blocked) {
62
+ return { block: true, reason: formatDenial(filePath, frozenResult.reason, absPath, index, cwd, config.moduleDescriptorFileName) };
63
+ }
64
+
65
+ const exportResult = checkExports(filePath, before, after, index, cwd, config.moduleDescriptorFileName);
66
+ if (exportResult.blocked) {
67
+ return { block: true, reason: formatDenial(filePath, exportResult.reason, absPath, index, cwd, config.moduleDescriptorFileName) };
68
+ }
69
+
70
+ const importResult = checkModuleInterfaceImports(filePath, after, index, cwd, config.disableModuleInterfaceImportGate, config.sourceRoot);
71
+ if (importResult.blocked) {
72
+ return { block: true, reason: formatDenial(filePath, importResult.reason, absPath, index, cwd, config.moduleDescriptorFileName) };
73
+ }
74
+
75
+ return undefined;
76
+ }
77
+
78
+ export function formatDenial(
79
+ relPath: string,
80
+ reason: string,
81
+ absPath: string,
82
+ index: ModuleIndex,
83
+ cwd: string,
84
+ descriptorFileName: string,
85
+ ): string {
86
+ const modulePath = findOwningModule(absPath, index);
87
+ const contract = modulePath
88
+ ? index.contracts.find((c) => c.modulePath === modulePath)
89
+ : undefined;
90
+
91
+ let message = `[Module Gate] Write blocked — ${relPath}\n\n${reason}`;
92
+
93
+ if (contract && contract.prose) {
94
+ const relModuleMd = path.relative(cwd, path.join(contract.modulePath, descriptorFileName));
95
+ message += `\n\nModule contract (${relModuleMd}):\n${contract.prose}`;
96
+ }
97
+
98
+ return message;
99
+ }
100
+
101
+ export function isDescriptorFile(absPath: string, descriptorFileName: string): boolean {
102
+ const basename = path.basename(absPath);
103
+ return basename.toLowerCase() === descriptorFileName.toLowerCase();
104
+ }
105
+
106
+ export function extractFrontmatter(content: string): Record<string, unknown> {
107
+ try {
108
+ return parseFrontmatter(content).frontmatter;
109
+ } catch {
110
+ return {};
111
+ }
112
+ }
@@ -1,7 +1,7 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import { readdir } from "node:fs/promises";
4
- import { parseFrontmatter } from "@earendil-works/pi-coding-agent";
4
+ import { parseFrontmatter } from "../utils/frontmatter.ts";
5
5
  import type { ModuleContract, ModuleIndex } from "../types.ts";
6
6
  import type { ModuleGateConfig } from "../config.ts";
7
7
 
package/src/index.ts CHANGED
@@ -4,18 +4,13 @@ import type {
4
4
  ToolCallEventResult,
5
5
  BeforeAgentStartEventResult,
6
6
  } from "@earendil-works/pi-coding-agent";
7
- import { isToolCallEventType, parseFrontmatter } from "@earendil-works/pi-coding-agent";
7
+ import { isToolCallEventType } from "@earendil-works/pi-coding-agent";
8
8
  import type { ModuleIndex } from "./types.ts";
9
9
  import { loadConfig } from "./config.ts";
10
10
  import type { ModuleGateConfig } from "./config.ts";
11
11
  import { buildModuleIndex } from "./graph/index.ts";
12
- import { findOwningModule, readFileSafe, applyEdits, isWithinSourceRoot } from "./utils.ts";
13
- import {
14
- checkReadonly,
15
- checkExports,
16
- checkFrozen,
17
- checkModuleInterfaceImports,
18
- } from "./gates/index.ts";
12
+ import { readFileSafe } from "./utils.ts";
13
+ import { runGates, type GateEdit } from "./gates/run-gates.ts";
19
14
  import { buildSystemPromptHint } from "./context/index.ts";
20
15
  import "./gates/checkers/index.ts";
21
16
 
@@ -43,107 +38,13 @@ export default function (pi: ExtensionAPI): void {
43
38
 
44
39
  pi.on("tool_call", async (event, ctx): Promise<ToolCallEventResult | void> => {
45
40
  if (isToolCallEventType("edit", event)) {
46
- return handleEdit(event.input.path, event.input.edits, ctx.cwd, index, config);
41
+ return runGates(event.input.path, event.input.edits, ctx.cwd, index, config);
47
42
  }
48
43
  if (isToolCallEventType("write", event)) {
49
44
  const absPath = path.resolve(ctx.cwd, event.input.path);
50
45
  const before = readFileSafe(absPath);
51
- return handleEdit(event.input.path, [{ oldText: before, newText: event.input.content }], ctx.cwd, index, config);
46
+ const edits: GateEdit[] = [{ oldText: before, newText: event.input.content }];
47
+ return runGates(event.input.path, edits, ctx.cwd, index, config);
52
48
  }
53
49
  });
54
- }
55
-
56
- function handleEdit(
57
- filePath: string,
58
- edits: { oldText: string; newText: string }[],
59
- cwd: string,
60
- index: ModuleIndex,
61
- config: ModuleGateConfig,
62
- ): ToolCallEventResult | undefined {
63
- const absPath = path.resolve(cwd, filePath);
64
-
65
- const before = readFileSafe(absPath);
66
- const after = applyEdits(before, edits);
67
- const srcRoot = path.resolve(cwd, config.sourceRoot);
68
-
69
- if (!isWithinSourceRoot(absPath, srcRoot)) return undefined;
70
-
71
- const readonlyResult = checkReadonly(filePath, index, cwd, config.moduleDescriptorFileName);
72
- if (readonlyResult.blocked) {
73
- if (
74
- config.moduleDescriptorReadonly === "frontmatter" &&
75
- isDescriptorFile(absPath, config.moduleDescriptorFileName)
76
- ) {
77
- const fmBefore = extractFrontmatter(before);
78
- const fmAfter = extractFrontmatter(after);
79
- if (JSON.stringify(fmBefore) === JSON.stringify(fmAfter)) {
80
- return undefined;
81
- }
82
- return {
83
- block: true,
84
- reason: formatDenial(
85
- filePath,
86
- `Readonly rule: frontmatter of ${config.moduleDescriptorFileName} is readonly`,
87
- absPath,
88
- index,
89
- cwd,
90
- config.moduleDescriptorFileName,
91
- ),
92
- };
93
- }
94
- return { block: true, reason: formatDenial(filePath, readonlyResult.reason, absPath, index, cwd, config.moduleDescriptorFileName) };
95
- }
96
-
97
- const frozenResult = checkFrozen(filePath, before, after, index, cwd, config.moduleDescriptorFileName);
98
- if (frozenResult.blocked) {
99
- return { block: true, reason: formatDenial(filePath, frozenResult.reason, absPath, index, cwd, config.moduleDescriptorFileName) };
100
- }
101
-
102
- const exportResult = checkExports(filePath, before, after, index, cwd, config.moduleDescriptorFileName);
103
- if (exportResult.blocked) {
104
- return { block: true, reason: formatDenial(filePath, exportResult.reason, absPath, index, cwd, config.moduleDescriptorFileName) };
105
- }
106
-
107
- const importResult = checkModuleInterfaceImports(filePath, after, index, cwd, config.disableModuleInterfaceImportGate, config.sourceRoot);
108
- if (importResult.blocked) {
109
- return { block: true, reason: formatDenial(filePath, importResult.reason, absPath, index, cwd, config.moduleDescriptorFileName) };
110
- }
111
-
112
- return undefined;
113
- }
114
-
115
- function formatDenial(
116
- relPath: string,
117
- reason: string,
118
- absPath: string,
119
- index: ModuleIndex,
120
- cwd: string,
121
- descriptorFileName: string,
122
- ): string {
123
- const modulePath = findOwningModule(absPath, index);
124
- const contract = modulePath
125
- ? index.contracts.find((c) => c.modulePath === modulePath)
126
- : undefined;
127
-
128
- let message = `[Module Gate] Write blocked — ${relPath}\n\n${reason}`;
129
-
130
- if (contract && contract.prose) {
131
- const relModuleMd = path.relative(cwd, path.join(contract.modulePath, descriptorFileName));
132
- message += `\n\nModule contract (${relModuleMd}):\n${contract.prose}`;
133
- }
134
-
135
- return message;
136
- }
137
-
138
- function isDescriptorFile(absPath: string, descriptorFileName: string): boolean {
139
- const basename = path.basename(absPath);
140
- return basename.toLowerCase() === descriptorFileName.toLowerCase();
141
- }
142
-
143
- function extractFrontmatter(content: string): Record<string, unknown> {
144
- try {
145
- return parseFrontmatter(content).frontmatter;
146
- } catch {
147
- return {};
148
- }
149
- }
50
+ }
@@ -0,0 +1,41 @@
1
+ import { parse } from "yaml";
2
+
3
+ type ParsedFrontmatter<T extends Record<string, unknown>> = {
4
+ frontmatter: T;
5
+ body: string;
6
+ };
7
+
8
+ const normalizeNewlines = (value: string): string =>
9
+ value.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
10
+
11
+ const extractFrontmatter = (
12
+ content: string,
13
+ ): { yamlString: string | null; body: string } => {
14
+ const normalized = normalizeNewlines(content);
15
+ if (!normalized.startsWith("---")) {
16
+ return { yamlString: null, body: normalized };
17
+ }
18
+ const endIndex = normalized.indexOf("\n---", 3);
19
+ if (endIndex === -1) {
20
+ return { yamlString: null, body: normalized };
21
+ }
22
+ return {
23
+ yamlString: normalized.slice(4, endIndex),
24
+ body: normalized.slice(endIndex + 4).trim(),
25
+ };
26
+ };
27
+
28
+ export function parseFrontmatter<T extends Record<string, unknown> = Record<string, unknown>>(
29
+ content: string,
30
+ ): ParsedFrontmatter<T> {
31
+ const { yamlString, body } = extractFrontmatter(content);
32
+ if (!yamlString) {
33
+ return { frontmatter: {} as T, body };
34
+ }
35
+ const parsed = parse(yamlString) as T | null | undefined;
36
+ return { frontmatter: (parsed ?? ({} as T)), body };
37
+ }
38
+
39
+ export function stripFrontmatter(content: string): string {
40
+ return parseFrontmatter(content).body;
41
+ }