@cuzfrog/pi-module-gates 0.6.0 → 0.10.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).
@@ -23,8 +11,9 @@ AI coding agents produce edits with limited context knowledge (myopia) — their
23
11
 
24
12
  **Module contracts as guardrails.** Each directory can contain a descriptor file that declares:
25
13
 
26
- - `visible` — the set of exports allowed to be added or modified in that module
27
14
  - `readonly` — files and directories the agent must not touch
15
+ - `frozen` — files where no new exports are allowed
16
+ - `visible` — the set of exports allowed to be added or modified in that module
28
17
 
29
18
  The extension intercepts agent `write`/`edit` operations and enforces these contracts. Violations are blocked with a clear reason.
30
19
 
@@ -34,26 +23,27 @@ The extension intercepts agent `write`/`edit` operations and enforces these cont
34
23
  2. **System prompt** — Injects a hint so the agent knows to respect descriptor file conventions.
35
24
  3. **Gating** — On every write/edit, checks:
36
25
  - **Readonly gate** — is the target file locked?
26
+ **Fronzen gate** — is there any surface change to the target file?
37
27
  - **Export gate** — would the change introduce an export not in the `visible` list?
38
28
  - **Import gate** (not implemented yet) — would the change introduce an import violating visibility scope?
39
29
 
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.
30
+ - System prompt: [system-prompt.md](src/context/system-prompt.ts)
31
+ - Currently [supported languages](src/gates/checkers/index.ts): **TypeScript/JavaScript**, **Rust**, **Java**, **Go**, **Kotlin**, **Scala**
32
+
33
+ ## Installation
34
+ ```bash
35
+ pi install npm:@cuzfrog/pi-module-gates
36
+ ```
37
+ Or load directly for a single session:
38
+ ```bash
39
+ pi -e npm:@cuzfrog/pi-module-gates
49
40
  ```
50
- `module.md` filename is configurable.
51
41
 
52
42
  ## Module Descriptor Semantics
53
43
 
54
44
  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
45
 
56
- ### Simple readonly constraints
46
+ ### Readonly constraints
57
47
 
58
48
  ```markdown
59
49
  ---
@@ -63,7 +53,16 @@ readonly: [mod.rs]
63
53
  Any prose for the agent to better understand the module.
64
54
  ```
65
55
 
66
- ### Visibility whitelist
56
+ ### Frozen constraints
57
+
58
+ ```yaml
59
+ frozen: [mod.rs]
60
+ ```
61
+ Frozen files cannot change their surface size: no new exports or public entries are allowed.
62
+
63
+ A skill [module-freeze-all](src/skills/module-freeze-all) has been included to auto-freeze modules.
64
+
65
+ ### Visibility whitelist (under redesign)
67
66
 
