@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 +24 -25
- package/package.json +13 -3
- package/skills/module-freeze-all/SKILL.md +35 -0
- package/skills/module-freeze-all/scripts/freeze-all.mjs +354 -0
- package/src/MODULE.md +6 -0
- package/src/config.ts +12 -4
- package/src/context/MODULE.md +5 -0
- package/src/context/system-prompt.ts +11 -2
- package/src/gates/MODULE.md +7 -0
- package/src/gates/checkers/MODULE.md +8 -0
- package/src/gates/checkers/go.ts +19 -0
- package/src/gates/checkers/index.ts +4 -0
- package/src/gates/checkers/java.ts +19 -0
- package/src/gates/checkers/kotlin.ts +26 -0
- package/src/gates/checkers/scala.ts +26 -0
- package/src/gates/frozen-gate.ts +42 -0
- package/src/gates/readonly-gate.ts +2 -24
- package/src/graph/MODULE.md +5 -1
- package/src/graph/frontmatter-parser.ts +1 -0
- package/src/graph/module-index-builder.ts +5 -2
- package/src/index.ts +47 -32
- package/src/types.ts +1 -0
- package/src/utils.ts +28 -0
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
###
|
|
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
|
-
###
|
|
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.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"description": "pi extension that controls the entropy of the codebase by enforcing code module boundaries.",
|
|
5
|
-
"keywords": [
|
|
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": [
|
|
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
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:
|
|
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:
|
|
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
|
-
|
|
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
|
}
|
|
@@ -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
|
|
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
|
-
|
|
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,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
|
+
}
|
|
@@ -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 (
|
|
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
|
-
}
|
package/src/graph/MODULE.md
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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,
|
|
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
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
|
+
}
|