From 4db6318e613ebe3a951897bb18d694aeae935516 Mon Sep 17 00:00:00 2001 From: bntvllnt <32437578+bntvllnt@users.noreply.github.com> Date: Wed, 1 Jul 2026 17:17:36 +0200 Subject: [PATCH] feat(boundaries): add architecture boundary checks --- README.md | 8 +- docs/architecture.md | 8 +- docs/cli-reference.md | 22 +- docs/data-model.md | 42 ++++ docs/mcp-tools.md | 21 +- llms-full.txt | 30 ++- llms.txt | 5 +- roadmap.md | 19 +- schema.json | 2 +- src/boundaries/index.ts | 305 +++++++++++++++++++++++++++ src/cli.ts | 39 ++++ src/mcp/hints.ts | 5 + src/mcp/index.ts | 1 + src/operations/formatters.ts | 49 +++++ src/operations/index.ts | 30 ++- src/rules/no-boundary-violations.ts | 23 ++ src/rules/registry.ts | 2 + src/types/index.ts | 47 ++++- tests/boundaries.e2e.test.ts | 267 +++++++++++++++++++++++ tests/mcp-tools.test.ts | 20 +- tests/operation-registry.e2e.test.ts | 23 ++ tests/operation-registry.test.ts | 1 + 22 files changed, 936 insertions(+), 33 deletions(-) create mode 100644 src/boundaries/index.ts create mode 100644 src/rules/no-boundary-violations.ts create mode 100644 tests/boundaries.e2e.test.ts diff --git a/README.md b/README.md index 9e5364f..7a68503 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,7 @@ npx codebase-intelligence duplicates ./src --mode mild --min-tokens 30 npx codebase-intelligence impact ./src parseCodebase npx codebase-intelligence dead-exports ./src --limit 20 npx codebase-intelligence changes ./src --json +npx codebase-intelligence boundaries ./src --preset layered --list --json ``` ### MCP (optional) @@ -57,7 +58,7 @@ claude mcp add -s user -t stdio codebase-intelligence -- npx -y codebase-intelli ## Features -- **23 CLI commands** for architecture analysis, dependency impact, duplicate families, focused map/context packs, content drift, health scoring, Highways convergence, improvement opportunities, dead code detection, search, CI rules, and agent setup +- **24 CLI commands** for architecture analysis, dependency impact, duplicate families, focused map/context packs, content drift, health scoring, boundary enforcement, Highways convergence, improvement opportunities, dead code detection, search, CI rules, and agent setup - **Machine-readable JSON output** (`--json`) for automation and CI pipelines - **Auto-cached index** in `.codebase-intelligence/` for fast repeat queries - **Cache migration facts** in JSON (`cacheDir`, `legacyCacheDir`, `migrated`, `gitignoreUpdated`, `warnings[]`) @@ -71,7 +72,7 @@ claude mcp add -s user -t stdio codebase-intelligence -- npx -y codebase-intelli - **Highways analysis** — find repeated routes that bypass canonical dataflow paths and synthesize safe proposed highways - **Community detection** — Louvain clustering for natural file groupings - **Agent adoption** — `init` writes per-agent instruction files + installs a skill so AI agents query CI before grep/read -- **MCP parity (secondary)** — same analysis and rules gate available as 24 MCP tools, 2 prompts, and 3 resources +- **MCP parity (secondary)** — same analysis and rules gate available as 25 MCP tools, 2 prompts, and 3 resources ## Installation @@ -117,6 +118,7 @@ codebase-intelligence [options] | `map` | Focused codebase graph + token-bounded context pack | | `drift` | Report-only content drift findings with evidence and recommendations | | `health` | CI-gateable health score with maintainability, CRAP, coverage, and risk hotspots | +| `boundaries` | Architecture boundary zones, allow/forbid import rules, and violation evidence | | `highways` | Repeated route convergence, canonical path opportunities, and synthesis proposals | | `clusters` | Community-detected file clusters | | `check` | Rules-engine gate for CI, including opt-in dead-code and dependency gates | @@ -139,6 +141,8 @@ codebase-intelligence [options] | `--context-budget ` | Bound `map` context pack size | | `--min-score ` | Minimum drift score for `drift` findings; minimum health score before `health` exits 1 | | `--score` | Print compact `health` score text | +| `--preset ` | Run `boundaries` with `bulletproof`, `layered`, `hexagonal`, or `feature-sliced` | +| `--list` | List resolved `boundaries` zones and rules | | `--format ` | Export `map` as `markdown`, `json`, `dot`, or `graphml`; export `check` as `text`, `json`, or `sarif` | | `--operation ` | Focus `highways` on one operation verb | | `--shape ` | Focus `highways` on one type/DTO shape | diff --git a/docs/architecture.md b/docs/architecture.md index 5a81a69..93d1079 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -25,7 +25,7 @@ Core (shared computation) | typed descriptors, input schemas, CLI/MCP adapters, result wrappers, text formatters v MCP (stdio) CLI (terminal/CI) - | 24 tools, 2 prompts, | 23 commands with text + JSON + | 25 tools, 2 prompts, | 24 commands with text + JSON | 3 resources for LLMs | output for humans and CI ``` @@ -50,8 +50,9 @@ src/ map/index.ts <- Focused codebase maps + token-bounded context packs drift/ <- Content drift scoring, profiles, findings, tokens, and stable evidence health/ <- Health score, maintainability, CRAP, coverage lookup, and risk scoring + boundaries/ <- Architecture zones, allow/forbid edge rules, and violation evidence highways/index.ts <- Repeated route convergence + bypass/cowpath/synthesis evidence - mcp/index.ts <- 24 MCP tools for LLM integration + mcp/index.ts <- 25 MCP tools for LLM integration mcp/hints.ts <- Operation-keyed next-step hints for MCP tool responses impact/index.ts <- Symbol-level impact analysis + rename planning search/index.ts <- BM25 search engine @@ -86,7 +87,7 @@ analyzeGraph(builtGraph, parsedFiles) } startMcpServer(codebaseGraph) - -> stdio MCP server with 24 tools, 2 prompts, 3 resources + -> stdio MCP server with 25 tools, 2 prompts, 3 resources runOperation(operation, codebaseGraph, input, context) -> { ok: true, data } | { ok: false, error, data? } @@ -104,6 +105,7 @@ runOperation(operation, codebaseGraph, input, context) - **Codebase map context packs**: `map` / `get_codebase_map` builds deterministic file/symbol/test/scope graphs with stable evidence IDs. `get_scope_graph` and `get_context_pack` are MCP-only filtered views derived from the same result, so agents get compact context without a second analyzer path. - **Content drift**: `drift` / `detect_content_drift` compares path/name/export intent with import/call/type/side-effect/test behavior. Findings are deterministic, evidence-backed, report-only, and baseline-gated before any future CI enforcement. - **Health score**: `health` / `get_health_score` computes one gateable score plus per-file maintainability, CRAP, coverage, and risk evidence. `hotspots --metric risk` uses the same file-risk formula. +- **Architecture boundaries**: `boundaries` / `check_boundaries` evaluates graph import edges against preset or custom zones and directed allow/forbid rules. The `no-boundary-violations` check rule reuses the same analyzer so CLI, MCP, and CI emit one stable finding shape. - **Shared graph-load pipeline**: CLI commands and MCP stdio startup both use `src/graph-loader/` for path checks, legacy cache migration, cache reuse, parse/build/analyze, optional persistence, and stderr progress events. - **graphology**: In-memory graph with O(1) neighbor lookup. PageRank and betweenness computed via graphology-metrics. - **Batch git churn**: Single `git log --all --name-only` call, parsed for all files. Avoids O(n) subprocess spawning. diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 14704fd..f016134 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -1,6 +1,6 @@ # CLI Reference -23 commands for terminal and CI use. The 21 analysis commands have full parity with MCP tools and auto-cache the index to `.codebase-intelligence/`; `check` runs the rules gate; `init` sets up agent adoption. +24 commands for terminal and CI use. The 22 analysis commands have full parity with MCP tools and auto-cache the index to `.codebase-intelligence/`; `check` runs the rules gate; `init` sets up agent adoption. ## Commands @@ -202,6 +202,22 @@ codebase-intelligence health [--score] [--min-score ] [--json] [--forc **Exit codes:** `0` when score is at least `minScore`; `1` when score is below `minScore`; `2` for invalid input. +### boundaries + +Evaluate architecture boundary zones and import rules. + +```bash +codebase-intelligence boundaries [--config ] [--preset ] [--list] [--json] [--force] +``` + +**Presets:** `bulletproof`, `layered`, `hexagonal`, `feature-sliced`. + +**Config:** top-level `boundaries.zones[]` defines named areas with `patterns[]` and optional `autoDiscover`; `boundaries.rules[]` defines directed `from -> allow/forbid` import rules. Explicit `--preset` overrides discovered boundary config for ad hoc inspection. + +**Output:** resolved zones, rules, checked edge count, unassigned file count, stable boundary violation IDs, `forbidden-edge`, `disallowed-edge`, and `risky-re-export-chain` kinds, source/target files, zones, imported/re-exported symbols, evidence, and advisory actions. + +**Exit codes:** `0` when no boundary violations are found; `1` when violations are found; `2` for invalid input. + ### highways Find repeated routes that should converge on one canonical operation path. @@ -238,7 +254,7 @@ codebase-intelligence check [--config ] [--format ] [--fail-on **Output:** pass/warn/fail verdict, findings, suppression ledger, and summary counts. Findings include stable `fingerprint`; cleanup findings may also include `kind`, `confidence`, and `evidence`. Summary counts include `suppressed` and `staleSuppressions`; `--summary` prints those counts when present. Exit code `0` on pass, `1` when the configured gate fails, `2` for invalid config or arguments. -**Rules:** `no-comments` (off by default), `no-circular-deps` (error), `no-dead-exports` (warn), `no-stale-suppressions` (warn), plus opt-in cleanup gates: `no-dead-files`, `no-unused-types`, `no-unused-members`, `no-unused-deps`. Dependency findings are scoped to the nearest `package.json` / workspace manifest and include unused, unlisted, type-only, test-only, and runtime-from-devDependencies drift. `ci-ignore-file`, `ci-ignore-next-line`, and JSDoc `@expected-unused` suppressions are reported as active/stale; JSDoc `@public` protects exported cleanup declarations while `@internal` remains checkable. Configure severities in `codebase-intelligence.json`. +**Rules:** `no-comments` (off by default), `no-boundary-violations` (error when top-level `boundaries` config exists), `no-circular-deps` (error), `no-dead-exports` (warn), `no-stale-suppressions` (warn), plus opt-in cleanup gates: `no-dead-files`, `no-unused-types`, `no-unused-members`, `no-unused-deps`. Dependency findings are scoped to the nearest `package.json` / workspace manifest and include unused, unlisted, type-only, test-only, and runtime-from-devDependencies drift. `ci-ignore-file`, `ci-ignore-next-line`, and JSDoc `@expected-unused` suppressions are reported as active/stale; JSDoc `@public` protects exported cleanup declarations while `@internal` remains checkable. Configure severities in `codebase-intelligence.json`. ### init @@ -282,6 +298,8 @@ codebase-intelligence init [path] [--agents ] [--all] [--skill] [--gitigno | `--context-budget ` | map | Approximate token budget for context pack | | `--min-score ` | drift, health | Minimum drift score to report; minimum health score before exit 1 | | `--score` | health | Print compact score text | +| `--preset ` | boundaries | Preset: bulletproof, layered, hexagonal, feature-sliced | +| `--list` | boundaries | List resolved zones and rules | | `--operation ` | highways | Focus on one operation verb | | `--shape ` | highways | Focus on one type/DTO shape | | `--min-routes ` | highways | Minimum routes reaching a sink before reporting | diff --git a/docs/data-model.md b/docs/data-model.md index 90444b2..ec35531 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -346,6 +346,48 @@ HealthFileResult { } ``` +## Boundaries Result + +`boundaries --json` and MCP `check_boundaries` return deterministic architecture-boundary findings. CLI `boundaries` exits `1` when violations exist. + +```typescript +BoundariesResult { + preset: "custom" | "none" | "bulletproof" | "layered" | "hexagonal" | "feature-sliced" + zones: Array<{ + name: string + patterns: string[] + matchedFiles: string[] + }> + rules: Array<{ + from: string + allow?: string[] + forbid?: string[] + }> + violations: BoundaryViolation[] + summary: { + checkedEdges: number + violations: number + unassignedFiles: number + } + verdict: "pass" | "fail" +} + +BoundaryViolation { + id: string + kind: "forbidden-edge" | "disallowed-edge" | "risky-re-export-chain" + ruleId: string + source: string + target: string + fromZone: string + toZone: string + symbols: string[] + isTypeOnly: boolean + message: string + evidence: string[] + actions: Array<{ command: string; reason: string }> +} +``` + ## Highways Result `highways --json` and MCP `analyze_highways` return deterministic route-convergence opportunities. diff --git a/docs/mcp-tools.md b/docs/mcp-tools.md index 961dbfc..04364be 100644 --- a/docs/mcp-tools.md +++ b/docs/mcp-tools.md @@ -1,6 +1,6 @@ # MCP Tools Reference -24 tools available via MCP stdio. +25 tools available via MCP stdio. Operation tools return JSON text payloads. Invalid operation inputs return `isError: true` with `{ "error": "..." }` using the same descriptor validation messages as CLI bad-argument exits. @@ -219,7 +219,17 @@ Compute a CI-gateable codebase health score. **Use when:** Gating PR quality, ranking complexity/churn/coupling/size hotspots, or tracking maintainability. **Not for:** Route discipline (use analyze_highways) or naming drift (use detect_content_drift). -## 22. analyze_highways +## 22. check_boundaries + +Evaluate architecture boundary zones against import edges. + +**Input:** `{ preset?: "bulletproof"|"layered"|"hexagonal"|"feature-sliced", list?: boolean }` +**Returns:** preset, zones, rules, summary, verdict, and violations. Violations include stable IDs, kind (`forbidden-edge`, `disallowed-edge`, `risky-re-export-chain`), source/target files, source/target zones, symbols, evidence, and advisory actions. + +**Use when:** Enforcing layering, ports/adapters, feature-sliced imports, or custom zone rules. +**Not for:** General quality gates with suppressions/new-only behavior (use check). + +## 23. analyze_highways Detect repeated entry-to-sink routes that should converge on one canonical operation path. @@ -229,7 +239,7 @@ Detect repeated entry-to-sink routes that should converge on one canonical opera **Use when:** Enforcing dataflow discipline, finding ad-hoc routes, or planning canonical shared paths. **Not for:** Raw execution flow listing (use get_processes). -## 23. get_clusters +## 24. get_clusters Community-detected clusters of related files. @@ -239,14 +249,14 @@ Community-detected clusters of related files. **Use when:** "What files are related?" "Find natural groupings." Discovering emergent groupings that differ from directory structure. **Not for:** Directory-based modules (use get_module_structure). -## 23. check +## 25. check Run the configurable rules engine and gate on findings. **Input:** `{}` (uses the loaded graph + discovered config) **Returns:** `{ verdict: "pass"|"warn"|"fail", summary: { error, warn, suppressed, staleSuppressions, rules }, configPath, suppressions[], findings[] }`. Each finding has ruleId, severity, file, line, column, message, fingerprint, optional `kind`, optional `confidence`, optional `evidence[]`, and optional advisory `actions[]` (the tool is read-only — actions are hints, never applied). Each suppression records directive, status, file, line, targetLine, matched rule IDs, and suppressed count. -Rules: `no-comments` (off by default), `no-circular-deps` (error), `no-dead-exports` (warn), `no-stale-suppressions` (warn), and opt-in cleanup gates `no-dead-files`, `no-unused-types`, `no-unused-members`, `no-unused-deps`. Dependency findings are scoped to the nearest `package.json` / workspace manifest and include unused, unlisted, type-only, test-only, and runtime-from-devDependencies drift. `ci-ignore-file`, `ci-ignore-next-line`, and JSDoc `@expected-unused` suppressions are reported as active/stale; JSDoc `@public` protects exported cleanup declarations while `@internal` remains checkable. Configure severities and options in `codebase-intelligence.json` (validated by `schema.json`). +Rules: `no-comments` (off by default), `no-boundary-violations` (error when `boundaries` config exists), `no-circular-deps` (error), `no-dead-exports` (warn), `no-stale-suppressions` (warn), and opt-in cleanup gates `no-dead-files`, `no-unused-types`, `no-unused-members`, `no-unused-deps`. Dependency findings are scoped to the nearest `package.json` / workspace manifest and include unused, unlisted, type-only, test-only, and runtime-from-devDependencies drift. `ci-ignore-file`, `ci-ignore-next-line`, and JSDoc `@expected-unused` suppressions are reported as active/stale; JSDoc `@public` protects exported cleanup declarations while `@internal` remains checkable. Configure severities and options in `codebase-intelligence.json` (validated by `schema.json`). **Use when:** Linting a codebase or enforcing a CI gate. "What rule violations exist?" **Not for:** Architecture metrics (use analyze_forces). @@ -277,6 +287,7 @@ Rules: `no-comments` (off by default), `no-circular-deps` (error), `no-dead-expo | "What are the riskiest files?" | `find_hotspots` (risk, coupling, churn, or blast_radius) | | "Which files need tests?" | `find_hotspots` (coverage) | | "What is the PR quality score?" | `get_health_score` | +| "Which imports violate architecture boundaries?" | `check_boundaries` | | "What should I improve first?" | `find_opportunities` | | "Find refactoring opportunities." | `find_opportunities` | | "Where is logic duplicated?" | `find_duplicates` | diff --git a/llms-full.txt b/llms-full.txt index 5b8302d..62b660b 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -31,8 +31,8 @@ Core (shared computation) | typed descriptors, input schemas, CLI/MCP adapters, result wrappers, text formatters v MCP (stdio) + CLI - | MCP: 24 tools, 2 prompts, 3 resources for LLM agents - | CLI: 23 commands with formatted + JSON output for humans/CI + | MCP: 25 tools, 2 prompts, 3 resources for LLM agents + | CLI: 24 commands with formatted + JSON output for humans/CI ``` ## Module Map @@ -53,9 +53,10 @@ src/ duplication/index.ts <- Duplicate family detection + trace evidence map/index.ts <- Focused codebase maps + token-bounded context packs drift/ <- Content drift findings, profiles, tokens, and stable evidence + boundaries/ <- Architecture zones, allow/forbid edge rules, and violation evidence highways/index.ts <- Repeated route convergence + bypass/cowpath/synthesis evidence health/ <- Health score, maintainability, CRAP, coverage lookup, and risk scoring - mcp/index.ts <- 24 MCP tools for LLM integration + mcp/index.ts <- 25 MCP tools for LLM integration mcp/hints.ts <- Operation-keyed next-step hints for MCP tool responses impact/index.ts <- Symbol-level impact analysis + rename planning search/index.ts <- BM25 search engine @@ -97,6 +98,7 @@ runOperation(operation, codebaseGraph, input, context) - **graphology**: In-memory graph with O(1) neighbor lookup. PageRank and betweenness computed via graphology-metrics. - **Operation registry foundation**: Analysis operations have typed descriptors in `src/operations/` with operation names, CLI command names, MCP tool names, input schemas, discriminated run results, and result-object text formatters. MCP tool registration and CLI command execution consume those descriptors; CLI JSON remains raw result data plus cache facts. - **Shared graph-load pipeline**: CLI commands and MCP stdio startup both use `src/graph-loader/` for path checks, legacy cache migration, cache reuse, parse/build/analyze, optional persistence, and stderr progress events. +- **Architecture boundaries**: `boundaries` / `check_boundaries` evaluates graph import edges against preset or custom zones and directed allow/forbid rules. `no-boundary-violations` reuses the same analyzer for CI gating. - **Batch git churn**: Single `git log --all --name-only` call, parsed for all files. Avoids O(n) subprocess spawning. - **Dead export detection**: Cross-references parsed exports against edge symbol lists. May miss `import *` or re-exports. - **Graceful degradation**: Non-git dirs get churn=0, no-test codebases get coverage=false. Never crashes. @@ -312,7 +314,7 @@ The most dangerous files have: high churn + high coupling + low coverage. # MCP Tools Reference -24 tools available via MCP stdio. +25 tools available via MCP stdio. ## 1. codebase_overview High-level summary. Input: `{ depth?: number }`. Returns: totalFiles, totalFunctions, modules, topDependedFiles, metrics, and analysis mode/call graph precision. @@ -377,13 +379,16 @@ Content drift. Input: `{ focus?: string, scope?: string, minScore?: number }`. R ## 21. get_health_score Health score. Input: `{ minScore?: number, score?: boolean }`. Returns: score, minScore, verdict, components, coverage source/counts, per-file maintainabilityIndex, crapScore, riskScore, hotspots, actions, and summary. -## 22. analyze_highways +## 22. check_boundaries +Architecture boundaries. Input: `{ preset?: "bulletproof" | "layered" | "hexagonal" | "feature-sliced", list?: boolean }`. Returns: preset, zones, rules, summary, verdict, and stable violations with kind, source/target files, zones, symbols, evidence, and advisory actions. + +## 23. analyze_highways Repeated route convergence. Input: `{ operation?: string, shape?: string, minRoutes?: number, propose?: boolean, trace?: string }`. Returns: bypass/cowpath/synthesis opportunities with route chains, evidence, blast radius, proposed canonical node, optional synthesis proposal, and context pack. -## 23. get_clusters +## 24. get_clusters Community-detected file clusters. Input: `{ minFiles?: number }`. Returns: clusters with cohesion. -## 24. check +## 25. check Rules-engine gate. Input: `{}`. Returns: pass/warn/fail verdict, findings, suppression ledger, config path, and summary counts. Findings always include ruleId, severity, file, line, column, message, and fingerprint; cleanup findings may also include kind, confidence, and evidence. Suppressions include directive, active/stale status, file, line, targetLine, ruleIds, and suppressed count. ## Tool Selection Guide @@ -397,6 +402,7 @@ Rules-engine gate. Input: `{}`. Returns: pass/warn/fail verdict, findings, suppr | What are the riskiest files? | find_hotspots (risk) | | Which files need tests? | find_hotspots (coverage) | | What is the PR quality score? | get_health_score | +| Which imports violate architecture boundaries? | check_boundaries | | What should I improve first? | find_opportunities | | Find refactoring opportunities | find_opportunities | | Where is logic duplicated? | find_duplicates | @@ -419,7 +425,7 @@ Rules-engine gate. Input: `{}`. Returns: pass/warn/fail verdict, findings, suppr # CLI Reference -23 commands — 21 analysis commands (full parity with MCP tools), `check` for CI rules, and `init` for agent adoption. +24 commands — 22 analysis commands (full parity with MCP tools), `check` for CI rules, and `init` for agent adoption. ## Commands @@ -537,6 +543,12 @@ codebase-intelligence health [--score] [--min-score ] [--json] [--forc ``` CI-gateable health score. Returns score/verdict, components, root-local Istanbul or static-test coverage source, per-file maintainabilityIndex, crapScore, riskScore, hotspots, evidence, and actions. Exits 1 when score is below `--min-score`. +### boundaries +```bash +codebase-intelligence boundaries [--config ] [--preset ] [--list] [--json] [--force] +``` +Evaluate architecture boundary zones and import rules. Presets: bulletproof, layered, hexagonal, feature-sliced. Top-level `boundaries.zones[]` config defines named areas; `boundaries.rules[]` config defines directed `from -> allow/forbid` imports. Returns stable violations for forbidden edges, disallowed edges, and risky re-export chains. Exits 1 when violations exist. + ### highways ```bash codebase-intelligence highways [--operation ] [--shape ] [--min-routes ] [--propose] [--trace ] [--json] [--force] @@ -568,7 +580,7 @@ content between the `codebase-intelligence:start`/`:end` markers is ever touched codebase-intelligence check [--config ] [--format ] [--fail-on ] [--gate ] [--base ] [--quiet] [--summary] [--json] [--force] ``` Rules-engine gate for CI. Formats: text, json, sarif. Fail-on: error, warn, never. -Gate modes: all, new-only. Returns pass/warn/fail verdict, findings, suppression ledger, and summary counts. Built-in rules include no-comments, no-circular-deps, no-dead-exports, no-stale-suppressions, and opt-in no-dead-files, no-unused-types, no-unused-members, no-unused-deps. Dependency findings are scoped to the nearest package/workspace manifest and include unused, unlisted, type-only, test-only, and runtime-from-devDependencies drift. Reports active/stale `ci-ignore-*` and `@expected-unused` suppressions; `@public` protects exported cleanup declarations while `@internal` remains checkable. +Gate modes: all, new-only. Returns pass/warn/fail verdict, findings, suppression ledger, and summary counts. Built-in rules include no-comments, no-boundary-violations, no-circular-deps, no-dead-exports, no-stale-suppressions, and opt-in no-dead-files, no-unused-types, no-unused-members, no-unused-deps. Dependency findings are scoped to the nearest package/workspace manifest and include unused, unlisted, type-only, test-only, and runtime-from-devDependencies drift. Reports active/stale `ci-ignore-*` and `@expected-unused` suppressions; `@public` protects exported cleanup declarations while `@internal` remains checkable. ## Global Behavior diff --git a/llms.txt b/llms.txt index f47de1b..b8360a8 100644 --- a/llms.txt +++ b/llms.txt @@ -7,7 +7,7 @@ - [Architecture](docs/architecture.md): Pipeline, module map, data flow, design decisions - [Data Model](docs/data-model.md): All TypeScript interfaces with field descriptions - [Metrics](docs/metrics.md): Per-file and module metrics, force analysis, complexity scoring -- [MCP Tools](docs/mcp-tools.md): 24 MCP tools — inputs, outputs, use cases, selection guide +- [MCP Tools](docs/mcp-tools.md): 25 MCP tools — inputs, outputs, use cases, selection guide - [CLI Reference](docs/cli-reference.md): CLI commands, flags, output formats, examples ## Quick Start @@ -17,7 +17,7 @@ MCP mode (AI agents): codebase-intelligence ./path/to/project ``` -CLI mode (humans/CI) — 23 commands (21 analysis with MCP parity + `check` + `init`): +CLI mode (humans/CI) — 24 commands (22 analysis with MCP parity + `check` + `init`): ```bash codebase-intelligence overview ./src codebase-intelligence hotspots ./src --metric coupling @@ -38,6 +38,7 @@ codebase-intelligence processes ./src --entry main codebase-intelligence map ./src --focus getUserById --context-budget 800 --json codebase-intelligence drift ./src --min-score 50 --json codebase-intelligence health ./src --score --min-score 70 +codebase-intelligence boundaries ./src --preset layered --list --json codebase-intelligence highways ./src --operation create --min-routes 3 --propose --json codebase-intelligence clusters ./src --min-files 3 codebase-intelligence check ./src # rules gate for CI; JSON includes findings plus active/stale suppressions diff --git a/roadmap.md b/roadmap.md index 1113162..efaa688 100644 --- a/roadmap.md +++ b/roadmap.md @@ -496,13 +496,22 @@ Provide one CI-gateable quality score plus file-level risk metrics. Evaluate repo dependency boundaries using the existing graph. -**To do:** +**Status:** CH-P2-05 boundary subchain shipped in the canary train. + +**Shipped:** +- CLI: add `boundaries ` with `--preset`, `--list`, `--config`, `--json`, and stable exit `1` when violations exist. +- MCP: add `check_boundaries`. - Add presets: bulletproof, layered, hexagonal, feature-sliced. -- Add custom `zones` with `autoDiscover`. -- Add `from -> allow/forbid` rules. -- Add `list --boundaries`. -- Emit boundary violations, forbidden cross-edges, and risky re-export chains. +- Add custom `boundaries.zones[]` with `patterns[]` and `autoDiscover`. +- Add directed `boundaries.rules[]` with `from -> allow/forbid`. +- Emit stable boundary violation IDs with `forbidden-edge`, `disallowed-edge`, and `risky-re-export-chain` kinds. +- Add `no-boundary-violations` to `check`, including `new-only` baseline behavior for PR gates. +- Add CH-P2-05 boundary subchain coverage for CLI JSON/text, exit codes, custom config, preset override, real stdio MCP parity, and `check --gate new-only`. + +**Remaining:** + +- Feed boundary findings into the future first-class `ci` wrapper and PR annotation formats. ### CI PR Quality Gate diff --git a/schema.json b/schema.json index a303030..8633aee 100644 --- a/schema.json +++ b/schema.json @@ -227,7 +227,7 @@ "additionalProperties": false, "properties": { "preset": { - "enum": ["layered", "hexagonal", "feature-sliced"], + "enum": ["bulletproof", "layered", "hexagonal", "feature-sliced"], "description": "Start from a preset architecture, then override with zones/rules." }, "zones": { diff --git a/src/boundaries/index.ts b/src/boundaries/index.ts new file mode 100644 index 0000000..3d1b5be --- /dev/null +++ b/src/boundaries/index.ts @@ -0,0 +1,305 @@ +import { createHash } from "node:crypto"; +import path from "node:path"; +import type { + BoundariesConfig, + BoundaryMatchedZone, + BoundaryPreset, + BoundaryRule, + BoundaryRuleSummary, + BoundaryViolation, + BoundariesResult, + CodebaseGraph, +} from "../types/index.js"; + +export interface BoundaryOptions { + preset?: BoundaryPreset; + list?: boolean; + config?: BoundariesConfig; +} + +type ZoneConfig = NonNullable[number]; + +interface ResolvedBoundaryConfig { + preset: BoundariesResult["preset"]; + zones: ZoneConfig[]; + rules: BoundaryRule[]; +} + +interface ZoneMatcher { + zone: ZoneConfig; + regexes: RegExp[]; + specificity: number; +} + +const PRESET_CONFIGS = { + layered: { + zones: [ + { name: "ui", patterns: ["src/ui/**", "src/components/**", "app/**"] }, + { name: "application", patterns: ["src/app/**", "src/application/**", "src/services/**"] }, + { name: "domain", patterns: ["src/domain/**", "src/core/**"] }, + { name: "infrastructure", patterns: ["src/infra/**", "src/infrastructure/**", "src/db/**", "src/adapters/**"] }, + { name: "shared", patterns: ["src/shared/**", "src/utils/**", "src/types/**"] }, + ], + rules: [ + { from: "domain", allow: ["shared"] }, + { from: "application", allow: ["domain", "shared"] }, + { from: "ui", allow: ["application", "domain", "shared"] }, + { from: "infrastructure", allow: ["domain", "shared"] }, + { from: "shared", allow: [] }, + ], + }, + hexagonal: { + zones: [ + { name: "domain", patterns: ["src/domain/**", "src/core/**"] }, + { name: "application", patterns: ["src/application/**", "src/app/**", "src/services/**"] }, + { name: "adapter", patterns: ["src/adapters/**", "src/http/**", "src/api/**"] }, + { name: "infrastructure", patterns: ["src/infra/**", "src/infrastructure/**", "src/db/**"] }, + { name: "shared", patterns: ["src/shared/**", "src/utils/**", "src/types/**"] }, + ], + rules: [ + { from: "domain", allow: ["shared"] }, + { from: "application", allow: ["domain", "shared"] }, + { from: "adapter", allow: ["application", "domain", "shared"] }, + { from: "infrastructure", allow: ["domain", "shared"] }, + { from: "shared", allow: [] }, + ], + }, + "feature-sliced": { + zones: [ + { name: "app", patterns: ["src/app/**", "app/**"] }, + { name: "pages", patterns: ["src/pages/**", "pages/**"] }, + { name: "widgets", patterns: ["src/widgets/**"] }, + { name: "features", patterns: ["src/features/**"] }, + { name: "entities", patterns: ["src/entities/**"] }, + { name: "shared", patterns: ["src/shared/**", "src/utils/**", "src/types/**"] }, + ], + rules: [ + { from: "shared", allow: [] }, + { from: "entities", allow: ["shared"] }, + { from: "features", allow: ["entities", "shared"] }, + { from: "widgets", allow: ["features", "entities", "shared"] }, + { from: "pages", allow: ["widgets", "features", "entities", "shared"] }, + { from: "app", allow: ["pages", "widgets", "features", "entities", "shared"] }, + ], + }, + bulletproof: { + zones: [ + { name: "entry", patterns: ["src/routes/**", "src/controllers/**", "src/api/**"] }, + { name: "feature", patterns: ["src/features/**", "src/modules/**"] }, + { name: "domain", patterns: ["src/domain/**", "src/core/**"] }, + { name: "data", patterns: ["src/data/**", "src/repositories/**", "src/db/**"] }, + { name: "shared", patterns: ["src/shared/**", "src/utils/**", "src/types/**"] }, + ], + rules: [ + { from: "entry", allow: ["feature", "domain", "shared"] }, + { from: "feature", allow: ["domain", "data", "shared"] }, + { from: "domain", allow: ["shared"] }, + { from: "data", allow: ["domain", "shared"] }, + { from: "shared", allow: [] }, + ], + }, +} satisfies Record; + +function normalizePath(value: string): string { + return value.split(path.sep).join("/"); +} + +function escapeRegex(value: string): string { + return value.replace(/[|\\{}()[\]^$+?.]/g, "\\$&"); +} + +function globToRegex(pattern: string): RegExp { + const normalized = normalizePath(pattern).replace(/^\/+/, ""); + let out = "^"; + for (let idx = 0; idx < normalized.length; idx += 1) { + const char = normalized[idx]; + const next = normalized[idx + 1]; + if (char === "*" && next === "*") { + out += ".*"; + idx += 1; + } else if (char === "*") { + out += "[^/]*"; + } else { + out += escapeRegex(char); + } + } + return new RegExp(`${out}$`); +} + +function autoDiscoveredPatterns(zone: ZoneConfig): string[] { + if (zone.autoDiscover !== true) return []; + const name = zone.name.toLowerCase(); + return [`${name}/**`, `src/${name}/**`, `app/${name}/**`, `packages/*/${name}/**`]; +} + +function unique(values: readonly string[]): string[] { + return [...new Set(values)].sort((left, right) => left.localeCompare(right)); +} + +function combineRules(rules: readonly BoundaryRule[]): BoundaryRule[] { + const bySource = new Map; forbid?: Set }>(); + for (const rule of rules) { + const existing = bySource.get(rule.from) ?? {}; + if (rule.allow) existing.allow = new Set([...(existing.allow ?? []), ...rule.allow]); + if (rule.forbid) existing.forbid = new Set([...(existing.forbid ?? []), ...rule.forbid]); + bySource.set(rule.from, existing); + } + + return [...bySource.entries()] + .map(([from, rule]) => ({ + from, + ...(rule.allow ? { allow: [...rule.allow].sort((left, right) => left.localeCompare(right)) } : {}), + ...(rule.forbid ? { forbid: [...rule.forbid].sort((left, right) => left.localeCompare(right)) } : {}), + })) + .sort((left, right) => left.from.localeCompare(right.from)); +} + +function resolveConfig(options: BoundaryOptions): ResolvedBoundaryConfig { + const configured = options.config; + if (options.preset) { + const preset = PRESET_CONFIGS[options.preset]; + return { preset: options.preset, zones: preset.zones, rules: combineRules(preset.rules) }; + } + + const presetName = configured?.preset; + const preset = presetName ? PRESET_CONFIGS[presetName] : undefined; + const zones = configured?.zones ?? preset?.zones ?? []; + const rules = configured?.rules ?? preset?.rules ?? []; + const label: BoundariesResult["preset"] = configured?.zones || configured?.rules + ? configured.preset ?? "custom" + : presetName ?? "none"; + return { preset: label, zones, rules: combineRules(rules) }; +} + +function buildMatchers(zones: readonly ZoneConfig[]): ZoneMatcher[] { + return zones + .map((zone) => { + const patterns = unique([...zone.patterns, ...autoDiscoveredPatterns(zone)]); + return { + zone: { ...zone, patterns }, + regexes: patterns.map(globToRegex), + specificity: Math.max(...patterns.map((pattern) => pattern.replace(/\*/g, "").length), 0), + }; + }) + .sort((left, right) => right.specificity - left.specificity || left.zone.name.localeCompare(right.zone.name)); +} + +function zoneForFile(file: string, matchers: readonly ZoneMatcher[]): string | null { + for (const matcher of matchers) { + if (matcher.regexes.some((regex) => regex.test(file))) return matcher.zone.name; + } + return null; +} + +function violationKind(rule: BoundaryRule, toZone: string, symbols: readonly string[]): BoundaryViolation["kind"] | null { + if (toZone === rule.from) return null; + const violatesForbid = (rule.forbid?.includes("*") ?? false) || (rule.forbid?.includes(toZone) ?? false); + const violatesAllow = rule.allow !== undefined && !rule.allow.includes(toZone); + if (!violatesForbid && !violatesAllow) return null; + if (symbols.includes("*")) return "risky-re-export-chain"; + if (violatesForbid) return "forbidden-edge"; + if (violatesAllow) return "disallowed-edge"; + return null; +} + +function stableViolationId(source: string, target: string, fromZone: string, toZone: string): string { + const hash = createHash("sha1").update(`${source}|${target}|${fromZone}|${toZone}`).digest("hex").slice(0, 10); + return `boundary:${hash}`; +} + +function ruleIdFor(rule: BoundaryRule): string { + if (rule.forbid && rule.forbid.length > 0) return `${rule.from}:forbid:${rule.forbid.join(",")}`; + if (rule.allow) return `${rule.from}:allow:${rule.allow.join(",")}`; + return `${rule.from}:allow:*`; +} + +function violationMessage(kind: BoundaryViolation["kind"], fromZone: string, toZone: string): string { + if (kind === "risky-re-export-chain") return `Boundary violation: ${fromZone} re-exports ${toZone} across a forbidden boundary`; + if (kind === "forbidden-edge") return `Boundary violation: ${fromZone} must not import ${toZone}`; + return `Boundary violation: ${fromZone} is not allowed to import ${toZone}`; +} + +function toRuleSummary(rule: BoundaryRule): BoundaryRuleSummary { + return { + from: rule.from, + ...(rule.allow ? { allow: [...rule.allow].sort((left, right) => left.localeCompare(right)) } : {}), + ...(rule.forbid ? { forbid: [...rule.forbid].sort((left, right) => left.localeCompare(right)) } : {}), + }; +} + +export function computeBoundaries(graph: CodebaseGraph, options: BoundaryOptions = {}): BoundariesResult { + const config = resolveConfig(options); + const matchers = buildMatchers(config.zones); + const ruleBySource = new Map(config.rules.map((rule) => [rule.from, rule])); + const filePaths = graph.nodes + .filter((node) => node.type === "file") + .map((node) => node.path) + .sort((left, right) => left.localeCompare(right)); + const zoneByFile = new Map(filePaths.map((file) => [file, zoneForFile(file, matchers)])); + const zones: BoundaryMatchedZone[] = matchers.map((matcher) => ({ + name: matcher.zone.name, + patterns: matcher.zone.patterns, + autoDiscover: matcher.zone.autoDiscover === true, + matchedFiles: filePaths.filter((file) => zoneByFile.get(file) === matcher.zone.name), + })); + + const violations: BoundaryViolation[] = []; + for (const edge of graph.edges) { + const fromZone = zoneByFile.get(edge.source); + const toZone = zoneByFile.get(edge.target); + if (!fromZone || !toZone) continue; + const rule = ruleBySource.get(fromZone); + if (!rule) continue; + const kind = violationKind(rule, toZone, edge.symbols); + if (!kind) continue; + const message = violationMessage(kind, fromZone, toZone); + violations.push({ + id: stableViolationId(edge.source, edge.target, fromZone, toZone), + kind, + ruleId: ruleIdFor(rule), + source: edge.source, + target: edge.target, + fromZone, + toZone, + symbols: [...edge.symbols].sort((left, right) => left.localeCompare(right)), + isTypeOnly: edge.isTypeOnly, + message, + evidence: [ + `edge=${edge.source}->${edge.target}`, + `fromZone=${fromZone}`, + `toZone=${toZone}`, + `symbols=${edge.symbols.length > 0 ? edge.symbols.join(",") : "*"}`, + `typeOnly=${String(edge.isTypeOnly)}`, + ], + actions: [ + { + command: `codebase-intelligence dependents . ${edge.target} --json`, + reason: "Inspect blast radius before moving imports behind an allowed boundary.", + }, + { + command: "codebase-intelligence boundaries . --list --json", + reason: "List resolved zones and rules before changing architecture config.", + }, + ], + }); + } + + violations.sort((left, right) => + left.source.localeCompare(right.source) || left.target.localeCompare(right.target) || left.ruleId.localeCompare(right.ruleId), + ); + + return { + preset: config.preset, + zones, + rules: config.rules.map(toRuleSummary).sort((left, right) => left.from.localeCompare(right.from)), + violations, + summary: { + zones: zones.length, + rules: config.rules.length, + checkedEdges: graph.edges.length, + violations: violations.length, + unassignedFiles: filePaths.filter((file) => zoneByFile.get(file) === null).length, + }, + verdict: violations.length > 0 ? "fail" : "pass", + }; +} diff --git a/src/cli.ts b/src/cli.ts index 1a16bee..c845667 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -221,6 +221,12 @@ interface HealthOptions extends CliCommandOptions { score?: boolean; } +interface BoundariesOptions extends CliCommandOptions { + config?: string; + preset?: string; + list?: boolean; +} + interface HighwaysOptions extends CliCommandOptions { operation?: string; shape?: string; @@ -759,6 +765,39 @@ program } }); +// ── Subcommand: boundaries ───────────────────────────────── + +program + .command("boundaries") + .description("Evaluate architecture boundary zones and import rules") + .argument("", "Path to TypeScript codebase") + .option("--config ", "Config file path (overrides discovery)") + .option("--preset ", "Preset: bulletproof, layered, hexagonal, or feature-sliced") + .option("--list", "List resolved zones and rules") + .option("--json", "Output as JSON") + .option("--force", "Re-index even if HEAD unchanged") + .action((targetPath: string, options: BoundariesOptions) => { + const input = parseCliOperationInput(operations.boundaries, { + preset: options.preset, + list: options.list, + }); + const { graph } = loadGraph(targetPath, forceOption(options)); + const result = runCliOperation(operations.boundaries, graph, input, { + rootDir: path.resolve(targetPath), + configPath: options.config, + }); + + if (options.json) { + outputJson(result); + } else { + outputOperationText(operations.boundaries, result, input); + } + + if (result.verdict === "fail") { + process.exitCode = 1; + } + }); + // ── Subcommand: highways ─────────────────────────────────── program diff --git a/src/mcp/hints.ts b/src/mcp/hints.ts index 6bce21a..a35e09d 100644 --- a/src/mcp/hints.ts +++ b/src/mcp/hints.ts @@ -97,6 +97,11 @@ const OPERATION_HINTS: Record = { "Use file_context on top health hotspots before refactoring", "Use --min-score in CI when you want health to fail below a threshold", ], + boundaries: [ + "Use --list to inspect resolved zones and rules before changing config", + "Use get_dependents on violation targets to inspect blast radius", + "Use check with no-boundary-violations enabled to gate boundary debt in CI", + ], highways: [ "Use impact_analysis on the proposed canonical node before editing", "Use symbol_context on bypass route entry points to inspect call chains", diff --git a/src/mcp/index.ts b/src/mcp/index.ts index 09d931f..030ef6c 100644 --- a/src/mcp/index.ts +++ b/src/mcp/index.ts @@ -181,6 +181,7 @@ export function registerTools(server: McpServer, graph: CodebaseGraph): void { registerOperationTool(server, graph, operations.codebaseMap); registerOperationTool(server, graph, operations.contentDrift); registerOperationTool(server, graph, operations.health); + registerOperationTool(server, graph, operations.boundaries); registerOperationTool(server, graph, operations.highways); registerOperationTool(server, graph, operations.clusters); registerDerivedMapTool( diff --git a/src/operations/formatters.ts b/src/operations/formatters.ts index 50343ef..a9028d9 100644 --- a/src/operations/formatters.ts +++ b/src/operations/formatters.ts @@ -24,6 +24,7 @@ import type { HealthOptions, HealthResult } from "../health/index.js"; import type { ImpactResult, RenameResult } from "../impact/index.js"; import type { CodebaseMapOptions, CodebaseMapResult } from "../map/index.js"; import type { ContentDriftResult } from "../drift/index.js"; +import type { BoundariesResult } from "../types/index.js"; function text(lines: readonly string[]): string { return lines.join("\n"); @@ -759,6 +760,54 @@ export function formatHealthText(result: HealthResult, input: HealthOptions = {} return text(lines); } +/** + * Format a boundaries operation result for human CLI output. + */ +export function formatBoundariesText(result: BoundariesResult, input: { list?: boolean } = {}): string { + const lines = [ + `Boundaries: ${result.verdict.toUpperCase()}`, + "────────────────", + `Preset: ${result.preset}`, + `Zones: ${result.summary.zones}`, + `Rules: ${result.summary.rules}`, + `Checked edges: ${result.summary.checkedEdges}`, + `Violations: ${result.summary.violations}`, + `Unassigned files: ${result.summary.unassignedFiles}`, + ]; + + if (input.list) { + lines.push("", "Zones"); + for (const zone of result.zones) { + lines.push(` ${zone.name}: ${zone.matchedFiles.length} file(s)`); + lines.push(` patterns: ${zone.patterns.join(", ")}`); + } + + lines.push("", "Rules"); + for (const rule of result.rules) { + const allow = rule.allow ? ` allow=${rule.allow.join(",")}` : ""; + const forbid = rule.forbid ? ` forbid=${rule.forbid.join(",")}` : ""; + lines.push(` from=${rule.from}${allow}${forbid}`); + } + } + + if (result.violations.length === 0) { + lines.push("", "No boundary violations."); + return text(lines); + } + + lines.push("", "Violations"); + for (const violation of result.violations.slice(0, 20)) { + lines.push( + ` ${violation.id} ${violation.source} -> ${violation.target}`, + ` ${violation.message}`, + ` rule=${violation.ruleId}; symbols=${violation.symbols.length > 0 ? violation.symbols.join(",") : "*"}`, + ` next=${violation.actions[0]?.command ?? "Inspect manually"}`, + ); + } + + return text(lines); +} + /** * Format a highways operation result for human CLI output. */ diff --git a/src/operations/index.ts b/src/operations/index.ts index 1b358b5..0812d8a 100644 --- a/src/operations/index.ts +++ b/src/operations/index.ts @@ -20,13 +20,16 @@ import { impactAnalysis, renameSymbol, } from "../core/index.js"; +import { computeBoundaries } from "../boundaries/index.js"; +import { loadConfig } from "../config/index.js"; import { DUPLICATION_MODES } from "../duplication/index.js"; import { computeHighways } from "../highways/index.js"; import { computeHealth } from "../health/index.js"; import { CODEBASE_MAP_FORMATS, computeCodebaseMap } from "../map/index.js"; import { computeContentDrift } from "../drift/index.js"; -import type { CodebaseGraph } from "../types/index.js"; +import type { BoundaryPreset, BoundariesConfig, CodebaseGraph } from "../types/index.js"; import { + formatBoundariesText, formatCodebaseMapText, formatChangesText, formatContentDriftText, @@ -70,6 +73,7 @@ export const operationNames = [ "codebaseMap", "contentDrift", "health", + "boundaries", "highways", "clusters", ] as const; @@ -78,6 +82,7 @@ export type OperationName = typeof operationNames[number]; export interface OperationContext { rootDir?: string; + configPath?: string; } export interface Operation { @@ -188,6 +193,12 @@ const healthInputShape = { score: z.boolean().optional().describe("Request compact score output on the CLI"), } satisfies z.ZodRawShape; const healthInputSchema = z.object(healthInputShape).strict(); +const boundaryPresets = ["bulletproof", "layered", "hexagonal", "feature-sliced"] as const satisfies readonly BoundaryPreset[]; +const boundariesInputShape = { + preset: z.enum(boundaryPresets).optional().describe("Boundary preset to use when config has no boundaries"), + list: z.boolean().optional().describe("List resolved zones and rules"), +} satisfies z.ZodRawShape; +const boundariesInputSchema = z.object(boundariesInputShape).strict(); const highwaysInputShape = { operation: z.string().min(1).optional().describe("Operation verb to focus on, such as create, update, or validate"), shape: z.string().min(1).optional().describe("Type/DTO shape to focus on"), @@ -220,9 +231,15 @@ type ProcessesInput = z.infer; export type CodebaseMapInput = z.infer; type ContentDriftInput = z.infer; type HealthInput = z.infer; +type BoundariesInput = z.infer; type HighwaysInput = z.infer; type ClustersInput = z.infer; +function loadBoundariesConfig(context: OperationContext): BoundariesConfig | undefined { + if (!context.rootDir && !context.configPath) return undefined; + return loadConfig(context.rootDir ?? process.cwd(), { configPath: context.configPath }).config.boundaries; +} + export const operations = { overview: { name: "overview", @@ -417,6 +434,17 @@ export const operations = { run: (graph: CodebaseGraph, input: HealthInput, context) => computeHealth(graph, input, context), formatText: formatHealthText, } satisfies Operation>, + boundaries: { + name: "boundaries", + cliCommand: "boundaries", + mcpTool: "check_boundaries", + description: "Evaluate architecture boundary zones against import edges. Supports presets, custom zones, allow/forbid rules, and a list view. Use when: enforcing layering, ports/adapters, or feature-sliced import direction. Not for: circular dependencies (use check)", + inputShape: boundariesInputShape, + inputSchema: boundariesInputSchema, + run: (graph: CodebaseGraph, input: BoundariesInput, context) => + computeBoundaries(graph, { preset: input.preset, list: input.list, config: loadBoundariesConfig(context) }), + formatText: formatBoundariesText, + } satisfies Operation>, highways: { name: "highways", cliCommand: "highways", diff --git a/src/rules/no-boundary-violations.ts b/src/rules/no-boundary-violations.ts new file mode 100644 index 0000000..ecad0be --- /dev/null +++ b/src/rules/no-boundary-violations.ts @@ -0,0 +1,23 @@ +import { computeBoundaries } from "../boundaries/index.js"; +import type { ReportedFinding, Rule, RuleContext } from "./engine.js"; + +/** Enforce configured architecture boundary zones against file import edges. */ +export const noBoundaryViolations: Rule = { + id: "no-boundary-violations", + meta: { description: "Forbid imports that cross configured architecture boundaries.", category: "architecture", fixable: false }, + defaultSeverity: "error", + run(ctx: RuleContext): ReportedFinding[] { + if (!ctx.config.boundaries) return []; + const result = computeBoundaries(ctx.graph, { config: ctx.config.boundaries }); + return result.violations.map((violation) => ({ + file: violation.source, + line: 1, + column: 1, + kind: violation.kind, + confidence: "high", + message: violation.message, + evidence: violation.evidence, + actions: [{ kind: "inspect-boundary", auto_fixable: false }], + })); + }, +}; diff --git a/src/rules/registry.ts b/src/rules/registry.ts index a107ddf..43831d6 100644 --- a/src/rules/registry.ts +++ b/src/rules/registry.ts @@ -1,5 +1,6 @@ import type { Rule } from "./engine.js"; import { noComments } from "./no-comments.js"; +import { noBoundaryViolations } from "./no-boundary-violations.js"; import { noCircularDeps } from "./no-circular-deps.js"; import { noDeadFiles, noUnusedDeps, noUnusedMembers, noUnusedTypes } from "./dead-code.js"; import { noDeadExports } from "./no-dead-exports.js"; @@ -7,6 +8,7 @@ import { noDeadExports } from "./no-dead-exports.js"; /** Every rule the engine knows about. Add a rule = add a file + an entry here. */ export const ALL_RULES: Rule[] = [ noComments, + noBoundaryViolations, noCircularDeps, noDeadExports, noDeadFiles, diff --git a/src/types/index.ts b/src/types/index.ts index 85b47d3..ad9d83c 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -325,6 +325,51 @@ export interface BoundariesConfig { rules?: BoundaryRule[]; } +export type BoundaryPreset = NonNullable; + +export interface BoundaryMatchedZone { + name: string; + patterns: string[]; + matchedFiles: string[]; + autoDiscover: boolean; +} + +export interface BoundaryRuleSummary { + from: string; + allow?: string[]; + forbid?: string[]; +} + +export interface BoundaryViolation { + id: string; + kind: "forbidden-edge" | "disallowed-edge" | "risky-re-export-chain"; + ruleId: string; + source: string; + target: string; + fromZone: string; + toZone: string; + symbols: string[]; + isTypeOnly: boolean; + message: string; + evidence: string[]; + actions: Array<{ command: string; reason: string }>; +} + +export interface BoundariesResult { + preset: BoundaryPreset | "custom" | "none"; + zones: BoundaryMatchedZone[]; + rules: BoundaryRuleSummary[]; + violations: BoundaryViolation[]; + summary: { + zones: number; + rules: number; + checkedEdges: number; + violations: number; + unassignedFiles: number; + }; + verdict: "pass" | "fail"; +} + export type OutputFormat = "text" | "json" | "sarif"; export interface CacheFacts { @@ -359,7 +404,7 @@ export interface CodebaseIntelligenceConfig { }; } -export type ActionKind = "remove-comment"; +export type ActionKind = "remove-comment" | "inspect-boundary"; export type FindingConfidence = "high" | "medium" | "low"; export interface FindingAction { diff --git a/tests/boundaries.e2e.test.ts b/tests/boundaries.e2e.test.ts new file mode 100644 index 0000000..b5ae990 --- /dev/null +++ b/tests/boundaries.e2e.test.ts @@ -0,0 +1,267 @@ +import { execFile, execFileSync, execSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { promisify } from "node:util"; +import { fileURLToPath } from "node:url"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; +import { beforeAll, describe, expect, it } from "vitest"; +import type { BoundariesResult } from "../src/types/index.js"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(here, ".."); +const cli = path.join(repoRoot, "dist", "cli.js"); +const pexec = promisify(execFile); + +interface RunResult { + status: number; + stdout: string; + stderr: string; +} + +type BoundaryPayload = BoundariesResult & { cache?: unknown; nextSteps?: unknown }; + +beforeAll(() => { + execSync("pnpm build", { cwd: repoRoot, stdio: "inherit" }); +}, 120_000); + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isBoundaryPayload(value: unknown): value is BoundaryPayload { + return isRecord(value) + && (value.verdict === "pass" || value.verdict === "fail") + && isRecord(value.summary) + && Array.isArray(value.zones) + && Array.isArray(value.rules) + && Array.isArray(value.violations); +} + +function parseBoundaryPayload(stdout: string): BoundaryPayload { + const parsed: unknown = JSON.parse(stdout); + if (!isBoundaryPayload(parsed)) throw new Error("Expected boundaries JSON object"); + return parsed; +} + +function mcpPayload(result: unknown): BoundaryPayload { + if (!isRecord(result) || !Array.isArray(result.content)) throw new Error("MCP result did not include content"); + const first = result.content[0]; + if (!isRecord(first) || typeof first.text !== "string") throw new Error("MCP result did not include text content"); + return parseBoundaryPayload(first.text); +} + +function withoutRuntimeFields(payload: BoundaryPayload): BoundariesResult { + const copy = { ...payload }; + delete copy.cache; + delete copy.nextSteps; + return copy; +} + +async function run(args: readonly string[]): Promise { + try { + const { stdout, stderr } = await pexec("node", [cli, ...args], { + cwd: repoRoot, + encoding: "utf-8", + maxBuffer: 10 * 1024 * 1024, + }); + return { status: 0, stdout, stderr }; + } catch (e) { + const err = isRecord(e) ? e : {}; + return { + status: typeof err.code === "number" ? err.code : 1, + stdout: typeof err.stdout === "string" ? err.stdout : "", + stderr: typeof err.stderr === "string" ? err.stderr : "", + }; + } +} + +function writeFile(root: string, relative: string, content: string): void { + const filePath = path.join(root, relative); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, content); +} + +function git(root: string, args: readonly string[]): void { + execFileSync("git", args, { cwd: root, stdio: "ignore" }); +} + +function gitOutput(root: string, args: readonly string[], input?: string): string { + return execFileSync("git", args, { + cwd: root, + encoding: "utf-8", + input, + stdio: ["pipe", "pipe", "pipe"], + env: { + ...process.env, + GIT_AUTHOR_NAME: "Boundary Test", + GIT_AUTHOR_EMAIL: "32437578+bntvllnt@users.noreply.github.com", + GIT_COMMITTER_NAME: "Boundary Test", + GIT_COMMITTER_EMAIL: "32437578+bntvllnt@users.noreply.github.com", + }, + }).trim(); +} + +function currentHead(root: string): string | undefined { + try { + return gitOutput(root, ["rev-parse", "--verify", "HEAD"]); + } catch { + return undefined; + } +} + +function commitAll(root: string, message: string): void { + git(root, ["add", "."]); + const tree = gitOutput(root, ["write-tree"]); + const parent = currentHead(root); + const args = ["-c", "commit.gpgsign=false", "commit-tree", tree]; + if (parent) args.push("-p", parent); + const commit = gitOutput(root, args, `${message}\n`); + git(root, ["update-ref", "HEAD", commit]); +} + +function createBoundaryFixture(): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "cbi-boundaries-")); + writeFile(root, "tsconfig.json", JSON.stringify({ compilerOptions: { target: "ES2022", module: "ESNext" } }, null, 2)); + writeFile(root, "codebase-intelligence.json", JSON.stringify({ + boundaries: { + zones: [ + { name: "ui", patterns: ["ui/**"], autoDiscover: true }, + { name: "app", patterns: ["app/**"], autoDiscover: true }, + { name: "domain", patterns: ["domain/**"], autoDiscover: true }, + { name: "infra", patterns: ["infra/**"], autoDiscover: true }, + { name: "shared", patterns: ["shared/**"], autoDiscover: true }, + ], + rules: [ + { from: "ui", allow: ["app", "domain"] }, + { from: "ui", forbid: ["infra"] }, + { from: "app", allow: ["domain"] }, + { from: "domain", forbid: ["infra"] }, + ], + }, + rules: { + "no-dead-exports": "off", + "no-circular-deps": "off", + }, + }, null, 2)); + writeFile(root, "src/infra/db.ts", "export function readDb(): string { return 'db'; }\n"); + writeFile(root, "src/domain/model.ts", "import { readDb } from '../infra/db';\nexport function makeModel(): string { return readDb(); }\n"); + writeFile(root, "src/app/service.ts", "import { makeModel } from '../domain/model';\nexport const service = makeModel();\n"); + writeFile(root, "src/ui/widget.ts", "import { readDb } from '../infra/db';\nexport const widget = readDb();\n"); + writeFile(root, "src/ui/barrel.ts", "export * from '../infra/db';\n"); + writeFile(root, "src/shared/tool.ts", "export const tool = 'tool';\n"); + writeFile(root, "src/ui/shared-leak.ts", "import { tool } from '../shared/tool';\nexport const leak = tool;\n"); + git(root, ["init"]); + git(root, ["config", "user.email", "32437578+bntvllnt@users.noreply.github.com"]); + git(root, ["config", "user.name", "Boundary Test"]); + commitAll(root, "initial"); + return root; +} + +describe("CH-P2-05 architecture boundary chain", () => { + it("reports rule evidence, supports new-only gating, and matches real stdio MCP output", async () => { + const root = createBoundaryFixture(); + try { + const boundaryRun = await run(["boundaries", root, "--list", "--json", "--force"]); + expect(boundaryRun.status).toBe(1); + expect(boundaryRun.stderr).toContain("Parsed"); + const boundaryPayload = parseBoundaryPayload(boundaryRun.stdout); + expect(boundaryPayload.verdict).toBe("fail"); + expect(boundaryPayload.summary.violations).toBe(4); + expect(boundaryPayload.zones.find((zone) => zone.name === "ui")?.patterns).toContain("src/ui/**"); + expect(boundaryPayload.violations.map((violation) => violation.kind).sort()).toEqual([ + "disallowed-edge", + "forbidden-edge", + "forbidden-edge", + "risky-re-export-chain", + ]); + expect(boundaryPayload.violations).toContainEqual(expect.objectContaining({ + source: "src/ui/shared-leak.ts", + target: "src/shared/tool.ts", + kind: "disallowed-edge", + })); + expect(boundaryPayload.violations).toContainEqual(expect.objectContaining({ + source: "src/ui/widget.ts", + target: "src/infra/db.ts", + kind: "forbidden-edge", + })); + expect(boundaryPayload.violations[0]?.evidence).toEqual(expect.arrayContaining([ + expect.stringContaining("edge="), + expect.stringContaining("fromZone="), + expect.stringContaining("toZone="), + ])); + expect(boundaryPayload.violations[0]?.actions.length).toBeGreaterThan(0); + + const textRun = await run(["boundaries", root, "--list"]); + expect(textRun.status).toBe(1); + expect(textRun.stdout).toContain("Boundaries: FAIL"); + expect(textRun.stdout).toContain("Zones"); + + const preexistingGate = await run(["check", root, "--json", "--gate", "new-only", "--base", "HEAD", "--fail-on", "error"]); + expect(preexistingGate.status).toBe(0); + const preexistingPayload: unknown = JSON.parse(preexistingGate.stdout); + if (!isRecord(preexistingPayload) || !isRecord(preexistingPayload.summary)) throw new Error("Expected check payload"); + expect(preexistingPayload.verdict).toBe("pass"); + expect(preexistingPayload.summary.error).toBe(0); + + const base = gitOutput(root, ["rev-parse", "--verify", "HEAD"]); + writeFile(root, "src/app/new-service.ts", "import { readDb } from '../infra/db';\nexport const newer = readDb();\n"); + commitAll(root, "add new boundary violation"); + const newOnlyGate = await run(["check", root, "--json", "--gate", "new-only", "--base", base, "--fail-on", "error", "--force"]); + expect(newOnlyGate.status).toBe(1); + const newOnlyPayload: unknown = JSON.parse(newOnlyGate.stdout); + if (!isRecord(newOnlyPayload) || !Array.isArray(newOnlyPayload.findings)) throw new Error("Expected check findings"); + expect(newOnlyPayload.verdict).toBe("fail"); + expect(newOnlyPayload.findings).toEqual([ + expect.objectContaining({ + ruleId: "no-boundary-violations", + file: "src/app/new-service.ts", + severity: "error", + kind: "disallowed-edge", + }), + ]); + + const transport = new StdioClientTransport({ + command: "node", + args: [cli, root, "--force"], + cwd: repoRoot, + stderr: "pipe", + }); + const client = new Client({ name: "boundaries-e2e", version: "0.1.0" }); + await client.connect(transport); + try { + const result = await client.callTool({ name: "check_boundaries", arguments: { list: true } }); + const mcp = mcpPayload(result); + expect(withoutRuntimeFields(mcp)).toEqual(withoutRuntimeFields(parseBoundaryPayload((await run(["boundaries", root, "--list", "--json", "--force"])).stdout))); + expect(mcp).toHaveProperty("nextSteps"); + } finally { + await client.close(); + await transport.close(); + } + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }, 120_000); + + it("ships preset boundary rules without requiring config", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "cbi-boundary-preset-")); + try { + writeFile(root, "tsconfig.json", JSON.stringify({ compilerOptions: { target: "ES2022", module: "ESNext" } }, null, 2)); + writeFile(root, "src/infra/db.ts", "export function readDb(): string { return 'db'; }\n"); + writeFile(root, "src/domain/model.ts", "import { readDb } from '../infra/db';\nexport const model = readDb();\n"); + const runResult = await run(["boundaries", root, "--preset", "hexagonal", "--json", "--force"]); + expect(runResult.status).toBe(1); + const payload = parseBoundaryPayload(runResult.stdout); + expect(payload.preset).toBe("hexagonal"); + expect(payload.violations[0]).toMatchObject({ + source: "src/domain/model.ts", + target: "src/infra/db.ts", + fromZone: "domain", + toZone: "infrastructure", + }); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }, 120_000); +}); diff --git a/tests/mcp-tools.test.ts b/tests/mcp-tools.test.ts index 5da240d..28871f0 100644 --- a/tests/mcp-tools.test.ts +++ b/tests/mcp-tools.test.ts @@ -520,7 +520,22 @@ describe("Tool 20: get_health_score", () => { }); }); -describe("Tool 21: analyze_highways", () => { +describe("Tool 21: check_boundaries", () => { + it("returns resolved zones, rules, violations, and next steps", async () => { + const r = await callTool("check_boundaries", { preset: "layered", list: true }); + expect(r).toHaveProperty("preset", "layered"); + expect(r).toHaveProperty("verdict"); + expect(r).toHaveProperty("zones"); + expect(r).toHaveProperty("rules"); + expect(r).toHaveProperty("violations"); + expect(r).toHaveProperty("nextSteps"); + expect(Array.isArray(r.zones)).toBe(true); + expect(Array.isArray(r.rules)).toBe(true); + expect(Array.isArray(r.violations)).toBe(true); + }); +}); + +describe("Tool 22: analyze_highways", () => { it("returns highway opportunities envelope", async () => { const r = await callTool("analyze_highways", { operation: "get", minRoutes: 2 }); expect(r).toHaveProperty("totalRoutes"); @@ -532,7 +547,7 @@ describe("Tool 21: analyze_highways", () => { }); }); -describe("Tool 22: get_clusters", () => { +describe("Tool 23: get_clusters", () => { it("returns community-detected clusters", async () => { const r = await callTool("get_clusters"); expect(r).toHaveProperty("clusters"); @@ -615,6 +630,7 @@ describe("MCP Resources", () => { expect(availableTools).toContain("get_context_pack"); expect(availableTools).toContain("detect_content_drift"); expect(availableTools).toContain("get_health_score"); + expect(availableTools).toContain("check_boundaries"); expect(availableTools).toContain("analyze_highways"); expect(availableTools).toContain("check"); }); diff --git a/tests/operation-registry.e2e.test.ts b/tests/operation-registry.e2e.test.ts index 77ad718..7e1c386 100644 --- a/tests/operation-registry.e2e.test.ts +++ b/tests/operation-registry.e2e.test.ts @@ -351,6 +351,14 @@ describe("operation registry chained parity", () => { { rootDir: getFixtureSrcPath() }, cachedRun, ); + expectCliMatchesRegistry( + operations.boundaries, + { preset: "layered", list: true }, + ["boundaries", getFixtureSrcPath(), "--preset", "layered", "--list"], + codebaseGraph, + {}, + cachedRun, + ); expectCliMatchesRegistry( operations.highways, { operation: "get", minRoutes: 2 }, @@ -505,6 +513,14 @@ describe("operation registry chained parity", () => { { rootDir: getFixtureSrcPath() }, cachedRun, ); + expectCliTextMatchesFormatter( + operations.boundaries, + { preset: "layered", list: true }, + ["boundaries", getFixtureSrcPath(), "--preset", "layered", "--list"], + codebaseGraph, + {}, + cachedRun, + ); expectCliTextMatchesFormatter( operations.highways, { operation: "get", minRoutes: 2 }, @@ -795,6 +811,13 @@ describe("operation registry chained parity", () => { mcp, { rootDir: getFixtureSrcPath() }, ); + await expectMcpMatchesRegistry( + operations.boundaries, + { preset: "layered", list: true }, + { preset: "layered", list: true }, + codebaseGraph, + mcp, + ); await expectMcpMatchesRegistry( operations.highways, { operation: "get", minRoutes: 2 }, diff --git a/tests/operation-registry.test.ts b/tests/operation-registry.test.ts index 4cb4b56..59a7a84 100644 --- a/tests/operation-registry.test.ts +++ b/tests/operation-registry.test.ts @@ -42,6 +42,7 @@ const expectedOperations: Array<{ { name: "codebaseMap", cliCommand: "map", mcpTool: "get_codebase_map", inputKeys: ["focus", "scope", "depth", "format", "contextBudget"], sampleInput: { focus: "getUserById", depth: 1, contextBudget: 420 } }, { name: "contentDrift", cliCommand: "drift", mcpTool: "detect_content_drift", inputKeys: ["focus", "scope", "minScore"], sampleInput: { scope: "users", minScore: 35 } }, { name: "health", cliCommand: "health", mcpTool: "get_health_score", inputKeys: ["minScore", "score"], sampleInput: { minScore: 0, score: true } }, + { name: "boundaries", cliCommand: "boundaries", mcpTool: "check_boundaries", inputKeys: ["preset", "list"], sampleInput: { preset: "layered", list: true } }, { name: "highways", cliCommand: "highways", mcpTool: "analyze_highways", inputKeys: ["operation", "shape", "minRoutes", "propose", "trace"], sampleInput: { operation: "create", minRoutes: 2 } }, { name: "clusters", cliCommand: "clusters", mcpTool: "get_clusters", inputKeys: ["minFiles"], sampleInput: { minFiles: 2 } }, ];