@cuzfrog/pi-module-gates 0.6.0 → 0.8.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 CHANGED
@@ -3,18 +3,6 @@
3
3
  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
- ## Installation
7
-
8
- ```bash
9
- pi install npm:@cuzfrog/pi-module-gates
10
- ```
11
-
12
- Or load directly for a single session:
13
-
14
- ```bash
15
- pi -e npm:@cuzfrog/pi-module-gates
16
- ```
17
-
18
6
  ## Problem
19
7
 
20
8
  AI coding agents produce edits with limited context knowledge (myopia) — their changes may leak implementation details, and break architectural contracts (slop).
@@ -34,26 +22,26 @@ The extension intercepts agent `write`/`edit` operations and enforces these cont
34
22
  2. **System prompt** — Injects a hint so the agent knows to respect descriptor file conventions.
35
23
  3. **Gating** — On every write/edit, checks:
36
24
  - **Readonly gate** — is the target file locked?
25
+ **Fronzen gate** — is there any surface change to the target file?
37
26
  - **Export gate** — would the change introduce an export not in the `visible` list?
38
27
  - **Import gate** (not implemented yet) — would the change introduce an import violating visibility scope?
39
28
 
40
- System prompt:
41
- ```markdown
42
- ## Module gates (boundary enforcement)
43
- This project uses `module.md` files to declare visibility and readonly rules that you should follow.
44
- If you cannot comply, reconsider your design, if impossible, raise to the user with tradeoffs.
45
- Each `module.md` gates its branching point in the tree.
46
- A `module.md` with a `visible` list means only entries in the list are allowed to be visible outside the module.
47
- A `module.md` and its mentioned `readonly` files are readonly.
48
- Violations will be blocked.
29
+ System prompt: [system-prompt.md](src/context/system-prompt.ts)
30
+
31
+ ## Installation
32
+ ```bash
33
+ pi install npm:@cuzfrog/pi-module-gates
34
+ ```
35
+ Or load directly for a single session:
36
+ ```bash
37
+ pi -e npm:@cuzfrog/pi-module-gates
49
38
  ```
50
- `module.md` filename is configurable.
51
39
 
52
40
  ## Module Descriptor Semantics
53
41
 
54
42
  A module descriptor is a Markdown file (default name: `MODULE.md`) placed in a directory. You can piggy-back on your module context file for example `CONTEXT.md`.
55
43
 
56
- ### Simple readonly constraints
44
+ ### Readonly constraints
57
45
 
58
46
  ```markdown
59
47
  ---
@@ -63,7 +51,15 @@ readonly: [mod.rs]
63
51
  Any prose for the agent to better understand the module.
64
52
  ```
65
53
 
66
- ### Visibility whitelist
54
+ ### Frozen constraints
55
+
56
+ ```yaml
57
+ frozen: [mod.rs]
58
+ ```
59
+ Frozen files cannot change their surface size: no new exports or public entries are allowed.
60
+
61
+ ### Visibility whitelist (under redesign)
62
+ Supported languages: Rust, TypeScript
67
63
 
68
64
  ```yaml
69
65
  visible:
