@cuzfrog/pi-module-gates 0.15.0 → 0.17.0
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 +48 -7
- package/bin/pi-module-gates.mjs +80 -0
- package/package.json +10 -2
- package/skills/{module-freeze-all → module-seal-all}/SKILL.md +7 -7
- package/skills/module-seal-all/scripts/seal-all.d.mts +1 -0
- package/skills/{module-freeze-all/scripts/freeze-all.mjs → module-seal-all/scripts/seal-all.mjs} +14 -14
- package/src/MODULE.md +2 -3
- package/src/claude/index-loader.ts +40 -0
- package/src/claude/pre-tool-use.ts +99 -0
- package/src/claude/settings-writer.ts +91 -0
- package/src/cli/install-claude.ts +54 -0
- package/src/cli/uninstall-claude.ts +40 -0
- package/src/context/MODULE.md +2 -3
- package/src/context/system-prompt.template.md +2 -2
- package/src/gates/MODULE.md +3 -4
- package/src/gates/checkers/MODULE.md +2 -3
- package/src/gates/checkers/index.ts +2 -1
- package/src/gates/index.ts +2 -2
- package/src/gates/run-gates.ts +112 -0
- package/src/gates/{frozen-gate.ts → sealed-gate.ts} +6 -6
- package/src/graph/MODULE.md +2 -4
- package/src/graph/frontmatter-parser.ts +1 -1
- package/src/graph/module-index-builder.ts +2 -2
- package/src/index.ts +7 -106
- package/src/types.ts +1 -1
- package/src/utils/frontmatter.ts +41 -0
- package/src/utils.ts +2 -2
- package/skills/module-freeze-all/scripts/freeze-all.d.mts +0 -1
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# pi-module-gates - Constraints liberate, liberties constrain.
|
|
2
2
|
|
|
3
|
-
|
|
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
|
|
@@ -12,7 +12,7 @@ AI coding agents produce edits with limited context knowledge (myopia) — their
|
|
|
12
12
|
**Module contracts as guardrails.** Each directory can contain a descriptor file that declares:
|
|
13
13
|
|
|
14
14
|
- `readonly` — files and directories the agent must not touch
|
|
15
|
-
- `
|
|
15
|
+
- `sealed` — files where no new exports are allowed (body still editable)
|
|
16
16
|
- `visible` — the set of exports allowed to be added or modified in that module
|
|
17
17
|
|
|
18
18
|
The extension intercepts agent `write`/`edit` operations and enforces these contracts. Violations are blocked with a clear reason.
|
|
@@ -26,7 +26,7 @@ The attempt to add 2 public helper functions is blocked, forcing the agent to re
|
|
|
26
26
|
2. **System prompt** — Injects a hint so the agent knows to respect descriptor file conventions.
|
|
27
27
|
3. **Gating** — On every write/edit, checks:
|
|
28
28
|
- **Readonly gate** — is the target file locked?
|
|
29
|
-
**
|
|
29
|
+
**Sealed gate** — would the change add new exports to a file in the `sealed` list?
|
|
30
30
|
- **Export gate** — would the change introduce an export not in the `visible` list?
|
|
31
31
|
- **Module interface import gate** — external files can only import from the module not internal files, i.e. re-exports from `index.ts` or `mod.rs`. A child module may import from a parent module's internal files (not recommended but allowed). (Only Typescript/JavaScript and Rust are supported)
|
|
32
32
|
- **Import gate** (not implemented yet) — would the change introduce an import violating visibility scope?
|
|
@@ -57,14 +57,14 @@ readonly: [mod.rs]
|
|
|
57
57
|
Any prose for the agent to better understand the module.
|
|
58
58
|
```
|
|
59
59
|
|
|
60
|
-
###
|
|
60
|
+
### Sealed constraints
|
|
61
61
|
|
|
62
62
|
```yaml
|
|
63
|
-
|
|
63
|
+
sealed: [mod.rs]
|
|
64
64
|
```
|
|
65
|
-
|
|
65
|
+
Sealed files cannot change their surface size: no new exports or public entries are allowed. The file body is still editable.
|
|
66
66
|
|
|
67
|
-
A skill [module-
|
|
67
|
+
A skill [module-seal-all](skills/module-seal-all) has been included to auto-seal modules.
|
|
68
68
|
|
|
69
69
|
### Visibility whitelist (under redesign)
|
|
70
70
|
|
|
@@ -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.
|
|
3
|
+
"version": "0.17.0",
|
|
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
|
-
"
|
|
55
|
+
"bun": ">=1.3.0"
|
|
48
56
|
},
|
|
49
57
|
"publishConfig": {
|
|
50
58
|
"access": "public",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
|
-
name: module-
|
|
3
|
-
description: Auto-
|
|
2
|
+
name: module-seal-all
|
|
3
|
+
description: Auto-seal all files in module descriptors so their module surface area cannot be increased.
|
|
4
4
|
disable-model-invocation: true
|
|
5
5
|
argument-hint: <user-instructions>
|
|
6
6
|
---
|
|
@@ -9,26 +9,26 @@ argument-hint: <user-instructions>
|
|
|
9
9
|
- Find out module descriptor filename in the context.
|
|
10
10
|
- Find out source root in the context or from configurations.
|
|
11
11
|
- Derive scripts args from user instructions.
|
|
12
|
-
- Call the script to scans the source tree for module descriptors and auto-populates their `
|
|
12
|
+
- Call the script to scans the source tree for module descriptors and auto-populates their `sealed` entries with code files in each module directory.
|
|
13
13
|
|
|
14
14
|
## Script Usage
|
|
15
15
|
|
|
16
16
|
```bash
|
|
17
|
-
node skills/module-
|
|
17
|
+
node skills/module-seal-all/scripts/seal-all.mjs [options]
|
|
18
18
|
```
|
|
19
19
|
|
|
20
20
|
Options:
|
|
21
21
|
- `--root <dir>` - Source root directory (default: `src`)
|
|
22
22
|
- `--dry-run` - Show what would change without writing files
|
|
23
|
-
- `--create` - Create module descriptor files for directories without one (adds `
|
|
23
|
+
- `--create` - Create module descriptor files for directories without one (adds `sealed` only)
|
|
24
24
|
- `--descriptor-name <name>` - Module descriptor filename (default: `module.md`)
|
|
25
25
|
|
|
26
26
|
### Behavior
|
|
27
27
|
|
|
28
28
|
1. Finds all module descriptor files under the source root.
|
|
29
29
|
2. For each module directory, lists all direct files (not subdirectories, not module descriptor file itself)
|
|
30
|
-
3. Adds those files to the `
|
|
31
|
-
4. Preserves existing `
|
|
30
|
+
3. Adds those files to the `sealed` frontmatter field
|
|
31
|
+
4. Preserves existing `sealed` entries, other fields in the frontmatter, and body prose
|
|
32
32
|
|
|
33
33
|
## Extra user instructions:
|
|
34
34
|
"$ARGUMENTS"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const SUPPORTED_EXTENSIONS: Set<string>;
|
package/skills/{module-freeze-all/scripts/freeze-all.mjs → module-seal-all/scripts/seal-all.mjs}
RENAMED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* Auto-
|
|
3
|
+
* Auto-seal all files in module descriptors.
|
|
4
4
|
*
|
|
5
5
|
* Scans module descriptor files under a source root and populates each
|
|
6
|
-
* descriptor's `
|
|
6
|
+
* descriptor's `sealed` field with every direct code file in the module
|
|
7
7
|
* directory. Files in nested sub-modules (directories with their own
|
|
8
8
|
* descriptor) are excluded from the parent.
|
|
9
9
|
*/
|
|
@@ -47,7 +47,7 @@ function main() {
|
|
|
47
47
|
const results = [];
|
|
48
48
|
|
|
49
49
|
for (const [modDir, modFile] of allModuleDirs) {
|
|
50
|
-
const result =
|
|
50
|
+
const result = sealModule(modDir, modFile, descriptorName, allModuleDirs);
|
|
51
51
|
if (result) results.push(result);
|
|
52
52
|
}
|
|
53
53
|
|
|
@@ -69,7 +69,7 @@ function main() {
|
|
|
69
69
|
console.log("Dry run — would modify:");
|
|
70
70
|
for (const r of results) {
|
|
71
71
|
const rel = relative(cwd, r.path);
|
|
72
|
-
console.log(` ${rel}:
|
|
72
|
+
console.log(` ${rel}: seal [${r.files.join(", ") || "(none)"}]`);
|
|
73
73
|
}
|
|
74
74
|
return;
|
|
75
75
|
}
|
|
@@ -169,7 +169,7 @@ function walkDir(dir, visitor) {
|
|
|
169
169
|
// Module processing
|
|
170
170
|
// ---------------------------------------------------------------------------
|
|
171
171
|
|
|
172
|
-
function
|
|
172
|
+
function sealModule(modDir, descriptorFileName, descriptorName, allModuleDirs) {
|
|
173
173
|
const modPath = join(modDir, descriptorFileName);
|
|
174
174
|
let raw;
|
|
175
175
|
try {
|
|
@@ -179,15 +179,15 @@ function freezeModule(modDir, descriptorFileName, descriptorName, allModuleDirs)
|
|
|
179
179
|
}
|
|
180
180
|
|
|
181
181
|
const parsed = parseFrontmatter(raw);
|
|
182
|
-
const
|
|
182
|
+
const existingSealed = normalizeSealed(parsed.frontmatter.sealed);
|
|
183
183
|
const directFiles = listDirectFiles(modDir, descriptorName, allModuleDirs);
|
|
184
|
-
const
|
|
184
|
+
const mergedSealed = mergePreservingOrder(existingSealed, directFiles);
|
|
185
185
|
|
|
186
|
-
if (arraysEqual(
|
|
186
|
+
if (arraysEqual(existingSealed, mergedSealed)) return undefined;
|
|
187
187
|
|
|
188
|
-
const newContent = serializeModule(parsed,
|
|
188
|
+
const newContent = serializeModule(parsed, mergedSealed);
|
|
189
189
|
|
|
190
|
-
return { path: modPath, content: newContent, files:
|
|
190
|
+
return { path: modPath, content: newContent, files: mergedSealed };
|
|
191
191
|
}
|
|
192
192
|
|
|
193
193
|
function createModuleDescriptor(dir, descriptorName) {
|
|
@@ -283,8 +283,8 @@ function parseInlineList(raw) {
|
|
|
283
283
|
return inner.split(",").map((s) => s.trim().replace(/^['"]|['"]$/g, ""));
|
|
284
284
|
}
|
|
285
285
|
|
|
286
|
-
function serializeModule(parsed,
|
|
287
|
-
const fm = { ...parsed.frontmatter,
|
|
286
|
+
function serializeModule(parsed, sealed) {
|
|
287
|
+
const fm = { ...parsed.frontmatter, sealed };
|
|
288
288
|
|
|
289
289
|
let yaml = parsed.prelude;
|
|
290
290
|
for (const [key, value] of Object.entries(fm)) {
|
|
@@ -344,7 +344,7 @@ function listDirectFiles(modDir, descriptorName, allModuleDirs) {
|
|
|
344
344
|
// Helpers
|
|
345
345
|
// ---------------------------------------------------------------------------
|
|
346
346
|
|
|
347
|
-
function
|
|
347
|
+
function normalizeSealed(value) {
|
|
348
348
|
if (Array.isArray(value)) return value.map(String);
|
|
349
349
|
return [];
|
|
350
350
|
}
|
|
@@ -366,4 +366,4 @@ function arraysEqual(a, b) {
|
|
|
366
366
|
|
|
367
367
|
if (import.meta.url === pathToFileURL(resolve(process.argv[1] ?? "")).href) {
|
|
368
368
|
main();
|
|
369
|
-
}
|
|
369
|
+
}
|
package/src/MODULE.md
CHANGED
|
@@ -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
|
+
}
|
package/src/context/MODULE.md
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
## Module gates (boundary enforcement)
|
|
2
|
-
This project uses `{{descriptorFileName}}`(case-insensitive) files to declare visibility, readonly and
|
|
2
|
+
This project uses `{{descriptorFileName}}`(case-insensitive) files to declare visibility, readonly and sealed rules that you should follow.
|
|
3
3
|
If you cannot comply, reconsider your design or raise to the user with tradeoffs if necessary.
|
|
4
4
|
Each `{{descriptorFileName}}` gates its branching point in the tree.
|
|
5
5
|
A `{{descriptorFileName}}` with a `visible` list means only entries in the list are allowed to be visible outside the module.
|
|
@@ -13,5 +13,5 @@ A `{{descriptorFileName}}` with a `visible` list means only entries in the list
|
|
|
13
13
|
- `external files`: files not in the module directory;
|
|
14
14
|
- `module interface`: the file representing the module surface, e.g. `index.ts` in Typescript, `mod.rs` in Rust;
|
|
15
15
|
- `readonly`: files are readonly;
|
|
16
|
-
- `
|
|
16
|
+
- `sealed`: files cannot add new exports, but the body is still editable; the export surface is sealed;
|
|
17
17
|
- `visible`: visible from outside the module; files not in the module directory are outside the module;
|
package/src/gates/MODULE.md
CHANGED
package/src/gates/index.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
export { checkReadonly } from "./readonly-gate.ts";
|
|
2
|
-
export {
|
|
2
|
+
export { checkSealed } from "./sealed-gate.ts";
|
|
3
3
|
export { checkExports } from "./export-gate.ts";
|
|
4
|
-
export { checkModuleInterfaceImports } from "./module-interface-import-gate.ts";
|
|
4
|
+
export { checkModuleInterfaceImports } from "./module-interface-import-gate.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
|
+
checkSealed,
|
|
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 sealedResult = checkSealed(filePath, before, after, index, cwd, config.moduleDescriptorFileName);
|
|
61
|
+
if (sealedResult.blocked) {
|
|
62
|
+
return { block: true, reason: formatDenial(filePath, sealedResult.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
|
+
}
|
|
@@ -3,18 +3,18 @@ import type { ModuleIndex } from "../types.ts";
|
|
|
3
3
|
import { getAncestorContracts, matchesPattern } from "../utils.ts";
|
|
4
4
|
import { getChecker } from "./checkers/registry.ts";
|
|
5
5
|
|
|
6
|
-
export type
|
|
6
|
+
export type SealedCheckResult =
|
|
7
7
|
| { blocked: true; reason: string }
|
|
8
8
|
| { blocked: false };
|
|
9
9
|
|
|
10
|
-
export function
|
|
10
|
+
export function checkSealed(
|
|
11
11
|
filePath: string,
|
|
12
12
|
beforeContent: string,
|
|
13
13
|
afterContent: string,
|
|
14
14
|
index: ModuleIndex,
|
|
15
15
|
cwd: string,
|
|
16
16
|
descriptorFileName: string,
|
|
17
|
-
):
|
|
17
|
+
): SealedCheckResult {
|
|
18
18
|
const absFile = path.resolve(cwd, filePath);
|
|
19
19
|
|
|
20
20
|
const checker = getChecker(absFile);
|
|
@@ -23,7 +23,7 @@ export function checkFrozen(
|
|
|
23
23
|
const ancestors = getAncestorContracts(absFile, index);
|
|
24
24
|
|
|
25
25
|
for (const contract of ancestors) {
|
|
26
|
-
for (const pattern of contract.
|
|
26
|
+
for (const pattern of contract.sealed) {
|
|
27
27
|
if (matchesPattern(absFile, pattern, contract.modulePath)) {
|
|
28
28
|
const newExports = checker.getNewExports(beforeContent, afterContent);
|
|
29
29
|
if (newExports.length === 0) return { blocked: false };
|
|
@@ -32,11 +32,11 @@ export function checkFrozen(
|
|
|
32
32
|
const names = newExports.map((s) => s.name).join(", ");
|
|
33
33
|
return {
|
|
34
34
|
blocked: true,
|
|
35
|
-
reason: `
|
|
35
|
+
reason: `Sealed rule: file is sealed in ${relModuleMd}. Cannot add new exports: ${names}`,
|
|
36
36
|
};
|
|
37
37
|
}
|
|
38
38
|
}
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
return { blocked: false };
|
|
42
|
-
}
|
|
42
|
+
}
|
package/src/graph/MODULE.md
CHANGED
|
@@ -5,7 +5,7 @@ export type VisibleEntryRaw = string | { path: string; modifier?: string };
|
|
|
5
5
|
export type ModuleFrontmatter = {
|
|
6
6
|
visible?: VisibleEntryRaw[];
|
|
7
7
|
readonly?: string[];
|
|
8
|
-
|
|
8
|
+
sealed?: string[];
|
|
9
9
|
};
|
|
10
10
|
|
|
11
11
|
export function parseVisibleEntry(raw: VisibleEntryRaw): Signature {
|
|
@@ -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 "
|
|
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
|
|
|
@@ -70,7 +70,7 @@ function buildContracts(
|
|
|
70
70
|
? frontmatter.visible.map(parseVisibleEntry)
|
|
71
71
|
: null,
|
|
72
72
|
readonly: readonlyEntries,
|
|
73
|
-
|
|
73
|
+
sealed: frontmatter.sealed ?? [],
|
|
74
74
|
prose: body.trim(),
|
|
75
75
|
});
|
|
76
76
|
}
|
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
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/utils.ts
CHANGED
|
@@ -42,10 +42,10 @@ export function isWithinSourceRoot(absPath: string, resolvedRoot: string): boole
|
|
|
42
42
|
export function getAncestorContracts(
|
|
43
43
|
absFile: string,
|
|
44
44
|
index: ModuleIndex,
|
|
45
|
-
): { modulePath: string; readonly: string[];
|
|
45
|
+
): { modulePath: string; readonly: string[]; sealed: string[] }[] {
|
|
46
46
|
return index.contracts
|
|
47
47
|
.filter((c) => absFile.startsWith(c.modulePath + path.sep) || absFile === c.modulePath)
|
|
48
|
-
.map(({ modulePath, readonly,
|
|
48
|
+
.map(({ modulePath, readonly, sealed }) => ({ modulePath, readonly, sealed }));
|
|
49
49
|
}
|
|
50
50
|
|
|
51
51
|
export function matchesPattern(
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export declare const SUPPORTED_EXTENSIONS: Set<string>;
|