From 798085d149c0057c33189a5beadf8a2bf9396c8c Mon Sep 17 00:00:00 2001 From: Cause Chung Date: Mon, 20 Jul 2026 18:45:43 -0400 Subject: [PATCH] Migrate to general architecture --- .claude-plugin/marketplace.json | 15 + .claude-plugin/plugin.json | 11 + CLAUDE.md | 2 +- README.md | 125 ++----- bin/{pi-module-gates.mjs => module-gates.mjs} | 31 +- doc/Development.md | 119 +++++-- doc/visible.md | 56 +++ package-lock.json | 38 +- package.json | 30 +- src/bootstrap-jiti.mjs | 60 ++++ src/{ => bridges}/claude/MODULE.md | 4 +- src/bridges/claude/config-sources.ts | 7 + src/bridges/claude/hooks.json | 27 ++ src/{ => bridges}/claude/pre-tool-use.test.ts | 14 +- src/{ => bridges}/claude/pre-tool-use.ts | 41 ++- src/bridges/claude/run.mjs | 24 ++ src/bridges/claude/session-start.ts | 55 +++ .../claude/settings-writer.test.ts | 59 +-- src/{ => bridges}/claude/settings-writer.ts | 70 ++-- src/bridges/pi/MODULE.md | 4 + src/bridges/pi/index.ts | 55 +++ src/claude/index-loader.test.ts | 56 --- src/claude/index-loader.ts | 40 --- src/cli/cli.test.ts | 19 +- src/cli/install-claude.ts | 34 +- src/cli/uninstall-claude.ts | 12 +- src/config.test.ts | 199 ----------- src/{ => core}/MODULE.md | 6 +- src/core/config.test.ts | 211 +++++++++++ src/{ => core}/config.ts | 68 ++-- src/{ => core}/context/MODULE.md | 0 src/{ => core}/context/index.ts | 0 .../context/system-prompt.template.md | 4 +- src/{ => core}/context/system-prompt.test.ts | 20 +- src/{ => core}/context/system-prompt.ts | 3 +- src/core/engine.ts | 45 +++ src/{ => core}/gates/MODULE.md | 12 +- src/{ => core}/gates/checkers/MODULE.md | 0 src/{ => core}/gates/checkers/go.test.ts | 0 src/{ => core}/gates/checkers/go.ts | 0 src/{ => core}/gates/checkers/index.ts | 0 src/{ => core}/gates/checkers/java.test.ts | 0 src/{ => core}/gates/checkers/java.ts | 0 src/{ => core}/gates/checkers/kotlin.test.ts | 0 src/{ => core}/gates/checkers/kotlin.ts | 0 src/{ => core}/gates/checkers/registry.ts | 0 src/{ => core}/gates/checkers/rust.test.ts | 0 src/{ => core}/gates/checkers/rust.ts | 0 src/{ => core}/gates/checkers/scala.test.ts | 0 src/{ => core}/gates/checkers/scala.ts | 0 .../gates/checkers/typescript.test.ts | 0 src/{ => core}/gates/checkers/typescript.ts | 0 src/{ => core}/gates/index.ts | 0 .../module-interface-import-gate.test.ts | 0 .../gates/module-interface-import-gate.ts | 0 .../gates/no-new-exports-gate.test.ts | 12 - src/{ => core}/gates/no-new-exports-gate.ts | 0 src/{ => core}/gates/readonly-gate.test.ts | 9 - src/{ => core}/gates/readonly-gate.ts | 0 src/{ => core}/gates/run-gates.test.ts | 26 +- src/{ => core}/gates/run-gates.ts | 6 - src/{ => core}/graph/MODULE.md | 4 - src/core/graph/index.ts | 1 + .../graph/module-index-builder.test.ts | 239 ++----------- src/{ => core}/graph/module-index-builder.ts | 72 +--- src/core/index.ts | 27 ++ src/{ => core}/types.ts | 11 +- src/{ => core}/utils.test.ts | 3 +- src/{ => core}/utils.ts | 0 src/{ => core}/utils/MODULE.md | 0 src/{ => core}/utils/frontmatter.ts | 0 src/gates/export-gate.test.ts | 336 ------------------ src/gates/export-gate.ts | 73 ---- src/graph/frontmatter-parser.test.ts | 46 --- src/graph/frontmatter-parser.ts | 28 -- src/graph/index.ts | 4 - src/graph/validation.test.ts | 241 ------------- src/graph/validation.ts | 116 ------ src/import-test.ts | 0 src/index.ts | 50 --- test/behavior/dangling-entries.test.ts | 48 --- .../descriptor-case-insensitive.test.ts | 6 +- test/behavior/export-gating.test.ts | 134 ------- test/behavior/frontmatter-gating.test.ts | 4 +- test/behavior/malformed-module.test.ts | 4 +- test/behavior/modifier-lock.test.ts | 89 ----- .../module-descriptor-readonly-gating.test.ts | 6 +- .../module-interface-import-gating.test.ts | 4 +- test/behavior/no-new-exports-gating.test.ts | 4 +- test/behavior/readonly-gating.test.ts | 4 +- test/behavior/system-prompt-e2e.test.ts | 4 +- test/claude/sandbox-e2e.test.ts | 44 +-- test/fixture/malformed-module/module.md | 1 - test/fixture/module-modifier-lock/lib.rs | 3 - test/fixture/module-modifier-lock/module.md | 10 - test/fixture/module-simulation/module.md | 7 +- .../fixture/module-simulation/other/module.md | 4 - .../module-simulation/src/internal/module.md | 4 +- test/fixture/module-simulation/src/module.md | 3 +- test/fixture/module-simulation/stub.ts | 2 - test/skills/no-new-exports-all.test.ts | 24 +- 101 files changed, 1085 insertions(+), 2204 deletions(-) create mode 100644 .claude-plugin/marketplace.json create mode 100644 .claude-plugin/plugin.json rename bin/{pi-module-gates.mjs => module-gates.mjs} (56%) create mode 100644 doc/visible.md create mode 100644 src/bootstrap-jiti.mjs rename src/{ => bridges}/claude/MODULE.md (72%) create mode 100644 src/bridges/claude/config-sources.ts create mode 100644 src/bridges/claude/hooks.json rename src/{ => bridges}/claude/pre-tool-use.test.ts (86%) rename src/{ => bridges}/claude/pre-tool-use.ts (71%) create mode 100644 src/bridges/claude/run.mjs create mode 100644 src/bridges/claude/session-start.ts rename src/{ => bridges}/claude/settings-writer.test.ts (62%) rename src/{ => bridges}/claude/settings-writer.ts (50%) create mode 100644 src/bridges/pi/MODULE.md create mode 100644 src/bridges/pi/index.ts delete mode 100644 src/claude/index-loader.test.ts delete mode 100644 src/claude/index-loader.ts delete mode 100644 src/config.test.ts rename src/{ => core}/MODULE.md (85%) create mode 100644 src/core/config.test.ts rename src/{ => core}/config.ts (52%) rename src/{ => core}/context/MODULE.md (100%) rename src/{ => core}/context/index.ts (100%) rename src/{ => core}/context/system-prompt.template.md (74%) rename src/{ => core}/context/system-prompt.test.ts (90%) rename src/{ => core}/context/system-prompt.ts (96%) create mode 100644 src/core/engine.ts rename src/{ => core}/gates/MODULE.md (85%) rename src/{ => core}/gates/checkers/MODULE.md (100%) rename src/{ => core}/gates/checkers/go.test.ts (100%) rename src/{ => core}/gates/checkers/go.ts (100%) rename src/{ => core}/gates/checkers/index.ts (100%) rename src/{ => core}/gates/checkers/java.test.ts (100%) rename src/{ => core}/gates/checkers/java.ts (100%) rename src/{ => core}/gates/checkers/kotlin.test.ts (100%) rename src/{ => core}/gates/checkers/kotlin.ts (100%) rename src/{ => core}/gates/checkers/registry.ts (100%) rename src/{ => core}/gates/checkers/rust.test.ts (100%) rename src/{ => core}/gates/checkers/rust.ts (100%) rename src/{ => core}/gates/checkers/scala.test.ts (100%) rename src/{ => core}/gates/checkers/scala.ts (100%) rename src/{ => core}/gates/checkers/typescript.test.ts (100%) rename src/{ => core}/gates/checkers/typescript.ts (100%) rename src/{ => core}/gates/index.ts (100%) rename src/{ => core}/gates/module-interface-import-gate.test.ts (100%) rename src/{ => core}/gates/module-interface-import-gate.ts (100%) rename src/{ => core}/gates/no-new-exports-gate.test.ts (96%) rename src/{ => core}/gates/no-new-exports-gate.ts (100%) rename src/{ => core}/gates/readonly-gate.test.ts (94%) rename src/{ => core}/gates/readonly-gate.ts (100%) rename src/{ => core}/gates/run-gates.test.ts (87%) rename src/{ => core}/gates/run-gates.ts (93%) rename src/{ => core}/graph/MODULE.md (50%) create mode 100644 src/core/graph/index.ts rename src/{ => core}/graph/module-index-builder.test.ts (53%) rename src/{ => core}/graph/module-index-builder.ts (60%) create mode 100644 src/core/index.ts rename src/{ => core}/types.ts (64%) rename src/{ => core}/utils.test.ts (98%) rename src/{ => core}/utils.ts (100%) rename src/{ => core}/utils/MODULE.md (100%) rename src/{ => core}/utils/frontmatter.ts (100%) delete mode 100644 src/gates/export-gate.test.ts delete mode 100644 src/gates/export-gate.ts delete mode 100644 src/graph/frontmatter-parser.test.ts delete mode 100644 src/graph/frontmatter-parser.ts delete mode 100644 src/graph/index.ts delete mode 100644 src/graph/validation.test.ts delete mode 100644 src/graph/validation.ts delete mode 100644 src/import-test.ts delete mode 100644 src/index.ts delete mode 100644 test/behavior/dangling-entries.test.ts delete mode 100644 test/behavior/export-gating.test.ts delete mode 100644 test/behavior/modifier-lock.test.ts delete mode 100644 test/fixture/module-modifier-lock/lib.rs delete mode 100644 test/fixture/module-modifier-lock/module.md delete mode 100644 test/fixture/module-simulation/stub.ts diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json new file mode 100644 index 0000000..b6a1acc --- /dev/null +++ b/.claude-plugin/marketplace.json @@ -0,0 +1,15 @@ +{ + "name": "cuzfrog", + "owner": { + "name": "Cause Chung", + "email": "cuzfrog@gmail.com" + }, + "plugins": [ + { + "name": "module-gates", + "source": "./", + "description": "Enforce module boundary contracts (readonly, no-new-exports) on Claude Code edits.", + "category": "development" + } + ] +} diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json new file mode 100644 index 0000000..45bf3f3 --- /dev/null +++ b/.claude-plugin/plugin.json @@ -0,0 +1,11 @@ +{ + "name": "module-gates", + "version": "0.22.0", + "description": "Enforce module boundary contracts (readonly, no-new-exports) on Claude Code edits.", + "author": { + "name": "Cause Chung", + "email": "cuzfrog@gmail.com" + }, + "homepage": "https://github.com/cuzfrog/module-gates", + "hooks": "./src/bridges/claude/hooks.json" +} diff --git a/CLAUDE.md b/CLAUDE.md index a2c85a1..ddd00ee 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -80,7 +80,7 @@ Also refer to @README.md ## File Edit Checklist Pre-action: -- Before adding utility functions/logic, check `src/utils/` for reuse. +- Before adding utility functions/logic, check `src/core/utils/` for reuse. Post-action: - After file edit (semantic or logic change), run: `npm run test` diff --git a/README.md b/README.md index c2347d9..5bf4f33 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,13 @@ -# pi-module-gates - Constraints liberate, liberties constrain. +# module-gates - Constraints liberate, liberties constrain. -Experimental pi cli extension that controls the entropy of the codebase by enforcing code module boundaries. +Coding-agent extension that controls the entropy of the codebase by enforcing code module boundaries. It helps combat slop generation and code architecture degradation. -- Claude Code supported via a hook, see below. + +The core is agent-agnostic; per-agent **bridges** connect it to each agent: +- **pi** — pi extension +- **Claude Code** — plugin, or plain hooks installed by the CLI + +Adding support for another agent (qwen-code, cursor, ...) means adding a bridge. ## Problem @@ -14,7 +19,6 @@ AI coding agents produce edits with limited context knowledge (myopia) — their - `readonly` — files and directories the agent must not touch - `no-new-exports` — files where no new exports are allowed (body still editable) -- `visible` — the set of exports allowed to be added or modified in that module The extension intercepts agent `write`/`edit` operations and enforces these contracts. Violations are blocked with a clear reason. @@ -27,26 +31,43 @@ The attempt to add 2 public helper functions is blocked, forcing the agent to re 2. **System prompt** — Injects a hint so the agent knows to respect descriptor file conventions. 3. **Gating** — On every write/edit, checks: - **Readonly gate** — is the target file locked? - **No-new-exports gate** — would the change add new exports to a file in the `no-new-exports` list? - - **Export gate** — would the change introduce an export not in the `visible` list? + - **No-new-exports gate** — would the change add new exports to a file in the `no-new-exports` list? - **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`. A child module may import from a parent module's internal files (not recommended but allowed). (Only Typescript/JavaScript and Rust are supported) - **Import gate** (not implemented yet) — would the change introduce an import violating visibility scope? -- System prompt: [system-prompt.md](src/context/system-prompt.template.md) -- Currently [supported languages](src/gates/checkers/index.ts): **TypeScript/JavaScript**, **Rust**, **Java**, **Go**, **Kotlin**, **Scala** +- System prompt: [system-prompt.md](src/core/context/system-prompt.template.md) +- Currently [supported languages](src/core/gates/checkers/index.ts): **TypeScript/JavaScript**, **Rust**, **Java**, **Go**, **Kotlin**, **Scala** ## Installation + +### pi ```bash -pi install npm:@cuzfrog/pi-module-gates +pi install npm:@cuzfrog/module-gates ``` Or load directly for a single session: ```bash -pi -e npm:@cuzfrog/pi-module-gates +pi -e npm:@cuzfrog/module-gates +``` + +### Claude Code + +As a plugin, from this repository's marketplace (no login required — public repo): +``` +/plugin marketplace add cuzfrog/module-gates +/plugin install module-gates@cuzfrog +``` +On the first hook invocation the plugin installs its runtime dependencies into its data directory. + +Or as plain hooks wired into a project (requires the package installed in the project): +```bash +npm install --save-dev @cuzfrog/module-gates +npx module-gates install-claude ``` +This writes `PreToolUse` and `SessionStart` hooks into `.claude/settings.json`; `npx module-gates uninstall-claude` removes them. The `SessionStart` hook injects the system prompt hint automatically. ## Module Descriptor Semantics -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`. +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`. A `MODULE.md` only enforces its own immediate directory. ### Readonly constraints @@ -67,53 +88,16 @@ No-new-exports files cannot change their surface size: no new exports or public A skill [module-no-new-exports-all](skills/module-no-new-exports-all) has been included to populate no-new-exports entries in modules. -### Visibility whitelist (under redesign) - -```yaml -visible: - - greet # equivalent to `path: ./greet` - - sub/mod1/Foo -``` -or: -```yaml -visible: - - path: my_function - modifier: pub(crate) # (optional) demands an exact match -``` - | Scenario | Behavior | |----------|----------| -| `visible` key absent or no `MODULE.md` | Module is unconstrained — exports are not gated. Equivalent to `null` internally. | -| `visible: []` | Module is fully closed — no new exports may be added. Editing existing exports is still allowed. | +| No `MODULE.md` | Module is unconstrained — nothing is gated. | | Malformed YAML frontmatter | The module is left unguarded and an info notification is emitted. | -### Export gating - -``` -project/ - MODULE.md visible: [Foo, Bar] - src/ - MODULE.md visible: [Bar, Baz] - app.ts ← checked against `src/MODULE.md` only -``` -A `MODULE.md` only enforces exports within its immediate directory. - -### Import gating (not implemented yet) - -```yaml -# parent/MODULE.md -visible: - - sub/Tool # type Tool is allowed to be imported from parent - -# parent/sub/MODULE.md (before complement pass) -visible: - - Bar # type Bar is allowed to be imported from parent/sub within parent, but not outside parent -``` -A `MODULE.md` semantically gates exposures at the module level it resides. +> The `visible` export whitelist was removed pending a redesign — see [doc/visible.md](doc/visible.md). ## Configuration -Add a `module-gates` entry to `.pi/settings.json`: +The canonical agent-independent location is `.module-gates/config.json` (the whole file is the config, no wrapper key). When it is absent, each bridge falls back to the agents' settings files under a `module-gates` key — pi reads `.pi/settings.json` then `.claude/settings.json`; Claude Code reads `.claude/settings.json` then `.pi/settings.json`. The first existing source wins. ```json { @@ -137,45 +121,10 @@ Add a `module-gates` entry to `.pi/settings.json`: When no settings file exists or no `module-gates` key is present, defaults apply. -## Claude Code Support - -### Install -Add the following to `.claude/settings.json` in the current project, pointing the `PreToolUse` hook at the installed binary. -```json -{ - "hooks": { - "PreToolUse": [ - { - "matcher": "Edit|MultiEdit|Write", - "hooks": [ - { - "type": "command", - "command": "bun ${CLAUDE_PROJECT_DIR}/node_modules/@cuzfrog/pi-module-gates/src/claude/pre-tool-use.ts", - "statusMessage": "Module gate checking edit..." - } - ] - } - ] - } -} -``` - -If `pi-module-gates` is already installed in pi global dir, you can use below path instead: -``` -~/.pi/agent/npm/node_modules/@cuzfrog/pi-module-gates/src/claude/pre-tool-use.ts -``` - -### System prompt -You need to add [system-prompt.md](src/context/system-prompt.template.md) manually to your context. - -### Configuration - -Claude Code uses the same `.pi/settings.json#module-gates` block as the pi extension. See the Configuration section above. - -### Troubleshooting +## Troubleshooting Prompt: ``` -Check if PreToolUse hook `pi-module-gates` is triggered and runs expectedly. +Check if PreToolUse hook `module-gates` is triggered and runs expectedly. ``` ## License diff --git a/bin/pi-module-gates.mjs b/bin/module-gates.mjs similarity index 56% rename from bin/pi-module-gates.mjs rename to bin/module-gates.mjs index f9794eb..3994338 100755 --- a/bin/pi-module-gates.mjs +++ b/bin/module-gates.mjs @@ -1,27 +1,26 @@ -#!/usr/bin/env bun -// Bin entrypoint: executed by `bun` (see package.json#bin) and dispatches to -// src/cli/*.ts modules. Lives in .mjs for npm-install shebang compatibility on -// platforms that resolve `bin` to a file path directly; `bun` will load this as -// JavaScript since it has no TypeScript-specific syntax. -import { resolve, isAbsolute } from "node:path"; +#!/usr/bin/env node +// Plain JavaScript so npm can invoke it directly; TypeScript CLI modules are +// loaded through jiti (see src/bootstrap-jiti.mjs). +import { isAbsolute, join, resolve, dirname } from "node:path"; import { fileURLToPath } from "node:url"; +import { loadTsModule } from "../src/bootstrap-jiti.mjs"; -const PKG_ROOT = resolve(fileURLToPath(new URL(".", import.meta.url)), ".."); +const PKG_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."); function printUsage() { - process.stderr.write(`Usage: pi-module-gates [...args] + process.stderr.write(`Usage: module-gates [...args] Commands: - install-claude [--project-dir ] Install Claude Code PreToolUse hooks into /.claude/settings.json - uninstall-claude [--project-dir ] Remove Claude Code PreToolUse hooks from /.claude/settings.json + install-claude [--project-dir ] Install Claude Code hooks into /.claude/settings.json + uninstall-claude [--project-dir ] Remove Claude Code hooks from /.claude/settings.json Environment: CLAUDE_PROJECT_DIR Default --project-dir when running inside Claude Code. Examples: - pi-module-gates install-claude - pi-module-gates install-claude --project-dir /path/to/project - pi-module-gates uninstall-claude + module-gates install-claude + module-gates install-claude --project-dir /path/to/project + module-gates uninstall-claude `); } @@ -49,7 +48,7 @@ async function main() { const projectDir = parseProjectDir(rest); if (cmd === "install-claude") { - const mod = await import(resolve(PKG_ROOT, "src/cli/install-claude.ts")); + const mod = await loadTsModule(join(PKG_ROOT, "src/cli/install-claude.ts")); const result = mod.installClaude({ projectDir }); if (!result.ok) { process.stderr.write(`${result.reason}\n`); @@ -59,7 +58,7 @@ async function main() { } if (cmd === "uninstall-claude") { - const mod = await import(resolve(PKG_ROOT, "src/cli/uninstall-claude.ts")); + const mod = await loadTsModule(join(PKG_ROOT, "src/cli/uninstall-claude.ts")); const result = mod.uninstallClaude({ projectDir }); if (!result.ok) { process.stderr.write(`${result.reason}\n`); @@ -75,6 +74,6 @@ async function main() { main().catch((err) => { const message = err instanceof Error ? err.message : String(err); - process.stderr.write(`[pi-module-gates] Unexpected error: ${message}\n`); + process.stderr.write(`[module-gates] Unexpected error: ${message}\n`); process.exit(1); }); diff --git a/doc/Development.md b/doc/Development.md index adba040..b3ea5cd 100644 --- a/doc/Development.md +++ b/doc/Development.md @@ -2,10 +2,20 @@ ## Project -This is a **pi extension** (`pi-module-gates`) that enforces module-boundary rules during coding agent sessions. When the agent edits or writes files, the extension checks whether the operation respects each module's contract — i.e., are you allowed to write to this file, and are you exposing the right public API? +`module-gates` controls the entropy of the codebase by enforcing code module boundaries during coding agent sessions. When the agent edits or writes files, it checks whether the operation respects each module's contract. + +The core is agent-agnostic; bridges connect it to each agent: + +- `src/core/` — core logic (config, module index, gates, system prompt hint). Knows nothing about agents. +- `src/bridges/pi/` — pi extension bridge; wires pi session/tool events into the core. +- `src/bridges/claude/` — Claude Code bridge; `PreToolUse`/`SessionStart` hooks plus the settings writer used by the CLI. +- `src/cli/` + `bin/module-gates.mjs` — `module-gates install-claude | uninstall-claude`. +- `src/bootstrap-jiti.mjs` — shared Node bootstrap: loads TS entry points on the fly via [jiti](https://github.com/unjs/jiti). No build step, no bun. ## Setup +Node >= 20. + ```bash npm install ``` @@ -20,42 +30,83 @@ npm install ## Project Structure ``` +bin/ + module-gates.mjs — CLI entry (node + jiti) src/ - index.ts — Extension entry point, wires gates to pi events - types.ts — Public domain types (ModuleContract, ModuleIndex) - context/ - system-prompt.ts — Builds system prompt hint from module index - gates/ - readonly-gate.ts — Blocks writes to readonly files in a module - no-new-exports-gate.ts — Blocks new exports on no-new-exports files in a module - export-gate.ts — Blocks changes that break a module's public API - checkers/ - index.ts — Auto-registers all checkers - registry.ts — Checker registry: dispatch by language - typescript.ts — TS-specific public-API checker - rust.ts — Rust-specific public-API checker - graph/ - module-index-builder.ts — Scans for module.md files, builds the index - validation.ts — Validates that visible entries actually exist + bootstrap-jiti.mjs — jiti bootstrap shared by bin/ and claude run.mjs + core/ — agent-agnostic core; public surface: index.ts + index.ts — public re-exports + engine.ts — createGateEngine facade used by bridges + types.ts — shared domain types + config.ts — ConfigSource chain, first existing source wins + context/ + system-prompt.ts — builds system prompt hint from module index + gates/ + run-gates.ts — composes all gates for one edit + readonly-gate.ts — blocks writes to readonly files + no-new-exports-gate.ts — blocks new exports on no-new-exports files + module-interface-import-gate.ts — blocks imports of module internals + checkers/ — per-language public-API checkers + registry + graph/ + module-index-builder.ts — scans for descriptors, builds module index + utils/ + frontmatter.ts — YAML frontmatter parsing + bridges/ + pi/index.ts — pi extension entry + claude/ + run.mjs — hook runner entry (node + jiti) + hooks.json — plugin hook declarations + pre-tool-use.ts — PreToolUse hook + session-start.ts — SessionStart hook (system prompt injection) + settings-writer.ts — upsert/remove hooks in .claude/settings.json + config-sources.ts — config discovery chain for the claude bridge + cli/ + install-claude.ts + uninstall-claude.ts +.claude-plugin/ — Claude Code plugin + marketplace manifests (git-only, not in the npm tarball) test/ - index.test.ts - context/system-prompt.test.ts - gates/export-gate.test.ts - gates/readonly-gate.test.ts - gates/checkers/typescript.test.ts - gates/checkers/rust.test.ts - graph/module-index-builder.test.ts - graph/validation.test.ts - fixture/ - module-simulation/ — Realistic project tree used by multiple tests - malformed-module/ — Edge-case fixture -doc/ - AGENTS_GIT.md — Git conventions + gh bot setup - Development.md — This file + behavior/ — end-to-end gate behavior against fixtures + fixture/ — realistic project trees used by tests ``` +## Runtime bootstrap + +TypeScript runs on the fly — there is no compile step: +- pi loads TS entry points natively. +- Node entry points (`bin/module-gates.mjs`, `src/bridges/claude/run.mjs`) go through `src/bootstrap-jiti.mjs`. +- Installed as a Claude plugin (no `node_modules` in the plugin dir), the bootstrap installs runtime deps (`jiti`, `yaml`) into `${CLAUDE_PLUGIN_DATA}` on first hook invocation. + +## Config discovery + +`loadConfig(cwd, sources)` walks an ordered list of `ConfigSource = { filePath; key? }`; the first source whose file exists wins. A source without `key` reads the whole file. The core never knows agent-specific locations — each bridge supplies its chain: + +- canonical: `.module-gates/config.json` (whole file) +- pi bridge: then `.pi/settings.json#module-gates`, then `.claude/settings.json#module-gates` +- claude bridge: then `.claude/settings.json#module-gates`, then `.pi/settings.json#module-gates` + ## Adding a checker (new language) -1. Create `src/gates/checkers/.ts` exporting a function matching the `Checker` signature -2. Add it to `src/gates/checkers/index.ts` with an import + auto-register call -3. Create `test/gates/checkers/.test.ts` with unit tests +1. Create `src/core/gates/checkers/.ts` exporting a function matching the `Checker` signature +2. Register it in `src/core/gates/checkers/index.ts` +3. Add unit tests at `src/core/gates/checkers/.test.ts` + +## Adding a bridge (new agent) + +1. Create `src/bridges//`. +2. Define an ordered `ConfigSource[]` for the agent's settings locations (canonical first). +3. Create the engine once per session or per invocation: `createGateEngine(cwd, { configSources })`. +4. Map the agent's edit events to `{ oldText, newText }` pairs and call `engine.checkEdit`. +5. Surface `engine.diagnostics` prefixed with `[Module Gate] ` and inject `engine.systemPromptHint` where the agent supports it. +6. Fail open: bootstrap or indexing errors must never block the agent — log and allow. +7. Register the entry point where the agent's runtime expects it (e.g. `pi.extensions` in package.json, or a hooks manifest). + +## Publishing + +npm packages cannot be renamed, so the generalized package ships under a new name and the old one is deprecated: + +1. Bump `version` in `package.json` (package name is `@cuzfrog/module-gates`). +2. `npm publish` — `publishConfig` already sets public access and provenance (provenance requires publishing from GitHub Actions with an `NPM_TOKEN` secret). +3. Deprecate the old package once: `npm deprecate @cuzfrog/pi-module-gates "Renamed to @cuzfrog/module-gates"`. +4. Old `@cuzfrog/pi-module-gates` versions stay installable; new development lands on `@cuzfrog/module-gates` only. + +The Claude plugin is installed from this git repo via the marketplace, not from npm. When the repo is renamed to `module-gates`, update the marketplace command in README and the homepage/repository URLs in package.json. diff --git a/doc/visible.md b/doc/visible.md new file mode 100644 index 0000000..c447263 --- /dev/null +++ b/doc/visible.md @@ -0,0 +1,56 @@ +# `visible` — removed, pending redesign + +The `visible` export-whitelist feature was removed from the codebase to be redesigned and re-implemented later. This document records the old semantics so nothing is lost. + +## What it was + +A per-module whitelist of exports declared in the descriptor frontmatter: + +```yaml +visible: + - greet # bare string: equivalent to `path: ./greet` + - sub/mod1/Foo # path form: name extracted from the last path segment +``` + +or with an explicit modifier requirement: + +```yaml +visible: + - path: my_function + modifier: pub(crate) # optional, demanded an exact match +``` + +## Semantics + +| Scenario | Behavior | +|----------|----------| +| `visible` key absent | Module unconstrained — exports not gated (`null` internally). | +| `visible: []` | Module fully closed — no new exports may be added; editing existing exports still allowed. | +| Export not in the list | Write blocked (export gate). | +| Modifier mismatch | Write blocked when the entry declared a `modifier` and the export's modifier differed. | + +A descriptor only enforced exports within its immediate directory. + +### Complement pass + +When a parent module listed a path-based entry pointing into a child module (e.g. `sub/Helper`), the child module's `visible` list was automatically complemented with that name, so the child could not lock out what the parent exposed. + +### Validation + +At index time, `visible` entries referencing symbols that existed nowhere in the module produced an info-level "dangling entry" notification. + +### Scope boundary + +`visible` gated *writes* (adding/modifying exports). The planned import-side gating — external files importing only symbols a module declared visible — was never implemented. + +## Why it was removed + +The whitelist model interacted poorly with agent workflows and needs a redesign. Removing it simplifies the core while the remaining gates (`readonly`, `no-new-exports`, module-interface-import) stay in place. + +## Re-adding it + +The architecture keeps gates composable: each gate is a pure check over `(filePath, before, after, index, cwd)` returning a denial, and `runGates` composes them in sequence. The language checker registry (`getNewExports` per language, still used by the `no-new-exports` gate) survives intact. Re-adding means: + +1. New fields on `ModuleFrontmatter` / `ModuleContract`. +2. A new gate file under `gates/`, wired into `runGates`. +3. (Optional) index-time validation of entries, reported as diagnostics. diff --git a/package-lock.json b/package-lock.json index 3f5b8e0..24a86cf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,31 +1,36 @@ { - "name": "@cuzfrog/pi-module-gates", - "version": "0.17.1", + "name": "@cuzfrog/module-gates", + "version": "0.22.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "@cuzfrog/pi-module-gates", - "version": "0.17.1", + "name": "@cuzfrog/module-gates", + "version": "0.22.0", "license": "MIT", "dependencies": { + "jiti": "2.7.0", "yaml": "2.9.0" }, "bin": { - "pi-module-gates": "bin/pi-module-gates.mjs" + "module-gates": "bin/module-gates.mjs" }, "devDependencies": { "@earendil-works/pi-coding-agent": "0.79.8", "@types/node": "22.19.19", - "bun-types": "1.3.14", "typescript": "5.9.3", "vitest": "4.1.7" }, "engines": { - "bun": ">=1.3.0" + "node": ">=20" }, "peerDependencies": { "@earendil-works/pi-coding-agent": "*" + }, + "peerDependenciesMeta": { + "@earendil-works/pi-coding-agent": { + "optional": true + } } }, "node_modules/@earendil-works/pi-coding-agent": { @@ -2529,16 +2534,6 @@ "node": ">=12" } }, - "node_modules/bun-types": { - "version": "1.3.14", - "resolved": "https://registry.npmjs.org/bun-types/-/bun-types-1.3.14.tgz", - "integrity": "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, "node_modules/chai": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", @@ -2626,6 +2621,15 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, "node_modules/lightningcss": { "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", diff --git a/package.json b/package.json index 72583c6..c261887 100644 --- a/package.json +++ b/package.json @@ -1,9 +1,12 @@ { - "name": "@cuzfrog/pi-module-gates", - "version": "0.22.0", - "description": "pi extension that controls the entropy of the codebase by enforcing code module boundaries.", + "name": "@cuzfrog/module-gates", + "version": "1.0.0", + "description": "Controls the entropy of the codebase by enforcing code module boundaries. Ships bridges for pi and Claude Code.", "keywords": [ - "pi-package" + "pi-package", + "claude-code", + "module-boundaries", + "architecture" ], "type": "module", "scripts": { @@ -11,37 +14,42 @@ "test": "vitest run" }, "bin": { - "pi-module-gates": "./bin/pi-module-gates.mjs" + "module-gates": "./bin/module-gates.mjs" }, "pi": { "extensions": [ - "./src/index.ts" + "./src/bridges/pi/index.ts" ], "skills": [ "./skills" ] }, "dependencies": { + "jiti": "2.7.0", "yaml": "2.9.0" }, "peerDependencies": { "@earendil-works/pi-coding-agent": "*" }, + "peerDependenciesMeta": { + "@earendil-works/pi-coding-agent": { + "optional": true + } + }, "devDependencies": { "@earendil-works/pi-coding-agent": "0.79.8", "@types/node": "22.19.19", - "bun-types": "1.3.14", "typescript": "5.9.3", "vitest": "4.1.7" }, "repository": { "type": "git", - "url": "git+https://github.com/cuzfrog/pi-module-gates.git" + "url": "git+https://github.com/cuzfrog/module-gates.git" }, "bugs": { - "url": "https://github.com/cuzfrog/pi-module-gates/issues" + "url": "https://github.com/cuzfrog/module-gates/issues" }, - "homepage": "https://github.com/cuzfrog/pi-module-gates#readme", + "homepage": "https://github.com/cuzfrog/module-gates#readme", "license": "MIT", "author": "Cause Chung (cuzfrog@gmail.com)", "files": [ @@ -52,7 +60,7 @@ "LICENSE" ], "engines": { - "bun": ">=1.3.0" + "node": ">=20" }, "publishConfig": { "access": "public", diff --git a/src/bootstrap-jiti.mjs b/src/bootstrap-jiti.mjs new file mode 100644 index 0000000..73d3796 --- /dev/null +++ b/src/bootstrap-jiti.mjs @@ -0,0 +1,60 @@ +import { copyFileSync, existsSync, mkdirSync } from "node:fs"; +import { spawnSync } from "node:child_process"; +import { createRequire } from "node:module"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const PKG_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."); + +export async function loadTsModule(absScriptPath) { + const jiti = await loadJiti(); + return jiti.import(pathToFileURL(absScriptPath).href); +} + +async function loadJiti() { + const fromPkg = await jitiFrom(PKG_ROOT); + if (fromPkg) return fromPkg; + + const dataDir = process.env.CLAUDE_PLUGIN_DATA; + if (dataDir) { + ensurePluginDeps(dataDir); + const fromData = await jitiFrom(dataDir); + if (fromData) return fromData; + } + + throw new Error("could not load jiti from the package or CLAUDE_PLUGIN_DATA"); +} + +async function jitiFrom(baseDir) { + const require_ = createRequire(join(baseDir, "noop.js")); + let jitiEntry; + try { + jitiEntry = require_.resolve("jiti"); + } catch { + return undefined; + } + let yamlEntry; + try { + yamlEntry = require_.resolve("yaml"); + } catch { + yamlEntry = undefined; + } + const mod = await import(pathToFileURL(jitiEntry).href); + const createJiti = mod.createJiti ?? mod.default; + return createJiti(import.meta.url, yamlEntry ? { alias: { yaml: yamlEntry } } : {}); +} + +function ensurePluginDeps(dataDir) { + if (existsSync(join(dataDir, "node_modules", "jiti"))) return; + mkdirSync(dataDir, { recursive: true }); + copyFileSync(join(PKG_ROOT, "package.json"), join(dataDir, "package.json")); + const npm = process.platform === "win32" ? "npm.cmd" : "npm"; + const result = spawnSync( + npm, + ["install", "--omit=dev", "--omit=peer", "--omit=optional", "--no-audit", "--no-fund"], + { cwd: dataDir, stdio: ["ignore", "ignore", "inherit"], timeout: 180_000 }, + ); + if (result.status !== 0) { + throw new Error("npm install of plugin dependencies failed"); + } +} diff --git a/src/claude/MODULE.md b/src/bridges/claude/MODULE.md similarity index 72% rename from src/claude/MODULE.md rename to src/bridges/claude/MODULE.md index 518f722..019956a 100644 --- a/src/claude/MODULE.md +++ b/src/bridges/claude/MODULE.md @@ -1,9 +1,9 @@ --- no-new-exports: - - index-loader.test.ts - - index-loader.ts + - config-sources.ts - pre-tool-use.test.ts - pre-tool-use.ts + - session-start.ts - settings-writer.test.ts - settings-writer.ts --- diff --git a/src/bridges/claude/config-sources.ts b/src/bridges/claude/config-sources.ts new file mode 100644 index 0000000..6540b41 --- /dev/null +++ b/src/bridges/claude/config-sources.ts @@ -0,0 +1,7 @@ +import type { ConfigSource } from "../../core/index.ts"; + +export const CLAUDE_CONFIG_SOURCES: ConfigSource[] = [ + { filePath: ".module-gates/config.json" }, + { filePath: ".claude/settings.json", key: "module-gates" }, + { filePath: ".pi/settings.json", key: "module-gates" }, +]; diff --git a/src/bridges/claude/hooks.json b/src/bridges/claude/hooks.json new file mode 100644 index 0000000..3cdfcf5 --- /dev/null +++ b/src/bridges/claude/hooks.json @@ -0,0 +1,27 @@ +{ + "hooks": { + "PreToolUse": [ + { + "matcher": "Edit|MultiEdit|Write", + "hooks": [ + { + "type": "command", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/src/bridges/claude/run.mjs\" pre-tool-use", + "statusMessage": "Module gate checking edit..." + } + ] + } + ], + "SessionStart": [ + { + "matcher": "startup|resume|clear", + "hooks": [ + { + "type": "command", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/src/bridges/claude/run.mjs\" session-start" + } + ] + } + ] + } +} diff --git a/src/claude/pre-tool-use.test.ts b/src/bridges/claude/pre-tool-use.test.ts similarity index 86% rename from src/claude/pre-tool-use.test.ts rename to src/bridges/claude/pre-tool-use.test.ts index 85b323a..4cc3030 100644 --- a/src/claude/pre-tool-use.test.ts +++ b/src/bridges/claude/pre-tool-use.test.ts @@ -2,21 +2,21 @@ import { describe, it, expect, beforeAll } from "vitest"; import * as fs from "node:fs"; import * as path from "node:path"; import { spawnSync } from "node:child_process"; -import { FIXTURES } from "../../test/behavior/helpers.ts"; +import { FIXTURES } from "../../../test/behavior/helpers.ts"; -const HOOK = path.resolve("src/claude/pre-tool-use.ts"); +const RUN = path.resolve("src/bridges/claude/run.mjs"); beforeAll(() => { - if (!fs.existsSync(HOOK)) { - throw new Error(`${HOOK} not found.`); + if (!fs.existsSync(RUN)) { + throw new Error(`${RUN} not found.`); } }); function runHook(stdinObj: unknown, cwd?: string) { - return spawnSync("bun", [HOOK], { + return spawnSync("node", [RUN, "pre-tool-use"], { input: JSON.stringify(stdinObj), encoding: "utf-8", - timeout: 10_000, + timeout: 30_000, cwd, }); } @@ -87,7 +87,7 @@ describe("pre-tool-use hook", () => { }); it("fails open on malformed JSON", () => { - const r = spawnSync("bun", [HOOK], { input: "not json", encoding: "utf-8", timeout: 10_000 }); + const r = spawnSync("node", [RUN, "pre-tool-use"], { input: "not json", encoding: "utf-8", timeout: 30_000 }); expect(r.status).toBe(0); expect(r.stderr).toContain("[Module Gate]"); }); diff --git a/src/claude/pre-tool-use.ts b/src/bridges/claude/pre-tool-use.ts similarity index 71% rename from src/claude/pre-tool-use.ts rename to src/bridges/claude/pre-tool-use.ts index 7321f88..cc25837 100644 --- a/src/claude/pre-tool-use.ts +++ b/src/bridges/claude/pre-tool-use.ts @@ -1,9 +1,12 @@ import * as fs from "node:fs"; import * as path from "node:path"; -import { applyEdits, readFileSafe } from "../utils.ts"; -import { runGates, type GateEdit } from "../gates/run-gates.ts"; -import { loadIndexForHook, notifyNoContracts } from "./index-loader.ts"; -import "../gates/checkers/index.ts"; +import { + applyEdits, + createGateEngine, + readFileSafe, + type GateEdit, +} from "../../core/index.ts"; +import { CLAUDE_CONFIG_SOURCES } from "./config-sources.ts"; type HookEvent = { hook_event_name?: string; @@ -18,13 +21,6 @@ type HookEvent = { cwd?: string; }; -const notifyCtx = (): { cwd: string; ui: { notify: (m: string) => void } } => ({ - cwd: process.cwd(), - ui: { - notify: (m) => process.stderr.write(`[Module Gate] ${m}\n`), - }, -}); - async function main(): Promise { let raw: string; try { @@ -75,14 +71,27 @@ async function main(): Promise { after = toolInput.content ?? ""; } - const { index, config } = await loadIndexForHook(cwd); + let engine; + try { + engine = await createGateEngine(cwd, { configSources: CLAUDE_CONFIG_SOURCES }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + process.stderr.write(`[Module Gate] index build failed: ${message}\n`); + process.exit(0); + } + + for (const d of engine.diagnostics) { + process.stderr.write(`[Module Gate] ${d.message}\n`); + } - if (index.contracts.length === 0) { - notifyNoContracts(notifyCtx()); + if (engine.index.contracts.length === 0) { + process.stderr.write("[Module Gate] No module descriptor files found. Gates are not active.\n"); process.exit(0); } - const result = runGates(filePath, [{ oldText: before, newText: after }], cwd, index, config, before); + const result = engine.checkEdit(filePath, [{ oldText: before, newText: after }], { + beforeOverride: before, + }); if (result?.block) { process.stderr.write(`${result.reason}\n`); @@ -96,4 +105,4 @@ main().catch((err) => { const message = err instanceof Error ? err.message : String(err); process.stderr.write(`[Module Gate] hook internal error: ${message}\n`); process.exit(0); -}); \ No newline at end of file +}); diff --git a/src/bridges/claude/run.mjs b/src/bridges/claude/run.mjs new file mode 100644 index 0000000..5674468 --- /dev/null +++ b/src/bridges/claude/run.mjs @@ -0,0 +1,24 @@ +#!/usr/bin/env node +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { loadTsModule } from "../../bootstrap-jiti.mjs"; + +const HOOK_SCRIPTS = { + "pre-tool-use": "pre-tool-use.ts", + "session-start": "session-start.ts", +}; + +const here = dirname(fileURLToPath(import.meta.url)); +const subcommand = process.argv[2]; +const script = HOOK_SCRIPTS[subcommand]; + +if (!script) { + process.stderr.write(`[Module Gate] unknown hook subcommand: ${subcommand ?? ""}\n`); + process.exit(0); +} + +loadTsModule(join(here, script)).catch((err) => { + const message = err instanceof Error ? err.message : String(err); + process.stderr.write(`[Module Gate] hook bootstrap failed: ${message}\n`); + process.exit(0); +}); diff --git a/src/bridges/claude/session-start.ts b/src/bridges/claude/session-start.ts new file mode 100644 index 0000000..955958c --- /dev/null +++ b/src/bridges/claude/session-start.ts @@ -0,0 +1,55 @@ +import * as fs from "node:fs"; +import { createGateEngine } from "../../core/index.ts"; +import { CLAUDE_CONFIG_SOURCES } from "./config-sources.ts"; + +type SessionStartEvent = { + hook_event_name?: string; + cwd?: string; +}; + +async function main(): Promise { + let raw: string; + try { + raw = fs.readFileSync(0, "utf-8"); + } catch { + process.exit(0); + } + + let event: SessionStartEvent; + try { + event = JSON.parse(raw); + } catch { + process.exit(0); + } + + const cwd = event.cwd ?? process.env.CLAUDE_PROJECT_DIR ?? process.cwd(); + + let engine; + try { + engine = await createGateEngine(cwd, { configSources: CLAUDE_CONFIG_SOURCES }); + } catch { + process.exit(0); + } + + for (const d of engine.diagnostics) { + process.stderr.write(`[Module Gate] ${d.message}\n`); + } + + if (engine.config.disableSystemPrompt) process.exit(0); + if (engine.index.contracts.length === 0) process.exit(0); + + const additionalContext = engine.systemPromptHint("").trim(); + process.stdout.write( + JSON.stringify({ + hookSpecificOutput: { + hookEventName: "SessionStart", + additionalContext, + }, + }), + ); + process.exit(0); +} + +main().catch(() => { + process.exit(0); +}); diff --git a/src/claude/settings-writer.test.ts b/src/bridges/claude/settings-writer.test.ts similarity index 62% rename from src/claude/settings-writer.test.ts rename to src/bridges/claude/settings-writer.test.ts index ba8cc75..5712877 100644 --- a/src/claude/settings-writer.test.ts +++ b/src/bridges/claude/settings-writer.test.ts @@ -4,8 +4,8 @@ import * as os from "node:os"; import * as path from "node:path"; import { readSettings, - upsertPreToolUse, - removePreToolUse, + upsertHooks, + removeHooks, writeSettings, HOOK_MARKER, } from "./settings-writer.ts"; @@ -18,61 +18,76 @@ afterEach(() => { fs.rmSync(tmp, { recursive: true, force: true }); }); +function markerCount(matchers: { hooks: { command?: string }[] }[] | undefined): number { + return (matchers ?? []).filter((m) => + m.hooks.some((h) => typeof h.command === "string" && h.command.includes(HOOK_MARKER)), + ).length; +} + describe("settings writer", () => { - it("upsert on empty produces the marker entry", () => { - const result = upsertPreToolUse({}); - const hooks = result.hooks?.PreToolUse ?? []; - expect(hooks).toHaveLength(1); - expect(hooks[0].matcher).toBe("Edit|MultiEdit|Write"); - expect(hooks[0].hooks[0].command).toContain(HOOK_MARKER); - expect(hooks[0].hooks[0].command).toContain("${CLAUDE_PROJECT_DIR}"); + it("upsert on empty produces marker entries for both events", () => { + const result = upsertHooks({}); + const pre = result.hooks?.PreToolUse ?? []; + const session = result.hooks?.SessionStart ?? []; + expect(pre).toHaveLength(1); + expect(pre[0].matcher).toBe("Edit|MultiEdit|Write"); + expect(pre[0].hooks[0].command).toContain(HOOK_MARKER); + expect(pre[0].hooks[0].command).toContain("${CLAUDE_PROJECT_DIR}"); + expect(pre[0].hooks[0].command).toContain("pre-tool-use"); + expect(session).toHaveLength(1); + expect(session[0].matcher).toBe("startup|resume|clear"); + expect(session[0].hooks[0].command).toContain("session-start"); }); it("is idempotent", () => { - const a = upsertPreToolUse({}); - const b = upsertPreToolUse(a); - expect((b.hooks?.PreToolUse ?? []).filter((m) => m.hooks.some((h) => typeof h.command === "string" && h.command.includes(HOOK_MARKER)))).toHaveLength(1); + const a = upsertHooks({}); + const b = upsertHooks(a); + expect(markerCount(b.hooks?.PreToolUse)).toBe(1); + expect(markerCount(b.hooks?.SessionStart)).toBe(1); }); it("preserves unrelated top-level keys", () => { - const a = upsertPreToolUse({ permissions: { allow: ["Bash"] }, model: "sonnet" }); + const a = upsertHooks({ permissions: { allow: ["Bash"] }, model: "sonnet" }); expect(a.permissions).toEqual({ allow: ["Bash"] }); expect(a.model).toBe("sonnet"); }); it("preserves other hook events", () => { - const a = upsertPreToolUse({ + const a = upsertHooks({ hooks: { PostToolUse: [{ matcher: "*", hooks: [{ type: "command", command: "echo done" }] }], }, }); expect(a.hooks?.PostToolUse).toHaveLength(1); expect(a.hooks?.PreToolUse).toHaveLength(1); + expect(a.hooks?.SessionStart).toHaveLength(1); }); it("remove strips only marker entries", () => { - const a = upsertPreToolUse({ + const a = upsertHooks({ hooks: { PreToolUse: [ { matcher: "*", hooks: [{ type: "command", command: "echo other" }] }, ], }, }); - const b = removePreToolUse(a); + const b = removeHooks(a); const pre = b.hooks?.PreToolUse ?? []; expect(pre).toHaveLength(1); expect(pre[0].hooks[0].command).toBe("echo other"); + expect(b.hooks?.SessionStart).toBeUndefined(); }); it("remove on marker-only yields empty settings", () => { - const a = upsertPreToolUse({}); - const b = removePreToolUse(a); + const a = upsertHooks({}); + const b = removeHooks(a); expect(b.hooks?.PreToolUse).toBeUndefined(); + expect(b.hooks?.SessionStart).toBeUndefined(); expect(b.hooks).toBeUndefined(); }); it("writeSettings creates .claude dir and writes JSON", () => { - const a = upsertPreToolUse({}); + const a = upsertHooks({}); const written = writeSettings(tmp, a); expect(written).toBe(path.join(tmp, ".claude", "settings.json")); const content = fs.readFileSync(written, "utf-8"); @@ -85,7 +100,7 @@ describe("settings writer", () => { }); it("preserves matcher entries with a non-command hook (e.g. type: prompt)", () => { - const a = upsertPreToolUse({ + const a = upsertHooks({ hooks: { PreToolUse: [ { matcher: "*", hooks: [{ type: "prompt" }] }, @@ -98,9 +113,9 @@ describe("settings writer", () => { expect(promptMatcher?.hooks[0].type).toBe("prompt"); const own = pre.find((m) => m.matcher === "Edit|MultiEdit|Write"); expect(own).toBeDefined(); - const b = removePreToolUse(a); + const b = removeHooks(a); const after = b.hooks?.PreToolUse ?? []; expect(after.find((m) => m.matcher === "*")?.hooks[0].type).toBe("prompt"); expect(after.find((m) => m.matcher === "Edit|MultiEdit|Write")).toBeUndefined(); }); -}); \ No newline at end of file +}); diff --git a/src/claude/settings-writer.ts b/src/bridges/claude/settings-writer.ts similarity index 50% rename from src/claude/settings-writer.ts rename to src/bridges/claude/settings-writer.ts index b530dfc..968fae4 100644 --- a/src/claude/settings-writer.ts +++ b/src/bridges/claude/settings-writer.ts @@ -1,16 +1,11 @@ import * as fs from "node:fs"; import * as path from "node:path"; -export const HOOK_MARKER = "@cuzfrog/pi-module-gates"; +export const HOOK_MARKER = "@cuzfrog/module-gates"; export const PRE_TOOL_USE_MATCHER = "Edit|MultiEdit|Write"; +export const SESSION_START_MATCHER = "startup|resume|clear"; -export type ClaudeHookCommand = { - type: "command"; - command: string; - statusMessage?: string; -}; - -export type ClaudeHook = ClaudeHookCommand | { +export type ClaudeHook = { type: string; command?: string; statusMessage?: string; @@ -47,38 +42,44 @@ export function buildPreToolUseEntry(): HookMatcher { hooks: [ { type: "command", - command: `bun \${CLAUDE_PROJECT_DIR}/node_modules/@cuzfrog/pi-module-gates/src/claude/pre-tool-use.ts`, + command: `node ${HOOK_BASE} pre-tool-use`, statusMessage: "Module gate checking edit...", }, ], }; } -export function upsertPreToolUse(settings: ClaudeSettings): ClaudeSettings { +export function buildSessionStartEntry(): HookMatcher { + return { + matcher: SESSION_START_MATCHER, + hooks: [ + { + type: "command", + command: `node ${HOOK_BASE} session-start`, + }, + ], + }; +} + +export function upsertHooks(settings: ClaudeSettings): ClaudeSettings { const next: ClaudeSettings = JSON.parse(JSON.stringify(settings)); next.hooks = next.hooks ?? {}; - const existing = next.hooks.PreToolUse ?? []; - next.hooks.PreToolUse = existing.filter( - (m) => !m.hooks.some((h) => typeof h.command === "string" && h.command.includes(HOOK_MARKER)), - ); - next.hooks.PreToolUse.push(buildPreToolUseEntry()); + next.hooks.PreToolUse = upsertEvent(next.hooks.PreToolUse ?? [], buildPreToolUseEntry()); + next.hooks.SessionStart = upsertEvent(next.hooks.SessionStart ?? [], buildSessionStartEntry()); return next; } -export function removePreToolUse(settings: ClaudeSettings): ClaudeSettings { +export function removeHooks(settings: ClaudeSettings): ClaudeSettings { const next: ClaudeSettings = JSON.parse(JSON.stringify(settings)); - if (!next.hooks?.PreToolUse) return next; - const filtered = next.hooks.PreToolUse.filter( - (m) => !m.hooks.some((h) => typeof h.command === "string" && h.command.includes(HOOK_MARKER)), - ); - if (filtered.length === 0) { - delete next.hooks.PreToolUse; - if (Object.keys(next.hooks).length === 0) { - delete next.hooks; - } - } else { - next.hooks.PreToolUse = filtered; + if (!next.hooks) return next; + for (const event of ["PreToolUse", "SessionStart"]) { + const existing = next.hooks[event]; + if (!existing) continue; + const filtered = existing.filter((m) => !hasMarker(m)); + if (filtered.length === 0) delete next.hooks[event]; + else next.hooks[event] = filtered; } + if (Object.keys(next.hooks).length === 0) delete next.hooks; return next; } @@ -88,4 +89,17 @@ export function writeSettings(projectDir: string, settings: ClaudeSettings): str const target = path.join(claudeDir, "settings.json"); fs.writeFileSync(target, JSON.stringify(settings, null, 2) + "\n", "utf-8"); return target; -} \ No newline at end of file +} + +const HOOK_BASE = + "${CLAUDE_PROJECT_DIR}/node_modules/@cuzfrog/module-gates/src/bridges/claude/run.mjs"; + +function upsertEvent(existing: HookMatcher[], entry: HookMatcher): HookMatcher[] { + return [...existing.filter((m) => !hasMarker(m)), entry]; +} + +function hasMarker(matcher: HookMatcher): boolean { + return matcher.hooks.some( + (h) => typeof h.command === "string" && h.command.includes(HOOK_MARKER), + ); +} diff --git a/src/bridges/pi/MODULE.md b/src/bridges/pi/MODULE.md new file mode 100644 index 0000000..e1049c4 --- /dev/null +++ b/src/bridges/pi/MODULE.md @@ -0,0 +1,4 @@ +--- +no-new-exports: + - index.ts +--- diff --git a/src/bridges/pi/index.ts b/src/bridges/pi/index.ts new file mode 100644 index 0000000..584d239 --- /dev/null +++ b/src/bridges/pi/index.ts @@ -0,0 +1,55 @@ +import * as path from "node:path"; +import type { + BeforeAgentStartEventResult, + ExtensionAPI, + ToolCallEventResult, +} from "@earendil-works/pi-coding-agent"; +import { isToolCallEventType } from "@earendil-works/pi-coding-agent"; +import { + createGateEngine, + readFileSafe, + type ConfigSource, + type GateEngine, +} from "../../core/index.ts"; + +const CONFIG_SOURCES: ConfigSource[] = [ + { filePath: ".module-gates/config.json" }, + { filePath: ".pi/settings.json", key: "module-gates" }, + { filePath: ".claude/settings.json", key: "module-gates" }, +]; + +export default function (pi: ExtensionAPI): void { + let engine: GateEngine | undefined; + + pi.on("session_start", async (_event, ctx) => { + engine = await createGateEngine(ctx.cwd, { configSources: CONFIG_SOURCES }); + for (const d of engine.diagnostics) { + ctx.ui.notify(`[Module Gate] ${d.message}`, d.level); + } + if (engine.index.contracts.length === 0) { + ctx.ui.notify( + "[Module Gate] No module descriptor files found. Gates are not active.", + "info", + ); + } + }); + + pi.on("before_agent_start", async (event): Promise => { + if (!engine || engine.index.contracts.length === 0) return; + return { systemPrompt: engine.systemPromptHint(event.systemPrompt) }; + }); + + pi.on("tool_call", async (event, ctx): Promise => { + if (!engine) return; + if (isToolCallEventType("edit", event)) { + return engine.checkEdit(event.input.path, event.input.edits); + } + if (isToolCallEventType("write", event)) { + const absPath = path.resolve(ctx.cwd, event.input.path); + const before = readFileSafe(absPath); + return engine.checkEdit(event.input.path, [ + { oldText: before, newText: event.input.content }, + ]); + } + }); +} diff --git a/src/claude/index-loader.test.ts b/src/claude/index-loader.test.ts deleted file mode 100644 index 7c9c4f1..0000000 --- a/src/claude/index-loader.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; -import * as fs from "node:fs"; -import * as os from "node:os"; -import * as path from "node:path"; - -let tmp: string; -beforeEach(() => { - vi.resetModules(); - tmp = fs.mkdtempSync(path.join(os.tmpdir(), "pmg-index-loader-")); -}); -afterEach(() => { - fs.rmSync(tmp, { recursive: true, force: true }); - vi.doUnmock("../graph/index.ts"); - vi.restoreAllMocks(); -}); - -describe("loadIndexForHook", () => { - it("returns an empty index when buildModuleIndex throws", async () => { - vi.doMock("../graph/index.ts", () => ({ - buildModuleIndex: () => { - throw new Error("boom"); - }, - })); - const mod = await import("./index-loader.ts"); - const result = await mod.loadIndexForHook(tmp); - expect(result.index.contracts).toEqual([]); - expect(result.index.dirToModule.size).toBe(0); - expect(result.config.moduleDescriptorFileName).toBe("module.md"); - }); - - it("returns the built index on success", async () => { - fs.mkdirSync(path.join(tmp, "src"), { recursive: true }); - fs.writeFileSync(path.join(tmp, "src", "app.ts"), ""); - fs.writeFileSync( - path.join(tmp, "src", "module.md"), - "---\nvisible: [greet]\n---\nGreeting module.\n", - "utf-8", - ); - const mod = await import("./index-loader.ts"); - const result = await mod.loadIndexForHook(tmp); - expect(result.index.contracts.length).toBeGreaterThanOrEqual(1); - expect(result.config.moduleDescriptorFileName).toBe("module.md"); - }); -}); - -describe("notifyNoContracts", () => { - it("writes the expected line via the ctx notify", async () => { - const mod = await import("./index-loader.ts"); - const recorded: string[] = []; - mod.notifyNoContracts({ - cwd: "/tmp", - ui: { notify: (m) => recorded.push(m) }, - }); - expect(recorded).toEqual(["No module descriptor files found. Gates are not active."]); - }); -}); diff --git a/src/claude/index-loader.ts b/src/claude/index-loader.ts deleted file mode 100644 index ac8151d..0000000 --- a/src/claude/index-loader.ts +++ /dev/null @@ -1,40 +0,0 @@ -import type { ModuleIndex } from "../types.ts"; -import type { ModuleGateConfig } from "../config.ts"; -import { loadConfig } from "../config.ts"; -import { buildModuleIndex } from "../graph/index.ts"; - -export type IndexContext = { - cwd: string; - ui: { notify: (msg: string) => void }; -}; - -export type LoadIndexResult = { - index: ModuleIndex; - config: ModuleGateConfig; -}; - -export async function loadIndexForHook(cwd: string): Promise { - const config = loadConfig(cwd); - const ctx: IndexContext = { - cwd, - ui: { - notify: (m) => process.stderr.write(`[Module Gate] ${m}\n`), - }, - }; - - try { - const index = await buildModuleIndex(ctx, config); - return { index, config }; - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - process.stderr.write(`[Module Gate] index build failed: ${message}\n`); - return { - index: { contracts: [], dirToModule: new Map() }, - config, - }; - } -} - -export function notifyNoContracts(ctx: IndexContext): void { - ctx.ui.notify("No module descriptor files found. Gates are not active."); -} \ No newline at end of file diff --git a/src/cli/cli.test.ts b/src/cli/cli.test.ts index 6128eb8..e4a28a9 100644 --- a/src/cli/cli.test.ts +++ b/src/cli/cli.test.ts @@ -5,7 +5,8 @@ import * as path from "node:path"; import { spawnSync } from "node:child_process"; import { FIXTURES } from "../../test/behavior/helpers.ts"; -const BIN = path.resolve("bin/pi-module-gates.mjs"); +const BIN = path.resolve("bin/module-gates.mjs"); +const RUN = path.resolve("src/bridges/claude/run.mjs"); let tmp: string; beforeEach(() => { @@ -16,10 +17,10 @@ afterEach(() => { }); function cli(...args: string[]) { - return spawnSync(BIN, args, { encoding: "utf-8", timeout: 15_000 }); + return spawnSync(BIN, args, { encoding: "utf-8", timeout: 30_000 }); } -describe("pi-module-gates CLI", () => { +describe("module-gates CLI", () => { it("install-claude writes settings.json with the marker", () => { const r = cli("install-claude", "--project-dir", tmp); expect(r.status).toBe(0); @@ -27,7 +28,7 @@ describe("pi-module-gates CLI", () => { expect(fs.existsSync(settingsPath)).toBe(true); const json = JSON.parse(fs.readFileSync(settingsPath, "utf-8")); const pre = json.hooks?.PreToolUse ?? []; - expect(pre.some((m: { hooks: { command: string }[] }) => m.hooks.some((h) => h.command.includes("@cuzfrog/pi-module-gates")))).toBe(true); + expect(pre.some((m: { hooks: { command: string }[] }) => m.hooks.some((h) => h.command.includes("@cuzfrog/module-gates")))).toBe(true); }); it("install-claude is idempotent", () => { @@ -35,7 +36,7 @@ describe("pi-module-gates CLI", () => { cli("install-claude", "--project-dir", tmp); const json = JSON.parse(fs.readFileSync(path.join(tmp, ".claude", "settings.json"), "utf-8")); const pre = json.hooks?.PreToolUse ?? []; - expect(pre.filter((m: { hooks: { command: string }[] }) => m.hooks.some((h) => h.command.includes("@cuzfrog/pi-module-gates")))).toHaveLength(1); + expect(pre.filter((m: { hooks: { command: string }[] }) => m.hooks.some((h) => h.command.includes("@cuzfrog/module-gates")))).toHaveLength(1); }); it("install-claude preserves unrelated settings keys", () => { @@ -51,7 +52,7 @@ describe("pi-module-gates CLI", () => { cli("uninstall-claude", "--project-dir", tmp); const json = JSON.parse(fs.readFileSync(path.join(tmp, ".claude", "settings.json"), "utf-8")); const pre = json.hooks?.PreToolUse ?? []; - expect(pre.filter((m: { hooks: { command: string }[] }) => m.hooks.some((h) => h.command.includes("@cuzfrog/pi-module-gates")))).toHaveLength(0); + expect(pre.filter((m: { hooks: { command: string }[] }) => m.hooks.some((h) => h.command.includes("@cuzfrog/module-gates")))).toHaveLength(0); }); it("end-to-end: install, invoke hook, deny on readonly, uninstall", () => { @@ -62,10 +63,10 @@ describe("pi-module-gates CLI", () => { tool_input: { file_path: "src/config.ts", content: "// modified" }, cwd: FIXTURES, }; - const hook = spawnSync("bun", [path.resolve("src/claude/pre-tool-use.ts")], { + const hook = spawnSync("node", [RUN, "pre-tool-use"], { input: JSON.stringify(payload), encoding: "utf-8", - timeout: 10_000, + timeout: 30_000, cwd: FIXTURES, }); expect(hook.status).toBe(2); @@ -73,7 +74,7 @@ describe("pi-module-gates CLI", () => { cli("uninstall-claude", "--project-dir", FIXTURES); const after = JSON.parse(fs.readFileSync(path.join(FIXTURES, ".claude", "settings.json"), "utf-8")); const pre = after.hooks?.PreToolUse ?? []; - expect(pre.filter((m: { hooks: { command: string }[] }) => m.hooks.some((h) => h.command.includes("@cuzfrog/pi-module-gates")))).toHaveLength(0); + expect(pre.filter((m: { hooks: { command: string }[] }) => m.hooks.some((h) => h.command.includes("@cuzfrog/module-gates")))).toHaveLength(0); fs.rmSync(path.join(FIXTURES, ".claude"), { recursive: true, force: true }); }); }); \ No newline at end of file diff --git a/src/cli/install-claude.ts b/src/cli/install-claude.ts index e5e0b95..17dbe4c 100644 --- a/src/cli/install-claude.ts +++ b/src/cli/install-claude.ts @@ -2,11 +2,11 @@ import * as fs from "node:fs"; import * as path from "node:path"; import { readSettings, - upsertPreToolUse, + upsertHooks, writeSettings, HOOK_MARKER, PRE_TOOL_USE_MATCHER, -} from "../claude/settings-writer.ts"; +} from "../bridges/claude/settings-writer.ts"; export type InstallClaudeOptions = { projectDir: string; @@ -22,33 +22,27 @@ export function installClaude(opts: InstallClaudeOptions): InstallClaudeResult { return { ok: false, reason: `Project directory does not exist: ${projectDir}` }; } - const settingsPath = path.join(projectDir, ".pi", "settings.json"); - if (!fs.existsSync(settingsPath)) { - process.stderr.write( - `[Module Gate] No .pi/settings.json found at ${settingsPath} — using defaults.\n`, - ); - } - const settings = readSettings(projectDir); - const updated = upsertPreToolUse(settings); + const updated = upsertHooks(settings); const written = writeSettings(projectDir, updated); const relPath = path.relative(projectDir, written) || written; process.stdout.write(`Wrote ${relPath}\n\n`); - process.stdout.write("Hook entry inserted under hooks.PreToolUse:\n"); - const matcher = updated.hooks?.PreToolUse?.find((m) => - m.hooks.some((h) => typeof h.command === "string" && h.command.includes(HOOK_MARKER)), - ); - if (matcher) { - process.stdout.write(` matcher: "${matcher.matcher}"\n`); + process.stdout.write("Hook entries inserted under hooks.PreToolUse and hooks.SessionStart:\n"); + for (const event of ["PreToolUse", "SessionStart"]) { + const matcher = updated.hooks?.[event]?.find((m) => + m.hooks.some((h) => typeof h.command === "string" && h.command.includes(HOOK_MARKER)), + ); + if (!matcher) continue; + process.stdout.write(` ${event} matcher: "${matcher.matcher}"\n`); for (const h of matcher.hooks) { if (typeof h.command === "string") { - process.stdout.write(` command: ${h.command}\n`); + process.stdout.write(` command: ${h.command}\n`); } - if (h.statusMessage) process.stdout.write(` status: ${h.statusMessage}\n`); + if (h.statusMessage) process.stdout.write(` status: ${h.statusMessage}\n`); } } - process.stdout.write(`\nMatcher targets: ${PRE_TOOL_USE_MATCHER}\n`); + process.stdout.write(`\nPreToolUse matcher targets: ${PRE_TOOL_USE_MATCHER}\n`); return { ok: true, written }; -} \ No newline at end of file +} diff --git a/src/cli/uninstall-claude.ts b/src/cli/uninstall-claude.ts index 81171f5..3faa060 100644 --- a/src/cli/uninstall-claude.ts +++ b/src/cli/uninstall-claude.ts @@ -2,10 +2,10 @@ import * as fs from "node:fs"; import * as path from "node:path"; import { readSettings, - removePreToolUse, + removeHooks, writeSettings, HOOK_MARKER, -} from "../claude/settings-writer.ts"; +} from "../bridges/claude/settings-writer.ts"; export type UninstallClaudeOptions = { projectDir: string; @@ -26,15 +26,15 @@ export function uninstallClaude(opts: UninstallClaudeOptions): UninstallClaudeRe const before = readSettings(projectDir); const beforeHadMarker = JSON.stringify(before).includes(HOOK_MARKER); - const after = removePreToolUse(before); + const after = removeHooks(before); const afterHasMarker = JSON.stringify(after).includes(HOOK_MARKER); if (beforeHadMarker && !afterHasMarker) { writeSettings(projectDir, after); - process.stdout.write(`Removed pi-module-gates hooks from ${settingsPath}.\n`); + process.stdout.write(`Removed module-gates hooks from ${settingsPath}.\n`); return { ok: true, removed: true, written: settingsPath }; } - process.stdout.write(`No pi-module-gates hooks found in ${settingsPath}.\n`); + process.stdout.write(`No module-gates hooks found in ${settingsPath}.\n`); return { ok: true, removed: false, written: settingsPath }; -} \ No newline at end of file +} diff --git a/src/config.test.ts b/src/config.test.ts deleted file mode 100644 index 8b21458..0000000 --- a/src/config.test.ts +++ /dev/null @@ -1,199 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import * as fs from "node:fs"; - -vi.mock("node:fs", () => ({ - readFileSync: vi.fn(), -})); - -import { loadConfig } from "./config.ts"; - -const mockedReadFileSync = vi.mocked(fs.readFileSync); - -describe("loadConfig", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("returns defaults when settings.json does not exist", () => { - mockedReadFileSync.mockImplementation(() => { - throw new Error("ENOENT"); - }); - - const config = loadConfig("/project"); - expect(config.moduleDescriptorFileName).toBe("module.md"); - expect(config.moduleDescriptorReadonly).toBe("frontmatter"); - expect(config.sourceRoots).toEqual(["src/"]); - expect(config.disableSystemPrompt).toBe(false); - expect(config.outputModuleProseOnBlock).toBe(false); - }); - - it("returns defaults when settings.json has no module-gates key", () => { - mockedReadFileSync.mockReturnValue( - JSON.stringify({ theme: "dark" }), - ); - - const config = loadConfig("/project"); - expect(config.moduleDescriptorFileName).toBe("module.md"); - expect(config.moduleDescriptorReadonly).toBe("frontmatter"); - expect(config.sourceRoots).toEqual(["src/"]); - }); - - it("returns defaults when settings.json has invalid JSON", () => { - mockedReadFileSync.mockReturnValue("{ broken"); - - const config = loadConfig("/project"); - expect(config.moduleDescriptorFileName).toBe("module.md"); - expect(config.moduleDescriptorReadonly).toBe("frontmatter"); - expect(config.sourceRoots).toEqual(["src/"]); - }); - - it("overrides defaults with module-gates values", () => { - mockedReadFileSync.mockReturnValue( - JSON.stringify({ - "module-gates": { - moduleDescriptorFileName: "CONTEXT.md", - moduleDescriptorReadonly: "off", - sourceRoots: ["lib/"], - }, - }), - ); - - const config = loadConfig("/project"); - expect(config.moduleDescriptorFileName).toBe("CONTEXT.md"); - expect(config.moduleDescriptorReadonly).toBe("off"); - expect(config.sourceRoots).toEqual(["lib/"]); - }); - - it("accepts multiple roots in sourceRoots array", () => { - mockedReadFileSync.mockReturnValue( - JSON.stringify({ - "module-gates": { - sourceRoots: ["lib/", "packages/", "src/"], - }, - }), - ); - - const config = loadConfig("/project"); - expect(config.sourceRoots).toEqual(["lib/", "packages/", "src/"]); - }); - - it("accepts legacy singular sourceRoot as a string", () => { - mockedReadFileSync.mockReturnValue( - JSON.stringify({ - "module-gates": { - sourceRoot: "lib/", - }, - }), - ); - - const config = loadConfig("/project"); - expect(config.sourceRoots).toEqual(["lib/"]); - }); - - it("overrides only provided keys in module-gates", () => { - mockedReadFileSync.mockReturnValue( - JSON.stringify({ - "module-gates": { - moduleDescriptorFileName: "CONTEXT.md", - }, - }), - ); - - const config = loadConfig("/project"); - expect(config.moduleDescriptorFileName).toBe("CONTEXT.md"); - expect(config.moduleDescriptorReadonly).toBe("frontmatter"); - expect(config.sourceRoots).toEqual(["src/"]); - }); - - it("accepts frontmatter mode", () => { - mockedReadFileSync.mockReturnValue( - JSON.stringify({ - "module-gates": { - moduleDescriptorReadonly: "frontmatter", - }, - }), - ); - - const config = loadConfig("/project"); - expect(config.moduleDescriptorReadonly).toBe("frontmatter"); - }); - - it("normalizes boolean true to file mode", () => { - mockedReadFileSync.mockReturnValue( - JSON.stringify({ - "module-gates": { - moduleDescriptorReadonly: true, - }, - }), - ); - - const config = loadConfig("/project"); - expect(config.moduleDescriptorReadonly).toBe("file"); - }); - - it("normalizes boolean false to off mode", () => { - mockedReadFileSync.mockReturnValue( - JSON.stringify({ - "module-gates": { - moduleDescriptorReadonly: false, - }, - }), - ); - - const config = loadConfig("/project"); - expect(config.moduleDescriptorReadonly).toBe("off"); - }); - - it("reads from .pi/settings.json relative to cwd", () => { - mockedReadFileSync.mockReturnValue("{}"); - - loadConfig("/my/project"); - expect(mockedReadFileSync).toHaveBeenCalledWith( - "/my/project/.pi/settings.json", - "utf-8", - ); - }); - - it("ignores legacy singular module-gate key", () => { - mockedReadFileSync.mockReturnValue( - JSON.stringify({ - "module-gate": { - moduleDescriptorFileName: "MODULE.md", - moduleDescriptorReadonly: "file", - sourceRoots: ["src/"], - }, - }), - ); - - const config = loadConfig("/project"); - expect(config.moduleDescriptorFileName).toBe("module.md"); - expect(config.moduleDescriptorReadonly).toBe("frontmatter"); - expect(config.sourceRoots).toEqual(["src/"]); - }); - - it("overrides disableSystemPrompt from settings", () => { - mockedReadFileSync.mockReturnValue( - JSON.stringify({ - "module-gates": { - disableSystemPrompt: true, - }, - }), - ); - - const config = loadConfig("/project"); - expect(config.disableSystemPrompt).toBe(true); - }); - - it("overrides outputModuleProseOnBlock from settings", () => { - mockedReadFileSync.mockReturnValue( - JSON.stringify({ - "module-gates": { - outputModuleProseOnBlock: true, - }, - }), - ); - - const config = loadConfig("/project"); - expect(config.outputModuleProseOnBlock).toBe(true); - }); -}); diff --git a/src/MODULE.md b/src/core/MODULE.md similarity index 85% rename from src/MODULE.md rename to src/core/MODULE.md index 405631f..4e7cfa1 100644 --- a/src/MODULE.md +++ b/src/core/MODULE.md @@ -1,10 +1,10 @@ --- no-new-exports: - - config.ts - config.test.ts - - import-test.ts + - config.ts + - engine.ts - index.ts - types.ts - - utils.ts - utils.test.ts + - utils.ts --- diff --git a/src/core/config.test.ts b/src/core/config.test.ts new file mode 100644 index 0000000..551061a --- /dev/null +++ b/src/core/config.test.ts @@ -0,0 +1,211 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import * as fs from "node:fs"; + +vi.mock("node:fs", () => ({ + readFileSync: vi.fn(), +})); + +import { loadConfig, type ConfigSource } from "./config.ts"; + +const mockedReadFileSync = vi.mocked(fs.readFileSync); + +const SOURCES: ConfigSource[] = [ + { filePath: ".module-gates/config.json" }, + { filePath: ".pi/settings.json", key: "module-gates" }, + { filePath: ".claude/settings.json", key: "module-gates" }, +]; + +function mockFiles(files: Record): void { + mockedReadFileSync.mockImplementation((p: unknown) => { + const content = files[String(p)]; + if (content === undefined) throw new Error(`ENOENT: ${String(p)}`); + return content; + }); +} + +function mockCanonical(config: unknown): void { + mockFiles({ + "/project/.module-gates/config.json": JSON.stringify(config), + }); +} + +describe("loadConfig", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns defaults when no source exists", () => { + mockFiles({}); + + const config = loadConfig("/project", SOURCES); + expect(config.moduleDescriptorFileName).toBe("module.md"); + expect(config.moduleDescriptorReadonly).toBe("frontmatter"); + expect(config.sourceRoots).toEqual(["src/"]); + expect(config.disableSystemPrompt).toBe(false); + expect(config.outputModuleProseOnBlock).toBe(false); + }); + + it("reads the whole file when the source has no key", () => { + mockCanonical({ moduleDescriptorFileName: "CONTEXT.md" }); + + const config = loadConfig("/project", SOURCES); + expect(config.moduleDescriptorFileName).toBe("CONTEXT.md"); + }); + + it("extracts the key from a keyed source", () => { + mockFiles({ + "/project/.pi/settings.json": JSON.stringify({ + theme: "dark", + "module-gates": { moduleDescriptorFileName: "CONTEXT.md" }, + }), + }); + + const config = loadConfig("/project", SOURCES); + expect(config.moduleDescriptorFileName).toBe("CONTEXT.md"); + }); + + it("returns defaults when a keyed source has no module-gates key", () => { + mockFiles({ + "/project/.pi/settings.json": JSON.stringify({ theme: "dark" }), + }); + + const config = loadConfig("/project", [ + { filePath: ".pi/settings.json", key: "module-gates" }, + ]); + expect(config.moduleDescriptorFileName).toBe("module.md"); + }); + + it("first existing source wins", () => { + mockFiles({ + "/project/.module-gates/config.json": JSON.stringify({ + moduleDescriptorFileName: "CANONICAL.md", + }), + "/project/.pi/settings.json": JSON.stringify({ + "module-gates": { moduleDescriptorFileName: "PI.md" }, + }), + }); + + const config = loadConfig("/project", SOURCES); + expect(config.moduleDescriptorFileName).toBe("CANONICAL.md"); + }); + + it("falls back to the next source when a file is missing", () => { + mockFiles({ + "/project/.pi/settings.json": JSON.stringify({ + "module-gates": { moduleDescriptorFileName: "PI.md" }, + }), + }); + + const config = loadConfig("/project", SOURCES); + expect(config.moduleDescriptorFileName).toBe("PI.md"); + }); + + it("falls back to the next source when the key is absent", () => { + mockFiles({ + "/project/.pi/settings.json": JSON.stringify({ theme: "dark" }), + "/project/.claude/settings.json": JSON.stringify({ + "module-gates": { moduleDescriptorFileName: "CLAUDE.md" }, + }), + }); + + const config = loadConfig("/project", SOURCES); + expect(config.moduleDescriptorFileName).toBe("CLAUDE.md"); + }); + + it("falls back to the next source when JSON is invalid", () => { + mockFiles({ + "/project/.module-gates/config.json": "{ broken", + "/project/.pi/settings.json": JSON.stringify({ + "module-gates": { moduleDescriptorFileName: "PI.md" }, + }), + }); + + const config = loadConfig("/project", SOURCES); + expect(config.moduleDescriptorFileName).toBe("PI.md"); + }); + + it("accepts multiple roots in sourceRoots array", () => { + mockCanonical({ sourceRoots: ["lib/", "packages/", "src/"] }); + + const config = loadConfig("/project", SOURCES); + expect(config.sourceRoots).toEqual(["lib/", "packages/", "src/"]); + }); + + it("accepts legacy singular sourceRoot as a string", () => { + mockCanonical({ sourceRoot: "lib/" }); + + const config = loadConfig("/project", SOURCES); + expect(config.sourceRoots).toEqual(["lib/"]); + }); + + it("overrides only provided keys", () => { + mockCanonical({ moduleDescriptorFileName: "CONTEXT.md" }); + + const config = loadConfig("/project", SOURCES); + expect(config.moduleDescriptorFileName).toBe("CONTEXT.md"); + expect(config.moduleDescriptorReadonly).toBe("frontmatter"); + expect(config.sourceRoots).toEqual(["src/"]); + }); + + it("accepts frontmatter mode", () => { + mockCanonical({ moduleDescriptorReadonly: "frontmatter" }); + + const config = loadConfig("/project", SOURCES); + expect(config.moduleDescriptorReadonly).toBe("frontmatter"); + }); + + it("normalizes boolean true to file mode", () => { + mockCanonical({ moduleDescriptorReadonly: true }); + + const config = loadConfig("/project", SOURCES); + expect(config.moduleDescriptorReadonly).toBe("file"); + }); + + it("normalizes boolean false to off mode", () => { + mockCanonical({ moduleDescriptorReadonly: false }); + + const config = loadConfig("/project", SOURCES); + expect(config.moduleDescriptorReadonly).toBe("off"); + }); + + it("reads the first source relative to cwd", () => { + mockFiles({}); + + loadConfig("/my/project", SOURCES); + expect(mockedReadFileSync).toHaveBeenCalledWith( + "/my/project/.module-gates/config.json", + "utf-8", + ); + }); + + it("ignores legacy singular module-gate key", () => { + mockFiles({ + "/project/.pi/settings.json": JSON.stringify({ + "module-gate": { + moduleDescriptorFileName: "MODULE.md", + moduleDescriptorReadonly: "file", + sourceRoots: ["src/"], + }, + }), + }); + + const config = loadConfig("/project", SOURCES); + expect(config.moduleDescriptorFileName).toBe("module.md"); + expect(config.moduleDescriptorReadonly).toBe("frontmatter"); + expect(config.sourceRoots).toEqual(["src/"]); + }); + + it("overrides disableSystemPrompt from settings", () => { + mockCanonical({ disableSystemPrompt: true }); + + const config = loadConfig("/project", SOURCES); + expect(config.disableSystemPrompt).toBe(true); + }); + + it("overrides outputModuleProseOnBlock from settings", () => { + mockCanonical({ outputModuleProseOnBlock: true }); + + const config = loadConfig("/project", SOURCES); + expect(config.outputModuleProseOnBlock).toBe(true); + }); +}); diff --git a/src/config.ts b/src/core/config.ts similarity index 52% rename from src/config.ts rename to src/core/config.ts index 45fe3e8..5aaad33 100644 --- a/src/config.ts +++ b/src/core/config.ts @@ -10,6 +10,15 @@ export type ModuleGateConfig = { outputModuleProseOnBlock: boolean; }; +export type ConfigSource = { + filePath: string; + key?: string; +}; + +export const DEFAULT_CONFIG_SOURCES: ConfigSource[] = [ + { filePath: ".module-gates/config.json" }, +]; + const DEFAULTS: ModuleGateConfig = { moduleDescriptorFileName: "module.md", moduleDescriptorReadonly: "frontmatter", @@ -19,34 +28,47 @@ const DEFAULTS: ModuleGateConfig = { outputModuleProseOnBlock: false, }; -export function loadConfig(cwd: string): ModuleGateConfig { - const settingsPath = path.join(cwd, ".pi", "settings.json"); - let userConfig: Partial & { - moduleDescriptorReadonly?: ModuleGateConfig["moduleDescriptorReadonly"] | boolean; - sourceRoots?: string | string[]; - }> = {}; - try { - const raw = fs.readFileSync(settingsPath, "utf-8"); - const settings = JSON.parse(raw); - if (settings && typeof settings === "object") { - const gates = (settings as Record)["module-gates"]; - if (gates && typeof gates === "object" && !Array.isArray(gates)) { - userConfig = gates as Partial; - } - } - } catch { - // file doesn't exist or invalid — use defaults - } +export function loadConfig( + cwd: string, + sources: ConfigSource[] = DEFAULT_CONFIG_SOURCES, +): ModuleGateConfig { + const userConfig = readFirstUserConfig(cwd, sources); const merged = { ...DEFAULTS, ...userConfig }; merged.moduleDescriptorReadonly = normalizeReadonly(merged.moduleDescriptorReadonly); - const raw = (userConfig as { sourceRoots?: string | string[]; sourceRoot?: string | string[] }); merged.sourceRoots = normalizeSourceRoots( - raw.sourceRoots ?? raw.sourceRoot, + userConfig.sourceRoots ?? userConfig.sourceRoot, DEFAULTS.sourceRoots, ); return merged as ModuleGateConfig; } +function readFirstUserConfig(cwd: string, sources: ConfigSource[]): UserConfig { + for (const source of sources) { + const config = readSource(cwd, source); + if (config) return config; + } + return {}; +} + +function readSource(cwd: string, source: ConfigSource): UserConfig | undefined { + try { + const raw = fs.readFileSync(path.join(cwd, source.filePath), "utf-8"); + const settings = JSON.parse(raw); + if (!settings || typeof settings !== "object" || Array.isArray(settings)) { + return undefined; + } + const candidate = source.key + ? (settings as Record)[source.key] + : settings; + if (candidate && typeof candidate === "object" && !Array.isArray(candidate)) { + return candidate as UserConfig; + } + return undefined; + } catch { + return undefined; + } +} + function normalizeReadonly(value: ModuleGateConfig["moduleDescriptorReadonly"] | boolean): ModuleGateConfig["moduleDescriptorReadonly"] { if (value === true || value === "file") return "file"; if (value === false || value === "off") return "off"; @@ -61,3 +83,9 @@ function normalizeSourceRoots( const list = Array.isArray(value) ? value : [value]; return list.filter((s) => typeof s === "string" && s.length > 0); } + +type UserConfig = Partial & { + moduleDescriptorReadonly?: ModuleGateConfig["moduleDescriptorReadonly"] | boolean; + sourceRoots?: string | string[]; + sourceRoot?: string | string[]; +}>; diff --git a/src/context/MODULE.md b/src/core/context/MODULE.md similarity index 100% rename from src/context/MODULE.md rename to src/core/context/MODULE.md diff --git a/src/context/index.ts b/src/core/context/index.ts similarity index 100% rename from src/context/index.ts rename to src/core/context/index.ts diff --git a/src/context/system-prompt.template.md b/src/core/context/system-prompt.template.md similarity index 74% rename from src/context/system-prompt.template.md rename to src/core/context/system-prompt.template.md index 7bd30e6..d49e41a 100644 --- a/src/context/system-prompt.template.md +++ b/src/core/context/system-prompt.template.md @@ -1,8 +1,7 @@ ## Module gates (boundary enforcement) -This project uses `{{descriptorFileName}}`(case-insensitive) files to declare visibility, readonly and no-new-exports rules that you should follow. +This project uses `{{descriptorFileName}}`(case-insensitive) files to declare readonly and no-new-exports rules that you should follow. If you cannot comply, reconsider your design or raise to the user with tradeoffs if necessary. Each `{{descriptorFileName}}` gates its branching point in the tree. -A `{{descriptorFileName}}` with a `visible` list means only entries in the list are allowed to be visible outside the module. - Violations will be blocked. {{#if descriptorReadonly}}- {{descriptorReadonly}}{{/if}} @@ -14,4 +13,3 @@ A `{{descriptorFileName}}` with a `visible` list means only entries in the list - `module interface`: the file representing the module surface, e.g. `index.ts` in Typescript, `mod.rs` in Rust; - `readonly`: files are managed by user; - `no-new-exports`: files cannot add new exports, but the body is still editable; the export surface is closed; -- `visible`: visible from outside the module; files not in the module directory are outside the module; diff --git a/src/context/system-prompt.test.ts b/src/core/context/system-prompt.test.ts similarity index 90% rename from src/context/system-prompt.test.ts rename to src/core/context/system-prompt.test.ts index bb1d59a..2548d17 100644 --- a/src/context/system-prompt.test.ts +++ b/src/core/context/system-prompt.test.ts @@ -19,7 +19,6 @@ describe("buildSystemPromptHint", () => { { modulePath: "/project/src", descriptorFileName: "module.md", - visible: [{ name: "greet" }, { name: "Config" }], readonly: ["secret.ts", "module.md"], noNewExports: [], prose: "Greeting module.", @@ -28,11 +27,10 @@ describe("buildSystemPromptHint", () => { dirToModule: new Map(), }; - const result = buildSystemPromptHint(index, "You are a helpful assistant.", "module.md", baseConfig); + const result = buildSystemPromptHint(index, "You are a helpful assistant.", baseConfig); expect(result).toContain("You are a helpful assistant."); expect(result).toContain("module.md"); - expect(result).toContain("visible"); expect(result).toContain("readonly"); }); @@ -42,7 +40,7 @@ describe("buildSystemPromptHint", () => { dirToModule: new Map(), }; - const result = buildSystemPromptHint(index, "You are a helpful assistant.", "module.md", baseConfig); + const result = buildSystemPromptHint(index, "You are a helpful assistant.", baseConfig); expect(result).toBe("You are a helpful assistant."); }); @@ -53,7 +51,6 @@ describe("buildSystemPromptHint", () => { { modulePath: "/project/src", descriptorFileName: "module.md", - visible: [{ name: "fnA" }], readonly: ["module.md"], noNewExports: [], prose: "Module A.", @@ -62,7 +59,7 @@ describe("buildSystemPromptHint", () => { dirToModule: new Map(), }; - const result = buildSystemPromptHint(index, "Base prompt.", "CONTEXT.md", baseConfig); + const result = buildSystemPromptHint(index, "Base prompt.", { ...baseConfig, moduleDescriptorFileName: "CONTEXT.md" }); expect(result).toContain("Base prompt."); expect(result).toContain("CONTEXT.md"); @@ -75,7 +72,6 @@ describe("buildSystemPromptHint", () => { { modulePath: "/project/src", descriptorFileName: "module.md", - visible: [{ name: "fnA" }], readonly: ["module.md"], noNewExports: [], prose: "Module A.", @@ -87,7 +83,6 @@ describe("buildSystemPromptHint", () => { const result = buildSystemPromptHint( index, "Base prompt.", - "module.md", { ...baseConfig, moduleDescriptorReadonly: "frontmatter" }, ); @@ -101,7 +96,6 @@ describe("buildSystemPromptHint", () => { { modulePath: "/project/src", descriptorFileName: "module.md", - visible: [{ name: "fnA" }], readonly: ["config.ts"], noNewExports: [], prose: "Module A.", @@ -113,7 +107,6 @@ describe("buildSystemPromptHint", () => { const result = buildSystemPromptHint( index, "Base prompt.", - "module.md", { ...baseConfig, moduleDescriptorReadonly: "off" }, ); @@ -127,7 +120,6 @@ describe("buildSystemPromptHint", () => { { modulePath: "/project/src", descriptorFileName: "module.md", - visible: [{ name: "fnA" }], readonly: ["module.md"], noNewExports: [], prose: "Module A.", @@ -136,7 +128,7 @@ describe("buildSystemPromptHint", () => { dirToModule: new Map(), }; - const result = buildSystemPromptHint(index, "Base prompt.", "module.md", baseConfig); + const result = buildSystemPromptHint(index, "Base prompt.", baseConfig); expect(result).toContain("External files can only import through the module interface"); }); @@ -147,7 +139,6 @@ describe("buildSystemPromptHint", () => { { modulePath: "/project/src", descriptorFileName: "module.md", - visible: [{ name: "fnA" }], readonly: ["module.md"], noNewExports: [], prose: "Module A.", @@ -159,7 +150,6 @@ describe("buildSystemPromptHint", () => { const result = buildSystemPromptHint( index, "Base prompt.", - "module.md", { ...baseConfig, disableModuleInterfaceImportGate: true }, ); @@ -172,7 +162,6 @@ describe("buildSystemPromptHint", () => { { modulePath: "/project/src", descriptorFileName: "module.md", - visible: [{ name: "fnA" }], readonly: ["module.md"], noNewExports: [], prose: "Module A.", @@ -184,7 +173,6 @@ describe("buildSystemPromptHint", () => { const result = buildSystemPromptHint( index, "Base prompt.", - "module.md", { ...baseConfig, disableSystemPrompt: true }, ); diff --git a/src/context/system-prompt.ts b/src/core/context/system-prompt.ts similarity index 96% rename from src/context/system-prompt.ts rename to src/core/context/system-prompt.ts index 244b2ac..b32fda6 100644 --- a/src/context/system-prompt.ts +++ b/src/core/context/system-prompt.ts @@ -8,12 +8,13 @@ import type { ModuleGateConfig } from "../config.ts"; export function buildSystemPromptHint( index: ModuleIndex, systemPrompt: string, - descriptorFileName: string, config: ModuleGateConfig, ): string { if (config.disableSystemPrompt) return systemPrompt; if (index.contracts.length === 0) return systemPrompt; + const descriptorFileName = config.moduleDescriptorFileName; + const descriptorReadonly = config.moduleDescriptorReadonly === "frontmatter" ? `The frontmatter of \`${descriptorFileName}\` is readonly.` diff --git a/src/core/engine.ts b/src/core/engine.ts new file mode 100644 index 0000000..8252096 --- /dev/null +++ b/src/core/engine.ts @@ -0,0 +1,45 @@ +import type { Diagnostic, ModuleIndex } from "./types.ts"; +import { loadConfig, type ConfigSource, type ModuleGateConfig } from "./config.ts"; +import { buildModuleIndex } from "./graph/index.ts"; +import { runGates, type GateDenial, type GateEdit } from "./gates/run-gates.ts"; +import { buildSystemPromptHint } from "./context/index.ts"; + +export type GateEngine = { + readonly cwd: string; + readonly config: ModuleGateConfig; + readonly index: ModuleIndex; + readonly diagnostics: readonly Diagnostic[]; + checkEdit( + filePath: string, + edits: GateEdit[], + opts?: CheckEditOptions, + ): GateDenial | undefined; + systemPromptHint(baseSystemPrompt: string): string; +}; + +export type CheckEditOptions = { + beforeOverride?: string; +}; + +export type CreateGateEngineOptions = { + configSources?: ConfigSource[]; +}; + +export async function createGateEngine( + cwd: string, + opts?: CreateGateEngineOptions, +): Promise { + const config = loadConfig(cwd, opts?.configSources); + const { index, diagnostics } = await buildModuleIndex(cwd, config); + + return { + cwd, + config, + index, + diagnostics, + checkEdit: (filePath, edits, editOpts) => + runGates(filePath, edits, cwd, index, config, editOpts?.beforeOverride), + systemPromptHint: (baseSystemPrompt) => + buildSystemPromptHint(index, baseSystemPrompt, config), + }; +} diff --git a/src/gates/MODULE.md b/src/core/gates/MODULE.md similarity index 85% rename from src/gates/MODULE.md rename to src/core/gates/MODULE.md index 0e96e50..6ea1475 100644 --- a/src/gates/MODULE.md +++ b/src/core/gates/MODULE.md @@ -1,14 +1,12 @@ --- no-new-exports: - - export-gate.ts - - export-gate.test.ts - index.ts - - module-interface-import-gate.ts - module-interface-import-gate.test.ts - - readonly-gate.ts + - module-interface-import-gate.ts + - no-new-exports-gate.test.ts + - no-new-exports-gate.ts - readonly-gate.test.ts - - run-gates.ts + - readonly-gate.ts - run-gates.test.ts - - no-new-exports-gate.ts - - no-new-exports-gate.test.ts + - run-gates.ts --- diff --git a/src/gates/checkers/MODULE.md b/src/core/gates/checkers/MODULE.md similarity index 100% rename from src/gates/checkers/MODULE.md rename to src/core/gates/checkers/MODULE.md diff --git a/src/gates/checkers/go.test.ts b/src/core/gates/checkers/go.test.ts similarity index 100% rename from src/gates/checkers/go.test.ts rename to src/core/gates/checkers/go.test.ts diff --git a/src/gates/checkers/go.ts b/src/core/gates/checkers/go.ts similarity index 100% rename from src/gates/checkers/go.ts rename to src/core/gates/checkers/go.ts diff --git a/src/gates/checkers/index.ts b/src/core/gates/checkers/index.ts similarity index 100% rename from src/gates/checkers/index.ts rename to src/core/gates/checkers/index.ts diff --git a/src/gates/checkers/java.test.ts b/src/core/gates/checkers/java.test.ts similarity index 100% rename from src/gates/checkers/java.test.ts rename to src/core/gates/checkers/java.test.ts diff --git a/src/gates/checkers/java.ts b/src/core/gates/checkers/java.ts similarity index 100% rename from src/gates/checkers/java.ts rename to src/core/gates/checkers/java.ts diff --git a/src/gates/checkers/kotlin.test.ts b/src/core/gates/checkers/kotlin.test.ts similarity index 100% rename from src/gates/checkers/kotlin.test.ts rename to src/core/gates/checkers/kotlin.test.ts diff --git a/src/gates/checkers/kotlin.ts b/src/core/gates/checkers/kotlin.ts similarity index 100% rename from src/gates/checkers/kotlin.ts rename to src/core/gates/checkers/kotlin.ts diff --git a/src/gates/checkers/registry.ts b/src/core/gates/checkers/registry.ts similarity index 100% rename from src/gates/checkers/registry.ts rename to src/core/gates/checkers/registry.ts diff --git a/src/gates/checkers/rust.test.ts b/src/core/gates/checkers/rust.test.ts similarity index 100% rename from src/gates/checkers/rust.test.ts rename to src/core/gates/checkers/rust.test.ts diff --git a/src/gates/checkers/rust.ts b/src/core/gates/checkers/rust.ts similarity index 100% rename from src/gates/checkers/rust.ts rename to src/core/gates/checkers/rust.ts diff --git a/src/gates/checkers/scala.test.ts b/src/core/gates/checkers/scala.test.ts similarity index 100% rename from src/gates/checkers/scala.test.ts rename to src/core/gates/checkers/scala.test.ts diff --git a/src/gates/checkers/scala.ts b/src/core/gates/checkers/scala.ts similarity index 100% rename from src/gates/checkers/scala.ts rename to src/core/gates/checkers/scala.ts diff --git a/src/gates/checkers/typescript.test.ts b/src/core/gates/checkers/typescript.test.ts similarity index 100% rename from src/gates/checkers/typescript.test.ts rename to src/core/gates/checkers/typescript.test.ts diff --git a/src/gates/checkers/typescript.ts b/src/core/gates/checkers/typescript.ts similarity index 100% rename from src/gates/checkers/typescript.ts rename to src/core/gates/checkers/typescript.ts diff --git a/src/gates/index.ts b/src/core/gates/index.ts similarity index 100% rename from src/gates/index.ts rename to src/core/gates/index.ts diff --git a/src/gates/module-interface-import-gate.test.ts b/src/core/gates/module-interface-import-gate.test.ts similarity index 100% rename from src/gates/module-interface-import-gate.test.ts rename to src/core/gates/module-interface-import-gate.test.ts diff --git a/src/gates/module-interface-import-gate.ts b/src/core/gates/module-interface-import-gate.ts similarity index 100% rename from src/gates/module-interface-import-gate.ts rename to src/core/gates/module-interface-import-gate.ts diff --git a/src/gates/no-new-exports-gate.test.ts b/src/core/gates/no-new-exports-gate.test.ts similarity index 96% rename from src/gates/no-new-exports-gate.test.ts rename to src/core/gates/no-new-exports-gate.test.ts index 02a0583..bcdb0e1 100644 --- a/src/gates/no-new-exports-gate.test.ts +++ b/src/core/gates/no-new-exports-gate.test.ts @@ -15,7 +15,6 @@ describe("checkNoNewExports", () => { { modulePath: "/project/src", descriptorFileName: "module.md", - visible: null, readonly: ["module.md"], noNewExports: ["no-new-exports.ts"], prose: "", @@ -38,7 +37,6 @@ describe("checkNoNewExports", () => { { modulePath: "/project/src", descriptorFileName: "module.md", - visible: null, readonly: ["module.md"], noNewExports: ["no-new-exports.ts"], prose: "", @@ -57,7 +55,6 @@ describe("checkNoNewExports", () => { { modulePath: "/project/src", descriptorFileName: "module.md", - visible: null, readonly: ["module.md"], noNewExports: ["no-new-exports.ts"], prose: "", @@ -76,7 +73,6 @@ describe("checkNoNewExports", () => { { modulePath: "/project/src", descriptorFileName: "module.md", - visible: null, readonly: ["module.md"], noNewExports: ["data.json"], prose: "", @@ -100,7 +96,6 @@ describe("checkNoNewExports", () => { { modulePath: "/project", descriptorFileName: "module.md", - visible: null, readonly: ["module.md"], noNewExports: ["src/no-new-exports.ts"], prose: "", @@ -108,7 +103,6 @@ describe("checkNoNewExports", () => { { modulePath: "/project/src", descriptorFileName: "module.md", - visible: null, readonly: ["module.md"], noNewExports: [], prose: "", @@ -130,7 +124,6 @@ describe("checkNoNewExports", () => { { modulePath: "/project/src", descriptorFileName: "module.md", - visible: null, readonly: ["module.md"], noNewExports: ["vendor"], prose: "", @@ -149,7 +142,6 @@ describe("checkNoNewExports", () => { { modulePath: "/project/src", descriptorFileName: "module.md", - visible: null, readonly: ["module.md"], noNewExports: ["generated*"], prose: "", @@ -178,7 +170,6 @@ describe("checkNoNewExports", () => { { modulePath: "/project/src", descriptorFileName: "module.md", - visible: null, readonly: ["module.md"], noNewExports: [], prose: "", @@ -197,7 +188,6 @@ describe("checkNoNewExports", () => { { modulePath: "/project/src", descriptorFileName: "module.md", - visible: null, readonly: ["module.md"], noNewExports: ["no-new-exports.ts"], prose: "", @@ -221,7 +211,6 @@ describe("checkNoNewExports", () => { { modulePath: "/project", descriptorFileName: "module.md", - visible: null, readonly: ["module.md"], noNewExports: [], prose: "", @@ -229,7 +218,6 @@ describe("checkNoNewExports", () => { { modulePath: "/project/src", descriptorFileName: "module.md", - visible: null, readonly: ["module.md"], noNewExports: ["no-new-exports.ts"], prose: "", diff --git a/src/gates/no-new-exports-gate.ts b/src/core/gates/no-new-exports-gate.ts similarity index 100% rename from src/gates/no-new-exports-gate.ts rename to src/core/gates/no-new-exports-gate.ts diff --git a/src/gates/readonly-gate.test.ts b/src/core/gates/readonly-gate.test.ts similarity index 94% rename from src/gates/readonly-gate.test.ts rename to src/core/gates/readonly-gate.test.ts index 0393e55..996449c 100644 --- a/src/gates/readonly-gate.test.ts +++ b/src/core/gates/readonly-gate.test.ts @@ -14,7 +14,6 @@ describe("checkReadonly", () => { { modulePath: "/project/src", descriptorFileName: "module.md", - visible: null, readonly: ["config.json", "module.md"], noNewExports: [], prose: "", @@ -30,7 +29,6 @@ describe("checkReadonly", () => { { modulePath: "/project/src", descriptorFileName: "module.md", - visible: null, readonly: ["vendor", "module.md"], noNewExports: [], prose: "", @@ -46,7 +44,6 @@ describe("checkReadonly", () => { { modulePath: "/project/src", descriptorFileName: "module.md", - visible: null, readonly: ["generated*", "module.md"], noNewExports: [], prose: "", @@ -62,7 +59,6 @@ describe("checkReadonly", () => { { modulePath: "/project/src", descriptorFileName: "module.md", - visible: null, readonly: ["config.json", "module.md"], noNewExports: [], prose: "", @@ -78,7 +74,6 @@ describe("checkReadonly", () => { { modulePath: "/project/src", descriptorFileName: "module.md", - visible: null, readonly: ["module.md"], noNewExports: [], prose: "", @@ -94,7 +89,6 @@ describe("checkReadonly", () => { { modulePath: "/project", descriptorFileName: "module.md", - visible: null, readonly: ["src/secret.ts", "module.md"], noNewExports: [], prose: "", @@ -102,7 +96,6 @@ describe("checkReadonly", () => { { modulePath: "/project/src", descriptorFileName: "module.md", - visible: null, readonly: ["module.md"], noNewExports: [], prose: "", @@ -118,7 +111,6 @@ describe("checkReadonly", () => { { modulePath: "/project/src", descriptorFileName: "CONTEXT.md", - visible: null, readonly: ["locked.ts"], noNewExports: [], prose: "", @@ -137,7 +129,6 @@ describe("checkReadonly", () => { { modulePath: "/project/src", descriptorFileName: "MODULE.md", - visible: null, readonly: ["locked.ts"], noNewExports: [], prose: "", diff --git a/src/gates/readonly-gate.ts b/src/core/gates/readonly-gate.ts similarity index 100% rename from src/gates/readonly-gate.ts rename to src/core/gates/readonly-gate.ts diff --git a/src/gates/run-gates.test.ts b/src/core/gates/run-gates.test.ts similarity index 87% rename from src/gates/run-gates.test.ts rename to src/core/gates/run-gates.test.ts index d7cb0e1..49619b3 100644 --- a/src/gates/run-gates.test.ts +++ b/src/core/gates/run-gates.test.ts @@ -58,7 +58,7 @@ describe("runGates", () => { it("blocks readonly files", () => { writeSource("src/locked.ts", ""); const index = makeIndex([ - { modulePath: path.join(tmp, "src"), descriptorFileName: "module.md", visible: null, readonly: ["module.md", "locked.ts"], noNewExports: [], prose: "" }, + { modulePath: path.join(tmp, "src"), descriptorFileName: "module.md", readonly: ["module.md", "locked.ts"], noNewExports: [], prose: "" }, ]); const result = runGates("src/locked.ts", [{ oldText: "", newText: "x" }], tmp, index, cfg()); expect(result?.block).toBe(true); @@ -68,7 +68,7 @@ describe("runGates", () => { it("blocks no-new-exports file when adding a new export", () => { writeSource("src/no-new-exports.ts", "export function a() {}"); const index = makeIndex([ - { modulePath: path.join(tmp, "src"), descriptorFileName: "module.md", visible: null, readonly: ["module.md"], noNewExports: ["no-new-exports.ts"], prose: "" }, + { modulePath: path.join(tmp, "src"), descriptorFileName: "module.md", readonly: ["module.md"], noNewExports: ["no-new-exports.ts"], prose: "" }, ]); const after = "export function a() {}\nexport function b() {}"; const result = runGates("src/no-new-exports.ts", [{ oldText: "export function a() {}", newText: after }], tmp, index, cfg()); @@ -77,21 +77,10 @@ describe("runGates", () => { expect(result?.reason).toContain("b"); }); - it("blocks exports not in visible list", () => { - writeSource("src/app.ts", "export function a() {}"); - const index = makeIndex([ - { modulePath: path.join(tmp, "src"), descriptorFileName: "module.md", visible: [{ name: "a" }], readonly: [], noNewExports: [], prose: "" }, - ]); - const after = "export function a() {}\nexport function b() {}"; - const result = runGates("src/app.ts", [{ oldText: "export function a() {}", newText: after }], tmp, index, cfg()); - expect(result?.block).toBe(true); - expect(result?.reason).toContain("b"); - }); - it("returns undefined when edit does not add exports on no-new-exports file", () => { writeSource("src/no-new-exports.ts", "export function a() { return 1; }"); const index = makeIndex([ - { modulePath: path.join(tmp, "src"), descriptorFileName: "module.md", visible: null, readonly: [], noNewExports: ["no-new-exports.ts"], prose: "" }, + { modulePath: path.join(tmp, "src"), descriptorFileName: "module.md", readonly: [], noNewExports: ["no-new-exports.ts"], prose: "" }, ]); const result = runGates( "src/no-new-exports.ts", @@ -109,7 +98,7 @@ describe("formatDenial", () => { const modulePath = path.join(tmp, "src"); const index: ModuleIndex = { contracts: [ - { modulePath, descriptorFileName: "module.md", visible: null, readonly: ["locked.ts"], noNewExports: [], prose: "Greeting module." }, + { modulePath, descriptorFileName: "module.md", readonly: ["locked.ts"], noNewExports: [], prose: "Greeting module." }, ], dirToModule: new Map([[modulePath, modulePath]]), }; @@ -123,7 +112,7 @@ describe("formatDenial", () => { const modulePath = path.join(tmp, "src"); const index: ModuleIndex = { contracts: [ - { modulePath, descriptorFileName: "module.md", visible: null, readonly: ["locked.ts"], noNewExports: [], prose: "Greeting module." }, + { modulePath, descriptorFileName: "module.md", readonly: ["locked.ts"], noNewExports: [], prose: "Greeting module." }, ], dirToModule: new Map([[modulePath, modulePath]]), }; @@ -136,7 +125,7 @@ describe("formatDenial", () => { const modulePath = path.join(tmp, "src"); const index: ModuleIndex = { contracts: [ - { modulePath, descriptorFileName: "MODULE.md", visible: null, readonly: ["locked.ts"], noNewExports: [], prose: "Greeting module." }, + { modulePath, descriptorFileName: "MODULE.md", readonly: ["locked.ts"], noNewExports: [], prose: "Greeting module." }, ], dirToModule: new Map([[modulePath, modulePath]]), }; @@ -275,7 +264,6 @@ describe("runGates descriptor protection (independent of readonly list)", () => { modulePath, descriptorFileName: "MODULE.md", - visible: null, readonly: [], noNewExports: ["config.ts"], prose: "Prose.", @@ -302,7 +290,7 @@ describe("runGates descriptor protection (independent of readonly list)", () => fs.writeFileSync(moduleAbs, "---\nno-new-exports: [config.ts]\n---\nProse.", "utf-8"); const index: ModuleIndex = { contracts: [ - { modulePath, descriptorFileName: "module.md", visible: null, readonly: [], noNewExports: ["config.ts"], prose: "Prose." }, + { modulePath, descriptorFileName: "module.md", readonly: [], noNewExports: ["config.ts"], prose: "Prose." }, ], dirToModule: new Map([[modulePath, modulePath]]), }; diff --git a/src/gates/run-gates.ts b/src/core/gates/run-gates.ts similarity index 93% rename from src/gates/run-gates.ts rename to src/core/gates/run-gates.ts index f2c967a..6656ead 100644 --- a/src/gates/run-gates.ts +++ b/src/core/gates/run-gates.ts @@ -5,7 +5,6 @@ import type { ModuleGateConfig } from "../config.ts"; import { readFileSafe, applyEdits, isWithinSourceRoot, findOwningModule } from "../utils.ts"; import { checkReadonly } from "./readonly-gate.ts"; import { checkNoNewExports } from "./no-new-exports-gate.ts"; -import { checkExports } from "./export-gate.ts"; import { checkModuleInterfaceImports } from "./module-interface-import-gate.ts"; import "./checkers/index.ts"; @@ -42,11 +41,6 @@ export function runGates( return { block: true, reason: formatDenial(filePath, noNewExportsResult.reason, absPath, index, cwd, config.outputModuleProseOnBlock) }; } - const exportResult = checkExports(filePath, before, after, index, cwd); - if (exportResult.blocked) { - return { block: true, reason: formatDenial(filePath, exportResult.reason, absPath, index, cwd, config.outputModuleProseOnBlock) }; - } - const importResult = checkModuleInterfaceImports(filePath, after, index, cwd, config.disableModuleInterfaceImportGate, config.sourceRoots); if (importResult.blocked) { return { block: true, reason: formatDenial(filePath, importResult.reason, absPath, index, cwd, config.outputModuleProseOnBlock) }; diff --git a/src/graph/MODULE.md b/src/core/graph/MODULE.md similarity index 50% rename from src/graph/MODULE.md rename to src/core/graph/MODULE.md index 7808728..30e4ad7 100644 --- a/src/graph/MODULE.md +++ b/src/core/graph/MODULE.md @@ -1,10 +1,6 @@ --- no-new-exports: - - frontmatter-parser.test.ts - - frontmatter-parser.ts - index.ts - module-index-builder.test.ts - module-index-builder.ts - - validation.test.ts - - validation.ts --- diff --git a/src/core/graph/index.ts b/src/core/graph/index.ts new file mode 100644 index 0000000..2a969d2 --- /dev/null +++ b/src/core/graph/index.ts @@ -0,0 +1 @@ +export { buildModuleIndex } from "./module-index-builder.ts"; diff --git a/src/graph/module-index-builder.test.ts b/src/core/graph/module-index-builder.test.ts similarity index 53% rename from src/graph/module-index-builder.test.ts rename to src/core/graph/module-index-builder.test.ts index d8e06c6..15d1d80 100644 --- a/src/graph/module-index-builder.test.ts +++ b/src/core/graph/module-index-builder.test.ts @@ -13,46 +13,23 @@ vi.mock("../utils/frontmatter.ts", () => ({ parseFrontmatter: vi.fn(), })); -vi.mock("./validation.ts", () => ({ - validateVisibleEntries: vi.fn(), -})); - -vi.mock("./frontmatter-parser.ts", () => ({ - parseVisibleEntry: vi.fn((raw: any) => { - let pathStr: string; - let modifier: string | undefined; - if (typeof raw === "string") { - pathStr = raw.trim(); - } else { - pathStr = raw.path; - modifier = raw.modifier; - } - if (pathStr.endsWith("/")) pathStr = pathStr.slice(0, -1); - const lastSlash = pathStr.lastIndexOf("/"); - const name = lastSlash >= 0 ? pathStr.slice(lastSlash + 1) : pathStr; - return { name, modifier, path: raw.path ?? raw }; - }), -})); - import { readdir } from "node:fs/promises"; import * as fs from "node:fs"; import { parseFrontmatter } from "../utils/frontmatter.ts"; -import { validateVisibleEntries } from "./index.ts"; import { buildModuleIndex } from "./module-index-builder.ts"; import type { ModuleGateConfig } from "../config.ts"; const mockedReaddir = readdir as unknown as ReturnType; const mockedReadFileSync = vi.mocked(fs.readFileSync); const mockedParseFrontmatter = vi.mocked(parseFrontmatter); -const mockedValidateVisibleEntries = vi.mocked(validateVisibleEntries); const defaultConfig: ModuleGateConfig = { moduleDescriptorFileName: "module.md", moduleDescriptorReadonly: "file", sourceRoots: [""], disableModuleInterfaceImportGate: false, - disableSystemPrompt: false, - outputModuleProseOnBlock: false, + disableSystemPrompt: false, + outputModuleProseOnBlock: false, }; function makeDirent(name: string, isDir: boolean): Dirent { @@ -70,16 +47,6 @@ function makeDirent(name: string, isDir: boolean): Dirent { } as Dirent; } -function makeCtx(cwd: string, onNotify?: (msg: string, type?: string) => void) { - return { - cwd, - ui: { - notify(msg: string, type?: string) { - onNotify?.(msg, type); - }, - }, - }; -} describe("buildModuleIndex", () => { beforeEach(() => { @@ -95,18 +62,17 @@ describe("buildModuleIndex", () => { return [] as Dirent[]; }); - mockedReadFileSync.mockReturnValue("---\nvisible: [greet]\n---\nGreeting module."); + mockedReadFileSync.mockReturnValue("---\nreadonly: [secret.ts]\n---\nGreeting module."); mockedParseFrontmatter.mockReturnValue({ - frontmatter: { visible: ["greet"], readonly: ["secret.ts"] }, + frontmatter: { readonly: ["secret.ts"] }, body: "Greeting module.", }); - const index = await buildModuleIndex(makeCtx("/project"), defaultConfig); + const { index } = await buildModuleIndex("/project", defaultConfig); expect(index.contracts).toHaveLength(1); expect(index.contracts[0].modulePath).toBe("/project/src"); - expect(index.contracts[0].visible).toEqual([{ name: "greet", path: "greet" }]); expect(index.contracts[0].readonly).toContain("secret.ts"); expect(index.contracts[0].prose).toBe("Greeting module."); }); @@ -125,7 +91,7 @@ describe("buildModuleIndex", () => { body: "Root.", }); - const index = await buildModuleIndex(makeCtx("/project"), defaultConfig); + const { index } = await buildModuleIndex("/project", defaultConfig); expect(index.contracts[0].readonly).not.toContain("module.md"); expect(index.contracts[0].readonly).toContain("config.json"); @@ -153,7 +119,7 @@ describe("buildModuleIndex", () => { disableSystemPrompt: false, outputModuleProseOnBlock: false, }; - const index = await buildModuleIndex(makeCtx("/project"), config); + const { index } = await buildModuleIndex("/project", config); expect(index.contracts[0].readonly).not.toContain("module.md"); expect(index.contracts[0].readonly).toContain("config.json"); @@ -181,13 +147,13 @@ describe("buildModuleIndex", () => { disableSystemPrompt: false, outputModuleProseOnBlock: false, }; - const index = await buildModuleIndex(makeCtx("/project"), config); + const { index } = await buildModuleIndex("/project", config); expect(index.contracts[0].readonly).not.toContain("module.md"); expect(index.contracts[0].readonly).toContain("config.json"); }); - it("parses frontmatter with visible and readonly", async () => { + it("parses frontmatter with readonly and no-new-exports", async () => { mockedReaddir.mockImplementation(async (dir: unknown) => { const d = dir as string; if (d === "/project") return [makeDirent("module.md", false)] as Dirent[]; @@ -197,60 +163,17 @@ describe("buildModuleIndex", () => { mockedReadFileSync.mockReturnValue("content"); mockedParseFrontmatter.mockReturnValue({ - frontmatter: { visible: ["exportA", "exportB"], readonly: ["locked/"] }, + frontmatter: { readonly: ["locked/"], "no-new-exports": ["app.ts"] }, body: "Some prose.", }); - const index = await buildModuleIndex(makeCtx("/project"), defaultConfig); + const { index } = await buildModuleIndex("/project", defaultConfig); - expect(index.contracts[0].visible).toEqual([{ name: "exportA", path: "exportA" }, { name: "exportB", path: "exportB" }]); expect(index.contracts[0].readonly).toContain("locked/"); + expect(index.contracts[0].noNewExports).toContain("app.ts"); expect(index.contracts[0].prose).toBe("Some prose."); }); - it("parses visible entries with paths and modifiers from object form", async () => { - mockedReaddir.mockImplementation(async (dir: unknown) => { - const d = dir as string; - if (d === "/project") return [makeDirent("module.md", false)] as Dirent[]; - return [] as Dirent[]; - }); - - mockedReadFileSync.mockReturnValue("content"); - - mockedParseFrontmatter.mockReturnValue({ - frontmatter: { - visible: [{ path: "sub/Foo", modifier: "pub(super)" }, { path: "Bar" }], - readonly: [], - }, - body: "", - }); - - const index = await buildModuleIndex(makeCtx("/project"), defaultConfig); - - expect(index.contracts[0].visible).toEqual([ - { modifier: "pub(super)", name: "Foo", path: "sub/Foo" }, - { name: "Bar", path: "Bar" }, - ]); - }); - - it("sets visible to null when not specified in frontmatter", async () => { - mockedReaddir.mockImplementation(async (dir: unknown) => { - const d = dir as string; - if (d === "/project") return [makeDirent("module.md", false)] as Dirent[]; - return [] as Dirent[]; - }); - - mockedReadFileSync.mockReturnValue("content"); - - mockedParseFrontmatter.mockReturnValue({ - frontmatter: { readonly: ["secret.ts"] }, - body: "No visible constraint.", - }); - - const index = await buildModuleIndex(makeCtx("/project"), defaultConfig); - expect(index.contracts[0].visible).toBeNull(); - }); - it("deepest module.md wins directory ownership in dirToModule", async () => { mockedReaddir.mockImplementation(async (dir: unknown) => { const d = dir as string; @@ -268,14 +191,14 @@ describe("buildModuleIndex", () => { body: "", }); - const index = await buildModuleIndex(makeCtx("/project"), defaultConfig); + const { index } = await buildModuleIndex("/project", defaultConfig); expect(index.contracts).toHaveLength(2); expect(index.dirToModule.get("/project/src")).toBe("/project/src"); expect(index.dirToModule.get("/project")).toBe("/project"); }); - it("skips malformed module.md and notifies with info level", async () => { + it("skips malformed module.md and reports an info diagnostic", async () => { mockedReaddir.mockImplementation(async (dir: unknown) => { const d = dir as string; if (d === "/project") @@ -288,32 +211,27 @@ describe("buildModuleIndex", () => { mockedReadFileSync.mockImplementation((p: unknown): string => { const filePath = p as string; if (filePath === "/project/module.md") return "---\nbroken: [\n---\nbad"; - return "---\nvisible: [ok]\n---\ngood"; + return "---\nreadonly: [ok.ts]\n---\ngood"; }); mockedParseFrontmatter.mockImplementation((content: string) => { if (content.includes("broken")) { throw new Error("YAML parse error"); } - return { frontmatter: { visible: ["ok"] }, body: "good" }; + return { frontmatter: { readonly: ["ok.ts"] }, body: "good" }; }); - const notifications: { message: string; type: string }[] = []; - const ctx = makeCtx("/project", (msg, type) => - notifications.push({ message: msg, type: type ?? "info" }), - ); + const { index, diagnostics } = await buildModuleIndex("/project", defaultConfig); - const index = await buildModuleIndex(ctx, defaultConfig); - - expect(notifications).toHaveLength(1); - expect(notifications[0].message).toContain("Failed to parse"); - expect(notifications[0].message).toContain("/project/module.md"); - expect(notifications[0].message).toContain("unguarded"); - expect(notifications[0].type).toBe("info"); + expect(diagnostics).toHaveLength(1); + expect(diagnostics[0].message).toContain("Failed to parse"); + expect(diagnostics[0].message).toContain("/project/module.md"); + expect(diagnostics[0].message).toContain("unguarded"); + expect(diagnostics[0].level).toBe("info"); expect(index.contracts).toHaveLength(1); expect(index.contracts[0].modulePath).toBe("/project/good"); - expect(index.contracts[0].visible).toEqual([{ name: "ok", path: "ok" }]); + expect(index.contracts[0].readonly).toContain("ok.ts"); }); it("matches configurable descriptor file name", async () => { @@ -338,7 +256,7 @@ describe("buildModuleIndex", () => { disableSystemPrompt: false, outputModuleProseOnBlock: false, }; - const index = await buildModuleIndex(makeCtx("/project"), config); + const { index } = await buildModuleIndex("/project", config); expect(index.contracts).toHaveLength(1); expect(index.contracts[0].modulePath).toBe("/project"); expect(index.contracts[0].readonly).not.toContain("CONTEXT.md"); @@ -358,7 +276,7 @@ describe("buildModuleIndex", () => { body: "Root.", }); - const index = await buildModuleIndex(makeCtx("/project"), defaultConfig); + const { index } = await buildModuleIndex("/project", defaultConfig); expect(index.contracts).toHaveLength(1); expect(index.contracts[0].modulePath).toBe("/project"); }); @@ -384,7 +302,7 @@ describe("buildModuleIndex", () => { disableSystemPrompt: false, outputModuleProseOnBlock: false, }; - const index = await buildModuleIndex(makeCtx("/project"), config); + const { index } = await buildModuleIndex("/project", config); expect(index.contracts).toHaveLength(1); expect(index.contracts[0].modulePath).toBe("/project/src"); @@ -412,113 +330,10 @@ describe("buildModuleIndex", () => { disableSystemPrompt: false, outputModuleProseOnBlock: false, }; - const index = await buildModuleIndex(makeCtx("/project"), config); + const { index } = await buildModuleIndex("/project", config); expect(index.contracts).toHaveLength(2); const paths = index.contracts.map((c) => c.modulePath).sort(); expect(paths).toEqual(["/project/lib", "/project/src"]); }); - - it("complements child visible with parent's path-based entry", async () => { - mockedReaddir.mockImplementation(async (dir: unknown) => { - const d = dir as string; - if (d === "/project") return [makeDirent("module.md", false), makeDirent("sub", true)] as Dirent[]; - if (d === "/project/sub") return [makeDirent("module.md", false)] as Dirent[]; - return [] as Dirent[]; - }); - - mockedReadFileSync.mockImplementation((p: unknown): string => { - const filePath = p as string; - if (filePath.endsWith("project/sub/module.md")) return "child"; - return "parent"; - }); - - mockedParseFrontmatter.mockImplementation((content: string) => { - if (content === "parent") return { frontmatter: { visible: [{ path: "sub/Helper" }] }, body: "" }; - return { frontmatter: { visible: ["OwnType"] }, body: "" }; - }); - - const index = await buildModuleIndex(makeCtx("/project"), defaultConfig); - - expect(index.contracts).toHaveLength(2); - const child = index.contracts.find((c) => c.modulePath === "/project/sub")!; - expect(child.visible).toEqual( - expect.arrayContaining([ - { name: "OwnType", path: "OwnType" }, - { name: "Helper" }, - ]) - ); - const parent = index.contracts.find((c) => c.modulePath === "/project")!; - expect(parent.visible).toEqual([{ name: "Helper", path: "sub/Helper" }]); - }); - - it("does not complement when child has no module.md", async () => { - mockedReaddir.mockImplementation(async (dir: unknown) => { - const d = dir as string; - if (d === "/project") return [makeDirent("module.md", false), makeDirent("sub", true)] as Dirent[]; - if (d === "/project/sub") return [makeDirent("lib.rs", false)] as Dirent[]; - return [] as Dirent[]; - }); - - mockedReadFileSync.mockReturnValue("content"); - mockedParseFrontmatter.mockReturnValue({ - frontmatter: { visible: [{ path: "sub/Helper" }] }, - body: "", - }); - - const index = await buildModuleIndex(makeCtx("/project"), defaultConfig); - - expect(index.contracts).toHaveLength(1); - expect(index.contracts[0].visible).toEqual([{ name: "Helper", path: "sub/Helper" }]); - }); - - it("complements with trailing slash (directory as module reference)", async () => { - mockedReaddir.mockImplementation(async (dir: unknown) => { - const d = dir as string; - if (d === "/project") return [makeDirent("module.md", false), makeDirent("sub", true)] as Dirent[]; - if (d === "/project/sub") return [makeDirent("module.md", false)] as Dirent[]; - return [] as Dirent[]; - }); - - mockedReadFileSync.mockImplementation((p: unknown): string => { - const filePath = p as string; - if (filePath.endsWith("project/sub/module.md")) return "child"; - return "parent"; - }); - - mockedParseFrontmatter.mockImplementation((content: string) => { - if (content === "parent") return { frontmatter: { visible: [{ path: "sub/" }] }, body: "" }; - return { frontmatter: { visible: [] }, body: "" }; - }); - - const index = await buildModuleIndex(makeCtx("/project"), defaultConfig); - - const child = index.contracts.find((c) => c.modulePath === "/project/sub")!; - expect(child.visible).toEqual(expect.arrayContaining([{ name: "sub" }])); - }); - - it("complements with bare string path containing slash", async () => { - mockedReaddir.mockImplementation(async (dir: unknown) => { - const d = dir as string; - if (d === "/project") return [makeDirent("module.md", false), makeDirent("sub", true)] as Dirent[]; - if (d === "/project/sub") return [makeDirent("module.md", false)] as Dirent[]; - return [] as Dirent[]; - }); - - mockedReadFileSync.mockImplementation((p: unknown): string => { - const filePath = p as string; - if (filePath.endsWith("project/sub/module.md")) return "child"; - return "parent"; - }); - - mockedParseFrontmatter.mockImplementation((content: string) => { - if (content === "parent") return { frontmatter: { visible: ["sub/Helper"] }, body: "" }; - return { frontmatter: { visible: [] }, body: "" }; - }); - - const index = await buildModuleIndex(makeCtx("/project"), defaultConfig); - - const child = index.contracts.find((c) => c.modulePath === "/project/sub")!; - expect(child.visible).toEqual(expect.arrayContaining([{ name: "Helper" }])); - }); }); diff --git a/src/graph/module-index-builder.ts b/src/core/graph/module-index-builder.ts similarity index 60% rename from src/graph/module-index-builder.ts rename to src/core/graph/module-index-builder.ts index 82d7f1a..564bd84 100644 --- a/src/graph/module-index-builder.ts +++ b/src/core/graph/module-index-builder.ts @@ -2,36 +2,28 @@ import * as fs from "node:fs"; import * as path from "node:path"; import { readdir } from "node:fs/promises"; import { parseFrontmatter } from "../utils/frontmatter.ts"; -import type { ModuleContract, ModuleIndex } from "../types.ts"; +import type { Diagnostic, ModuleContract, ModuleFrontmatter, ModuleIndex } from "../types.ts"; import type { ModuleGateConfig } from "../config.ts"; import type { Dirent } from "node:fs"; -import { validateVisibleEntries } from "./validation.ts"; -import { parseVisibleEntry, type ModuleFrontmatter } from "./frontmatter-parser.ts"; -type IndexContext = { - cwd: string; - ui: { notify(message: string, type?: string): void }; +export type ModuleIndexBuildResult = { + index: ModuleIndex; + diagnostics: Diagnostic[]; }; export async function buildModuleIndex( - ctx: IndexContext, + cwd: string, config: ModuleGateConfig, -): Promise { - const notify = (msg: string) => ctx.ui.notify(msg, "info"); - const scanRoots = resolveScanRoots(ctx.cwd, config.sourceRoots); +): Promise { + const scanRoots = resolveScanRoots(cwd, config.sourceRoots); const moduleFilesList = await Promise.all( scanRoots.map((root) => findModuleFiles(root, config.moduleDescriptorFileName)), ); - const moduleFiles = moduleFilesList.flat(); - const contracts = buildContracts(moduleFiles, notify); - applyComplementPass(contracts); + const { contracts, diagnostics } = buildContracts(moduleFilesList.flat()); const dirToModule = await buildDirToModuleMap(contracts); - const index: ModuleIndex = { contracts, dirToModule }; - await validateVisibleEntries(index, ctx.cwd, ctx.ui.notify, config.moduleDescriptorFileName); - - return index; + return { index: { contracts, dirToModule }, diagnostics }; } function resolveScanRoots(cwd: string, sourceRoots: string[]): string[] { @@ -41,9 +33,9 @@ function resolveScanRoots(cwd: string, sourceRoots: string[]): string[] { function buildContracts( moduleFiles: string[], - onInfo: (message: string) => void, -): ModuleContract[] { +): { contracts: ModuleContract[]; diagnostics: Diagnostic[] } { const contracts: ModuleContract[] = []; + const diagnostics: Diagnostic[] = []; for (const absModuleFile of moduleFiles) { const modulePath = path.dirname(absModuleFile); @@ -56,19 +48,16 @@ function buildContracts( frontmatter = parsed.frontmatter; body = parsed.body; } catch { - onInfo( - `[Module Gate] Failed to parse ${absModuleFile} — module will be unguarded`, - ); + diagnostics.push({ + level: "info", + message: `Failed to parse ${absModuleFile} — module will be unguarded`, + }); continue; } contracts.push({ modulePath, descriptorFileName: path.basename(absModuleFile), - visible: - frontmatter.visible !== undefined - ? frontmatter.visible.map(parseVisibleEntry) - : null, readonly: frontmatter.readonly ?? [], noNewExports: frontmatter["no-new-exports"] ?? [], prose: body.trim(), @@ -76,7 +65,7 @@ function buildContracts( } contracts.sort((a, b) => a.modulePath.length - b.modulePath.length); - return contracts; + return { contracts, diagnostics }; } async function buildDirToModuleMap( @@ -124,35 +113,6 @@ async function findModuleFiles(dir: string, descriptorFileName: string): Promise return results; } -function applyComplementPass(contracts: ModuleContract[]): void { - for (const contract of contracts) { - if (contract.visible === null) continue; - for (const sig of contract.visible) { - if (!sig.path) continue; - const targetDir = resolveDir(contract.modulePath, sig.path); - if (targetDir === contract.modulePath) continue; - const target = contracts.find((c) => c.modulePath === targetDir); - if (!target || target.visible === null) continue; - if (target.visible.some((s) => s.name === sig.name)) continue; - target.visible.push({ name: sig.name, modifier: sig.modifier }); - } - } -} - -function resolveDir(modulePath: string, entryPath: string): string { - const dirPart = pathDirPart(entryPath); - if (!dirPart) return modulePath; - const joined = path.join(modulePath, dirPart); - return joined.endsWith(path.sep) ? joined.slice(0, -1) : joined; -} - -function pathDirPart(entryPath: string): string { - const trimmed = entryPath.trim(); - const lastSlash = trimmed.lastIndexOf("/"); - if (lastSlash < 0) return ""; - return trimmed.slice(0, lastSlash + 1); -} - async function walkDirs(root: string): Promise { const results: string[] = [root]; const stack: string[] = [root]; diff --git a/src/core/index.ts b/src/core/index.ts new file mode 100644 index 0000000..db04a3a --- /dev/null +++ b/src/core/index.ts @@ -0,0 +1,27 @@ +export { createGateEngine } from "./engine.ts"; +export type { + CheckEditOptions, + CreateGateEngineOptions, + GateEngine, +} from "./engine.ts"; + +export { DEFAULT_CONFIG_SOURCES, loadConfig } from "./config.ts"; +export type { ConfigSource, ModuleGateConfig } from "./config.ts"; + +export { buildModuleIndex } from "./graph/index.ts"; +export type { ModuleIndexBuildResult } from "./graph/module-index-builder.ts"; + +export { runGates } from "./gates/index.ts"; +export type { GateDenial, GateEdit } from "./gates/run-gates.ts"; + +export { buildSystemPromptHint } from "./context/index.ts"; + +export { applyEdits, readFileSafe } from "./utils.ts"; + +export type { + Diagnostic, + ModuleContract, + ModuleFrontmatter, + ModuleIndex, + Signature, +} from "./types.ts"; diff --git a/src/types.ts b/src/core/types.ts similarity index 64% rename from src/types.ts rename to src/core/types.ts index d34fb59..6c7f4ba 100644 --- a/src/types.ts +++ b/src/core/types.ts @@ -5,10 +5,14 @@ export type Signature = { path?: string; }; +export type ModuleFrontmatter = { + readonly?: string[]; + "no-new-exports"?: string[]; +}; + export type ModuleContract = { modulePath: string; descriptorFileName: string; - visible: Signature[] | null; readonly: string[]; noNewExports: string[]; prose: string; @@ -18,3 +22,8 @@ export type ModuleIndex = { contracts: ModuleContract[]; dirToModule: Map; }; + +export type Diagnostic = { + level: "info" | "warning" | "error"; + message: string; +}; diff --git a/src/utils.test.ts b/src/core/utils.test.ts similarity index 98% rename from src/utils.test.ts rename to src/core/utils.test.ts index f26e797..344b36b 100644 --- a/src/utils.test.ts +++ b/src/core/utils.test.ts @@ -159,7 +159,7 @@ describe("isWithinSourceRoot", () => { describe("getAncestorContracts", () => { function contract(modulePath: string, readonly: string[] = [], noNewExports: string[] = []): ModuleContract { - return { modulePath, descriptorFileName: "module.md", visible: null, readonly, noNewExports, prose: "" }; + return { modulePath, descriptorFileName: "module.md", readonly, noNewExports, prose: "" }; } it("returns matching contracts for file under module path", () => { @@ -197,7 +197,6 @@ describe("getAncestorContracts", () => { expect(result[0]).toEqual({ modulePath: "/project/src", descriptorFileName: "module.md", - visible: null, readonly: ["a.ts"], noNewExports: ["b.ts"], prose: "", diff --git a/src/utils.ts b/src/core/utils.ts similarity index 100% rename from src/utils.ts rename to src/core/utils.ts diff --git a/src/utils/MODULE.md b/src/core/utils/MODULE.md similarity index 100% rename from src/utils/MODULE.md rename to src/core/utils/MODULE.md diff --git a/src/utils/frontmatter.ts b/src/core/utils/frontmatter.ts similarity index 100% rename from src/utils/frontmatter.ts rename to src/core/utils/frontmatter.ts diff --git a/src/gates/export-gate.test.ts b/src/gates/export-gate.test.ts deleted file mode 100644 index 7e50991..0000000 --- a/src/gates/export-gate.test.ts +++ /dev/null @@ -1,336 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { checkExports } from "./export-gate.ts"; -import type { ModuleIndex, ModuleContract } from "../types.ts"; -import "./checkers/typescript.ts"; - -function makeIndex(contracts: ModuleContract[]): ModuleIndex { - return { contracts, dirToModule: new Map() }; -} - -describe("checkExports", () => { - const cwd = "/project"; - - it("blocks new export not in visible list", () => { - const index = makeIndex([ - { - modulePath: "/project/src", - descriptorFileName: "module.md", - visible: [{ name: "allowedFn" }], - readonly: ["module.md"], - noNewExports: [], - prose: "", - }, - ]); - - const before = ""; - const after = "export function notAllowed() {}"; - const result = checkExports("src/app.ts", before, after, index, cwd); - - expect(result.blocked).toBe(true); - if (result.blocked) { - expect(result.violations).toEqual( - expect.arrayContaining([ - expect.objectContaining({ name: "notAllowed" }), - ]), - ); - } - }); - - it("passes new export that is in visible list", () => { - const index = makeIndex([ - { - modulePath: "/project/src", - descriptorFileName: "module.md", - visible: [{ name: "allowedFn" }], - readonly: ["module.md"], - noNewExports: [], - prose: "", - }, - ]); - - const before = ""; - const after = "export function allowedFn() {}"; - const result = checkExports("src/app.ts", before, after, index, cwd); - - expect(result.blocked).toBe(false); - }); - - it("does not block when no checker exists for extension", () => { - const index = makeIndex([ - { - modulePath: "/project/src", - descriptorFileName: "module.md", - visible: [], - readonly: ["module.md"], - noNewExports: [], - prose: "", - }, - ]); - - const result = checkExports( - "src/data.json", - "{}", - '{"new": true}', - index, - cwd, - - ); - - expect(result.blocked).toBe(false); - }); - - it("all ancestor modules with visible key form intersection", () => { - const index = makeIndex([ - { - modulePath: "/project", - descriptorFileName: "module.md", - visible: [{ name: "sharedFn" }, { name: "rootOnly" }], - readonly: ["module.md"], - noNewExports: [], - prose: "", - }, - { - modulePath: "/project/src", - descriptorFileName: "module.md", - visible: [{ name: "sharedFn" }, { name: "srcOnly" }], - readonly: ["module.md"], - noNewExports: [], - prose: "", - }, - ]); - - const before = ""; - const after = "export function rootOnly() {}"; - const result = checkExports("src/app.ts", before, after, index, cwd); - - expect(result.blocked).toBe(true); - if (result.blocked) { - expect(result.violations[0].name).toBe("rootOnly"); - } - }); - - it("module with no visible key does not constrain", () => { - const index = makeIndex([ - { - modulePath: "/project/src", - descriptorFileName: "module.md", - visible: null, - readonly: ["module.md"], - noNewExports: [], - prose: "", - }, - ]); - - const before = ""; - const after = "export function anything() {}"; - const result = checkExports("src/app.ts", before, after, index, cwd); - - expect(result.blocked).toBe(false); - }); - - it("empty visible list blocks all exports", () => { - const index = makeIndex([ - { - modulePath: "/project/src", - descriptorFileName: "module.md", - visible: [], - readonly: ["module.md"], - noNewExports: [], - prose: "", - }, - ]); - - const before = ""; - const after = "export function blocked() {}"; - const result = checkExports("src/app.ts", before, after, index, cwd); - - expect(result.blocked).toBe(true); - if (result.blocked) { - expect(result.violations[0].name).toBe("blocked"); - } - }); - - it("does not block when there are no new exports", () => { - const index = makeIndex([ - { - modulePath: "/project/src", - descriptorFileName: "module.md", - visible: [], - readonly: ["module.md"], - noNewExports: [], - prose: "", - }, - ]); - - const code = "export function existing() {}"; - const result = checkExports("src/app.ts", code, code, index, cwd); - - expect(result.blocked).toBe(false); - }); - - it("reports multiple new unlisted exports in one write", () => { - const index = makeIndex([ - { - modulePath: "/project/src", - descriptorFileName: "module.md", - visible: [{ name: "allowedFn" }], - readonly: ["module.md"], - noNewExports: [], - prose: "", - }, - ]); - - const before = "export function allowedFn() {}"; - const after = - "export function allowedFn() {}\nexport function unlistedA() {}\nexport type unlistedB = string;"; - const result = checkExports("src/app.ts", before, after, index, cwd); - - expect(result.blocked).toBe(true); - if (result.blocked) { - expect(result.violations).toHaveLength(2); - expect(result.violations.map((v) => v.name)).toEqual( - expect.arrayContaining(["unlistedA", "unlistedB"]), - ); - } - }); - - it("allows anything when index has no contracts", () => { - const index = makeIndex([]); - - const before = ""; - const after = "export function anything() {}"; - const result = checkExports("src/app.ts", before, after, index, cwd); - - expect(result.blocked).toBe(false); - }); - - it("allows symbol listed in both child and parent visible", () => { - const index = makeIndex([ - { - modulePath: "/project", - descriptorFileName: "module.md", - visible: [{ name: "sharedFn" }, { name: "rootOnly" }], - readonly: ["module.md"], - noNewExports: [], - prose: "", - }, - { - modulePath: "/project/src", - descriptorFileName: "module.md", - visible: [{ name: "sharedFn" }, { name: "srcOnly" }], - readonly: ["module.md"], - noNewExports: [], - prose: "", - }, - ]); - - const before = ""; - const after = "export function sharedFn() {}"; - const result = checkExports("src/app.ts", before, after, index, cwd); - - expect(result.blocked).toBe(false); - }); - - it("allows export when parent has no visible key but child does", () => { - const index = makeIndex([ - { - modulePath: "/project", - descriptorFileName: "module.md", - visible: null, - readonly: ["module.md"], - noNewExports: [], - prose: "", - }, - { - modulePath: "/project/src", - descriptorFileName: "module.md", - visible: [{ name: "childFn" }], - readonly: ["module.md"], - noNewExports: [], - prose: "", - }, - ]); - - const before = ""; - const after = "export function childFn() {}"; - const result = checkExports("src/app.ts", before, after, index, cwd); - - expect(result.blocked).toBe(false); - }); - - it("allows export when immediate parent lists it, regardless of ancestor", () => { - const index = makeIndex([ - { - modulePath: "/project", - descriptorFileName: "module.md", - visible: [{ name: "rootOnly" }], - readonly: ["module.md"], - noNewExports: [], - prose: "", - }, - { - modulePath: "/project/src", - descriptorFileName: "module.md", - visible: [{ name: "sharedFn" }], - readonly: ["module.md"], - noNewExports: [], - prose: "", - }, - { - modulePath: "/project/src/payments", - descriptorFileName: "module.md", - visible: [{ name: "leafOnly" }], - readonly: ["module.md"], - noNewExports: [], - prose: "", - }, - ]); - - const before = ""; - const after = "export function leafOnly() {}"; - const result = checkExports( - "src/payments/app.ts", - before, - after, - index, - cwd, - - ); - - expect(result.blocked).toBe(false); - }); - - it("blocks export not in immediate parent's visible, even if grandparent has it", () => { - const index = makeIndex([ - { - modulePath: "/project", - descriptorFileName: "module.md", - visible: [{ name: "rootOnly" }], - readonly: ["module.md"], - noNewExports: [], - prose: "", - }, - { - modulePath: "/project/src", - descriptorFileName: "module.md", - visible: [{ name: "sharedFn" }, { name: "rootOnly" }], - readonly: ["module.md"], - noNewExports: [], - prose: "", - }, - ]); - - const before = ""; - const after = "export function sharedFn() {}"; - const result = checkExports( - "src/app.ts", - before, - after, - index, - cwd, - - ); - - expect(result.blocked).toBe(false); - }); -}); diff --git a/src/gates/export-gate.ts b/src/gates/export-gate.ts deleted file mode 100644 index 8b1888f..0000000 --- a/src/gates/export-gate.ts +++ /dev/null @@ -1,73 +0,0 @@ -import * as path from "node:path"; -import { getChecker } from "./checkers/registry.ts"; -import type { ModuleIndex, ModuleContract } from "../types.ts"; - -export type ExportViolation = { name: string; imposedBy: string }; - -export type ExportCheckResult = - | { blocked: true; violations: ExportViolation[]; reason: string } - | { blocked: false }; - -export function checkExports( - filePath: string, - beforeContent: string, - afterContent: string, - index: ModuleIndex, - cwd: string, -): ExportCheckResult { - const absFile = path.resolve(cwd, filePath); - const checker = getChecker(absFile); - if (!checker) return { blocked: false }; - - const contract = findImmediateContract(absFile, index.contracts); - if (!contract || contract.visible === null) return { blocked: false }; - - const newExports = checker.getNewExports(beforeContent, afterContent); - const violations: ExportViolation[] = []; - - for (const sig of newExports) { - const visibleEntry = contract.visible.find((s) => s.name === sig.name); - if (!visibleEntry) { - violations.push({ - name: sig.name, - imposedBy: path.relative(cwd, path.join(contract.modulePath, contract.descriptorFileName)), - }); - continue; - } - - const requiredMod = visibleEntry.modifier; - if (requiredMod !== undefined && sig.modifier !== requiredMod) { - violations.push({ - name: `${sig.modifier ?? ""} ${sig.name}`.trim(), - imposedBy: path.relative(cwd, path.join(contract.modulePath, contract.descriptorFileName)), - }); - continue; - } - } - - if (violations.length === 0) return { blocked: false }; - - const lines = violations.map( - (v) => ` \u2022 ${v.name} not in visible list of ${v.imposedBy}`, - ); - return { - blocked: true, - violations, - reason: `Export violations:\n${lines.join("\n")}`, - }; -} - -function findImmediateContract( - absFile: string, - contracts: ModuleContract[], -): ModuleContract | undefined { - let best: ModuleContract | undefined; - for (const c of contracts) { - if (absFile.startsWith(c.modulePath + path.sep) || absFile === c.modulePath) { - if (!best || c.modulePath.length > best.modulePath.length) { - best = c; - } - } - } - return best; -} diff --git a/src/graph/frontmatter-parser.test.ts b/src/graph/frontmatter-parser.test.ts deleted file mode 100644 index 50012c4..0000000 --- a/src/graph/frontmatter-parser.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { parseVisibleEntry } from "./frontmatter-parser.ts"; - -describe("parseVisibleEntry", () => { - it("parses bare string as path with extracted name", () => { - expect(parseVisibleEntry("Foo")).toEqual({ name: "Foo", path: "Foo" }); - }); - - it("extracts name from sub-path bare string", () => { - expect(parseVisibleEntry("sub/Helper")).toEqual({ name: "Helper", path: "sub/Helper" }); - }); - - it("extracts name from bare string with trailing slash (dir reference)", () => { - expect(parseVisibleEntry("sub/mod1/")).toEqual({ name: "mod1", path: "sub/mod1/" }); - }); - - it("parses object form with path and modifier", () => { - expect(parseVisibleEntry({ path: "sub/Type", modifier: "pub(super)" })).toEqual({ - name: "Type", - modifier: "pub(super)", - path: "sub/Type", - }); - }); - - it("parses object form with path only, no modifier", () => { - expect(parseVisibleEntry({ path: "Foo" })).toEqual({ - name: "Foo", - path: "Foo", - }); - }); - - it("parses object form with trailing slash", () => { - expect(parseVisibleEntry({ path: "sub/mod1/" })).toEqual({ - name: "mod1", - path: "sub/mod1/", - }); - }); - - it("trims whitespace from bare string", () => { - expect(parseVisibleEntry(" Foo ")).toEqual({ name: "Foo", path: "Foo" }); - }); - - it("handles empty string", () => { - expect(parseVisibleEntry("")).toEqual({ name: "", path: "" }); - }); -}); diff --git a/src/graph/frontmatter-parser.ts b/src/graph/frontmatter-parser.ts deleted file mode 100644 index d0c2b7d..0000000 --- a/src/graph/frontmatter-parser.ts +++ /dev/null @@ -1,28 +0,0 @@ -import type { Signature } from "../types.ts"; - -export type VisibleEntryRaw = string | { path: string; modifier?: string }; - -export type ModuleFrontmatter = { - visible?: VisibleEntryRaw[]; - readonly?: string[]; - "no-new-exports"?: string[]; -}; - -export function parseVisibleEntry(raw: VisibleEntryRaw): Signature { - if (typeof raw === "string") { - const trimmed = raw.trim(); - return { name: extractNameFromPath(trimmed), path: trimmed }; - } - return { - name: extractNameFromPath(raw.path), - modifier: raw.modifier, - path: raw.path, - }; -} - -function extractNameFromPath(pathStr: string): string { - let p = pathStr.trim(); - if (p.endsWith("/")) p = p.slice(0, -1); - const lastSlash = p.lastIndexOf("/"); - return lastSlash >= 0 ? p.slice(lastSlash + 1) : p; -} diff --git a/src/graph/index.ts b/src/graph/index.ts deleted file mode 100644 index 249a6b8..0000000 --- a/src/graph/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export { buildModuleIndex } from "./module-index-builder.ts"; -export { validateVisibleEntries } from "./validation.ts"; -export { parseVisibleEntry } from "./frontmatter-parser.ts"; -export type { VisibleEntryRaw, ModuleFrontmatter } from "./frontmatter-parser.ts"; diff --git a/src/graph/validation.test.ts b/src/graph/validation.test.ts deleted file mode 100644 index f8e813d..0000000 --- a/src/graph/validation.test.ts +++ /dev/null @@ -1,241 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import type { Dirent } from "node:fs"; - -vi.mock("node:fs/promises", () => ({ - readdir: vi.fn(), -})); - -vi.mock("node:fs", () => ({ - readFileSync: vi.fn(), -})); - -vi.mock("../gates/checkers/index.ts", () => ({ - getChecker: vi.fn(), -})); - -import { readdir } from "node:fs/promises"; -import * as fs from "node:fs"; -import { getChecker } from "../gates/checkers/index.ts"; -import { validateVisibleEntries } from "./validation.ts"; -import type { ModuleIndex, ModuleContract } from "../types.ts"; - -const mockedReaddir = readdir as unknown as ReturnType; -const mockedReadFileSync = vi.mocked(fs.readFileSync); -const mockedGetChecker = vi.mocked(getChecker); - -function makeDirent(name: string, isDir: boolean): Dirent { - return { - name, - isDirectory: () => isDir, - isFile: () => !isDir, - isBlockDevice: () => false, - isCharacterDevice: () => false, - isFIFO: () => false, - isSocket: () => false, - isSymbolicLink: () => false, - parentPath: "", - path: "", - } as Dirent; -} - -function makeIndex(contracts: ModuleContract[]): ModuleIndex { - const dirToModule = new Map(); - for (const c of contracts) { - dirToModule.set(c.modulePath, c.modulePath); - } - return { contracts, dirToModule }; -} - -describe("validateVisibleEntries", () => { - const cwd = "/project"; - let notifications: { message: string; type: string }[]; - - beforeEach(() => { - vi.clearAllMocks(); - notifications = []; - }); - - const notify = (message: string, type?: string): void => { - notifications.push({ message, type: type ?? "info" }); - }; - - it("warns on dangling visible entry with no matching export", async () => { - const index = makeIndex([ - { - modulePath: "/project", - descriptorFileName: "module.md", - visible: [{ name: "GhostType" }], - readonly: ["module.md"], - noNewExports: [], - prose: "", - }, - ]); - - mockedReaddir.mockImplementation(async () => { - return [makeDirent("stub.ts", false)] as Dirent[]; - }); - - mockedReadFileSync.mockReturnValue("// no exports here"); - mockedGetChecker.mockReturnValue({ - extensions: [".ts"], - getNewExports: () => [], - }); - - await validateVisibleEntries(index, cwd, notify, "module.md"); - - expect(notifications).toHaveLength(1); - expect(notifications[0].message).toContain("Dangling visible entry"); - expect(notifications[0].message).toContain("GhostType"); - expect(notifications[0].type).toBe("info"); - }); - - it("does not warn when visible entry exists as export", async () => { - const index = makeIndex([ - { - modulePath: "/project", - descriptorFileName: "module.md", - visible: [{ name: "greet" }], - readonly: ["module.md"], - noNewExports: [], - prose: "", - }, - ]); - - mockedReaddir.mockImplementation(async () => { - return [makeDirent("app.ts", false)] as Dirent[]; - }); - - mockedReadFileSync.mockReturnValue("export function greet() {}"); - mockedGetChecker.mockReturnValue({ - extensions: [".ts"], - getNewExports: () => [{ name: "greet" }], - }); - - await validateVisibleEntries(index, cwd, notify, "module.md"); - - expect(notifications).toHaveLength(0); - }); - - it("skips modules with no visible key", async () => { - const index = makeIndex([ - { - modulePath: "/project", - descriptorFileName: "module.md", - visible: null, - readonly: ["module.md"], - noNewExports: [], - prose: "", - }, - ]); - - await validateVisibleEntries(index, cwd, notify, "module.md"); - expect(notifications).toHaveLength(0); - }); - - it("skips files without a checker", async () => { - const index = makeIndex([ - { - modulePath: "/project", - descriptorFileName: "module.md", - visible: [{ name: "anything" }], - readonly: ["module.md"], - noNewExports: [], - prose: "", - }, - ]); - - mockedReaddir.mockImplementation(async () => { - return [makeDirent("data.json", false)] as Dirent[]; - }); - - mockedGetChecker.mockReturnValue(undefined); - - await validateVisibleEntries(index, cwd, notify, "module.md"); - - expect(notifications).toHaveLength(1); - }); - - it("skips nested child module directories", async () => { - const index = makeIndex([ - { - modulePath: "/project", - descriptorFileName: "module.md", - visible: [{ name: "GhostType" }], - readonly: ["module.md"], - noNewExports: [], - prose: "", - }, - { - modulePath: "/project/src", - descriptorFileName: "module.md", - visible: [{ name: "greet" }], - readonly: ["module.md"], - noNewExports: [], - prose: "", - }, - ]); - - const filesInRoot: Dirent[] = [makeDirent("stub.ts", false), makeDirent("src", true)]; - const filesInSrc: Dirent[] = [makeDirent("app.ts", false)]; - - mockedReaddir.mockImplementation(async (dir: unknown) => { - const d = dir as string; - if (d === "/project") return filesInRoot; - if (d === "/project/src") return filesInSrc; - return [] as Dirent[]; - }); - - mockedReadFileSync.mockImplementation((path: unknown): string => { - const p = path as string; - if (p === "/project/stub.ts") return "// no exports here"; - if (p === "/project/src/app.ts") return "export function greet() {}"; - return ""; - }); - - mockedGetChecker.mockImplementation((filePath) => { - if (filePath.endsWith(".ts")) { - return { - extensions: [".ts"], - getNewExports: (_before: string, after: string) => { - if (after.includes("export function greet")) return [{ name: "greet" }]; - return []; - }, - }; - } - return undefined; - }); - - await validateVisibleEntries(index, cwd, notify, "module.md"); - - expect(notifications).toHaveLength(1); - expect(notifications[0].message).toContain("GhostType"); - expect(notifications[0].message).toContain("module.md"); - }); - - it("reports multiple dangling entries from same module", async () => { - const index = makeIndex([ - { - modulePath: "/project", - descriptorFileName: "module.md", - visible: [{ name: "GhostA" }, { name: "GhostB" }], - readonly: ["module.md"], - noNewExports: [], - prose: "", - }, - ]); - - mockedReaddir.mockImplementation(async () => { - return [makeDirent("stub.ts", false)] as Dirent[]; - }); - - mockedReadFileSync.mockReturnValue("// no exports"); - mockedGetChecker.mockReturnValue({ - extensions: [".ts"], - getNewExports: () => [], - }); - - await validateVisibleEntries(index, cwd, notify, "module.md"); - - expect(notifications).toHaveLength(2); - }); -}); diff --git a/src/graph/validation.ts b/src/graph/validation.ts deleted file mode 100644 index 9c7c16c..0000000 --- a/src/graph/validation.ts +++ /dev/null @@ -1,116 +0,0 @@ -import * as path from "node:path"; -import { readdir } from "node:fs/promises"; -import type { ModuleIndex } from "../types.ts"; -import { getChecker } from "../gates/checkers/index.ts"; -import { readFileSafe } from "../utils.ts"; -import type { Dirent } from "node:fs"; - -type NotifyFn = (message: string, type?: "info" | "warning" | "error") => void; - -export async function validateVisibleEntries( - idx: ModuleIndex, - cwd: string, - notify: NotifyFn, - descriptorFileName: string, -): Promise { - const childModules = new Set( - idx.contracts.map((c) => c.modulePath), - ); - - for (const contract of idx.contracts) { - if (contract.visible === null) continue; - - const exportedSymbols = await collectExports(contract.modulePath, childModules); - // Cache for non-local path resolution - const pathExportsCache = new Map>(); - - for (const sig of contract.visible) { - const targetDir = resolveValidationTarget(contract.modulePath, sig.path); - const symbols = - targetDir !== contract.modulePath - ? await resolvePathExports(targetDir, pathExportsCache, childModules) - : exportedSymbols; - - if (!symbols.has(sig.name)) { - const relModule = path.relative(cwd, path.join(contract.modulePath, descriptorFileName)); - notify( - `[Module Gate] Dangling visible entry "${sig.name}" in ${relModule}`, - "info", - ); - } - } - } -} - -function resolveValidationTarget(modulePath: string, entryPath?: string): string { - if (!entryPath) return modulePath; - const lastSlash = entryPath.lastIndexOf("/"); - if (lastSlash < 0) return modulePath; - const dirPart = entryPath.slice(0, lastSlash + 1); - const joined = path.join(modulePath, dirPart); - return joined.endsWith(path.sep) ? joined.slice(0, -1) : joined; -} - -async function collectExports( - modulePath: string, - childModules: Set, -): Promise> { - const files = await listFiles(modulePath, childModules); - const symbols = new Set(); - for (const filePath of files) { - const checker = getChecker(filePath); - if (!checker) continue; - const content = readFileSafe(filePath); - const exports = checker.getNewExports("", content); - for (const sig of exports) { - symbols.add(sig.name); - } - } - return symbols; -} - -async function resolvePathExports( - targetDir: string, - cache: Map>, - childModules: Set, -): Promise> { - let symbols = cache.get(targetDir); - if (!symbols) { - symbols = await collectExports(targetDir, childModules); - cache.set(targetDir, symbols); - } - return symbols; -} - -async function listFiles( - dir: string, - childModules: Set, -): Promise { - const results: string[] = []; - const stack: string[] = [dir]; - - while (stack.length > 0) { - const current = stack.pop()!; - let entries: Dirent[]; - try { - entries = await readdir(current, { withFileTypes: true }); - } catch { - continue; - } - - for (const entry of entries) { - if (entry.name === "node_modules" || entry.name === ".git") continue; - const fullPath = path.join(current, entry.name); - if (entry.isDirectory()) { - if (childModules.has(fullPath) && fullPath !== dir) continue; - stack.push(fullPath); - } else { - results.push(fullPath); - } - } - } - - return results; -} - - diff --git a/src/import-test.ts b/src/import-test.ts deleted file mode 100644 index e69de29..0000000 diff --git a/src/index.ts b/src/index.ts deleted file mode 100644 index 446dc60..0000000 --- a/src/index.ts +++ /dev/null @@ -1,50 +0,0 @@ -import * as path from "node:path"; -import type { - ExtensionAPI, - ToolCallEventResult, - BeforeAgentStartEventResult, -} from "@earendil-works/pi-coding-agent"; -import { isToolCallEventType } from "@earendil-works/pi-coding-agent"; -import type { ModuleIndex } from "./types.ts"; -import { loadConfig } from "./config.ts"; -import type { ModuleGateConfig } from "./config.ts"; -import { buildModuleIndex } from "./graph/index.ts"; -import { readFileSafe } from "./utils.ts"; -import { runGates, type GateEdit } from "./gates/run-gates.ts"; -import { buildSystemPromptHint } from "./context/index.ts"; -import "./gates/checkers/index.ts"; - -export default function (pi: ExtensionAPI): void { - let index: ModuleIndex; - let config: ModuleGateConfig; - - pi.on("session_start", async (_event, ctx) => { - config = loadConfig(ctx.cwd); - index = await buildModuleIndex(ctx, config); - if (index.contracts.length === 0) { - ctx.ui.notify( - "[Module Gate] No module descriptor files found. Gates are not active.", - "info", - ); - } - }); - - pi.on("before_agent_start", async (event): Promise => { - if (index.contracts.length === 0) return; - return { - systemPrompt: buildSystemPromptHint(index, event.systemPrompt, config.moduleDescriptorFileName, config), - }; - }); - - pi.on("tool_call", async (event, ctx): Promise => { - if (isToolCallEventType("edit", event)) { - return runGates(event.input.path, event.input.edits, ctx.cwd, index, config); - } - if (isToolCallEventType("write", event)) { - const absPath = path.resolve(ctx.cwd, event.input.path); - const before = readFileSafe(absPath); - const edits: GateEdit[] = [{ oldText: before, newText: event.input.content }]; - return runGates(event.input.path, edits, ctx.cwd, index, config); - } - }); -} \ No newline at end of file diff --git a/test/behavior/dangling-entries.test.ts b/test/behavior/dangling-entries.test.ts deleted file mode 100644 index ce4fa85..0000000 --- a/test/behavior/dangling-entries.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { describe, it, expect, beforeEach, vi } from "vitest"; -import { - MockExtensionAPI, - FIXTURES, - startSession, -} from "./helpers.ts"; - -vi.mock("../../src/config.ts", () => ({ - loadConfig: () => ({ moduleDescriptorFileName: "module.md", moduleDescriptorReadonly: "file", sourceRoots: [""] }), -})); - -import mod from "../../src/index.ts"; - -describe("dangling visible entries", () => { - let mock: MockExtensionAPI; - - beforeEach(() => { - mock = new MockExtensionAPI(); - mod(mock); - }); - - it("reports all three root-module dangling entries with correct format", async () => { - const cwd = FIXTURES; - await startSession(mock, cwd); - - const rootDangling = mock.notifications.filter( - (n) => - n.type === "info" && - n.message.includes("Dangling visible entry") && - /\sin module\.md$/.test(n.message), - ); - - expect(rootDangling).toHaveLength(3); - - const names = rootDangling.map((w) => { - const match = w.message.match(/"(\w+)"/); - return match ? match[1] : ""; - }); - - expect(names).toEqual( - expect.arrayContaining(["GhostType", "AnotherGhost", "ThirdGhost"]), - ); - - const ghostWarn = rootDangling.find((n) => n.message.includes("GhostType"))!; - expect(ghostWarn.message).toContain("module.md"); - expect(ghostWarn.type).toBe("info"); - }); -}); diff --git a/test/behavior/descriptor-case-insensitive.test.ts b/test/behavior/descriptor-case-insensitive.test.ts index 64ccb62..40d8161 100644 --- a/test/behavior/descriptor-case-insensitive.test.ts +++ b/test/behavior/descriptor-case-insensitive.test.ts @@ -7,17 +7,17 @@ import { doWrite, doEdit, } from "./helpers.ts"; -import type { ModuleGateConfig } from "../../src/config.ts"; +import type { ModuleGateConfig } from "../../src/core/config.ts"; const { mockedLoadConfig } = vi.hoisted(() => ({ mockedLoadConfig: vi.fn(), })); -vi.mock("../../src/config.ts", () => ({ +vi.mock("../../src/core/config.ts", () => ({ loadConfig: mockedLoadConfig, })); -import mod from "../../src/index.ts"; +import mod from "../../src/bridges/pi/index.ts"; function setConfig(over: Partial): void { mockedLoadConfig.mockReturnValue({ diff --git a/test/behavior/export-gating.test.ts b/test/behavior/export-gating.test.ts deleted file mode 100644 index 47474b7..0000000 --- a/test/behavior/export-gating.test.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { describe, it, expect, beforeEach, vi } from "vitest"; -import * as path from "node:path"; -import { - MockExtensionAPI, - FIXTURES, - startSession, - doWrite, - doEdit, -} from "./helpers.ts"; - -const { mockedLoadConfig } = vi.hoisted(() => ({ - mockedLoadConfig: vi.fn(), -})); - -vi.mock("../../src/config.ts", () => ({ - loadConfig: mockedLoadConfig, -})); - -import mod from "../../src/index.ts"; -import type { ModuleGateConfig } from "../../src/config.ts"; - -function setConfig(over: Partial): void { - mockedLoadConfig.mockReturnValue({ - moduleDescriptorFileName: "module.md", - moduleDescriptorReadonly: "file", - sourceRoots: [""], - disableModuleInterfaceImportGate: false, - disableSystemPrompt: false, - outputModuleProseOnBlock: false, - ...over, - }); -} - -describe("export gating", () => { - let mock: MockExtensionAPI; - - beforeEach(() => { - setConfig({ outputModuleProseOnBlock: false }); - mock = new MockExtensionAPI(); - mod(mock); - }); - - describe("write gate", () => { - it("blocks write to readonly file and includes prose", async () => { - setConfig({ outputModuleProseOnBlock: true }); - const cwd = path.join(FIXTURES, "src"); - await startSession(mock, cwd); - - const result = await doWrite(mock, "config.ts", "// overwrite", cwd); - expect((result as any).block).toBe(true); - expect((result as any).reason).toContain("module.md"); - expect((result as any).reason).toContain("Greeting module"); - }); - - it("omits prose by default when blocking a readonly file", async () => { - const cwd = path.join(FIXTURES, "src"); - await startSession(mock, cwd); - - const result = await doWrite(mock, "config.ts", "// overwrite", cwd); - expect((result as any).block).toBe(true); - expect((result as any).reason).not.toContain("Greeting module"); - }); - - it("blocks write to module.md itself", async () => { - const cwd = path.join(FIXTURES, "src"); - await startSession(mock, cwd); - - const result = await doWrite(mock, "module.md", "---", cwd); - expect((result as any).block).toBe(true); - }); - - it("blocks export not in visible list", async () => { - const cwd = path.join(FIXTURES, "src"); - await startSession(mock, cwd); - - const result = await doWrite( - mock, - "app.ts", - "export function greet() {}\nexport function hidden() {}", - cwd, - ); - expect((result as any).block).toBe(true); - if (result?.block) { - expect((result as any).reason).toContain("hidden"); - } - }); - - it("allows export listed in visible", async () => { - const cwd = path.join(FIXTURES, "src"); - await startSession(mock, cwd); - - const result = await doWrite( - mock, - "app.ts", - "export function greet() {}", - cwd, - ); - expect(result?.block).toBeFalsy(); - }); - }); - - describe("edit gate", () => { - it("blocks edit on readonly file", async () => { - const cwd = path.join(FIXTURES, "src"); - await startSession(mock, cwd); - - const result = await doEdit( - mock, - "config.ts", - [{ oldText: "API_URL", newText: "DIFFERENT_URL" }], - cwd, - ); - expect((result as any).block).toBe(true); - }); - - it("blocks edit that introduces new unlisted export", async () => { - const cwd = path.join(FIXTURES, "src"); - await startSession(mock, cwd); - - const result = await doEdit( - mock, - "app.ts", - [ - { - oldText: "export function greet(", - newText: "export function secret() {}\nexport function greet(", - }, - ], - cwd, - ); - expect((result as any).block).toBe(true); - }); - }); -}); diff --git a/test/behavior/frontmatter-gating.test.ts b/test/behavior/frontmatter-gating.test.ts index 5d25576..196f597 100644 --- a/test/behavior/frontmatter-gating.test.ts +++ b/test/behavior/frontmatter-gating.test.ts @@ -8,11 +8,11 @@ import { doEdit, } from "./helpers.ts"; -vi.mock("../../src/config.ts", () => ({ +vi.mock("../../src/core/config.ts", () => ({ loadConfig: () => ({ moduleDescriptorFileName: "module.md", moduleDescriptorReadonly: "frontmatter", sourceRoots: [""] }), })); -import mod from "../../src/index.ts"; +import mod from "../../src/bridges/pi/index.ts"; describe("frontmatter readonly mode", () => { let mock: MockExtensionAPI; diff --git a/test/behavior/malformed-module.test.ts b/test/behavior/malformed-module.test.ts index 738481d..8a8f5e4 100644 --- a/test/behavior/malformed-module.test.ts +++ b/test/behavior/malformed-module.test.ts @@ -6,11 +6,11 @@ import { doWrite, } from "./helpers.ts"; -vi.mock("../../src/config.ts", () => ({ +vi.mock("../../src/core/config.ts", () => ({ loadConfig: () => ({ moduleDescriptorFileName: "module.md", moduleDescriptorReadonly: "file", sourceRoots: [""] }), })); -import mod from "../../src/index.ts"; +import mod from "../../src/bridges/pi/index.ts"; describe("malformed frontmatter", () => { let mock: MockExtensionAPI; diff --git a/test/behavior/modifier-lock.test.ts b/test/behavior/modifier-lock.test.ts deleted file mode 100644 index 4775870..0000000 --- a/test/behavior/modifier-lock.test.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { describe, it, expect, beforeEach, vi } from "vitest"; -import { - MockExtensionAPI, - MODIFIER_LOCK, - startSession, - doWrite, -} from "./helpers.ts"; - -vi.mock("../../src/config.ts", () => ({ - loadConfig: () => ({ moduleDescriptorFileName: "module.md", moduleDescriptorReadonly: "file", sourceRoots: [""] }), -})); - -import mod from "../../src/index.ts"; - -describe("modifier lock", () => { - let mock: MockExtensionAPI; - - beforeEach(() => { - mock = new MockExtensionAPI(); - mod(mock); - }); - - it("blocks new export with mismatched modifier", async () => { - const cwd = MODIFIER_LOCK; - await startSession(mock, cwd); - - // Add pub fn Foo — visible requires pub(super) - const result = await doWrite( - mock, - "new_file.rs", - "pub fn Foo() {}", - cwd, - ); - expect(result?.block).toBe(true); - }); - - it("allows new export with matching modifier", async () => { - const cwd = MODIFIER_LOCK; - await startSession(mock, cwd); - - const result = await doWrite( - mock, - "new_file.rs", - "pub(super) fn Foo() {}", - cwd, - ); - expect(result?.block).toBeFalsy(); - }); - - it("allows new export when no modifier constraint", async () => { - const cwd = MODIFIER_LOCK; - await startSession(mock, cwd); - - const result = await doWrite( - mock, - "new_file.rs", - "pub fn Baz() {}", - cwd, - ); - expect(result?.block).toBeFalsy(); - }); - - it("blocks new export with mismatched crate modifier", async () => { - const cwd = MODIFIER_LOCK; - await startSession(mock, cwd); - - // Add pub fn Bar — visible requires pub(crate) - const result = await doWrite( - mock, - "new_file.rs", - "pub fn Bar() {}", - cwd, - ); - expect(result?.block).toBe(true); - }); - - it("allows new export with matching crate modifier", async () => { - const cwd = MODIFIER_LOCK; - await startSession(mock, cwd); - - const result = await doWrite( - mock, - "new_file.rs", - "pub(crate) fn Bar() {}", - cwd, - ); - expect(result?.block).toBeFalsy(); - }); -}); diff --git a/test/behavior/module-descriptor-readonly-gating.test.ts b/test/behavior/module-descriptor-readonly-gating.test.ts index 4ff8c37..918885a 100644 --- a/test/behavior/module-descriptor-readonly-gating.test.ts +++ b/test/behavior/module-descriptor-readonly-gating.test.ts @@ -7,17 +7,17 @@ import { doWrite, doEdit, } from "./helpers.ts"; -import type { ModuleGateConfig } from "../../src/config.ts"; +import type { ModuleGateConfig } from "../../src/core/config.ts"; const { mockedLoadConfig } = vi.hoisted(() => ({ mockedLoadConfig: vi.fn(), })); -vi.mock("../../src/config.ts", () => ({ +vi.mock("../../src/core/config.ts", () => ({ loadConfig: mockedLoadConfig, })); -import mod from "../../src/index.ts"; +import mod from "../../src/bridges/pi/index.ts"; function setReadonlyMode(mode: ModuleGateConfig["moduleDescriptorReadonly"]): void { mockedLoadConfig.mockReturnValue({ diff --git a/test/behavior/module-interface-import-gating.test.ts b/test/behavior/module-interface-import-gating.test.ts index 868a519..5350f7f 100644 --- a/test/behavior/module-interface-import-gating.test.ts +++ b/test/behavior/module-interface-import-gating.test.ts @@ -6,7 +6,7 @@ import { doWrite, } from "./helpers.ts"; -vi.mock("../../src/config.ts", () => ({ +vi.mock("../../src/core/config.ts", () => ({ loadConfig: () => ({ moduleDescriptorFileName: "module.md", moduleDescriptorReadonly: "file", @@ -17,7 +17,7 @@ vi.mock("../../src/config.ts", () => ({ }), })); -import mod from "../../src/index.ts"; +import mod from "../../src/bridges/pi/index.ts"; describe("module interface import gating", () => { let mock: MockExtensionAPI; diff --git a/test/behavior/no-new-exports-gating.test.ts b/test/behavior/no-new-exports-gating.test.ts index cf7a34e..b13ae58 100644 --- a/test/behavior/no-new-exports-gating.test.ts +++ b/test/behavior/no-new-exports-gating.test.ts @@ -8,11 +8,11 @@ import { doEdit, } from "./helpers.ts"; -vi.mock("../../src/config.ts", () => ({ +vi.mock("../../src/core/config.ts", () => ({ loadConfig: () => ({ moduleDescriptorFileName: "module.md", moduleDescriptorReadonly: "file", sourceRoots: [""] }), })); -import mod from "../../src/index.ts"; +import mod from "../../src/bridges/pi/index.ts"; describe("no-new-exports gating", () => { let mock: MockExtensionAPI; diff --git a/test/behavior/readonly-gating.test.ts b/test/behavior/readonly-gating.test.ts index e05158f..f1dfac4 100644 --- a/test/behavior/readonly-gating.test.ts +++ b/test/behavior/readonly-gating.test.ts @@ -7,11 +7,11 @@ import { doWrite, } from "./helpers.ts"; -vi.mock("../../src/config.ts", () => ({ +vi.mock("../../src/core/config.ts", () => ({ loadConfig: () => ({ moduleDescriptorFileName: "module.md", moduleDescriptorReadonly: "file", sourceRoots: [""] }), })); -import mod from "../../src/index.ts"; +import mod from "../../src/bridges/pi/index.ts"; describe("readonly gating", () => { let mock: MockExtensionAPI; diff --git a/test/behavior/system-prompt-e2e.test.ts b/test/behavior/system-prompt-e2e.test.ts index 38227b3..1bd7e9f 100644 --- a/test/behavior/system-prompt-e2e.test.ts +++ b/test/behavior/system-prompt-e2e.test.ts @@ -7,11 +7,11 @@ import { doBeforeAgentStart, } from "./helpers.ts"; -vi.mock("../../src/config.ts", () => ({ +vi.mock("../../src/core/config.ts", () => ({ loadConfig: () => ({ moduleDescriptorFileName: "module.md", moduleDescriptorReadonly: "file", sourceRoots: [""] }), })); -import mod from "../../src/index.ts"; +import mod from "../../src/bridges/pi/index.ts"; describe("system prompt augmentation", () => { let mock: MockExtensionAPI; diff --git a/test/claude/sandbox-e2e.test.ts b/test/claude/sandbox-e2e.test.ts index 47fa6ac..2f6dcc0 100644 --- a/test/claude/sandbox-e2e.test.ts +++ b/test/claude/sandbox-e2e.test.ts @@ -4,13 +4,13 @@ import * as os from "node:os"; import * as path from "node:path"; import { spawnSync } from "node:child_process"; -const HOOK = path.resolve("src/claude/pre-tool-use.ts"); -const BIN = path.resolve("bin/pi-module-gates.mjs"); -const HOOK_MARKER = "@cuzfrog/pi-module-gates"; +const RUN = path.resolve("src/bridges/claude/run.mjs"); +const BIN = path.resolve("bin/module-gates.mjs"); +const HOOK_MARKER = "@cuzfrog/module-gates"; beforeAll(() => { - if (!fs.existsSync(HOOK)) { - throw new Error(`${HOOK} not found.`); + if (!fs.existsSync(RUN)) { + throw new Error(`${RUN} not found.`); } if (!fs.existsSync(BIN)) { throw new Error(`${BIN} not found.`); @@ -49,21 +49,19 @@ function materializeProject(): void { "---", "readonly: [locked.ts]", "no-new-exports: [no-new-exports.ts]", - "visible: [greet]", "---", "", - "Sandbox inner module exercising all three gates.", + "Sandbox inner module exercising both gates.", "", ].join("\n"), ); write("src/locked.ts", "export const LOCKED = 1;\n"); write("src/no-new-exports.ts", "export function existingFn() { return 1; }\n"); - write("src/greet.ts", "export function greet(name: string) { return name; }\n"); write("src/app.ts", "export const app = 1;\n"); } function runHook(stdinObj: unknown) { - return spawnSync("bun", [HOOK], { + return spawnSync("node", [RUN, "pre-tool-use"], { input: JSON.stringify(stdinObj), encoding: "utf-8", timeout: 30_000, @@ -71,8 +69,8 @@ function runHook(stdinObj: unknown) { }); } -describe("sandbox e2e: install + all three gates", () => { - it("installs the hook and exercises readonly, no-new-exports, visible deny+allow", { timeout: 60_000 }, () => { +describe("sandbox e2e: install + both gates", () => { + it("installs the hook and exercises readonly and no-new-exports deny+allow", { timeout: 60_000 }, () => { materializeProject(); const install = spawnSync(BIN, ["install-claude", "--project-dir", tmp], { @@ -131,29 +129,5 @@ describe("sandbox e2e: install + all three gates", () => { cwd: tmp, }); expect(editNoNewExportsInPlace.status).toBe(0); - - const writeVisibleAddExport = runHook({ - hook_event_name: "PreToolUse", - tool_name: "Write", - tool_input: { - file_path: "src/greet.ts", - content: "export function greet(name: string) { return name; }\nexport function leaky() { return 1; }\n", - }, - cwd: tmp, - }); - expect(writeVisibleAddExport.status).toBe(2); - const stderr = writeVisibleAddExport.stderr.toLowerCase(); - expect(stderr.includes("visible") || stderr.includes("export")).toBe(true); - - const writeVisibleOnlyGreet = runHook({ - hook_event_name: "PreToolUse", - tool_name: "Write", - tool_input: { - file_path: "src/greet.ts", - content: "export function greet(name: string) { return name; }\n", - }, - cwd: tmp, - }); - expect(writeVisibleOnlyGreet.status).toBe(0); }); }); diff --git a/test/fixture/malformed-module/module.md b/test/fixture/malformed-module/module.md index a92fef7..2a34d5c 100644 --- a/test/fixture/malformed-module/module.md +++ b/test/fixture/malformed-module/module.md @@ -1,5 +1,4 @@ --- -visible: [ readonly: [broken --- This YAML is intentionally malformed for testing graceful handling. diff --git a/test/fixture/module-modifier-lock/lib.rs b/test/fixture/module-modifier-lock/lib.rs deleted file mode 100644 index 6b6d833..0000000 --- a/test/fixture/module-modifier-lock/lib.rs +++ /dev/null @@ -1,3 +0,0 @@ -pub(super) fn Foo() {} -pub(crate) fn Bar() {} -pub fn Baz() {} diff --git a/test/fixture/module-modifier-lock/module.md b/test/fixture/module-modifier-lock/module.md deleted file mode 100644 index f03ad3f..0000000 --- a/test/fixture/module-modifier-lock/module.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -visible: - - path: Foo - modifier: pub(super) - - path: Bar - modifier: pub(crate) - - Baz ---- - -Modifier-lock module. `Foo` must be `pub(super)`, `Bar` must be `pub(crate)`, `Baz` has no modifier constraint. diff --git a/test/fixture/module-simulation/module.md b/test/fixture/module-simulation/module.md index 680a0bd..7ae6fda 100644 --- a/test/fixture/module-simulation/module.md +++ b/test/fixture/module-simulation/module.md @@ -1,6 +1 @@ ---- -visible: [GhostType, AnotherGhost, ThirdGhost] ---- - -Root module with dangling visible entries for e2e testing. -None of these symbols are actually exported anywhere. +Root module for behavior tests. diff --git a/test/fixture/module-simulation/other/module.md b/test/fixture/module-simulation/other/module.md index 28a4888..856ec76 100644 --- a/test/fixture/module-simulation/other/module.md +++ b/test/fixture/module-simulation/other/module.md @@ -1,5 +1 @@ ---- -visible: [fun1] ---- - Other module for interface import testing. diff --git a/test/fixture/module-simulation/src/internal/module.md b/test/fixture/module-simulation/src/internal/module.md index 56c8af1..0707faf 100644 --- a/test/fixture/module-simulation/src/internal/module.md +++ b/test/fixture/module-simulation/src/internal/module.md @@ -1,7 +1,5 @@ --- -visible: [helper, greet] readonly: [secrets.ts] --- -Internal utilities module. Inherits `greet` from parent visible. -`helper` is only listed at this level. +Internal utilities module. diff --git a/test/fixture/module-simulation/src/module.md b/test/fixture/module-simulation/src/module.md index 48b0c7a..b249235 100644 --- a/test/fixture/module-simulation/src/module.md +++ b/test/fixture/module-simulation/src/module.md @@ -1,6 +1,5 @@ --- -visible: [greet] readonly: [config.ts] --- -Greeting module. Only `greet` may be exported. +Greeting module. diff --git a/test/fixture/module-simulation/stub.ts b/test/fixture/module-simulation/stub.ts deleted file mode 100644 index 5f5a7d3..0000000 --- a/test/fixture/module-simulation/stub.ts +++ /dev/null @@ -1,2 +0,0 @@ -// Empty stub so the root module has files for export scanning. -// Required for dangling visible entry detection (scenarios #22, #23). diff --git a/test/skills/no-new-exports-all.test.ts b/test/skills/no-new-exports-all.test.ts index 40b5d10..98d0923 100644 --- a/test/skills/no-new-exports-all.test.ts +++ b/test/skills/no-new-exports-all.test.ts @@ -4,13 +4,13 @@ import { mkdtempSync, writeFileSync, rmSync, mkdirSync, existsSync, readFileSync import { join, sep } from "node:path"; import { execSync } from "node:child_process"; import { SUPPORTED_EXTENSIONS } from "../../skills/module-no-new-exports-all/scripts/no-new-exports-all.mjs"; -import { getChecker } from "../../src/gates/checkers/registry.ts"; -import "../../src/gates/checkers/typescript.ts"; -import "../../src/gates/checkers/rust.ts"; -import "../../src/gates/checkers/java.ts"; -import "../../src/gates/checkers/go.ts"; -import "../../src/gates/checkers/kotlin.ts"; -import "../../src/gates/checkers/scala.ts"; +import { getChecker } from "../../src/core/gates/checkers/registry.ts"; +import "../../src/core/gates/checkers/typescript.ts"; +import "../../src/core/gates/checkers/rust.ts"; +import "../../src/core/gates/checkers/java.ts"; +import "../../src/core/gates/checkers/go.ts"; +import "../../src/core/gates/checkers/kotlin.ts"; +import "../../src/core/gates/checkers/scala.ts"; const scriptPath = join( import.meta.dirname, @@ -22,7 +22,7 @@ const scriptPath = join( "no-new-exports-all.mjs", ); -const checkersDir = join(import.meta.dirname, "..", "..", "src", "gates", "checkers"); +const checkersDir = join(import.meta.dirname, "..", "..", "src", "core", "gates", "checkers"); function extractCheckerExtensions(): Set { const extensions = new Set(); @@ -73,7 +73,7 @@ describe("no-new-exports-all.mjs", () => { writeFileSync( join(dir, "src", "module.md"), - "---\nvisible: [greet]\n---\n\nSome prose.\n", + "---\nnote: [greet]\n---\n\nSome prose.\n", ); writeFileSync(join(dir, "src", "app.ts"), "export function greet() {}"); writeFileSync(join(dir, "src", "utils.ts"), "export function helper() {}"); @@ -90,7 +90,7 @@ describe("no-new-exports-all.mjs", () => { expect(updated).not.toContain("nested.ts"); // Should preserve existing fields - expect(updated).toContain("visible:"); + expect(updated).toContain("note:"); expect(updated).toContain("greet"); expect(updated).toContain("Some prose."); @@ -151,7 +151,7 @@ describe("no-new-exports-all.mjs", () => { writeFileSync( join(dir, "src", "module.md"), - "---\nvisible: [greet]\n---\n\n", + "---\nnote: [greet]\n---\n\n", ); writeFileSync(join(dir, "src", "app.ts"), ""); @@ -169,7 +169,7 @@ describe("no-new-exports-all.mjs", () => { it("dry-run does not modify files", () => { const dir = setupFixture(); - const original = "---\nvisible: [greet]\n---\n\n"; + const original = "---\nnote: [greet]\n---\n\n"; writeFileSync(join(dir, "src", "module.md"), original); writeFileSync(join(dir, "src", "app.ts"), "");