package/package.json CHANGED
@@ -1,8 +1,10 @@
1
1
  {
2
2
  "name": "@cuzfrog/pi-module-gates",
3
- "version": "0.6.0",
3
+ "version": "0.8.0",
4
4
  "description": "pi extension that controls the entropy of the codebase by enforcing code module boundaries.",
5
- "keywords": ["pi-package"],
5
+ "keywords": [
6
+ "pi-package"
7
+ ],
6
8
  "type": "module",
7
9
  "scripts": {
8
10
  "check": "tsc --noEmit",
@@ -11,6 +13,9 @@
11
13
  "pi": {
12
14
  "extensions": [
13
15
  "./src/index.ts"
16
+ ],
17
+ "skills": [
18
+ "./skills"
14
19
  ]
15
20
  },
16
21
  "peerDependencies": {
@@ -32,7 +37,12 @@
32
37
  "homepage": "https://github.com/cuzfrog/pi-module-gate#readme",
33
38
  "license": "MIT",
34
39
  "author": "Cause Chung (cuzfrog@gmail.com)",
35
- "files": ["src/", "README.md", "LICENSE"],
40
+ "files": [
41
+ "src/",
42
+ "skills/",
43
+ "README.md",
44
+ "LICENSE"
45
+ ],
36
46
  "engines": {
37
47
  "node": ">=18"
38
48
  },
@@ -0,0 +1,35 @@
1
+ ---
2
+ name: module-freeze-all
3
+ description: Auto-freeze all files in module descriptors so their module surface area cannot be increased.
4
+ disable-model-invocation: true
5
+ argument-hint: <user-instructions>
6
+ ---
7
+
8
+ ## What you do
9
+ - Find out module descriptor filename in the context.
10
+ - Find out source root in the context or from configurations.
11
+ - Derive scripts args from user instructions.
12
+ - Call the script to scans the source tree for module descriptors and auto-populates their `frozen` entries with all files in each module directory.
13
+
14
+ ## Script Usage
15
+
16
+ ```bash
17
+ node skills/module-freeze-all/scripts/freeze-all.mjs [options]
18
+ ```
19
+
20
+ Options:
21
+ - `--root <dir>` - Source root directory (default: `src`)
22
+ - `--dry-run` - Show what would change without writing files
23
+ - `--create` - Create module descriptor files for directories without one (adds `frozen` only)
24
+ - `--descriptor-name <name>` - Module descriptor filename (default: `module.md`)
25
+
26
+ ### Behavior
27
+
28
+ 1. Finds all module descriptor files under the source root.
29
+ 2. For each module directory, lists all direct files (not subdirectories, not module descriptor file itself)
30
+ 3. Adds those files to the `frozen` frontmatter field
31
+ 4. Preserves existing `frozen` entries, other fields in the frontmatter, and body prose
32
+
33
+
34
+ ## Extra user instructions:
35
+ "$ARGUMENTS"
@@ -0,0 +1,354 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Auto-freeze all files in module descriptors.
4
+ *
5
+ * Scans module descriptor files under a source root and populates each
6
+ * descriptor's `frozen` field with every direct file in the module directory.
7
+ * Files in nested sub-modules (directories with their own descriptor) are
8
+ * excluded from the parent.
9
+ */
10
+ import { readFileSync, writeFileSync, readdirSync, existsSync } from "node:fs";
11
+ import { resolve, relative, join, dirname } from "node:path";
12
+ import { parseArgs } from "node:util";
13
+
14
+ function main() {
15
+ const { values } = parseArgs({
16
+ options: {
17
+ root: { type: "string", default: "src" },
18
+ "dry-run": { type: "boolean", default: false },
19
+ create: { type: "boolean", default: false },
20
+ "descriptor-name": { type: "string", default: "module.md" },
21
+ },
22
+ });
23
+
24
+ const cwd = process.cwd();
25
+ const root = resolve(cwd, values.root);
26
+
27
+ if (!existsSync(root)) {
28
+ console.error(`Root directory not found: ${root}`);
29
+ process.exit(1);
30
+ }
31
+
32
+ const descriptorName = values["descriptor-name"];
33
+
34
+ const allModuleDirs = findModuleDirs(root, descriptorName);
35
+ const results = [];
36
+
37
+ for (const [modDir, modFile] of allModuleDirs) {
38
+ const result = freezeModule(modDir, modFile, descriptorName, allModuleDirs);
39
+ if (result) results.push(result);
40
+ }
41
+
42
+ if (values.create) {
43
+ const allDirs = findAllDirsWithFiles(root);
44
+ const dirsWithoutModule = allDirs.filter((d) => !allModuleDirs.has(d));
45
+ for (const dir of dirsWithoutModule) {
46
+ const result = createModuleDescriptor(dir, descriptorName);
47
+ if (result) results.push(result);
48
+ }
49
+ }
50
+
51
+ if (results.length === 0) {
52
+ console.log("All module descriptors are up to date.");
53
+ return;
54
+ }
55
+
56
+ if (values["dry-run"]) {
57
+ console.log("Dry run — would modify:");
58
+ for (const r of results) {
59
+ const rel = relative(cwd, r.path);
60
+ console.log(` ${rel}: freeze [${r.files.join(", ") || "(none)"}]`);
61
+ }
62
+ return;
63
+ }
64
+
65
+ for (const r of results) {
66
+ writeFileSync(r.path, r.content, "utf-8");
67
+ console.log(`Updated: ${relative(cwd, r.path)}`);
68
+ }
69
+ console.log(`Done. ${results.length} module descriptor(s) processed.`);
70
+ }
71
+
72
+ // ---------------------------------------------------------------------------
73
+ // File system scanning
74
+ // ---------------------------------------------------------------------------
75
+
76
+ function findModuleDirs(root, descriptorName) {
77
+ const map = new Map();
78
+ const lowerName = descriptorName.toLowerCase();
79
+ walkDir(root, (fullPath, entry) => {
80
+ if (entry.isFile() && entry.name.toLowerCase() === lowerName) {
81
+ map.set(dirname(fullPath), entry.name);
82
+ return "prune";
83
+ }
84
+ return "continue";
85
+ });
86
+ return map;
87
+ }
88
+
89
+ function findAllDirsWithFiles(root) {
90
+ const dirHasFiles = new Map();
91
+
92
+ function collectFileDirs(dir) {
93
+ let entries;
94
+ try {
95
+ entries = readdirSync(dir, { withFileTypes: true });
96
+ } catch {
97
+ return;
98
+ }
99
+ for (const entry of entries) {
100
+ if (entry.name === "node_modules" || entry.name === ".git") continue;
101
+ const fullPath = join(dir, entry.name);
102
+ if (entry.isDirectory()) {
103
+ collectFileDirs(fullPath);
104
+ } else {
105
+ dirHasFiles.set(dir, true);
106
+ }
107
+ }
108
+ }
109
+ collectFileDirs(root);
110
+
111
+ const dirs = [];
112
+ function collectAllDirs(dir) {
113
+ let entries;
114
+ try {
115
+ entries = readdirSync(dir, { withFileTypes: true });
116
+ } catch {
117
+ return;
118
+ }
119
+ for (const entry of entries) {
120
+ if (entry.name === "node_modules" || entry.name === ".git") continue;
121
+ const fullPath = join(dir, entry.name);
122
+ if (entry.isDirectory()) {
123
+ collectAllDirs(fullPath);
124
+ }
125
+ }
126
+ if (dirHasFiles.has(dir)) {
127
+ dirs.push(dir);
128
+ }
129
+ }
130
+ collectAllDirs(root);
131
+ return dirs;
132
+ }
133
+
134
+ function walkDir(dir, visitor) {
135
+ let entries;
136
+ try {
137
+ entries = readdirSync(dir, { withFileTypes: true });
138
+ } catch {
139
+ return;
140
+ }
141
+ for (const entry of entries) {
142
+ if (entry.name === "node_modules" || entry.name === ".git") continue;
143
+ const fullPath = join(dir, entry.name);
144
+ const action = visitor(fullPath, {
145
+ name: entry.name,
146
+ isFile: () => entry.isFile(),
147
+ isDirectory: () => entry.isDirectory(),
148
+ });
149
+ if (action === "prune") continue;
150
+ if (entry.isDirectory()) {
151
+ walkDir(fullPath, visitor);
152
+ }
153
+ }
154
+ }
155
+
156
+ // ---------------------------------------------------------------------------
157
+ // Module processing
158
+ // ---------------------------------------------------------------------------
159
+
160
+ function freezeModule(modDir, descriptorFileName, descriptorName, allModuleDirs) {
161
+ const modPath = join(modDir, descriptorFileName);
162
+ let raw;
163
+ try {
164
+ raw = readFileSync(modPath, "utf-8");
165
+ } catch {
166
+ return undefined;
167
+ }
168
+
169
+ const parsed = parseFrontmatter(raw);
170
+ const existingFrozen = normalizeFrozen(parsed.frontmatter.frozen);
171
+ const directFiles = listDirectFiles(modDir, descriptorName, allModuleDirs);
172
+ const mergedFrozen = mergePreservingOrder(existingFrozen, directFiles);
173
+
174
+ if (arraysEqual(existingFrozen, mergedFrozen)) return undefined;
175
+
176
+ const newContent = serializeModule(parsed, mergedFrozen);
177
+
178
+ return { path: modPath, content: newContent, files: mergedFrozen };
179
+ }
180
+
181
+ function createModuleDescriptor(dir, descriptorName) {
182
+ const directFiles = listDirectFiles(dir, descriptorName, new Map());
183
+ if (directFiles.length === 0) return undefined;
184
+
185
+ const content = serializeModule(
186
+ { prelude: "---\n", frontmatter: {}, body: "\n" },
187
+ directFiles,
188
+ );
189
+
190
+ const modPath = join(dir, descriptorName);
191
+
192
+ return { path: modPath, content, files: directFiles };
193
+ }
194
+
195
+ // ---------------------------------------------------------------------------
196
+ // Frontmatter parsing & serialization
197
+ // ---------------------------------------------------------------------------
198
+
199
+ function parseFrontmatter(raw) {
200
+ const trimmed = raw.trimStart();
201
+ if (!trimmed.startsWith("---")) {
202
+ return { prelude: "", frontmatter: {}, body: raw };
203
+ }
204
+
205
+ const endIdx = trimmed.indexOf("\n---", 3);
206
+ const actualEnd = endIdx === -1 ? trimmed.indexOf("---", 3) : endIdx;
207
+
208
+ if (actualEnd === -1) {
209
+ return { prelude: "", frontmatter: {}, body: raw };
210
+ }
211
+
212
+ const prelude = "---\n";
213
+ const fmBlock = trimmed.slice(3, actualEnd).trim();
214
+ const body = trimmed.slice(actualEnd + (trimmed[actualEnd] === "\n" ? 4 : 3));
215
+
216
+ const frontmatter = parseYamlBlock(fmBlock);
217
+
218
+ return { prelude, frontmatter, body };
219
+ }
220
+
221
+ function parseYamlBlock(block) {
222
+ const result = {};
223
+ if (block.length === 0) return result;
224
+
225
+ const lines = block.split("\n");
226
+ let currentKey = null;
227
+ let currentList = [];
228
+
229
+ for (const line of lines) {
230
+ const trimmed = line.trim();
231
+ if (trimmed.length === 0) continue;
232
+
233
+ const listMatch = trimmed.match(/^\s*-\s+(.+)$/);
234
+ if (listMatch && currentKey) {
235
+ currentList.push(listMatch[1].trim());
236
+ continue;
237
+ }
238
+
239
+ if (currentKey) {
240
+ result[currentKey] = currentList;
241
+ currentKey = null;
242
+ currentList = [];
243
+ }
244
+
245
+ const kvMatch = trimmed.match(/^(\w[\w-]*)\s*:\s*(.*)$/);
246
+ if (kvMatch) {
247
+ const key = kvMatch[1];
248
+ const value = kvMatch[2].trim();
249
+
250
+ if (value === "" || value === "[]") {
251
+ currentKey = key;
252
+ currentList = [];
253
+ } else if (value.startsWith("[") && value.endsWith("]")) {
254
+ result[key] = parseInlineList(value);
255
+ } else {
256
+ result[key] = value;
257
+ }
258
+ }
259
+ }
260
+
261
+ if (currentKey) {
262
+ result[currentKey] = currentList;
263
+ }
264
+
265
+ return result;
266
+ }
267
+
268
+ function parseInlineList(raw) {
269
+ const inner = raw.slice(1, -1).trim();
270
+ if (inner.length === 0) return [];
271
+ return inner.split(",").map((s) => s.trim().replace(/^['"]|['"]$/g, ""));
272
+ }
273
+
274
+ function serializeModule(parsed, frozen) {
275
+ const fm = { ...parsed.frontmatter, frozen };
276
+
277
+ let yaml = parsed.prelude;
278
+ for (const [key, value] of Object.entries(fm)) {
279
+ if (Array.isArray(value)) {
280
+ yaml += `${key}:\n`;
281
+ for (const item of value) {
282
+ yaml += ` - ${item}\n`;
283
+ }
284
+ } else if (typeof value === "string") {
285
+ yaml += `${key}: ${value}\n`;
286
+ }
287
+ }
288
+ yaml += "---\n";
289
+ yaml += parsed.body;
290
+
291
+ if (!parsed.body.endsWith("\n")) {
292
+ yaml += "\n";
293
+ }
294
+
295
+ return yaml;
296
+ }
297
+
298
+ // ---------------------------------------------------------------------------
299
+ // File listing
300
+ // ---------------------------------------------------------------------------
301
+
302
+ function listDirectFiles(modDir, descriptorName, allModuleDirs) {
303
+ const lowerDesc = descriptorName.toLowerCase();
304
+ const files = [];
305
+
306
+ let entries;
307
+ try {
308
+ entries = readdirSync(modDir, { withFileTypes: true });
309
+ } catch {
310
+ return [];
311
+ }
312
+
313
+ for (const entry of entries) {
314
+ if (entry.name === "node_modules" || entry.name === ".git") continue;
315
+
316
+ if (entry.isDirectory()) {
317
+ const subDir = join(modDir, entry.name);
318
+ if (allModuleDirs.has(subDir)) continue;
319
+ // Skip directory entries entirely — sub-modules handle their own
320
+ // Non-module subdirs are skipped too to avoid granularity issues
321
+ } else {
322
+ if (entry.name.toLowerCase() === lowerDesc) continue;
323
+ files.push(entry.name);
324
+ }
325
+ }
326
+
327
+ return files;
328
+ }
329
+
330
+ // ---------------------------------------------------------------------------
331
+ // Helpers
332
+ // ---------------------------------------------------------------------------
333
+
334
+ function normalizeFrozen(value) {
335
+ if (Array.isArray(value)) return value.map(String);
336
+ return [];
337
+ }
338
+
339
+ function mergePreservingOrder(existing, newFiles) {
340
+ const result = [...existing];
341
+ for (const f of newFiles) {
342
+ if (!existing.includes(f)) result.push(f);
343
+ }
344
+ return result;
345
+ }
346
+
347
+ function arraysEqual(a, b) {
348
+ if (a.length !== b.length) return false;
349
+ const sortedA = [...a].sort();
350
+ const sortedB = [...b].sort();
351
+ return sortedA.every((v, i) => v === sortedB[i]);
352
+ }
353
+
354
+ main();
package/src/MODULE.md ADDED
@@ -0,0 +1,6 @@
1
+ ---
2
+ frozen:
3
+ - config.ts
4
+ - index.ts
5
+ ---
6
+
@@ -0,0 +1,5 @@
1
+ ---
2
+ frozen:
3
+ - system-prompt.ts
4
+ ---
5
+
@@ -4,16 +4,21 @@ export function buildSystemPromptHint(
4
4
  index: ModuleIndex,
5
5
  systemPrompt: string,
6
6
  descriptorFileName: string,
7
+ moduleDescriptorReadonly: boolean,
7
8
  ): string {
8
9
  if (index.contracts.length === 0) return systemPrompt;
9
10
 
11
+ const descriptorNote = moduleDescriptorReadonly
12
+ ? ` The \`${descriptorFileName}\` file itself is readonly.`
13
+ : "";
14
+
10
15
  return systemPrompt + `
11
16
 
12
17
  ## Module gates (boundary enforcement)
13
- This project uses \`${descriptorFileName}\`(case-insensitive) files to declare visibility and readonly rules that you should follow.
18
+ This project uses \`${descriptorFileName}\`(case-insensitive) files to declare visibility, readonly and frozen rules that you should follow.
14
19
  If you cannot comply, reconsider your design, if impossible, raise to the user with tradeoffs.
15
20
  Each \`${descriptorFileName}\` gates its branching point in the tree.
16
21
  A \`${descriptorFileName}\` with a \`visible\` list means only entries in the list are allowed to be visible outside the module.
17
- A \`${descriptorFileName}\` and its mentioned \`readonly\` files are readonly.
22
+ \`readonly\` files are readonly; \`frozen\` files cannot grow their surface size (no new exports).${descriptorNote}
18
23
  Violations will be blocked.`;
19
24
  }
@@ -0,0 +1,7 @@
1
+ ---
2
+ frozen:
3
+ - export-gate.ts
4
+ - frozen-gate.ts
5
+ - readonly-gate.ts
6
+ ---
7
+
@@ -0,0 +1,8 @@
1
+ ---
2
+ frozen:
3
+ - index.ts
4
+ - registry.ts
5
+ - rust.ts
6
+ - typescript.ts
7
+ ---
8
+
@@ -0,0 +1,42 @@
1
+ import * as path from "node:path";
2
+ import type { ModuleIndex } from "../types.ts";
3
+ import { getAncestorContracts, matchesPattern } from "../utils.ts";
4
+ import { getChecker } from "./checkers/registry.ts";
5
+
6
+ export type FrozenCheckResult =
7
+ | { blocked: true; reason: string }
8
+ | { blocked: false };
9
+
10
+ export function checkFrozen(
11
+ filePath: string,
12
+ beforeContent: string,
13
+ afterContent: string,
14
+ index: ModuleIndex,
15
+ cwd: string,
16
+ descriptorFileName: string,
17
+ ): FrozenCheckResult {
18
+ const absFile = path.resolve(cwd, filePath);
19
+
20
+ const checker = getChecker(absFile);
21
+ if (!checker) return { blocked: false };
22
+
23
+ const ancestors = getAncestorContracts(absFile, index);
24
+
25
+ for (const contract of ancestors) {
26
+ for (const pattern of contract.frozen) {
27
+ if (matchesPattern(absFile, pattern, contract.modulePath)) {
28
+ const newExports = checker.getNewExports(beforeContent, afterContent);
29
+ if (newExports.length === 0) return { blocked: false };
30
+
31
+ const relModuleMd = path.relative(cwd, path.join(contract.modulePath, descriptorFileName));
32
+ const names = newExports.map((s) => s.name).join(", ");
33
+ return {
34
+ blocked: true,
35
+ reason: `Frozen rule: file is frozen in ${relModuleMd}. Cannot add new exports: ${names}`,
36
+ };
37
+ }
38
+ }
39
+ }
40
+
41
+ return { blocked: false };
42
+ }
@@ -1,5 +1,6 @@
1
1
  import * as path from "node:path";
2
2
  import type { ModuleIndex } from "../types.ts";
3
+ import { getAncestorContracts, matchesPattern } from "../utils.ts";
3
4
 
4
5
  export type ReadonlyCheckResult =
5
6
  | { blocked: true; reason: string }
@@ -16,7 +17,7 @@ export function checkReadonly(
16
17
 
17
18
  for (const contract of ancestors) {
18
19
  for (const pattern of contract.readonly) {
19
- if (matchesReadonlyPattern(absFile, pattern, contract.modulePath)) {
20
+ if (matchesPattern(absFile, pattern, contract.modulePath)) {
20
21
  const relModuleMd = path.relative(cwd, path.join(contract.modulePath, descriptorFileName));
21
22
  return {
22
23
  blocked: true,
@@ -28,26 +29,3 @@ export function checkReadonly(
28
29
 
29
30
  return { blocked: false };
30
31
  }
31
-
32
- function getAncestorContracts(absFile: string, index: ModuleIndex) {
33
- return index.contracts.filter((c) => absFile.startsWith(c.modulePath + path.sep) || absFile === c.modulePath);
34
- }
35
-
36
- function matchesReadonlyPattern(
37
- absFile: string,
38
- pattern: string,
39
- modulePath: string,
40
- ): boolean {
41
- const resolved = path.resolve(modulePath, pattern);
42
-
43
- if (pattern.endsWith("*")) {
44
- const prefix = path.resolve(modulePath, pattern.slice(0, -1));
45
- return absFile.startsWith(prefix);
46
- }
47
-
48
- if (absFile === resolved) return true;
49
-
50
- if (absFile.startsWith(resolved + path.sep)) return true;
51
-
52
- return false;
53
- }
@@ -1,4 +1,8 @@
1
1
  ---
2
- visible: [buildModuleIndex]
2
+ frozen:
3
+ - frontmatter-parser.ts
4
+ - module-index-builder.ts
5
+ - validation.ts
3
6
  ---
4
7
 
8
+
@@ -5,6 +5,7 @@ export type VisibleEntryRaw = string | { path: string; modifier?: string };
5
5
  export type ModuleFrontmatter = {
6
6
  visible?: VisibleEntryRaw[];
7
7
  readonly?: string[];
8
+ frozen?: string[];
8
9
  };
9
10
 
10
11
  export function parseVisibleEntry(raw: VisibleEntryRaw): Signature {
@@ -68,6 +68,7 @@ function buildContracts(
68
68
  ? frontmatter.visible.map(parseVisibleEntry)
69
69
  : null,
70
70
  readonly: readonlyEntries,
71
+ frozen: frontmatter.frozen ?? [],
71
72
  prose: body.trim(),
72
73
  });
73
74
  }
package/src/index.ts CHANGED
@@ -12,6 +12,7 @@ import { buildModuleIndex } from "./graph/module-index-builder.ts";
12
12
  import { findOwningModule, readFileSafe, applyEdits, isWithinSourceRoot } from "./utils.ts";
13
13
  import { checkReadonly } from "./gates/readonly-gate.ts";
14
14
  import { checkExports } from "./gates/export-gate.ts";
15
+ import { checkFrozen } from "./gates/frozen-gate.ts";
15
16
  import { buildSystemPromptHint } from "./context/system-prompt.ts";
16
17
  import "./gates/checkers/index.ts";
17
18
 
@@ -33,7 +34,7 @@ export default function (pi: ExtensionAPI): void {
33
34
  pi.on("before_agent_start", async (event): Promise<BeforeAgentStartEventResult | void> => {
34
35
  if (index.contracts.length === 0) return;
35
36
  return {
36
- systemPrompt: buildSystemPromptHint(index, event.systemPrompt, config.moduleDescriptorFileName),
37
+ systemPrompt: buildSystemPromptHint(index, event.systemPrompt, config.moduleDescriptorFileName, config.moduleDescriptorReadonly),
37
38
  };
38
39
  });
39
40
 
@@ -42,7 +43,9 @@ export default function (pi: ExtensionAPI): void {
42
43
  return handleEdit(event.input.path, event.input.edits, ctx.cwd, index, config);
43
44
  }
44
45
  if (isToolCallEventType("write", event)) {
45
- return handleWrite(event.input.path, event.input.content, ctx.cwd, index, config);
46
+ const absPath = path.resolve(ctx.cwd, event.input.path);
47
+ const before = readFileSafe(absPath);
48
+ return handleEdit(event.input.path, [{ oldText: before, newText: event.input.content }], ctx.cwd, index, config);
46
49
  }
47
50
  });
48
51
  }
@@ -55,46 +58,24 @@ function handleEdit(
55
58
  config: ModuleGateConfig,
56
59
  ): ToolCallEventResult | undefined {
57
60
  const absPath = path.resolve(cwd, filePath);
58
- const resolvedRoot = path.resolve(cwd, config.sourceRoot);
59
-
60
- if (!isWithinSourceRoot(absPath, resolvedRoot)) return undefined;
61
-
62
- const readonlyResult = checkReadonly(filePath, index, cwd, config.moduleDescriptorFileName);
63
- if (readonlyResult.blocked) {
64
- return { block: true, reason: formatDenial(filePath, readonlyResult.reason, absPath, index, cwd, config.moduleDescriptorFileName) };
65
- }
66
61
 
67
62
  const before = readFileSafe(absPath);
68
63
  const after = applyEdits(before, edits);
64
+ const srcRoot = path.resolve(cwd, config.sourceRoot);
69
65
 
70
- const exportResult = checkExports(filePath, before, after, index, cwd, config.moduleDescriptorFileName);
71
- if (exportResult.blocked) {
72
- return { block: true, reason: formatDenial(filePath, exportResult.reason, absPath, index, cwd, config.moduleDescriptorFileName) };
73
- }
74
-
75
- return undefined;
76
- }
77
-
78
- function handleWrite(
79
- filePath: string,
80
- content: string,
81
- cwd: string,
82
- index: ModuleIndex,
83
- config: ModuleGateConfig,
84
- ): ToolCallEventResult | undefined {
85
- const absPath = path.resolve(cwd, filePath);
86
- const resolvedRoot = path.resolve(cwd, config.sourceRoot);
87
-
88
- if (!isWithinSourceRoot(absPath, resolvedRoot)) return undefined;
66
+ if (!isWithinSourceRoot(absPath, srcRoot)) return undefined;
89
67
 
90
68
  const readonlyResult = checkReadonly(filePath, index, cwd, config.moduleDescriptorFileName);
91
69
  if (readonlyResult.blocked) {
92
70
  return { block: true, reason: formatDenial(filePath, readonlyResult.reason, absPath, index, cwd, config.moduleDescriptorFileName) };
93
71
  }
94
72
 
95
- const before = readFileSafe(absPath);
73
+ const frozenResult = checkFrozen(filePath, before, after, index, cwd, config.moduleDescriptorFileName);
74
+ if (frozenResult.blocked) {
75
+ return { block: true, reason: formatDenial(filePath, frozenResult.reason, absPath, index, cwd, config.moduleDescriptorFileName) };
76
+ }
96
77
 
97
- const exportResult = checkExports(filePath, before, content, index, cwd, config.moduleDescriptorFileName);
78
+ const exportResult = checkExports(filePath, before, after, index, cwd, config.moduleDescriptorFileName);
98
79
  if (exportResult.blocked) {
99
80
  return { block: true, reason: formatDenial(filePath, exportResult.reason, absPath, index, cwd, config.moduleDescriptorFileName) };
100
81
  }
package/src/types.ts CHANGED
@@ -8,6 +8,7 @@ export type ModuleContract = {
8
8
  modulePath: string;
9
9
  visible: Signature[] | null;
10
10
  readonly: string[];
11
+ frozen: string[];
11
12
  prose: string;
12
13
  };
13
14
 
package/src/utils.ts CHANGED
@@ -38,3 +38,31 @@ export function applyEdits(content: string, edits: { oldText: string; newText: s
38
38
  export function isWithinSourceRoot(absPath: string, resolvedRoot: string): boolean {
39
39
  return absPath.startsWith(resolvedRoot + path.sep) || absPath === resolvedRoot;
40
40
  }
41
+
42
+ export function getAncestorContracts(
43
+ absFile: string,
44
+ index: ModuleIndex,
45
+ ): { modulePath: string; readonly: string[]; frozen: string[] }[] {
46
+ return index.contracts
47
+ .filter((c) => absFile.startsWith(c.modulePath + path.sep) || absFile === c.modulePath)
48
+ .map(({ modulePath, readonly, frozen }) => ({ modulePath, readonly, frozen }));
49
+ }
50
+
51
+ export function matchesPattern(
52
+ absFile: string,
53
+ pattern: string,
54
+ modulePath: string,
55
+ ): boolean {
56
+ const resolved = path.resolve(modulePath, pattern);
57
+
58
+ if (pattern.endsWith("*")) {
59
+ const prefix = path.resolve(modulePath, pattern.slice(0, -1));
60
+ return absFile.startsWith(prefix);
61
+ }
62
+
63
+ if (absFile === resolved) return true;
64
+
65
+ if (absFile.startsWith(resolved + path.sep)) return true;
66
+
67
+ return false;
68
+ }