@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cuzfrog/pi-module-gates",
3
- "version": "0.8.0",
3
+ "version": "0.10.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"
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: 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);
@@ -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
- 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
+ }
@@ -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
+ }