@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 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-gate` entry to `.pi/settings.json`:
111
+ Add a `module-gates` entry to `.pi/settings.json`:
109
112
 
110
113
  ```json
111
114
  {
112
- "module-gate": {
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` | `"MODULE.md"` | File name used for module descriptors (case-insensitive) |
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-gate` key is present, defaults apply.
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.8.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-gate.git"
32
+ "url": "git+https://github.com/cuzfrog/pi-module-gates.git"
33
33
  },
34
34
  "bugs": {
35
- "url": "https://github.com/cuzfrog/pi-module-gate/issues"
35
+ "url": "https://github.com/cuzfrog/pi-module-gates/issues"
36
36
  },
37
- "homepage": "https://github.com/cuzfrog/pi-module-gate#readme",
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: "module.md" },
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: boolean;
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: true,
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-gate"] && typeof settings["module-gate"] === "object") {
23
- userConfig = settings["module-gate"];
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
- return { ...DEFAULTS, ...userConfig };
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: boolean,
8
+ moduleDescriptorReadonly: ModuleGateConfig["moduleDescriptorReadonly"],
8
9
  ): string {
9
10
  if (index.contracts.length === 0) return systemPrompt;
10
11
 
11
- const descriptorNote = moduleDescriptorReadonly
12
- ? ` The \`${descriptorFileName}\` file itself is readonly.`
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
+ }
@@ -1,2 +1,6 @@
1
1
  import "./typescript.ts";
2
2
  import "./rust.ts";
3
+ import "./java.ts";
4
+ import "./go.ts";
5
+ import "./kotlin.ts";
6
+ import "./scala.ts";
@@ -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
- 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] }));
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: boolean,
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
+ }