@cuzfrog/pi-module-gates 0.10.0 → 0.13.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 +7 -5
- package/package.json +4 -4
- package/skills/module-freeze-all/scripts/freeze-all.mjs +1 -1
- package/src/config.ts +4 -2
- package/src/context/MODULE.md +1 -0
- package/src/context/index.ts +1 -0
- package/src/gates/MODULE.md +1 -0
- package/src/gates/checkers/index.ts +2 -0
- package/src/gates/checkers/typescript.ts +25 -3
- package/src/gates/index.ts +4 -0
- package/src/gates/module-interface-import-gate.ts +148 -0
- package/src/graph/MODULE.md +1 -0
- package/src/graph/index.ts +4 -0
- package/src/graph/module-index-builder.ts +1 -1
- package/src/graph/validation.ts +1 -1
- package/src/import-test.ts +0 -0
- package/src/index.ts +13 -5
- package/src/types.ts +1 -0
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
|
|
@@ -25,6 +25,7 @@ The extension intercepts agent `write`/`edit` operations and enforces these cont
|
|
|
25
25
|
- **Readonly gate** — is the target file locked?
|
|
26
26
|
**Fronzen gate** — is there any surface change to the target file?
|
|
27
27
|
- **Export gate** — would the change introduce an export not in the `visible` list?
|
|
28
|
+
- **Module interface import gate** — external files can only import from the module not internal files, i.e. re-exports from `index.ts` or `mod.rs`. (Only Typescript/JavaScript and Rust are supported)
|
|
28
29
|
- **Import gate** (not implemented yet) — would the change introduce an import violating visibility scope?
|
|
29
30
|
|
|
30
31
|
- System prompt: [system-prompt.md](src/context/system-prompt.ts)
|
|
@@ -108,11 +109,11 @@ A `MODULE.md` semantically gates exposures at the module level it resides.
|
|
|
108
109
|
|
|
109
110
|
## Configuration
|
|
110
111
|
|
|
111
|
-
Add a `module-
|
|
112
|
+
Add a `module-gates` entry to `.pi/settings.json`:
|
|
112
113
|
|
|
113
114
|
```json
|
|
114
115
|
{
|
|
115
|
-
"module-
|
|
116
|
+
"module-gates": {
|
|
116
117
|
"moduleDescriptorFileName": "MODULE.md",
|
|
117
118
|
"moduleDescriptorReadonly": true,
|
|
118
119
|
"sourceRoot": "src/"
|
|
@@ -122,11 +123,12 @@ Add a `module-gate` entry to `.pi/settings.json`:
|
|
|
122
123
|
|
|
123
124
|
| Option | Default | Description |
|
|
124
125
|
|--------|---------|-------------|
|
|
125
|
-
| `moduleDescriptorFileName` | `
|
|
126
|
+
| `moduleDescriptorFileName` | `MODULE.md` | File name used for module descriptors (case-insensitive) |
|
|
126
127
|
| `moduleDescriptorReadonly` | `true` | When `true`, descriptor files are readonly.|
|
|
127
128
|
| `sourceRoot` | `"src/"` | Directory to scan for descriptor files and enforce gates. Set to `""` to scan from project root. |
|
|
129
|
+
| `disableModuleInterfaceImportGate` | `false` | By default, external files can only import from the module not internal files, i.e. re-exports from `index.ts` or `mod.rs` |
|
|
128
130
|
|
|
129
|
-
When no settings file exists or no `module-
|
|
131
|
+
When no settings file exists or no `module-gates` key is present, defaults apply.
|
|
130
132
|
|
|
131
133
|
## License
|
|
132
134
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cuzfrog/pi-module-gates",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.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
|
@@ -5,12 +5,14 @@ export type ModuleGateConfig = {
|
|
|
5
5
|
moduleDescriptorFileName: string;
|
|
6
6
|
moduleDescriptorReadonly: "file" | "frontmatter" | "off";
|
|
7
7
|
sourceRoot: string;
|
|
8
|
+
disableModuleInterfaceImportGate: boolean;
|
|
8
9
|
};
|
|
9
10
|
|
|
10
11
|
const DEFAULTS: ModuleGateConfig = {
|
|
11
12
|
moduleDescriptorFileName: "module.md",
|
|
12
13
|
moduleDescriptorReadonly: "file",
|
|
13
14
|
sourceRoot: "src/",
|
|
15
|
+
disableModuleInterfaceImportGate: false,
|
|
14
16
|
};
|
|
15
17
|
|
|
16
18
|
export function loadConfig(cwd: string): ModuleGateConfig {
|
|
@@ -19,8 +21,8 @@ export function loadConfig(cwd: string): ModuleGateConfig {
|
|
|
19
21
|
try {
|
|
20
22
|
const raw = fs.readFileSync(settingsPath, "utf-8");
|
|
21
23
|
const settings = JSON.parse(raw);
|
|
22
|
-
if (settings["module-
|
|
23
|
-
userConfig = settings["module-
|
|
24
|
+
if (settings["module-gates"] && typeof settings["module-gates"] === "object") {
|
|
25
|
+
userConfig = settings["module-gates"];
|
|
24
26
|
}
|
|
25
27
|
} catch {
|
|
26
28
|
// file doesn't exist or invalid — use defaults
|
package/src/context/MODULE.md
CHANGED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { buildSystemPromptHint } from "./system-prompt.ts";
|
package/src/gates/MODULE.md
CHANGED
|
@@ -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
|
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import type { ModuleIndex } from "../types.ts";
|
|
4
|
+
import { findOwningModule } from "../utils.ts";
|
|
5
|
+
|
|
6
|
+
export type ImportCheckResult =
|
|
7
|
+
| { blocked: true; reason: string }
|
|
8
|
+
| { blocked: false };
|
|
9
|
+
|
|
10
|
+
export function checkModuleInterfaceImports(
|
|
11
|
+
filePath: string,
|
|
12
|
+
afterContent: string,
|
|
13
|
+
index: ModuleIndex,
|
|
14
|
+
cwd: string,
|
|
15
|
+
disabled: boolean,
|
|
16
|
+
sourceRoot: string,
|
|
17
|
+
): ImportCheckResult {
|
|
18
|
+
if (disabled) return { blocked: false };
|
|
19
|
+
|
|
20
|
+
const absFile = path.resolve(cwd, filePath);
|
|
21
|
+
const fileDir = path.dirname(absFile);
|
|
22
|
+
const srcRoot = path.resolve(cwd, sourceRoot);
|
|
23
|
+
const violations: string[] = [];
|
|
24
|
+
|
|
25
|
+
for (const importPath of extractJsImportPaths(afterContent)) {
|
|
26
|
+
const resolved = resolveRelativeImport(importPath, fileDir);
|
|
27
|
+
if (!resolved) continue;
|
|
28
|
+
if (isInNodeModules(resolved, cwd)) continue;
|
|
29
|
+
|
|
30
|
+
const violation = checkViolation(resolved, fileDir, cwd, index);
|
|
31
|
+
if (violation) violations.push(violation);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
for (const modulePath of extractRustUsePaths(afterContent)) {
|
|
35
|
+
const resolved = resolveRustCratePath(modulePath, srcRoot);
|
|
36
|
+
if (!resolved) continue;
|
|
37
|
+
if (isInNodeModules(resolved, cwd)) continue;
|
|
38
|
+
|
|
39
|
+
const violation = checkViolation(resolved, fileDir, cwd, index);
|
|
40
|
+
if (violation) violations.push(violation);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (violations.length === 0) return { blocked: false };
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
blocked: true,
|
|
47
|
+
reason: `Module interface import violations:\n${violations.map((v) => ` - ${v}`).join("\n")}`,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function checkViolation(
|
|
52
|
+
resolved: string,
|
|
53
|
+
fileDir: string,
|
|
54
|
+
cwd: string,
|
|
55
|
+
index: ModuleIndex,
|
|
56
|
+
): string | undefined {
|
|
57
|
+
const targetModule = findOwningModule(resolved, index);
|
|
58
|
+
if (!targetModule) return undefined;
|
|
59
|
+
|
|
60
|
+
const targetDir = path.dirname(resolved);
|
|
61
|
+
if (targetDir === fileDir) return undefined;
|
|
62
|
+
|
|
63
|
+
if (isInterfaceFile(resolved)) return undefined;
|
|
64
|
+
|
|
65
|
+
const sourceModule = findOwningModule(path.join(fileDir, "dummy.ts"), index);
|
|
66
|
+
if (sourceModule && sourceModule === targetModule) return undefined;
|
|
67
|
+
|
|
68
|
+
const relTarget = path.relative(cwd, resolved);
|
|
69
|
+
const relModule = path.relative(cwd, targetModule);
|
|
70
|
+
return `Import from "${relTarget}" bypasses module interface of ${relModule}/`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function extractJsImportPaths(content: string): string[] {
|
|
74
|
+
const results: string[] = [];
|
|
75
|
+
|
|
76
|
+
for (const m of content.matchAll(/^\s*import\s+[\s\S]*?\s+from\s+["']([^"']+)["']/gm)) {
|
|
77
|
+
results.push(m[1]);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
for (const m of content.matchAll(/^\s*(?:const|let|var)\s+[\s\S]*?=\s*require\s*\(\s*["']([^"']+)["']\s*\)/gm)) {
|
|
81
|
+
results.push(m[1]);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return results;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function extractRustUsePaths(content: string): string[] {
|
|
88
|
+
const results: string[] = [];
|
|
89
|
+
|
|
90
|
+
for (const m of content.matchAll(/^\s*(?:pub\s+)?use\s+crate::(\w+(?:::\w+)*)::/gm)) {
|
|
91
|
+
const segments = m[1].split("::");
|
|
92
|
+
if (segments.length >= 2) {
|
|
93
|
+
results.push(segments.join("/"));
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return results;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function resolveRelativeImport(importPath: string, fileDir: string): string | undefined {
|
|
101
|
+
if (!importPath.startsWith(".")) return undefined;
|
|
102
|
+
|
|
103
|
+
const resolved = path.resolve(fileDir, importPath);
|
|
104
|
+
const ext = path.extname(resolved);
|
|
105
|
+
|
|
106
|
+
if (ext) {
|
|
107
|
+
return fs.existsSync(resolved) ? resolved : undefined;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
for (const tryExt of [".ts", ".tsx", ".js", ".jsx", ".rs"]) {
|
|
111
|
+
const candidate = resolved + tryExt;
|
|
112
|
+
if (fs.existsSync(candidate)) return candidate;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return undefined;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function resolveRustCratePath(modulePath: string, srcRoot: string): string | undefined {
|
|
119
|
+
const segments = modulePath.split("/");
|
|
120
|
+
const base = path.resolve(srcRoot, ...segments);
|
|
121
|
+
|
|
122
|
+
const asFile = base + ".rs";
|
|
123
|
+
if (fs.existsSync(asFile)) return asFile;
|
|
124
|
+
|
|
125
|
+
const asMod = path.join(base, "mod.rs");
|
|
126
|
+
if (fs.existsSync(asMod)) return asMod;
|
|
127
|
+
|
|
128
|
+
return undefined;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function isInNodeModules(resolvedPath: string, _cwd: string): boolean {
|
|
132
|
+
return resolvedPath.split(path.sep).includes("node_modules");
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function isInterfaceFile(absPath: string): boolean {
|
|
136
|
+
const basename = path.basename(absPath);
|
|
137
|
+
const ext = path.extname(absPath);
|
|
138
|
+
|
|
139
|
+
if ([".ts", ".tsx", ".js", ".jsx"].includes(ext)) {
|
|
140
|
+
return ["index.ts", "index.tsx", "index.js", "index.jsx"].includes(basename);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (ext === ".rs") {
|
|
144
|
+
return basename === "mod.rs";
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return false;
|
|
148
|
+
}
|
package/src/graph/MODULE.md
CHANGED
|
@@ -8,7 +8,7 @@ import type { ModuleGateConfig } from "../config.ts";
|
|
|
8
8
|
type ModuleDescriptorReadonly = ModuleGateConfig["moduleDescriptorReadonly"];
|
|
9
9
|
import type { Dirent } from "node:fs";
|
|
10
10
|
import { validateVisibleEntries } from "./validation.ts";
|
|
11
|
-
import { parseVisibleEntry, type
|
|
11
|
+
import { parseVisibleEntry, type ModuleFrontmatter } from "./frontmatter-parser.ts";
|
|
12
12
|
|
|
13
13
|
type IndexContext = {
|
|
14
14
|
cwd: string;
|
package/src/graph/validation.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import * as path from "node:path";
|
|
2
2
|
import { readdir } from "node:fs/promises";
|
|
3
3
|
import type { ModuleIndex } from "../types.ts";
|
|
4
|
-
import { getChecker } from "../gates/checkers/
|
|
4
|
+
import { getChecker } from "../gates/checkers/index.ts";
|
|
5
5
|
import { readFileSafe } from "../utils.ts";
|
|
6
6
|
import type { Dirent } from "node:fs";
|
|
7
7
|
|
|
File without changes
|
package/src/index.ts
CHANGED
|
@@ -8,12 +8,15 @@ import { isToolCallEventType, parseFrontmatter } from "@earendil-works/pi-coding
|
|
|
8
8
|
import type { ModuleIndex } from "./types.ts";
|
|
9
9
|
import { loadConfig } from "./config.ts";
|
|
10
10
|
import type { ModuleGateConfig } from "./config.ts";
|
|
11
|
-
import { buildModuleIndex } from "./graph/
|
|
11
|
+
import { buildModuleIndex } from "./graph/index.ts";
|
|
12
12
|
import { findOwningModule, readFileSafe, applyEdits, isWithinSourceRoot } from "./utils.ts";
|
|
13
|
-
import {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
13
|
+
import {
|
|
14
|
+
checkReadonly,
|
|
15
|
+
checkExports,
|
|
16
|
+
checkFrozen,
|
|
17
|
+
checkModuleInterfaceImports,
|
|
18
|
+
} from "./gates/index.ts";
|
|
19
|
+
import { buildSystemPromptHint } from "./context/index.ts";
|
|
17
20
|
import "./gates/checkers/index.ts";
|
|
18
21
|
|
|
19
22
|
export default function (pi: ExtensionAPI): void {
|
|
@@ -101,6 +104,11 @@ function handleEdit(
|
|
|
101
104
|
return { block: true, reason: formatDenial(filePath, exportResult.reason, absPath, index, cwd, config.moduleDescriptorFileName) };
|
|
102
105
|
}
|
|
103
106
|
|
|
107
|
+
const importResult = checkModuleInterfaceImports(filePath, after, index, cwd, config.disableModuleInterfaceImportGate, config.sourceRoot);
|
|
108
|
+
if (importResult.blocked) {
|
|
109
|
+
return { block: true, reason: formatDenial(filePath, importResult.reason, absPath, index, cwd, config.moduleDescriptorFileName) };
|
|
110
|
+
}
|
|
111
|
+
|
|
104
112
|
return undefined;
|
|
105
113
|
}
|
|
106
114
|
|
package/src/types.ts
CHANGED