68
67
  ```yaml
69
68
  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.10.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
+
package/src/config.ts CHANGED
@@ -3,19 +3,19 @@ import * as path from "node:path";
3
3
 
4
4
  export type ModuleGateConfig = {
5
5
  moduleDescriptorFileName: string;
6
- moduleDescriptorReadonly: boolean;
6
+ moduleDescriptorReadonly: "file" | "frontmatter" | "off";
7
7
  sourceRoot: string;
8
8
  };
9
9
 
10
10
  const DEFAULTS: ModuleGateConfig = {
11
11
  moduleDescriptorFileName: "module.md",
12
- moduleDescriptorReadonly: true,
12
+ moduleDescriptorReadonly: "file",
13
13
  sourceRoot: "src/",
14
14
  };
15
15
 
16
16
  export function loadConfig(cwd: string): ModuleGateConfig {
17
17
  const settingsPath = path.join(cwd, ".pi", "settings.json");
18
- let userConfig: Partial<ModuleGateConfig> = {};
18
+ let userConfig: Partial<Omit<ModuleGateConfig, "moduleDescriptorReadonly"> & { moduleDescriptorReadonly?: ModuleGateConfig["moduleDescriptorReadonly"] | boolean }> = {};
19
19
  try {
20
20
  const raw = fs.readFileSync(settingsPath, "utf-8");
21
21
  const settings = JSON.parse(raw);
@@ -25,5 +25,13 @@ export function loadConfig(cwd: string): ModuleGateConfig {
25
25
  } catch {
26
26
  // file doesn't exist or invalid — use defaults
27
27
  }
28
- return { ...DEFAULTS, ...userConfig };
28
+ const merged = { ...DEFAULTS, ...userConfig };
29
+ merged.moduleDescriptorReadonly = normalizeReadonly(merged.moduleDescriptorReadonly);
30
+ return merged as ModuleGateConfig;
31
+ }
32
+
33
+ function normalizeReadonly(value: ModuleGateConfig["moduleDescriptorReadonly"] | boolean): ModuleGateConfig["moduleDescriptorReadonly"] {
34
+ if (value === true || value === "file") return "file";
35
+ if (value === false || value === "off") return "off";
36
+ return value;
29
37
  }
@@ -0,0 +1,5 @@
1
+ ---
2
+ frozen:
3
+ - system-prompt.ts
4
+ ---
5
+
@@ -1,19 +1,28 @@
1
1
  import type { ModuleIndex } from "../types.ts";
2
+ import type { ModuleGateConfig } from "../config.ts";
2
3
 
3
4
  export function buildSystemPromptHint(
4
5
  index: ModuleIndex,
5
6
  systemPrompt: string,
6
7
  descriptorFileName: string,
8
+ moduleDescriptorReadonly: ModuleGateConfig["moduleDescriptorReadonly"],
7
9
  ): string {
8
10
  if (index.contracts.length === 0) return systemPrompt;
9
11
 
12
+ const descriptorNote =
13
+ moduleDescriptorReadonly === "frontmatter"
14
+ ? ` The frontmatter of \`${descriptorFileName}\` is readonly.`
15
+ : moduleDescriptorReadonly === "file"
16
+ ? ` The \`${descriptorFileName}\` file itself is readonly.`
17
+ : "";
18
+
10
19
  return systemPrompt + `
11
20
 
12
21
  ## Module gates (boundary enforcement)
13
- This project uses \`${descriptorFileName}\`(case-insensitive) files to declare visibility and readonly rules that you should follow.
22
+ This project uses \`${descriptorFileName}\`(case-insensitive) files to declare visibility, readonly and frozen rules that you should follow.
14
23
  If you cannot comply, reconsider your design, if impossible, raise to the user with tradeoffs.
15
24
  Each \`${descriptorFileName}\` gates its branching point in the tree.
16
25
  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.
26
+ \`readonly\` files are readonly; \`frozen\` files cannot grow their surface size (no new exports).${descriptorNote}
18
27
  Violations will be blocked.`;
19
28
  }
