@cuzfrog/pi-module-gates 0.6.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Cause Chung
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,137 @@
1
+ # pi-module-gates - Constraints liberate, liberties constrain.
2
+
3
+ pi cli extension that controls the entropy of the codebase by enforcing code module boundaries.
4
+ It helps combat slop generation and code architecture degradation.
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
+ ## Problem
19
+
20
+ AI coding agents produce edits with limited context knowledge (myopia) — their changes may leak implementation details, and break architectural contracts (slop).
21
+
22
+ ### Approach
23
+
24
+ **Module contracts as guardrails.** Each directory can contain a descriptor file that declares:
25
+
26
+ - `visible` — the set of exports allowed to be added or modified in that module
27
+ - `readonly` — files and directories the agent must not touch
28
+
29
+ The extension intercepts agent `write`/`edit` operations and enforces these contracts. Violations are blocked with a clear reason.
30
+
31
+ ### How it works
32
+
33
+ 1. **Indexing** — On session start, scans the project tree for descriptor files and builds a module index.
34
+ 2. **System prompt** — Injects a hint so the agent knows to respect descriptor file conventions.
35
+ 3. **Gating** — On every write/edit, checks:
36
+ - **Readonly gate** — is the target file locked?
37
+ - **Export gate** — would the change introduce an export not in the `visible` list?
38
+ - **Import gate** (not implemented yet) — would the change introduce an import violating visibility scope?
39
+
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.
49
+ ```
50
+ `module.md` filename is configurable.
51
+
52
+ ## Module Descriptor Semantics
53
+
54
+ 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
+
56
+ ### Simple readonly constraints
57
+
58
+ ```markdown
59
+ ---
60
+ readonly: [mod.rs]
61
+ ---
62
+
63
+ Any prose for the agent to better understand the module.
64
+ ```
65
+
66
+ ### Visibility whitelist
67
+
68
+ ```yaml
69
+ visible:
70
+ - greet # equivalent to `path: ./greet`
71
+ - sub/mod1/Foo
72
+ ```
73
+ or:
74
+ ```yaml
75
+ visible:
76
+ - path: my_function
77
+ modifier: pub(crate) # (optional) demands an exact match
78
+ ```
79
+
80
+ | Scenario | Behavior |
81
+ |----------|----------|
82
+ | `visible` key absent or no `MODULE.md` | Module is unconstrained — exports are not gated. Equivalent to `null` internally. |
83
+ | `visible: []` | Module is fully closed — no new exports may be added. Editing existing exports is still allowed. |
84
+ | Malformed YAML frontmatter | The module is left unguarded and an info notification is emitted. |
85
+
86
+ ### Export gating
87
+
88
+ ```
89
+ project/
90
+ MODULE.md visible: [Foo, Bar]
91
+ src/
92
+ MODULE.md visible: [Bar, Baz]
93
+ app.ts ← checked against `src/MODULE.md` only
94
+ ```
95
+ A `MODULE.md` only enforces exports within its immediate directory.
96
+
97
+ ### Import gating (not implemented yet)
98
+
99
+ ```yaml
100
+ # parent/MODULE.md
101
+ visible:
102
+ - sub/Tool # type Tool is allowed to be imported from parent
103
+
104
+ # parent/sub/MODULE.md (before complement pass)
105
+ visible:
106
+ - Bar # type Bar is allowed to be imported from parent/sub within parent, but not outside parent
107
+ ```
108
+ A `MODULE.md` semantically gates exposures at the module level it resides.
109
+
110
+ ## Configuration
111
+
112
+ Add a `module-gate` entry to `.pi/settings.json`:
113
+
114
+ ```json
115
+ {
116
+ "module-gate": {
117
+ "moduleDescriptorFileName": "MODULE.md",
118
+ "moduleDescriptorReadonly": true,
119
+ "sourceRoot": "src/"
120
+ }
121
+ }
122
+ ```
123
+
124
+ | Option | Default | Description |
125
+ |--------|---------|-------------|
126
+ | `moduleDescriptorFileName` | `"MODULE.md"` | File name used for module descriptors (case-insensitive) |
127
+ | `moduleDescriptorReadonly` | `true` | When `true`, descriptor files are readonly.|
128
+ | `sourceRoot` | `"src/"` | Directory to scan for descriptor files and enforce gates. Set to `""` to scan from project root. |
129
+
130
+ When no settings file exists or no `module-gate` key is present, defaults apply.
131
+
132
+ ## License
133
+
134
+ MIT
135
+
136
+ ## Author
137
+ Cause Chung (cuzfrog@gmail.com)
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "@cuzfrog/pi-module-gates",
3
+ "version": "0.6.0",
4
+ "description": "pi extension that controls the entropy of the codebase by enforcing code module boundaries.",
5
+ "keywords": ["pi-package"],
6
+ "type": "module",
7
+ "scripts": {
8
+ "check": "tsc --noEmit",
9
+ "test": "vitest run"
10
+ },
11
+ "pi": {
12
+ "extensions": [
13
+ "./src/index.ts"
14
+ ]
15
+ },
16
+ "peerDependencies": {
17
+ "@earendil-works/pi-coding-agent": "*"
18
+ },
19
+ "devDependencies": {
20
+ "@earendil-works/pi-coding-agent": "0.75.4",
21
+ "@types/node": "22.19.19",
22
+ "typescript": "5.9.3",
23
+ "vitest": "4.1.7"
24
+ },
25
+ "repository": {
26
+ "type": "git",
27
+ "url": "git+https://github.com/cuzfrog/pi-module-gate.git"
28
+ },
29
+ "bugs": {
30
+ "url": "https://github.com/cuzfrog/pi-module-gate/issues"
31
+ },
32
+ "homepage": "https://github.com/cuzfrog/pi-module-gate#readme",
33
+ "license": "MIT",
34
+ "author": "Cause Chung (cuzfrog@gmail.com)",
35
+ "files": ["src/", "README.md", "LICENSE"],
36
+ "engines": {
37
+ "node": ">=18"
38
+ },
39
+ "publishConfig": {
40
+ "access": "public",
41
+ "provenance": true
42
+ }
43
+ }
package/src/config.ts ADDED
@@ -0,0 +1,29 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+
4
+ export type ModuleGateConfig = {
5
+ moduleDescriptorFileName: string;
6
+ moduleDescriptorReadonly: boolean;
7
+ sourceRoot: string;
8
+ };
9
+
10
+ const DEFAULTS: ModuleGateConfig = {
11
+ moduleDescriptorFileName: "module.md",
12
+ moduleDescriptorReadonly: true,
13
+ sourceRoot: "src/",
14
+ };
15
+
16
+ export function loadConfig(cwd: string): ModuleGateConfig {
17
+ const settingsPath = path.join(cwd, ".pi", "settings.json");
18
+ let userConfig: Partial<ModuleGateConfig> = {};
19
+ try {
20
+ const raw = fs.readFileSync(settingsPath, "utf-8");
21
+ const settings = JSON.parse(raw);
22
+ if (settings["module-gate"] && typeof settings["module-gate"] === "object") {
23
+ userConfig = settings["module-gate"];
24
+ }
25
+ } catch {
26
+ // file doesn't exist or invalid — use defaults
27
+ }
28
+ return { ...DEFAULTS, ...userConfig };
29
+ }
@@ -0,0 +1,19 @@
1
+ import type { ModuleIndex } from "../types.ts";
2
+
3
+ export function buildSystemPromptHint(
4
+ index: ModuleIndex,
5
+ systemPrompt: string,
6
+ descriptorFileName: string,
7
+ ): string {
8
+ if (index.contracts.length === 0) return systemPrompt;
9
+
10
+ return systemPrompt + `
11
+
12
+ ## Module gates (boundary enforcement)
13
+ This project uses \`${descriptorFileName}\`(case-insensitive) files to declare visibility and readonly rules that you should follow.
14
+ If you cannot comply, reconsider your design, if impossible, raise to the user with tradeoffs.
15
+ Each \`${descriptorFileName}\` gates its branching point in the tree.
16
+ 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.
18
+ Violations will be blocked.`;
19
+ }
@@ -0,0 +1,2 @@
1
+ import "./typescript.ts";
2
+ import "./rust.ts";
@@ -0,0 +1,19 @@
1
+ import * as path from "node:path";
2
+ import type { Signature } from "../../types.ts";
3
+
4
+ export interface ExportChecker {
5
+ extensions: string[];
6
+ getNewExports(before: string, after: string): Signature[];
7
+ }
8
+
9
+ const checkerRegistry = new Map<string, ExportChecker>();
10
+
11
+ export function registerChecker(checker: ExportChecker): void {
12
+ for (const ext of checker.extensions) {
13
+ checkerRegistry.set(ext, checker);
14
+ }
15
+ }
16
+
17
+ export function getChecker(filePath: string): ExportChecker | undefined {
18
+ return checkerRegistry.get(path.extname(filePath));
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 rustChecker: ExportChecker = {
6
+ extensions: [".rs"],
7
+ getNewExports(before: string, after: string): Signature[] {
8
+ const beforeNames = new Set(extractPubItems(before).map((s) => s.name));
9
+ return extractPubItems(after).filter((sig) => !beforeNames.has(sig.name));
10
+ },
11
+ };
12
+
13
+ registerChecker(rustChecker);
14
+
15
+ function extractPubItems(src: string): Signature[] {
16
+ return [...src.matchAll(
17
+ /^(pub(?:\([^)]*\))?)\s+(?:fn|struct|enum|trait|type|const|mod)\s+(\w+)/gm,
18
+ )].map((m) => ({ modifier: m[1], name: m[2] }));
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 tsChecker: ExportChecker = {
6
+ extensions: [".ts", ".tsx", ".js", ".jsx"],
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(tsChecker);
14
+
15
+ function extractExports(src: string): Signature[] {
16
+ return [...src.matchAll(
17
+ /^export\s+(?:default\s+)?(?:\w+\s+)*(?:function(?:\s*\*)?|class|const|let|var|type|interface|enum)\s+(\w+)/gm,
18
+ )].map((m) => ({ name: m[1] }));
19
+ }
@@ -0,0 +1,74 @@
1
+ import * as path from "node:path";
2
+ import { getChecker } from "./checkers/registry.ts";
3
+ import type { ModuleIndex, Signature } from "../types.ts";
4
+
5
+ export type ExportViolation = { name: string; imposedBy: string };
6
+
7
+ export type ExportCheckResult =
8
+ | { blocked: true; violations: ExportViolation[]; reason: string }
9
+ | { blocked: false };
10
+
11
+ export function checkExports(
12
+ filePath: string,
13
+ beforeContent: string,
14
+ afterContent: string,
15
+ index: ModuleIndex,
16
+ cwd: string,
17
+ descriptorFileName: string,
18
+ ): ExportCheckResult {
19
+ const absFile = path.resolve(cwd, filePath);
20
+ const checker = getChecker(absFile);
21
+ if (!checker) return { blocked: false };
22
+
23
+ const contract = findImmediateContract(absFile, index.contracts);
24
+ if (!contract || contract.visible === null) return { blocked: false };
25
+
26
+ const newExports = checker.getNewExports(beforeContent, afterContent);
27
+ const violations: ExportViolation[] = [];
28
+
29
+ for (const sig of newExports) {
30
+ const visibleEntry = contract.visible.find((s) => s.name === sig.name);
31
+ if (!visibleEntry) {
32
+ violations.push({
33
+ name: sig.name,
34
+ imposedBy: path.relative(cwd, path.join(contract.modulePath, descriptorFileName)),
35
+ });
36
+ continue;
37
+ }
38
+
39
+ const requiredMod = visibleEntry.modifier;
40
+ if (requiredMod !== undefined && sig.modifier !== requiredMod) {
41
+ violations.push({
42
+ name: `${sig.modifier ?? ""} ${sig.name}`.trim(),
43
+ imposedBy: path.relative(cwd, path.join(contract.modulePath, descriptorFileName)),
44
+ });
45
+ continue;
46
+ }
47
+ }
48
+
49
+ if (violations.length === 0) return { blocked: false };
50
+
51
+ const lines = violations.map(
52
+ (v) => ` \u2022 ${v.name} not in visible list of ${v.imposedBy}`,
53
+ );
54
+ return {
55
+ blocked: true,
56
+ violations,
57
+ reason: `Export violations:\n${lines.join("\n")}`,
58
+ };
59
+ }
60
+
61
+ function findImmediateContract(
62
+ absFile: string,
63
+ contracts: { modulePath: string; visible: Signature[] | null }[],
64
+ ): { modulePath: string; visible: Signature[] | null } | undefined {
65
+ let best: { modulePath: string; visible: Signature[] | null } | undefined;
66
+ for (const c of contracts) {
67
+ if (absFile.startsWith(c.modulePath + path.sep) || absFile === c.modulePath) {
68
+ if (!best || c.modulePath.length > best.modulePath.length) {
69
+ best = c;
70
+ }
71
+ }
72
+ }
73
+ return best;
74
+ }
@@ -0,0 +1,53 @@
1
+ import * as path from "node:path";
2
+ import type { ModuleIndex } from "../types.ts";
3
+
4
+ export type ReadonlyCheckResult =
5
+ | { blocked: true; reason: string }
6
+ | { blocked: false };
7
+
8
+ export function checkReadonly(
9
+ filePath: string,
10
+ index: ModuleIndex,
11
+ cwd: string,
12
+ descriptorFileName: string,
13
+ ): ReadonlyCheckResult {
14
+ const absFile = path.resolve(cwd, filePath);
15
+ const ancestors = getAncestorContracts(absFile, index);
16
+
17
+ for (const contract of ancestors) {
18
+ for (const pattern of contract.readonly) {
19
+ if (matchesReadonlyPattern(absFile, pattern, contract.modulePath)) {
20
+ const relModuleMd = path.relative(cwd, path.join(contract.modulePath, descriptorFileName));
21
+ return {
22
+ blocked: true,
23
+ reason: `Readonly rule: file is listed as readonly in ${relModuleMd}`,
24
+ };
25
+ }
26
+ }
27
+ }
28
+
29
+ return { blocked: false };
30
+ }
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
+ }
@@ -0,0 +1,4 @@
1
+ ---
2
+ visible: [buildModuleIndex]
3
+ ---
4
+
@@ -0,0 +1,27 @@
1
+ import type { Signature } from "../types.ts";
2
+
3
+ export type VisibleEntryRaw = string | { path: string; modifier?: string };
4
+
5
+ export type ModuleFrontmatter = {
6
+ visible?: VisibleEntryRaw[];
7
+ readonly?: string[];
8
+ };
9
+
10
+ export function parseVisibleEntry(raw: VisibleEntryRaw): Signature {
11
+ if (typeof raw === "string") {
12
+ const trimmed = raw.trim();
13
+ return { name: extractNameFromPath(trimmed), path: trimmed };
14
+ }
15
+ return {
16
+ name: extractNameFromPath(raw.path),
17
+ modifier: raw.modifier,
18
+ path: raw.path,
19
+ };
20
+ }
21
+
22
+ function extractNameFromPath(pathStr: string): string {
23
+ let p = pathStr.trim();
24
+ if (p.endsWith("/")) p = p.slice(0, -1);
25
+ const lastSlash = p.lastIndexOf("/");
26
+ return lastSlash >= 0 ? p.slice(lastSlash + 1) : p;
27
+ }
@@ -0,0 +1,179 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { readdir } from "node:fs/promises";
4
+ import { parseFrontmatter } from "@earendil-works/pi-coding-agent";
5
+ import type { ModuleContract, ModuleIndex } from "../types.ts";
6
+ import type { ModuleGateConfig } from "../config.ts";
7
+ import type { Dirent } from "node:fs";
8
+ import { validateVisibleEntries } from "./validation.ts";
9
+ import { parseVisibleEntry, type VisibleEntryRaw, type ModuleFrontmatter } from "./frontmatter-parser.ts";
10
+
11
+ type IndexContext = {
12
+ cwd: string;
13
+ ui: { notify(message: string, type?: string): void };
14
+ };
15
+
16
+ export async function buildModuleIndex(
17
+ ctx: IndexContext,
18
+ config: ModuleGateConfig,
19
+ ): Promise<ModuleIndex> {
20
+ const notify = (msg: string) => ctx.ui.notify(msg, "info");
21
+ const scanRoot = path.resolve(ctx.cwd, config.sourceRoot);
22
+
23
+ const moduleFiles = await findModuleFiles(scanRoot, config.moduleDescriptorFileName);
24
+ const contracts = buildContracts(moduleFiles, notify, config.moduleDescriptorFileName, config.moduleDescriptorReadonly);
25
+ applyComplementPass(contracts);
26
+ const dirToModule = await buildDirToModuleMap(contracts);
27
+ const index: ModuleIndex = { contracts, dirToModule };
28
+
29
+ await validateVisibleEntries(index, ctx.cwd, ctx.ui.notify, config.moduleDescriptorFileName);
30
+
31
+ return index;
32
+ }
33
+
34
+ function buildContracts(
35
+ moduleFiles: string[],
36
+ onInfo: (message: string) => void,
37
+ descriptorFileName: string,
38
+ moduleDescriptorReadonly: boolean,
39
+ ): ModuleContract[] {
40
+ const contracts: ModuleContract[] = [];
41
+
42
+ for (const absModuleFile of moduleFiles) {
43
+ const modulePath = path.dirname(absModuleFile);
44
+ const content = fs.readFileSync(absModuleFile, "utf-8");
45
+
46
+ let frontmatter: ModuleFrontmatter;
47
+ let body: string;
48
+ try {
49
+ const parsed = parseFrontmatter<ModuleFrontmatter>(content);
50
+ frontmatter = parsed.frontmatter;
51
+ body = parsed.body;
52
+ } catch {
53
+ onInfo(
54
+ `[Module Gate] Failed to parse ${absModuleFile} — module will be unguarded`,
55
+ );
56
+ continue;
57
+ }
58
+
59
+ const readonlyEntries = frontmatter.readonly ?? [];
60
+ if (moduleDescriptorReadonly) {
61
+ readonlyEntries.push(descriptorFileName);
62
+ }
63
+
64
+ contracts.push({
65
+ modulePath,
66
+ visible:
67
+ frontmatter.visible !== undefined
68
+ ? frontmatter.visible.map(parseVisibleEntry)
69
+ : null,
70
+ readonly: readonlyEntries,
71
+ prose: body.trim(),
72
+ });
73
+ }
74
+
75
+ contracts.sort((a, b) => a.modulePath.length - b.modulePath.length);
76
+ return contracts;
77
+ }
78
+
79
+ async function buildDirToModuleMap(
80
+ contracts: ModuleContract[],
81
+ ): Promise<Map<string, string>> {
82
+ const dirToModule = new Map<string, string>();
83
+ const sortedByDepth = [...contracts].sort(
84
+ (a, b) => a.modulePath.length - b.modulePath.length,
85
+ );
86
+
87
+ for (const contract of sortedByDepth) {
88
+ const dirs = await walkDirs(contract.modulePath);
89
+ for (const dir of dirs) {
90
+ dirToModule.set(dir, contract.modulePath);
91
+ }
92
+ }
93
+
94
+ return dirToModule;
95
+ }
96
+
97
+ async function findModuleFiles(dir: string, descriptorFileName: string): Promise<string[]> {
98
+ const results: string[] = [];
99
+ const stack: string[] = [dir];
100
+
101
+ while (stack.length > 0) {
102
+ const current = stack.pop()!;
103
+ let entries: Dirent[];
104
+ try {
105
+ entries = await readdir(current, { withFileTypes: true });
106
+ } catch {
107
+ continue;
108
+ }
109
+
110
+ for (const entry of entries) {
111
+ if (entry.name === "node_modules" || entry.name === ".git") continue;
112
+ const fullPath = path.join(current, entry.name);
113
+ if (entry.isDirectory()) {
114
+ stack.push(fullPath);
115
+ } else if (entry.name.toLowerCase() === descriptorFileName.toLowerCase()) {
116
+ results.push(fullPath);
117
+ }
118
+ }
119
+ }
120
+
121
+ return results;
122
+ }
123
+
124
+ function applyComplementPass(contracts: ModuleContract[]): void {
125
+ for (const contract of contracts) {
126
+ if (contract.visible === null) continue;
127
+ for (const sig of contract.visible) {
128
+ if (!sig.path) continue;
129
+ const targetDir = resolveDir(contract.modulePath, sig.path);
130
+ if (targetDir === contract.modulePath) continue;
131
+ const target = contracts.find((c) => c.modulePath === targetDir);
132
+ if (!target || target.visible === null) continue;
133
+ if (target.visible.some((s) => s.name === sig.name)) continue;
134
+ target.visible.push({ name: sig.name, modifier: sig.modifier });
135
+ }
136
+ }
137
+ }
138
+
139
+ function resolveDir(modulePath: string, entryPath: string): string {
140
+ const dirPart = pathDirPart(entryPath);
141
+ if (!dirPart) return modulePath;
142
+ const joined = path.join(modulePath, dirPart);
143
+ return joined.endsWith(path.sep) ? joined.slice(0, -1) : joined;
144
+ }
145
+
146
+ function pathDirPart(entryPath: string): string {
147
+ const trimmed = entryPath.trim();
148
+ const lastSlash = trimmed.lastIndexOf("/");
149
+ if (lastSlash < 0) return "";
150
+ return trimmed.slice(0, lastSlash + 1);
151
+ }
152
+
153
+ async function walkDirs(root: string): Promise<string[]> {
154
+ const results: string[] = [root];
155
+ const stack: string[] = [root];
156
+
157
+ while (stack.length > 0) {
158
+ const current = stack.pop()!;
159
+ let entries: Dirent[];
160
+ try {
161
+ entries = await readdir(current, { withFileTypes: true });
162
+ } catch {
163
+ continue;
164
+ }
165
+
166
+ for (const entry of entries) {
167
+ if (entry.name === "node_modules" || entry.name === ".git") continue;
168
+ if (entry.isDirectory()) {
169
+ const fullPath = path.join(current, entry.name);
170
+ results.push(fullPath);
171
+ stack.push(fullPath);
172
+ }
173
+ }
174
+ }
175
+
176
+ return results;
177
+ }
178
+
179
+
@@ -0,0 +1,116 @@
1
+ import * as path from "node:path";
2
+ import { readdir } from "node:fs/promises";
3
+ import type { ModuleIndex } from "../types.ts";
4
+ import { getChecker } from "../gates/checkers/registry.ts";
5
+ import { readFileSafe } from "../utils.ts";
6
+ import type { Dirent } from "node:fs";
7
+
8
+ type NotifyFn = (message: string, type?: "info" | "warning" | "error") => void;
9
+
10
+ export async function validateVisibleEntries(
11
+ idx: ModuleIndex,
12
+ cwd: string,
13
+ notify: NotifyFn,
14
+ descriptorFileName: string,
15
+ ): Promise<void> {
16
+ const childModules = new Set(
17
+ idx.contracts.map((c) => c.modulePath),
18
+ );
19
+
20
+ for (const contract of idx.contracts) {
21
+ if (contract.visible === null) continue;
22
+
23
+ const exportedSymbols = await collectExports(contract.modulePath, childModules);
24
+ // Cache for non-local path resolution
25
+ const pathExportsCache = new Map<string, Set<string>>();
26
+
27
+ for (const sig of contract.visible) {
28
+ const targetDir = resolveValidationTarget(contract.modulePath, sig.path);
29
+ const symbols =
30
+ targetDir !== contract.modulePath
31
+ ? await resolvePathExports(targetDir, pathExportsCache, childModules)
32
+ : exportedSymbols;
33
+
34
+ if (!symbols.has(sig.name)) {
35
+ const relModule = path.relative(cwd, path.join(contract.modulePath, descriptorFileName));
36
+ notify(
37
+ `[Module Gate] Dangling visible entry "${sig.name}" in ${relModule}`,
38
+ "info",
39
+ );
40
+ }
41
+ }
42
+ }
43
+ }
44
+
45
+ function resolveValidationTarget(modulePath: string, entryPath?: string): string {
46
+ if (!entryPath) return modulePath;
47
+ const lastSlash = entryPath.lastIndexOf("/");
48
+ if (lastSlash < 0) return modulePath;
49
+ const dirPart = entryPath.slice(0, lastSlash + 1);
50
+ const joined = path.join(modulePath, dirPart);
51
+ return joined.endsWith(path.sep) ? joined.slice(0, -1) : joined;
52
+ }
53
+
54
+ async function collectExports(
55
+ modulePath: string,
56
+ childModules: Set<string>,
57
+ ): Promise<Set<string>> {
58
+ const files = await listFiles(modulePath, childModules);
59
+ const symbols = new Set<string>();
60
+ for (const filePath of files) {
61
+ const checker = getChecker(filePath);
62
+ if (!checker) continue;
63
+ const content = readFileSafe(filePath);
64
+ const exports = checker.getNewExports("", content);
65
+ for (const sig of exports) {
66
+ symbols.add(sig.name);
67
+ }
68
+ }
69
+ return symbols;
70
+ }
71
+
72
+ async function resolvePathExports(
73
+ targetDir: string,
74
+ cache: Map<string, Set<string>>,
75
+ childModules: Set<string>,
76
+ ): Promise<Set<string>> {
77
+ let symbols = cache.get(targetDir);
78
+ if (!symbols) {
79
+ symbols = await collectExports(targetDir, childModules);
80
+ cache.set(targetDir, symbols);
81
+ }
82
+ return symbols;
83
+ }
84
+
85
+ async function listFiles(
86
+ dir: string,
87
+ childModules: Set<string>,
88
+ ): Promise<string[]> {
89
+ const results: string[] = [];
90
+ const stack: string[] = [dir];
91
+
92
+ while (stack.length > 0) {
93
+ const current = stack.pop()!;
94
+ let entries: Dirent[];
95
+ try {
96
+ entries = await readdir(current, { withFileTypes: true });
97
+ } catch {
98
+ continue;
99
+ }
100
+
101
+ for (const entry of entries) {
102
+ if (entry.name === "node_modules" || entry.name === ".git") continue;
103
+ const fullPath = path.join(current, entry.name);
104
+ if (entry.isDirectory()) {
105
+ if (childModules.has(fullPath) && fullPath !== dir) continue;
106
+ stack.push(fullPath);
107
+ } else {
108
+ results.push(fullPath);
109
+ }
110
+ }
111
+ }
112
+
113
+ return results;
114
+ }
115
+
116
+
package/src/index.ts ADDED
@@ -0,0 +1,126 @@
1
+ import * as path from "node:path";
2
+ import type {
3
+ ExtensionAPI,
4
+ ToolCallEventResult,
5
+ BeforeAgentStartEventResult,
6
+ } from "@earendil-works/pi-coding-agent";
7
+ import { isToolCallEventType } from "@earendil-works/pi-coding-agent";
8
+ import type { ModuleIndex } from "./types.ts";
9
+ import { loadConfig } from "./config.ts";
10
+ import type { ModuleGateConfig } from "./config.ts";
11
+ import { buildModuleIndex } from "./graph/module-index-builder.ts";
12
+ import { findOwningModule, readFileSafe, applyEdits, isWithinSourceRoot } from "./utils.ts";
13
+ import { checkReadonly } from "./gates/readonly-gate.ts";
14
+ import { checkExports } from "./gates/export-gate.ts";
15
+ import { buildSystemPromptHint } from "./context/system-prompt.ts";
16
+ import "./gates/checkers/index.ts";
17
+
18
+ export default function (pi: ExtensionAPI): void {
19
+ let index: ModuleIndex;
20
+ let config: ModuleGateConfig;
21
+
22
+ pi.on("session_start", async (_event, ctx) => {
23
+ config = loadConfig(ctx.cwd);
24
+ index = await buildModuleIndex(ctx, config);
25
+ if (index.contracts.length === 0) {
26
+ ctx.ui.notify(
27
+ "[Module Gate] No module descriptor files found. Gates are not active.",
28
+ "info",
29
+ );
30
+ }
31
+ });
32
+
33
+ pi.on("before_agent_start", async (event): Promise<BeforeAgentStartEventResult | void> => {
34
+ if (index.contracts.length === 0) return;
35
+ return {
36
+ systemPrompt: buildSystemPromptHint(index, event.systemPrompt, config.moduleDescriptorFileName),
37
+ };
38
+ });
39
+
40
+ pi.on("tool_call", async (event, ctx): Promise<ToolCallEventResult | void> => {
41
+ if (isToolCallEventType("edit", event)) {
42
+ return handleEdit(event.input.path, event.input.edits, ctx.cwd, index, config);
43
+ }
44
+ if (isToolCallEventType("write", event)) {
45
+ return handleWrite(event.input.path, event.input.content, ctx.cwd, index, config);
46
+ }
47
+ });
48
+ }
49
+
50
+ function handleEdit(
51
+ filePath: string,
52
+ edits: { oldText: string; newText: string }[],
53
+ cwd: string,
54
+ index: ModuleIndex,
55
+ config: ModuleGateConfig,
56
+ ): ToolCallEventResult | undefined {
57
+ 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
+
67
+ const before = readFileSafe(absPath);
68
+ const after = applyEdits(before, edits);
69
+
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;
89
+
90
+ const readonlyResult = checkReadonly(filePath, index, cwd, config.moduleDescriptorFileName);
91
+ if (readonlyResult.blocked) {
92
+ return { block: true, reason: formatDenial(filePath, readonlyResult.reason, absPath, index, cwd, config.moduleDescriptorFileName) };
93
+ }
94
+
95
+ const before = readFileSafe(absPath);
96
+
97
+ const exportResult = checkExports(filePath, before, content, index, cwd, config.moduleDescriptorFileName);
98
+ if (exportResult.blocked) {
99
+ return { block: true, reason: formatDenial(filePath, exportResult.reason, absPath, index, cwd, config.moduleDescriptorFileName) };
100
+ }
101
+
102
+ return undefined;
103
+ }
104
+
105
+ function formatDenial(
106
+ relPath: string,
107
+ reason: string,
108
+ absPath: string,
109
+ index: ModuleIndex,
110
+ cwd: string,
111
+ descriptorFileName: string,
112
+ ): string {
113
+ const modulePath = findOwningModule(absPath, index);
114
+ const contract = modulePath
115
+ ? index.contracts.find((c) => c.modulePath === modulePath)
116
+ : undefined;
117
+
118
+ let message = `[Module Gate] Write blocked — ${relPath}\n\n${reason}`;
119
+
120
+ if (contract && contract.prose) {
121
+ const relModuleMd = path.relative(cwd, path.join(contract.modulePath, descriptorFileName));
122
+ message += `\n\nModule contract (${relModuleMd}):\n${contract.prose}`;
123
+ }
124
+
125
+ return message;
126
+ }
package/src/types.ts ADDED
@@ -0,0 +1,17 @@
1
+ export type Signature = {
2
+ modifier?: string;
3
+ name: string;
4
+ path?: string;
5
+ };
6
+
7
+ export type ModuleContract = {
8
+ modulePath: string;
9
+ visible: Signature[] | null;
10
+ readonly: string[];
11
+ prose: string;
12
+ };
13
+
14
+ export type ModuleIndex = {
15
+ contracts: ModuleContract[];
16
+ dirToModule: Map<string, string>;
17
+ };
package/src/utils.ts ADDED
@@ -0,0 +1,40 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import type { ModuleIndex } from "./types.ts";
4
+
5
+ export function findOwningModule(
6
+ absPath: string,
7
+ index: ModuleIndex,
8
+ ): string | undefined {
9
+ let current = path.dirname(absPath);
10
+ const root = path.parse(current).root;
11
+
12
+ while (true) {
13
+ const owner = index.dirToModule.get(current);
14
+ if (owner !== undefined) return owner;
15
+ if (current === root) break;
16
+ current = path.dirname(current);
17
+ }
18
+
19
+ return undefined;
20
+ }
21
+
22
+ export function readFileSafe(absPath: string): string {
23
+ try {
24
+ return fs.readFileSync(absPath, "utf-8");
25
+ } catch {
26
+ return "";
27
+ }
28
+ }
29
+
30
+ export function applyEdits(content: string, edits: { oldText: string; newText: string }[]): string {
31
+ let result = content;
32
+ for (const edit of edits) {
33
+ result = result.replace(edit.oldText, edit.newText);
34
+ }
35
+ return result;
36
+ }
37
+
38
+ export function isWithinSourceRoot(absPath: string, resolvedRoot: string): boolean {
39
+ return absPath.startsWith(resolvedRoot + path.sep) || absPath === resolvedRoot;
40
+ }