@cuzfrog/pi-module-gates 0.17.0 → 0.17.1
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 +6 -6
- package/package.json +1 -1
- package/skills/{module-seal-all → module-freeze-all}/SKILL.md +7 -7
- package/skills/module-freeze-all/scripts/freeze-all.d.mts +1 -0
- package/skills/{module-seal-all/scripts/seal-all.mjs → module-freeze-all/scripts/freeze-all.mjs} +14 -14
- package/src/MODULE.md +3 -2
- package/src/context/MODULE.md +3 -2
- package/src/context/system-prompt.template.md +4 -4
- package/src/gates/MODULE.md +4 -3
- package/src/gates/checkers/MODULE.md +3 -2
- package/src/gates/{sealed-gate.ts → frozen-gate.ts} +6 -6
- package/src/gates/index.ts +2 -2
- package/src/gates/run-gates.ts +4 -4
- package/src/graph/MODULE.md +4 -2
- package/src/graph/frontmatter-parser.ts +1 -1
- package/src/graph/module-index-builder.ts +1 -1
- package/src/types.ts +1 -1
- package/src/utils.ts +2 -2
- package/skills/module-seal-all/scripts/seal-all.d.mts +0 -1
package/README.md
CHANGED
|
@@ -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
|
+
- `frozen` — files where no new exports are allowed
|
|
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
|
+
**Fronzen gate** — is there any surface change to the target file?
|
|
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
|
+
### Frozen constraints
|
|
61
61
|
|
|
62
62
|
```yaml
|
|
63
|
-
|
|
63
|
+
frozen: [mod.rs]
|
|
64
64
|
```
|
|
65
|
-
|
|
65
|
+
Frozen files cannot change their surface size: no new exports or public entries are allowed.
|
|
66
66
|
|
|
67
|
-
A skill [module-
|
|
67
|
+
A skill [module-freeze-all](src/skills/module-freeze-all) has been included to auto-freeze modules.
|
|
68
68
|
|
|
69
69
|
### Visibility whitelist (under redesign)
|
|
70
70
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
|
-
name: module-
|
|
3
|
-
description: Auto-
|
|
2
|
+
name: module-freeze-all
|
|
3
|
+
description: Auto-freeze 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 `frozen` 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-freeze-all/scripts/freeze-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 `frozen` 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 `frozen` frontmatter field
|
|
31
|
+
4. Preserves existing `frozen` 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-seal-all/scripts/seal-all.mjs → module-freeze-all/scripts/freeze-all.mjs}
RENAMED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* Auto-
|
|
3
|
+
* Auto-freeze 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 `frozen` 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 = freezeModule(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}: freeze [${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 freezeModule(modDir, descriptorFileName, descriptorName, allModuleDirs) {
|
|
173
173
|
const modPath = join(modDir, descriptorFileName);
|
|
174
174
|
let raw;
|
|
175
175
|
try {
|
|
@@ -179,15 +179,15 @@ function sealModule(modDir, descriptorFileName, descriptorName, allModuleDirs) {
|
|
|
179
179
|
}
|
|
180
180
|
|
|
181
181
|
const parsed = parseFrontmatter(raw);
|
|
182
|
-
const
|
|
182
|
+
const existingFrozen = normalizeFrozen(parsed.frontmatter.frozen);
|
|
183
183
|
const directFiles = listDirectFiles(modDir, descriptorName, allModuleDirs);
|
|
184
|
-
const
|
|
184
|
+
const mergedFrozen = mergePreservingOrder(existingFrozen, directFiles);
|
|
185
185
|
|
|
186
|
-
if (arraysEqual(
|
|
186
|
+
if (arraysEqual(existingFrozen, mergedFrozen)) return undefined;
|
|
187
187
|
|
|
188
|
-
const newContent = serializeModule(parsed,
|
|
188
|
+
const newContent = serializeModule(parsed, mergedFrozen);
|
|
189
189
|
|
|
190
|
-
return { path: modPath, content: newContent, files:
|
|
190
|
+
return { path: modPath, content: newContent, files: mergedFrozen };
|
|
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, frozen) {
|
|
287
|
+
const fm = { ...parsed.frontmatter, frozen };
|
|
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 normalizeFrozen(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
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 frozen 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.
|
|
@@ -9,9 +9,9 @@ A `{{descriptorFileName}}` with a `visible` list means only entries in the list
|
|
|
9
9
|
{{#if moduleInterfaceImportGate}}- {{moduleInterfaceImportGate}}{{/if}}
|
|
10
10
|
|
|
11
11
|
### Glossary
|
|
12
|
-
- `module`: a directory containing code;
|
|
13
|
-
- `external files`: files not in the module directory;
|
|
12
|
+
- `module`: a directory containing code, all files in its recursive subdirectories are internal files of the module;
|
|
13
|
+
- `external files`: files not in the module directory and subdirectories;
|
|
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
|
+
- `frozen`: files cannot add new exports, but still editable;
|
|
17
17
|
- `visible`: visible from outside the module; files not in the module directory are outside the module;
|
package/src/gates/MODULE.md
CHANGED
|
@@ -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 FrozenCheckResult =
|
|
7
7
|
| { blocked: true; reason: string }
|
|
8
8
|
| { blocked: false };
|
|
9
9
|
|
|
10
|
-
export function
|
|
10
|
+
export function checkFrozen(
|
|
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
|
+
): FrozenCheckResult {
|
|
18
18
|
const absFile = path.resolve(cwd, filePath);
|
|
19
19
|
|
|
20
20
|
const checker = getChecker(absFile);
|
|
@@ -23,7 +23,7 @@ export function checkSealed(
|
|
|
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.frozen) {
|
|
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 checkSealed(
|
|
|
32
32
|
const names = newExports.map((s) => s.name).join(", ");
|
|
33
33
|
return {
|
|
34
34
|
blocked: true,
|
|
35
|
-
reason: `
|
|
35
|
+
reason: `Frozen rule: file is frozen 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/gates/index.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
export { checkReadonly } from "./readonly-gate.ts";
|
|
2
|
-
export {
|
|
2
|
+
export { checkFrozen } from "./frozen-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";
|
package/src/gates/run-gates.ts
CHANGED
|
@@ -5,7 +5,7 @@ import type { ModuleGateConfig } from "../config.ts";
|
|
|
5
5
|
import { readFileSafe, applyEdits, isWithinSourceRoot, findOwningModule } from "../utils.ts";
|
|
6
6
|
import {
|
|
7
7
|
checkReadonly,
|
|
8
|
-
|
|
8
|
+
checkFrozen,
|
|
9
9
|
checkExports,
|
|
10
10
|
checkModuleInterfaceImports,
|
|
11
11
|
} from "./index.ts";
|
|
@@ -57,9 +57,9 @@ export function runGates(
|
|
|
57
57
|
return { block: true, reason: formatDenial(filePath, readonlyResult.reason, absPath, index, cwd, config.moduleDescriptorFileName) };
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
-
const
|
|
61
|
-
if (
|
|
62
|
-
return { block: true, reason: formatDenial(filePath,
|
|
60
|
+
const frozenResult = checkFrozen(filePath, before, after, index, cwd, config.moduleDescriptorFileName);
|
|
61
|
+
if (frozenResult.blocked) {
|
|
62
|
+
return { block: true, reason: formatDenial(filePath, frozenResult.reason, absPath, index, cwd, config.moduleDescriptorFileName) };
|
|
63
63
|
}
|
|
64
64
|
|
|
65
65
|
const exportResult = checkExports(filePath, before, after, index, cwd, config.moduleDescriptorFileName);
|
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
|
+
frozen?: string[];
|
|
9
9
|
};
|
|
10
10
|
|
|
11
11
|
export function parseVisibleEntry(raw: VisibleEntryRaw): Signature {
|
package/src/types.ts
CHANGED
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[]; frozen: 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, frozen }) => ({ modulePath, readonly, frozen }));
|
|
49
49
|
}
|
|
50
50
|
|
|
51
51
|
export function matchesPattern(
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export declare const SUPPORTED_EXTENSIONS: Set<string>;
|