@@ -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,19 @@
1
+ import { registerChecker } from "./registry.ts";
2
+ import type { ExportChecker } from "./registry.ts";
3
+ import type { Signature } from "../../types.ts";
4
+
5
+ const goChecker: ExportChecker = {
6
+ extensions: [".go"],
7
+ getNewExports(before: string, after: string): Signature[] {
8
+ const beforeNames = new Set(extractExports(before).map((s) => s.name));
9
+ return extractExports(after).filter((sig) => !beforeNames.has(sig.name));
10
+ },
11
+ };
12
+
13
+ registerChecker(goChecker);
14
+
15
+ function extractExports(src: string): Signature[] {
16
+ return [...src.matchAll(
17
+ /^(?:func\s+(?!\()|type\s+|var\s+|const\s+)([\p{Lu}]\w*)/gmu,
18
+ )].map((m) => ({ name: m[1] }));
19
+ }
@@ -1,2 +1,6 @@
1
1
  import "./typescript.ts";
2
2
  import "./rust.ts";
3
+ import "./java.ts";
4
+ import "./go.ts";
5
+ import "./kotlin.ts";
6
+ import "./scala.ts";
@@ -0,0 +1,19 @@
1
+ import { registerChecker } from "./registry.ts";
2
+ import type { ExportChecker } from "./registry.ts";
3
+ import type { Signature } from "../../types.ts";
4
+
5
+ const javaChecker: ExportChecker = {
6
+ extensions: [".java"],
7
+ getNewExports(before: string, after: string): Signature[] {
8
+ const beforeNames = new Set(extractExports(before).map((s) => s.name));
9
+ return extractExports(after).filter((sig) => !beforeNames.has(sig.name));
10
+ },
11
+ };
12
+
13
+ registerChecker(javaChecker);
14
+
15
+ function extractExports(src: string): Signature[] {
16
+ return [...src.matchAll(
17
+ /^public\s+(?:class|interface|enum|@interface|record)\s+(\w+)/gm,
18
+ )].map((m) => ({ modifier: "public", name: m[1] }));
19
+ }
@@ -0,0 +1,26 @@
1
+ import { registerChecker } from "./registry.ts";
2
+ import type { ExportChecker } from "./registry.ts";
3
+ import type { Signature } from "../../types.ts";
4
+
5
+ const kotlinChecker: ExportChecker = {
6
+ extensions: [".kt", ".kts"],
7
+ getNewExports(before: string, after: string): Signature[] {
8
+ const beforeNames = new Set(extractExports(before).map((s) => s.name));
9
+ return extractExports(after).filter((sig) => !beforeNames.has(sig.name));
10
+ },
11
+ };
12
+
13
+ registerChecker(kotlinChecker);
14
+
15
+ function extractExports(src: string): Signature[] {
16
+ const re = /^(?:(public|internal|protected|private)\s+)?(?:(?:data|sealed|enum|abstract|open)\s+)?(?:class|interface|object|fun|val|var|typealias)\s+(\w+)/gm;
17
+ const results: Signature[] = [];
18
+ for (const m of src.matchAll(re)) {
19
+ if (m[1] === "private") continue;
20
+ results.push({
21
+ modifier: m[1] || undefined,
22
+ name: m[2],
23
+ });
24
+ }
25
+ return results;
26
+ }
@@ -0,0 +1,26 @@
1
+ import { registerChecker } from "./registry.ts";
2
+ import type { ExportChecker } from "./registry.ts";
3
+ import type { Signature } from "../../types.ts";
4
+
5
+ const scalaChecker: ExportChecker = {
6
+ extensions: [".scala", ".sc"],
7
+ getNewExports(before: string, after: string): Signature[] {
8
+ const beforeNames = new Set(extractExports(before).map((s) => s.name));
9
+ return extractExports(after).filter((sig) => !beforeNames.has(sig.name));
10
+ },
11
+ };
12
+
13
+ registerChecker(scalaChecker);
14
+
15
+ function extractExports(src: string): Signature[] {
16
+ const re = /^(?:(private(?:\[[^\]]*\])?|protected(?:\[[^\]]*\])?)\s+)?(?:class|object|trait|def|val|var|type|given|extension)\s+(\w+)/gm;
17
+ const results: Signature[] = [];
18
+ for (const m of src.matchAll(re)) {
19
+ if (m[1] === "private" || m[1] === "protected") continue;
20
+ results.push({
21
+ modifier: m[1] || undefined,
22
+ name: m[2],
23
+ });
24
+ }
25
+ return results;
26
+ }
@@ -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 {
@@ -4,6 +4,8 @@ import { readdir } from "node:fs/promises";
4
4
  import { parseFrontmatter } from "@earendil-works/pi-coding-agent";
5
5
  import type { ModuleContract, ModuleIndex } from "../types.ts";
6
6
  import type { ModuleGateConfig } from "../config.ts";
7
+
8
+ type ModuleDescriptorReadonly = ModuleGateConfig["moduleDescriptorReadonly"];
7
9
  import type { Dirent } from "node:fs";
8
10
  import { validateVisibleEntries } from "./validation.ts";
9
11
  import { parseVisibleEntry, type VisibleEntryRaw, type ModuleFrontmatter } from "./frontmatter-parser.ts";
@@ -35,7 +37,7 @@ function buildContracts(
35
37
  moduleFiles: string[],
36
38
  onInfo: (message: string) => void,
37
39
  descriptorFileName: string,
38
- moduleDescriptorReadonly: boolean,
40
+ moduleDescriptorReadonly: ModuleDescriptorReadonly,
39
41
  ): ModuleContract[] {
40
42
  const contracts: ModuleContract[] = [];
41
43
 
@@ -57,7 +59,7 @@ function buildContracts(
57
59
  }
58
60
 
59
61
  const readonlyEntries = frontmatter.readonly ?? [];
60
- if (moduleDescriptorReadonly) {
62
+ if (moduleDescriptorReadonly !== "off") {
61
63
  readonlyEntries.push(descriptorFileName);
62
64
  }
63
65
 
@@ -68,6 +70,7 @@ function buildContracts(
68
70
  ? frontmatter.visible.map(parseVisibleEntry)
69
71
  : null,
70
72
  readonly: readonlyEntries,
73
+ frozen: frontmatter.frozen ?? [],
71
74
  prose: body.trim(),
72
75
  });
73
76
  }
package/src/index.ts CHANGED
@@ -4,7 +4,7 @@ import type {
4
4
  ToolCallEventResult,
5
5
  BeforeAgentStartEventResult,
6
6
  } from "@earendil-works/pi-coding-agent";
7
- import { isToolCallEventType } from "@earendil-works/pi-coding-agent";
7
+ import { isToolCallEventType, parseFrontmatter } 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";
@@ -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,45 @@ 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) {
70
+ if (
71
+ config.moduleDescriptorReadonly === "frontmatter" &&
72
+ isDescriptorFile(absPath, config.moduleDescriptorFileName)
73
+ ) {
74
+ const fmBefore = extractFrontmatter(before);
75
+ const fmAfter = extractFrontmatter(after);
76
+ if (JSON.stringify(fmBefore) === JSON.stringify(fmAfter)) {
77
+ return undefined;
78
+ }
79
+ return {
80
+ block: true,
81
+ reason: formatDenial(
82
+ filePath,
83
+ `Readonly rule: frontmatter of ${config.moduleDescriptorFileName} is readonly`,
84
+ absPath,
85
+ index,
86
+ cwd,
87
+ config.moduleDescriptorFileName,
88
+ ),
89
+ };
90
+ }
92
91
  return { block: true, reason: formatDenial(filePath, readonlyResult.reason, absPath, index, cwd, config.moduleDescriptorFileName) };
93
92
  }
