@cuzfrog/pi-module-gates 0.8.0 → 0.12.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 +11 -8
- package/package.json +4 -4
- package/skills/module-freeze-all/scripts/freeze-all.mjs +1 -1
- package/src/config.ts +14 -6
- package/src/context/system-prompt.ts +8 -4
- 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/checkers/typescript.ts +25 -3
- package/src/graph/module-index-builder.ts +4 -2
- package/src/index.ts +35 -1
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# pi-module-gates - Constraints liberate, liberties constrain.
|
|
2
2
|
|
|
3
|
-
pi cli extension that controls the entropy of the codebase by enforcing code module boundaries.
|
|
3
|
+
Expirimental pi cli extension that controls the entropy of the codebase by enforcing code module boundaries.
|
|
4
4
|
It helps combat slop generation and code architecture degradation.
|
|
5
5
|
|
|
6
6
|
## Problem
|
|
@@ -11,8 +11,9 @@ AI coding agents produce edits with limited context knowledge (myopia) — their
|
|
|
11
11
|
|
|
12
12
|
**Module contracts as guardrails.** Each directory can contain a descriptor file that declares:
|
|
13
13
|
|
|
14
|
-
- `visible` — the set of exports allowed to be added or modified in that module
|
|
15
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
|
|
16
17
|
|
|
17
18
|
The extension intercepts agent `write`/`edit` operations and enforces these contracts. Violations are blocked with a clear reason.
|
|
18
19
|
|
|
@@ -26,7 +27,8 @@ The extension intercepts agent `write`/`edit` operations and enforces these cont
|
|
|
26
27
|
- **Export gate** — would the change introduce an export not in the `visible` list?
|
|
27
28
|
- **Import gate** (not implemented yet) — would the change introduce an import violating visibility scope?
|
|
28
29
|
|
|
29
|
-
System prompt: [system-prompt.md](src/context/system-prompt.ts)
|
|
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**
|
|
30
32
|
|
|
31
33
|
## Installation
|
|
32
34
|
```bash
|
|
@@ -58,8 +60,9 @@ frozen: [mod.rs]
|
|
|
58
60
|
```
|
|
59
61
|
Frozen files cannot change their surface size: no new exports or public entries are allowed.
|
|
60
62
|
|
|
63
|
+
A skill [module-freeze-all](src/skills/module-freeze-all) has been included to auto-freeze modules.
|
|
64
|
+
|
|
61
65
|
### Visibility whitelist (under redesign)
|
|
62
|
-
Supported languages: Rust, TypeScript
|
|
63
66
|
|
|
64
67
|
```yaml
|
|
65
68
|
visible:
|
|
@@ -105,11 +108,11 @@ A `MODULE.md` semantically gates exposures at the module level it resides.
|
|
|
105
108
|
|
|
106
109
|
## Configuration
|
|
107
110
|
|
|
108
|
-
Add a `module-
|
|
111
|
+
Add a `module-gates` entry to `.pi/settings.json`:
|
|
109
112
|
|
|
110
113
|
```json
|
|
111
114
|
{
|
|
112
|
-
"module-
|
|
115
|
+
"module-gates": {
|
|
113
116
|
"moduleDescriptorFileName": "MODULE.md",
|
|
114
117
|
"moduleDescriptorReadonly": true,
|
|
115
118
|
"sourceRoot": "src/"
|
|
@@ -119,11 +122,11 @@ Add a `module-gate` entry to `.pi/settings.json`:
|
|
|
119
122
|
|
|
120
123
|
| Option | Default | Description |
|
|
121
124
|
|--------|---------|-------------|
|
|
122
|
-
| `moduleDescriptorFileName` | `
|
|
125
|
+
| `moduleDescriptorFileName` | `MODULE.md` | File name used for module descriptors (case-insensitive) |
|
|
123
126
|
| `moduleDescriptorReadonly` | `true` | When `true`, descriptor files are readonly.|
|
|
124
127
|
| `sourceRoot` | `"src/"` | Directory to scan for descriptor files and enforce gates. Set to `""` to scan from project root. |
|
|
125
128
|
|
|
126
|
-
When no settings file exists or no `module-
|
|
129
|
+
When no settings file exists or no `module-gates` key is present, defaults apply.
|
|
127
130
|
|
|
128
131
|
## License
|
|
129
132
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cuzfrog/pi-module-gates",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
4
4
|
"description": "pi extension that controls the entropy of the codebase by enforcing code module boundaries.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package"
|
|
@@ -29,12 +29,12 @@
|
|
|
29
29
|
},
|
|
30
30
|
"repository": {
|
|
31
31
|
"type": "git",
|
|
32
|
-
"url": "git+https://github.com/cuzfrog/pi-module-
|
|
32
|
+
"url": "git+https://github.com/cuzfrog/pi-module-gates.git"
|
|
33
33
|
},
|
|
34
34
|
"bugs": {
|
|
35
|
-
"url": "https://github.com/cuzfrog/pi-module-
|
|
35
|
+
"url": "https://github.com/cuzfrog/pi-module-gates/issues"
|
|
36
36
|
},
|
|
37
|
-
"homepage": "https://github.com/cuzfrog/pi-module-
|
|
37
|
+
"homepage": "https://github.com/cuzfrog/pi-module-gates#readme",
|
|
38
38
|
"license": "MIT",
|
|
39
39
|
"author": "Cause Chung (cuzfrog@gmail.com)",
|
|
40
40
|
"files": [
|
|
@@ -17,7 +17,7 @@ function main() {
|
|
|
17
17
|
root: { type: "string", default: "src" },
|
|
18
18
|
"dry-run": { type: "boolean", default: false },
|
|
19
19
|
create: { type: "boolean", default: false },
|
|
20
|
-
"descriptor-name": { type: "string", default: "
|
|
20
|
+
"descriptor-name": { type: "string", default: "MODULE.md" },
|
|
21
21
|
},
|
|
22
22
|
});
|
|
23
23
|
|
package/src/config.ts
CHANGED
|
@@ -3,27 +3,35 @@ 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);
|
|
22
|
-
if (settings["module-
|
|
23
|
-
userConfig = settings["module-
|
|
22
|
+
if (settings["module-gates"] && typeof settings["module-gates"] === "object") {
|
|
23
|
+
userConfig = settings["module-gates"];
|
|
24
24
|
}
|
|
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,16 +1,20 @@
|
|
|
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,
|
|
7
|
-
moduleDescriptorReadonly:
|
|
8
|
+
moduleDescriptorReadonly: ModuleGateConfig["moduleDescriptorReadonly"],
|
|
8
9
|
): string {
|
|
9
10
|
if (index.contracts.length === 0) return systemPrompt;
|
|
10
11
|
|
|
11
|
-
const descriptorNote =
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
const descriptorNote =
|
|
13
|
+
moduleDescriptorReadonly === "frontmatter"
|
|
14
|
+
? ` The frontmatter of \`${descriptorFileName}\` is readonly.`
|
|
15
|
+
: moduleDescriptorReadonly === "file"
|
|
16
|
+
? ` The \`${descriptorFileName}\` file itself is readonly.`
|
|
17
|
+
: "";
|
|
14
18
|
|
|
15
19
|
return systemPrompt + `
|
|
16
20
|
|
|
@@ -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
|
+
}
|
|
@@ -13,7 +13,29 @@ const tsChecker: ExportChecker = {
|
|
|
13
13
|
registerChecker(tsChecker);
|
|
14
14
|
|
|
15
15
|
function extractExports(src: string): Signature[] {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
16
|
+
const results: Signature[] = [
|
|
17
|
+
...src.matchAll(
|
|
18
|
+
/^export\s+(?:default\s+)?(?:\w+\s+)*(?:function(?:\s*\*)?|class|const|let|var|type|interface|enum)\s+(\w+)/gm,
|
|
19
|
+
),
|
|
20
|
+
].map((m) => ({ name: m[1] }));
|
|
21
|
+
|
|
22
|
+
for (const m of src.matchAll(/^export\s*\{\s*([^}]+)\s*\}\s*from/gm)) {
|
|
23
|
+
const inner = m[1];
|
|
24
|
+
for (const entry of inner.split(",")) {
|
|
25
|
+
const trimmed = entry.trim();
|
|
26
|
+
if (!trimmed) continue;
|
|
27
|
+
const asMatch = trimmed.match(/^(\w+)\s+as\s+(\w+)$/);
|
|
28
|
+
if (asMatch) {
|
|
29
|
+
results.push({ name: asMatch[2] });
|
|
30
|
+
} else {
|
|
31
|
+
results.push({ name: trimmed });
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
for (const m of src.matchAll(/^export\s*\*\s*as\s+(\w+)\s+from/gm)) {
|
|
37
|
+
results.push({ name: m[1] });
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return results;
|
|
19
41
|
}
|
|
@@ -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
|
|
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";
|
|
@@ -67,6 +67,27 @@ function handleEdit(
|
|
|
67
67
|
|
|
68
68
|
const readonlyResult = checkReadonly(filePath, index, cwd, config.moduleDescriptorFileName);
|
|
69
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
|
+
}
|
|
70
91
|
return { block: true, reason: formatDenial(filePath, readonlyResult.reason, absPath, index, cwd, config.moduleDescriptorFileName) };
|
|
71
92
|
}
|
|
72
93
|
|
|
@@ -105,3 +126,16 @@ function formatDenial(
|
|
|
105
126
|
|
|
106
127
|
return message;
|
|
107
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
|
+
}
|