@cuzfrog/pi-module-gates 0.8.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 +6 -3
- package/package.json +1 -1
- package/src/config.ts +12 -4
- 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/graph/module-index-builder.ts +4 -2
- package/src/index.ts +35 -1
package/README.md
CHANGED
|
@@ -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:
|
package/package.json
CHANGED
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,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
|
+
}
|
|
@@ -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
|
+
}
|