94
93
 
95
- const before = readFileSafe(absPath);
94
+ const frozenResult = checkFrozen(filePath, before, after, index, cwd, config.moduleDescriptorFileName);
95
+ if (frozenResult.blocked) {
96
+ return { block: true, reason: formatDenial(filePath, frozenResult.reason, absPath, index, cwd, config.moduleDescriptorFileName) };
97
+ }
96
98
 
97
- const exportResult = checkExports(filePath, before, content, index, cwd, config.moduleDescriptorFileName);
99
+ const exportResult = checkExports(filePath, before, after, index, cwd, config.moduleDescriptorFileName);
98
100
  if (exportResult.blocked) {
99
101
  return { block: true, reason: formatDenial(filePath, exportResult.reason, absPath, index, cwd, config.moduleDescriptorFileName) };
100
102
  }
@@ -124,3 +126,16 @@ function formatDenial(
124
126
 
125
127
  return message;
126
128
  }
129
+
130
+ function isDescriptorFile(absPath: string, descriptorFileName: string): boolean {
131
+ const basename = path.basename(absPath);
132
+ return basename.toLowerCase() === descriptorFileName.toLowerCase();
133
+ }
134
+
135
+ function extractFrontmatter(content: string): Record<string, unknown> {
136
+ try {
137
+ return parseFrontmatter(content).frontmatter;
138
+ } catch {
139
+ return {};
140
+ }
141
+ }
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
+ }