diff --git a/README.md b/README.md index 2e3e114..9e5364f 100644 --- a/README.md +++ b/README.md @@ -57,20 +57,21 @@ claude mcp add -s user -t stdio codebase-intelligence -- npx -y codebase-intelli ## Features -- **22 CLI commands** for architecture analysis, dependency impact, duplicate families, focused map/context packs, content drift, Highways convergence, improvement opportunities, dead code detection, search, CI rules, and agent setup +- **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 - **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[]`) -- **11 architectural metrics** — PageRank, betweenness, coupling, cohesion, tension, churn, complexity, blast radius, dead exports, test coverage, escape velocity +- **Quality metrics** — PageRank, betweenness, coupling, cohesion, tension, churn, complexity, blast radius, dead exports, test coverage, escape velocity, risk, maintainability, and CRAP score - **Symbol-level analysis** — callers/callees, symbol importance, impact blast radius - **BM25 search** — ranked keyword search across files, symbols, and type/shape facts - **Process tracing** — detect entry points and execution flows through the call graph - **Codebase maps + context packs** — focused file/symbol/test graph with deterministic evidence IDs and token-bounded context for agents - **Content drift** — report-only detection for file/name/scope/side-effect/shape/test placement mismatch with stable evidence +- **Health score** — CI-gateable composite score with per-file maintainability, CRAP, coverage source, and risk hotspots - **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 23 MCP tools, 2 prompts, and 3 resources +- **MCP parity (secondary)** — same analysis and rules gate available as 24 MCP tools, 2 prompts, and 3 resources ## Installation @@ -98,7 +99,7 @@ codebase-intelligence [options] | Command | What it does | |---|---| | `overview` | High-level codebase snapshot | -| `hotspots` | Rank files by metric (coupling, churn, complexity, blast radius, coverage, etc.) | +| `hotspots` | Rank files by metric (coupling, churn, complexity, blast radius, coverage, risk, etc.) | | `file` | Full context for one file | | `search` | BM25 keyword and shape search | | `changes` | Git diff analysis with risk metrics | @@ -115,6 +116,7 @@ codebase-intelligence [options] | `processes` | Entry-point execution flow tracing | | `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 | | `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 | @@ -135,7 +137,8 @@ codebase-intelligence [options] | `--trace ` | Return token evidence for one duplicate family | | `--focus ` | Focus `map` or `drift` on one symbol, file, or scope | | `--context-budget ` | Bound `map` context pack size | -| `--min-score ` | Minimum drift score for `drift` findings | +| `--min-score ` | Minimum drift score for `drift` findings; minimum health score before `health` exits 1 | +| `--score` | Print compact `health` score text | | `--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 | @@ -240,6 +243,9 @@ For MCP tool details, see [docs/mcp-tools.md](docs/mcp-tools.md). | **Blast Radius** | Transitive dependents affected by a change | | **Dead Exports** | Unused exports (safe to remove) | | **Test Coverage** | Whether a test file exists for each source file | +| **Risk Score** | Composite file risk from complexity, churn, coupling, size, blast radius, and test reachability | +| **Maintainability Index** | Health-derived 0-100 per-file maintainability score | +| **CRAP Score** | Complexity x uncovered-code risk, using Istanbul coverage when present or static test reachability otherwise | ## Architecture diff --git a/docs/architecture.md b/docs/architecture.md index 0c6e588..5a81a69 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) - | 23 tools, 2 prompts, | 22 commands with text + JSON + | 24 tools, 2 prompts, | 23 commands with text + JSON | 3 resources for LLMs | output for humans and CI ``` @@ -49,8 +49,9 @@ src/ rules/index.ts <- Rules engine + registry (check command + MCP check tool) 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 highways/index.ts <- Repeated route convergence + bypass/cowpath/synthesis evidence - mcp/index.ts <- 23 MCP tools for LLM integration + mcp/index.ts <- 24 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 @@ -85,7 +86,7 @@ analyzeGraph(builtGraph, parsedFiles) } startMcpServer(codebaseGraph) - -> stdio MCP server with 23 tools, 2 prompts, 3 resources + -> stdio MCP server with 24 tools, 2 prompts, 3 resources runOperation(operation, codebaseGraph, input, context) -> { ok: true, data } | { ok: false, error, data? } @@ -102,6 +103,7 @@ runOperation(operation, codebaseGraph, input, context) - **Highways H1/H2**: `highways` / `analyze_highways` enumerates entry-to-sink call routes, groups repeated routes by operation/shape/sink, detects bypasses and cowpaths around an existing canonical node, and with `--propose` synthesizes a read-only proposed highway with name, file, signature, skeleton, reroute plan, cycle safety, evidence, blast radius, recommendations, and a context pack for agents. - **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. - **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 262c7ba..14704fd 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -1,6 +1,6 @@ # CLI Reference -22 commands for terminal and CI use. The 20 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. +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. ## Commands @@ -22,7 +22,7 @@ Rank files by metric. codebase-intelligence hotspots [--metric ] [--limit ] [--json] [--force] ``` -**Metrics:** `coupling` (default), `pagerank`, `fan_in`, `fan_out`, `betweenness`, `tension`, `churn`, `complexity`, `blast_radius`, `coverage`, `escape_velocity`. +**Metrics:** `coupling` (default), `pagerank`, `fan_in`, `fan_out`, `betweenness`, `tension`, `churn`, `complexity`, `blast_radius`, `coverage`, `risk`, `escape_velocity`. ### file @@ -190,6 +190,18 @@ codebase-intelligence drift [--focus ] [--scope ] [- **Output:** `mode: "report-only"`, baseline status, findings with stable `drift-*` IDs, `kind` (`name-drift`, `scope-drift`, `mixed-responsibility`, `hidden-side-effect`, `shape-drift`, `orphan-scope`, `misplaced-test`), score, severity, declared intent, actual behavior, evidence IDs/summaries, recommendations, and advisory actions. Drift does not fail CI until a baseline/gate is configured. +### health + +Compute a CI-gateable codebase health score. + +```bash +codebase-intelligence health [--score] [--min-score ] [--json] [--force] +``` + +**Output:** `score`, `minScore`, `verdict`, component scores, coverage source (`static-tests` or root-local Istanbul coverage), per-file `maintainabilityIndex`, `crapScore`, `riskScore`, evidence, hotspots, and advisory actions. + +**Exit codes:** `0` when score is at least `minScore`; `1` when score is below `minScore`; `2` for invalid input. + ### highways Find repeated routes that should converge on one canonical operation path. @@ -268,7 +280,8 @@ codebase-intelligence init [path] [--agents ] [--all] [--skill] [--gitigno | `--entry ` | processes | Filter by entry point name | | `--focus ` | map, drift | Focus on one symbol, file, or scope | | `--context-budget ` | map | Approximate token budget for context pack | -| `--min-score ` | drift | Minimum drift score to report | +| `--min-score ` | drift, health | Minimum drift score to report; minimum health score before exit 1 | +| `--score` | health | Print compact score text | | `--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 b3f45fc..90444b2 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -296,6 +296,56 @@ ContentDriftEvidence { } ``` +## Health Result + +`health --json` and MCP `get_health_score` return a deterministic health envelope. CLI `health --min-score ` exits `1` when `score < minScore`. + +```typescript +HealthResult { + score: number + minScore: number + verdict: "pass" | "fail" + summary: string + components: { + maintainability: number + complexity: number + churn: number + coupling: number + coverage: number + blastRadius: number + } + coverage: { + source: "istanbul" | "static-tests" + coveredFiles: number + totalFiles: number + warning?: string + } + files: HealthFileResult[] + hotspots: HealthFileResult[] + actions: Array<{ command: string; reason: string }> +} + +HealthFileResult { + file: string + loc: number + maintainabilityIndex: number + crapScore: number + riskScore: number + coverage: number + coverageSource: "istanbul" | "static-tests" + metrics: { + complexity: number + churn: number + coupling: number + blastRadius: number + fanIn: number + fanOut: number + hasTests: boolean + } + evidence: 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 c2b3711..961dbfc 100644 --- a/docs/mcp-tools.md +++ b/docs/mcp-tools.md @@ -1,6 +1,6 @@ # MCP Tools Reference -23 tools available via MCP stdio. +24 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. @@ -41,7 +41,7 @@ File-level blast radius analysis — what breaks if this file changes. Rank files by any metric. **Input:** `{ metric: string, limit?: number }` (default limit: 10) -**Metrics:** coupling, pagerank, fan_in, fan_out, betweenness, tension, escape_velocity, churn, complexity, blast_radius, coverage +**Metrics:** coupling, pagerank, fan_in, fan_out, betweenness, tension, escape_velocity, churn, complexity, blast_radius, coverage, risk **Returns:** ranked files with score + reason, summary **Use when:** "What are the riskiest files?" "Which files need tests?" "Most complex files?" @@ -209,7 +209,17 @@ Detect report-only mismatch between declared file/folder intent and observed beh **Use when:** Finding file/folder/content drift before a refactor, roadmap cleanup, or baseline creation. **Not for:** Failing CI without a baseline (use check or a future ci wrapper). -## 21. analyze_highways +## 21. get_health_score + +Compute a CI-gateable codebase health score. + +**Input:** `{ minScore?: number, score?: boolean }` +**Returns:** score, minScore, verdict, components, coverage source/counts, files[] (maintainabilityIndex, crapScore, riskScore, metrics, evidence), hotspots[], actions[], summary + +**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 Detect repeated entry-to-sink routes that should converge on one canonical operation path. @@ -219,7 +229,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). -## 22. get_clusters +## 23. get_clusters Community-detected clusters of related files. @@ -264,8 +274,9 @@ Rules: `no-comments` (off by default), `no-circular-deps` (error), `no-dead-expo | "Tell me about file X" | `file_context` | | "What breaks if I change file X?" | `get_dependents` | | "What breaks if I change function X?" | `impact_analysis` | -| "What are the riskiest files?" | `find_hotspots` (coupling, churn, or blast_radius) | +| "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` | | "What should I improve first?" | `find_opportunities` | | "Find refactoring opportunities." | `find_opportunities` | | "Where is logic duplicated?" | `find_duplicates` | diff --git a/docs/metrics.md b/docs/metrics.md index 6a58c33..3a0e0fb 100644 --- a/docs/metrics.md +++ b/docs/metrics.md @@ -29,6 +29,14 @@ All metrics are computed per-file and stored in `FileMetrics`. Module-level aggr | **escapeVelocity** | number | 0-1 | Readiness for extraction. High = few internal deps, many external consumers. | | **verdict** | string | LEAF/COHESIVE/MODERATE/JUNK_DRAWER | Single non-test file = LEAF (cohesion meaningless). Otherwise: cohesion >= 0.6 = COHESIVE, >= 0.4 = MODERATE, else JUNK_DRAWER. | +## Health Metrics + +| Metric | Range | Source | Description | +|--------|-------|--------|-------------| +| **maintainabilityIndex** | 0-100 | derived | Per-file score from complexity, LOC, churn, coupling, blast radius, and test reachability. Higher is better. | +| **crapScore** | 0-N | derived | `complexity^2 * uncovered^3 + complexity`. Coverage comes from root-local Istanbul coverage when present, otherwise static test reachability. Lower is better. | +| **riskScore** | 0-100 | derived | Composite risk from low maintainability, complexity, churn, coupling, size, and blast radius. Used by `hotspots --metric risk`. Higher is riskier. | + ## Force Analysis | Signal | Threshold | Meaning | @@ -66,4 +74,4 @@ The most dangerous files have all three: - **High coupling** (many dependents) - **Low coverage** (no tests) -Use `find_hotspots` with different metrics to find these files, or visually compare Churn and Coverage views. +Use `find_hotspots --metric risk` or `health --json` to find files where complexity, churn, coupling, size, and test reachability compound into one score. diff --git a/llms-full.txt b/llms-full.txt index ad5a75a..5b8302d 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: 23 tools, 2 prompts, 3 resources for LLM agents - | CLI: 22 commands with formatted + JSON output for humans/CI + | MCP: 24 tools, 2 prompts, 3 resources for LLM agents + | CLI: 23 commands with formatted + JSON output for humans/CI ``` ## Module Map @@ -54,7 +54,8 @@ src/ map/index.ts <- Focused codebase maps + token-bounded context packs drift/ <- Content drift findings, profiles, tokens, and stable evidence highways/index.ts <- Repeated route convergence + bypass/cowpath/synthesis evidence - mcp/index.ts <- 23 MCP tools for LLM integration + health/ <- Health score, maintainability, CRAP, coverage lookup, and risk scoring + mcp/index.ts <- 24 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 @@ -84,7 +85,7 @@ analyzeGraph(builtGraph, parsedFiles) -> CodebaseGraph { nodes, edges, symbolNodes, callEdges, symbolMetrics, fileMetrics, moduleMetrics, forceAnalysis, stats, - groups, processes, highways, clusters + groups, processes, clusters } runOperation(operation, codebaseGraph, input, context) @@ -311,7 +312,7 @@ The most dangerous files have: high churn + high coupling + low coverage. # MCP Tools Reference -23 tools available via MCP stdio. +24 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. @@ -323,7 +324,7 @@ Detailed file context. Input: `{ filePath: string }`. Returns: exports, imports, File-level blast radius. Input: `{ filePath: string, depth?: number }`. Returns: direct + transitive dependents, riskLevel. ## 4. find_hotspots -Rank files by metric. Input: `{ metric: string, limit?: number }`. Metrics: coupling, pagerank, fan_in, fan_out, betweenness, tension, escape_velocity, churn, complexity, blast_radius, coverage. +Rank files by metric. Input: `{ metric: string, limit?: number }`. Metrics: coupling, pagerank, fan_in, fan_out, betweenness, tension, escape_velocity, churn, complexity, blast_radius, coverage, risk. ## 5. get_module_structure Module architecture. Input: `{ depth?: number }`. Returns: modules with metrics, cross-module deps, circular deps. @@ -373,13 +374,16 @@ Token-bounded context pack from the same map result. Input: same as `get_codebas ## 20. detect_content_drift Content drift. Input: `{ focus?: string, scope?: string, minScore?: number }`. Returns: report-only findings with stable IDs, kind, score, severity, declaredIntent, actualBehavior, evidence, recommendation, actions, and baseline status. -## 21. analyze_highways +## 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 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. -## 22. get_clusters +## 23. get_clusters Community-detected file clusters. Input: `{ minFiles?: number }`. Returns: clusters with cohesion. -## 23. check +## 24. 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 @@ -390,8 +394,9 @@ Rules-engine gate. Input: `{}`. Returns: pass/warn/fail verdict, findings, suppr | Tell me about file X | file_context | | What breaks if I change file X? | get_dependents | | What breaks if I change function X? | impact_analysis | -| What are the riskiest files? | find_hotspots | +| What are the riskiest files? | find_hotspots (risk) | | Which files need tests? | find_hotspots (coverage) | +| What is the PR quality score? | get_health_score | | What should I improve first? | find_opportunities | | Find refactoring opportunities | find_opportunities | | Where is logic duplicated? | find_duplicates | @@ -414,7 +419,7 @@ Rules-engine gate. Input: `{}`. Returns: pass/warn/fail verdict, findings, suppr # CLI Reference -22 commands — 20 analysis commands (full parity with MCP tools), `check` for CI rules, and `init` for agent adoption. +23 commands — 21 analysis commands (full parity with MCP tools), `check` for CI rules, and `init` for agent adoption. ## Commands @@ -428,7 +433,7 @@ High-level codebase snapshot: files, functions, modules, dependencies. ```bash codebase-intelligence hotspots [--metric ] [--limit ] [--json] [--force] ``` -Rank files by metric. Default: coupling. Available: coupling, pagerank, fan_in, fan_out, betweenness, tension, churn, complexity, blast_radius, coverage, escape_velocity. +Rank files by metric. Default: coupling. Available: coupling, pagerank, fan_in, fan_out, betweenness, tension, churn, complexity, blast_radius, coverage, risk, escape_velocity. ### file ```bash @@ -526,6 +531,12 @@ codebase-intelligence drift [--focus ] [--scope ] [- ``` Report-only content drift findings. Returns stable `drift-*` IDs, drift kind, score/severity, declaredIntent, actualBehavior, evidence, recommendation, advisory actions, and baseline status. +### health +```bash +codebase-intelligence health [--score] [--min-score ] [--json] [--force] +``` +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`. + ### highways ```bash codebase-intelligence highways [--operation ] [--shape ] [--min-routes ] [--propose] [--trace ] [--json] [--force] diff --git a/llms.txt b/llms.txt index 9baf730..f47de1b 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): 23 MCP tools — inputs, outputs, use cases, selection guide +- [MCP Tools](docs/mcp-tools.md): 24 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) — 22 commands (20 analysis with MCP parity + `check` + `init`): +CLI mode (humans/CI) — 23 commands (21 analysis with MCP parity + `check` + `init`): ```bash codebase-intelligence overview ./src codebase-intelligence hotspots ./src --metric coupling @@ -37,6 +37,7 @@ codebase-intelligence rename ./src oldName newName 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 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 586aafc..1113162 100644 --- a/roadmap.md +++ b/roadmap.md @@ -170,7 +170,7 @@ Purpose: prove the CLI, MCP server, library outputs, docs, CI behavior, and agen | CH-P2-02 highway synthesis | As US-CLI-HUMAN, I can understand the proposed canonical path before coding. | Fixture with no existing canonical node -> run `highways --propose --trace` -> assert synthesized name/location/signature/skeleton/reroute plan and cycle-safety check. | | CH-P2-03 codebase map context pack | As US-MCP-AGENT, I can request only the files needed for one task. | Run `map --focus --context-budget --json` -> assert nodes/edges/evidence IDs -> request MCP context pack -> assert token-bounded ranked files/symbols/tests. | | CH-P2-04 content drift | As US-MAINTAINER, I can find files whose names lie about behavior. | Fixture name/scope/side-effect/test mismatch -> run `drift --json` -> assert drift score, deterministic evidence, recommendation, baseline report-only first run. | -| CH-P2-05 health/boundaries | As US-CLI-CI, I can gate new architecture debt. | Fixture boundary violation + complexity/churn hotspot -> run health/boundary commands -> assert score, rule evidence, baseline/new-only behavior, stable exit codes. | +| CH-P2-05 health/boundaries | As US-CLI-CI, I can gate new architecture debt. | Health subchain: fixture complexity/churn hotspot -> run `health` + `hotspots --metric risk` -> assert score, maintainability, CRAP, coverage source, stable exit codes, and MCP parity. Boundary subchain: fixture boundary violation -> run boundary commands -> assert rule evidence, baseline/new-only behavior, and stable exit codes. | | CH-P2-06 CI wrapper | As US-MAINTAINER, I can add one PR gate. | Run `ci --base origin/main --new-only --format sarif` in temp git repo -> assert exit code, SARIF artifact, PR markdown, compact summary, and no failure on pre-existing debt. | | CH-P2-07 doctor onboarding | As US-CLI-AGENT, I can self-diagnose setup. | Run `doctor --json` in repo missing config/cache/agent docs -> assert checks, levels, exact fix commands, docs links, read-only behavior. | | CH-P2-08 output actions | As US-CLI-AGENT, every finding gives safe next steps. | For each analyzer finding kind -> assert stable ID, evidence, `actions[]`, no source mutation, and JSON schema compatibility. | @@ -477,12 +477,20 @@ drift score = mismatch(declared intent, actual behavior) Provide one CI-gateable quality score plus file-level risk metrics. -**To do:** +**Status:** Health foundation shipped in the canary train. + +**Shipped:** + +- CLI: add `health ` with `--score`, `--min-score`, `--json`, and stable exit `1` when score falls below the threshold. +- MCP: add `get_health_score`. +- Add per-file `maintainabilityIndex`, `crapScore`, `riskScore`, evidence, hotspots, and actions. +- Use static test reachability by default and root-local Istanbul `coverage/coverage-final.json` or `coverage/coverage.json` when present. +- Extend `hotspots` with `--metric risk` using complexity x churn x coupling x size x blast radius/test reachability. +- Add CH-P2-05 health subchain coverage for CLI JSON/text, exit codes, risk hotspots, Istanbul coverage, and real stdio MCP parity. + +**Remaining:** -- Add composite `health --score --min-score `. -- Add per-file maintainability index. -- Add CRAP score using static test reachability and optional Istanbul `coverage.json`. -- Extend `hotspots` with complexity x churn x coupling x size. +- Feed health score into the future first-class `ci` wrapper and health badge output format. ### Architecture Boundaries diff --git a/src/cli.ts b/src/cli.ts index 5b05512..1a16bee 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -216,6 +216,11 @@ interface ContentDriftOptions extends CliCommandOptions { minScore?: string; } +interface HealthOptions extends CliCommandOptions { + minScore?: string; + score?: boolean; +} + interface HighwaysOptions extends CliCommandOptions { operation?: string; shape?: string; @@ -725,6 +730,35 @@ program outputOperationText(operations.contentDrift, result, input); }); +// ── Subcommand: health ───────────────────────────────────── + +program + .command("health") + .description("Compute a CI-gateable health score with maintainability, CRAP, coverage, and risk hotspots") + .argument("", "Path to TypeScript codebase") + .option("--score", "Print compact health score text") + .option("--min-score ", "Minimum health score before exit 1 (default: 70)") + .option("--json", "Output as JSON") + .option("--force", "Re-index even if HEAD unchanged") + .action((targetPath: string, options: HealthOptions) => { + const input = parseCliOperationInput(operations.health, { + minScore: optionalNumberInput(options.minScore), + score: options.score, + }); + const { graph } = loadGraph(targetPath, forceOption(options)); + const result = runCliOperation(operations.health, graph, input, { rootDir: path.resolve(targetPath) }); + + if (options.json) { + outputJson(result); + } else { + outputOperationText(operations.health, result, input); + } + + if (result.verdict === "fail") { + process.exitCode = 1; + } + }); + // ── Subcommand: highways ─────────────────────────────────── program diff --git a/src/core/index.ts b/src/core/index.ts index 945b2b7..52a2ba1 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -4,6 +4,7 @@ import type { AnalysisMode, CallGraphPrecision, CodebaseGraph, SymbolTypeFacts } import { createSearchIndex, search, getSuggestions } from "../search/index.js"; import type { SearchIndex } from "../search/index.js"; import { impactAnalysis, renameSymbol } from "../impact/index.js"; +import { computeRiskScore } from "../health/scoring.js"; export { computeDuplication } from "../duplication/index.js"; export type { DuplicationOptions, DuplicationResult } from "../duplication/index.js"; @@ -257,6 +258,7 @@ export const HOTSPOT_METRICS = [ "complexity", "blast_radius", "coverage", + "risk", ] as const; export type HotspotMetric = typeof HOTSPOT_METRICS[number]; @@ -284,9 +286,15 @@ export function computeHotspots( }); } } else { - const filterTestFiles = metric === "coverage" || metric === "coupling"; + const filterTestFiles = metric === "coverage" || metric === "coupling" || metric === "risk"; + const fileNodes = new Map( + graph.nodes + .filter((node) => node.type === "file") + .map((node) => [node.path, node.loc]), + ); for (const [filePath, metrics] of graph.fileMetrics) { if (filterTestFiles && metrics.isTestFile) continue; + const loc = fileNodes.get(filePath) ?? 0; let score: number; let reason: string; @@ -332,6 +340,10 @@ export function computeHotspots( score = metrics.hasTests ? 0 : 1; reason = metrics.hasTests ? `tested (${metrics.testFile})` : "no test file found"; break; + case "risk": + score = computeRiskScore({ loc, coverage: metrics.hasTests ? 1 : 0, metrics }); + reason = `complexity ${metrics.cyclomaticComplexity.toFixed(1)} x churn ${metrics.churn} x coupling ${metrics.coupling.toFixed(2)} x size ${loc}`; + break; default: score = 0; reason = ""; diff --git a/src/health/coverage.ts b/src/health/coverage.ts new file mode 100644 index 0000000..4d04f39 --- /dev/null +++ b/src/health/coverage.ts @@ -0,0 +1,71 @@ +import fs from "fs"; +import path from "path"; + +export type CoverageSource = "istanbul" | "static-tests"; + +export interface CoverageLookup { + source: CoverageSource; + files: Map; + warning?: string; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function normalizeFilePath(value: string): string { + return value.split(path.sep).join("/"); +} + +function statementCoverage(entry: unknown): number | undefined { + if (!isRecord(entry) || !isRecord(entry.s)) return undefined; + const counts = Object.values(entry.s).filter((value): value is number => typeof value === "number"); + if (counts.length === 0) return undefined; + const covered = counts.filter((count) => count > 0).length; + return covered / counts.length; +} + +function normalizeCoverageKey(rootDir: string, key: string): string { + const absolute = path.isAbsolute(key) ? key : path.resolve(rootDir, key); + return normalizeFilePath(path.relative(rootDir, absolute)); +} + +function readCoverageCandidate(rootDir: string, candidate: string): CoverageLookup | undefined { + const filePath = path.join(rootDir, candidate); + if (!fs.existsSync(filePath)) return undefined; + + try { + const parsed: unknown = JSON.parse(fs.readFileSync(filePath, "utf-8")); + if (!isRecord(parsed)) { + return { source: "static-tests", files: new Map(), warning: `${candidate} is not a JSON object` }; + } + + const files = new Map(); + for (const [key, entry] of Object.entries(parsed)) { + const coverage = statementCoverage(entry); + if (coverage === undefined) continue; + files.set(normalizeCoverageKey(rootDir, key), coverage); + } + + if (files.size === 0) { + return { source: "static-tests", files, warning: `${candidate} has no Istanbul statement counts` }; + } + + return { source: "istanbul", files }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return { source: "static-tests", files: new Map(), warning: `${candidate} could not be read: ${message}` }; + } +} + +export function loadCoverage(rootDir?: string): CoverageLookup { + if (!rootDir) return { source: "static-tests", files: new Map() }; + + const resolvedRoot = path.resolve(rootDir); + for (const candidate of ["coverage/coverage-final.json", "coverage/coverage.json"]) { + const lookup = readCoverageCandidate(resolvedRoot, candidate); + if (lookup) return lookup; + } + + return { source: "static-tests", files: new Map() }; +} diff --git a/src/health/index.ts b/src/health/index.ts new file mode 100644 index 0000000..9bf65b3 --- /dev/null +++ b/src/health/index.ts @@ -0,0 +1,197 @@ +import type { CodebaseGraph, FileMetrics, GraphNode } from "../types/index.js"; +import { loadCoverage, type CoverageSource } from "./coverage.js"; +import { + averageHealth, + componentHealth, + computeCrapScore, + computeMaintainabilityIndex, + computeRiskScore, + roundHealthScore, +} from "./scoring.js"; + +export interface HealthOptions { + minScore?: number; + score?: boolean; +} + +export interface HealthContext { + rootDir?: string; +} + +export type HealthVerdict = "pass" | "fail"; + +export interface HealthFileResult { + file: string; + loc: number; + maintainabilityIndex: number; + crapScore: number; + riskScore: number; + coverage: number; + coverageSource: CoverageSource; + metrics: { + complexity: number; + churn: number; + coupling: number; + blastRadius: number; + fanIn: number; + fanOut: number; + hasTests: boolean; + }; + evidence: string[]; +} + +export interface HealthResult { + score: number; + minScore: number; + verdict: HealthVerdict; + summary: string; + components: { + maintainability: number; + complexity: number; + churn: number; + coupling: number; + coverage: number; + blastRadius: number; + }; + coverage: { + source: CoverageSource; + coveredFiles: number; + totalFiles: number; + warning?: string; + }; + files: HealthFileResult[]; + hotspots: HealthFileResult[]; + actions: Array<{ command: string; reason: string }>; +} + +function nodeMap(graph: CodebaseGraph): Map { + const nodes = new Map(); + for (const node of graph.nodes) { + if (node.type === "file") nodes.set(node.path, node); + } + return nodes; +} + +function fileEvidence(metrics: FileMetrics, coverage: number, loc: number): string[] { + const evidence = [ + `complexity=${metrics.cyclomaticComplexity.toFixed(1)}`, + `churn=${metrics.churn}`, + `coupling=${metrics.coupling.toFixed(2)}`, + `loc=${loc}`, + `coverage=${Math.round(coverage * 100)}%`, + ]; + + if (!metrics.hasTests) evidence.push("hasTests=false"); + if (metrics.blastRadius > 0) evidence.push(`blastRadius=${metrics.blastRadius}`); + return evidence; +} + +function coverageForFile( + filePath: string, + metrics: FileMetrics, + lookup: ReturnType, +): { coverage: number; source: CoverageSource } { + const exactCoverage = lookup.files.get(filePath); + if (lookup.source === "istanbul" && exactCoverage !== undefined) { + return { coverage: exactCoverage, source: "istanbul" }; + } + + return { coverage: metrics.hasTests ? 1 : 0, source: "static-tests" }; +} + +function compareFiles(left: HealthFileResult, right: HealthFileResult): number { + return right.riskScore - left.riskScore + || right.crapScore - left.crapScore + || left.maintainabilityIndex - right.maintainabilityIndex + || left.file.localeCompare(right.file); +} + +function healthSummary(hotspots: readonly HealthFileResult[], score: number, minScore: number, verdict: HealthVerdict): string { + if (hotspots.length === 0) return `Health ${score.toFixed(2)}/${minScore} ${verdict}; no source files found.`; + const [top] = hotspots; + return `Health ${score.toFixed(2)}/${minScore} ${verdict}; top risk ${top.file} (${top.riskScore.toFixed(2)}).`; +} + +export function computeHealth( + graph: CodebaseGraph, + options: HealthOptions = {}, + context: HealthContext = {}, +): HealthResult { + const minScore = options.minScore ?? 70; + const nodes = nodeMap(graph); + const coverageLookup = loadCoverage(context.rootDir); + const files: HealthFileResult[] = []; + + for (const [filePath, metrics] of graph.fileMetrics) { + if (metrics.isTestFile) continue; + + const loc = nodes.get(filePath)?.loc ?? 0; + const fileCoverage = coverageForFile(filePath, metrics, coverageLookup); + const scoreInput = { loc, coverage: fileCoverage.coverage, metrics }; + const maintainabilityIndex = computeMaintainabilityIndex(scoreInput); + const crapScore = computeCrapScore(metrics.cyclomaticComplexity, fileCoverage.coverage); + const riskScore = computeRiskScore(scoreInput); + + files.push({ + file: filePath, + loc, + maintainabilityIndex, + crapScore, + riskScore, + coverage: roundHealthScore(fileCoverage.coverage * 100), + coverageSource: fileCoverage.source, + metrics: { + complexity: metrics.cyclomaticComplexity, + churn: metrics.churn, + coupling: metrics.coupling, + blastRadius: metrics.blastRadius, + fanIn: metrics.fanIn, + fanOut: metrics.fanOut, + hasTests: metrics.hasTests, + }, + evidence: fileEvidence(metrics, fileCoverage.coverage, loc), + }); + } + + const sortedFiles = files.sort(compareFiles); + const components = { + maintainability: averageHealth(sortedFiles.map((file) => file.maintainabilityIndex)), + complexity: componentHealth(sortedFiles.map((file) => file.metrics.complexity), 20), + churn: componentHealth(sortedFiles.map((file) => file.metrics.churn), 20), + coupling: componentHealth(sortedFiles.map((file) => file.metrics.coupling), 20), + coverage: averageHealth(sortedFiles.map((file) => file.coverage)), + blastRadius: componentHealth(sortedFiles.map((file) => file.metrics.blastRadius), 50), + }; + + const score = roundHealthScore( + components.maintainability * 0.35 + + components.coverage * 0.2 + + components.complexity * 0.15 + + components.coupling * 0.1 + + components.churn * 0.1 + + components.blastRadius * 0.1, + ); + const verdict: HealthVerdict = score >= minScore ? "pass" : "fail"; + const hotspots = sortedFiles.slice(0, 10); + const coveredFiles = sortedFiles.filter((file) => file.coverage > 0).length; + + return { + score, + minScore, + verdict, + summary: healthSummary(hotspots, score, minScore, verdict), + components, + coverage: { + source: coverageLookup.source, + coveredFiles, + totalFiles: sortedFiles.length, + warning: coverageLookup.warning, + }, + files: sortedFiles, + hotspots, + actions: hotspots.slice(0, 3).map((file) => ({ + command: `codebase-intelligence file . ${file.file}`, + reason: `risk=${file.riskScore.toFixed(2)}, maintainability=${file.maintainabilityIndex.toFixed(2)}, crap=${file.crapScore.toFixed(2)}`, + })), + }; +} diff --git a/src/health/scoring.ts b/src/health/scoring.ts new file mode 100644 index 0000000..7aa2313 --- /dev/null +++ b/src/health/scoring.ts @@ -0,0 +1,67 @@ +import type { FileMetrics } from "../types/index.js"; + +export interface HealthScoreInput { + loc: number; + coverage: number; + metrics: FileMetrics; +} + +function clamp(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, value)); +} + +function normalize(value: number, ceiling: number): number { + if (ceiling <= 0) return 0; + return clamp(value / ceiling, 0, 1); +} + +export function roundHealthScore(value: number): number { + return Math.round(clamp(value, 0, 100) * 100) / 100; +} + +export function roundCrapScore(value: number): number { + return Math.round(Math.max(0, value) * 100) / 100; +} + +export function computeMaintainabilityIndex(input: HealthScoreInput): number { + const { coverage, loc, metrics } = input; + const penalty = + normalize(metrics.cyclomaticComplexity, 20) * 35 + + normalize(loc, 500) * 20 + + normalize(metrics.churn, 20) * 15 + + normalize(metrics.coupling, 20) * 15 + + normalize(metrics.blastRadius, 50) * 10 + + (1 - clamp(coverage, 0, 1)) * 5; + + return roundHealthScore(100 - penalty); +} + +export function computeCrapScore(complexity: number, coverage: number): number { + const gap = 1 - clamp(coverage, 0, 1); + return roundCrapScore((complexity ** 2 * gap ** 3) + complexity); +} + +export function computeRiskScore(input: HealthScoreInput): number { + const { loc, metrics } = input; + const maintainability = computeMaintainabilityIndex(input); + const risk = + (100 - maintainability) * 0.45 + + normalize(metrics.cyclomaticComplexity, 20) * 20 + + normalize(metrics.churn, 20) * 12 + + normalize(metrics.coupling, 20) * 10 + + normalize(loc, 500) * 8 + + normalize(metrics.blastRadius, 50) * 5; + + return roundHealthScore(risk); +} + +export function averageHealth(values: readonly number[]): number { + if (values.length === 0) return 100; + return roundHealthScore(values.reduce((sum, value) => sum + value, 0) / values.length); +} + +export function componentHealth(values: readonly number[], ceiling: number): number { + if (values.length === 0) return 100; + const avg = values.reduce((sum, value) => sum + value, 0) / values.length; + return roundHealthScore(100 - normalize(avg, ceiling) * 100); +} diff --git a/src/mcp/hints.ts b/src/mcp/hints.ts index 3116f24..6bce21a 100644 --- a/src/mcp/hints.ts +++ b/src/mcp/hints.ts @@ -92,6 +92,11 @@ const OPERATION_HINTS: Record = { "Use map with the same focus to inspect neighboring files and symbols", "Create a drift baseline before using drift findings as a CI gate", ], + health: [ + "Use find_hotspots with metric='risk' to inspect the same risk formula", + "Use file_context on top health hotspots before refactoring", + "Use --min-score in CI when you want health to fail below a threshold", + ], 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 b0113e3..09d931f 100644 --- a/src/mcp/index.ts +++ b/src/mcp/index.ts @@ -180,6 +180,7 @@ export function registerTools(server: McpServer, graph: CodebaseGraph): void { registerOperationTool(server, graph, operations.processes); registerOperationTool(server, graph, operations.codebaseMap); registerOperationTool(server, graph, operations.contentDrift); + registerOperationTool(server, graph, operations.health); registerOperationTool(server, graph, operations.highways); registerOperationTool(server, graph, operations.clusters); registerDerivedMapTool( diff --git a/src/operations/formatters.ts b/src/operations/formatters.ts index 10c764d..50343ef 100644 --- a/src/operations/formatters.ts +++ b/src/operations/formatters.ts @@ -20,6 +20,7 @@ import type { SymbolContextResult, } from "../core/index.js"; import type { HighwaysResult } from "../highways/index.js"; +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"; @@ -716,6 +717,48 @@ export function formatContentDriftText(result: ContentDriftResult): string { return text(lines); } +/** + * Format a health operation result for human CLI output. + */ +export function formatHealthText(result: HealthResult, input: HealthOptions = {}): string { + if (input.score) { + return `Health Score: ${result.score.toFixed(2)} (${result.verdict}, min ${result.minScore})`; + } + + const lines = [ + `Health Score: ${result.score.toFixed(2)} (${result.verdict})`, + "────────────────────────", + `Min score: ${result.minScore}`, + `Coverage: ${result.coverage.source} (${result.coverage.coveredFiles}/${result.coverage.totalFiles})`, + result.coverage.warning ? `Coverage warning: ${result.coverage.warning}` : "", + "", + "Components", + ` Maintainability: ${result.components.maintainability.toFixed(2)}`, + ` Complexity: ${result.components.complexity.toFixed(2)}`, + ` Churn: ${result.components.churn.toFixed(2)}`, + ` Coupling: ${result.components.coupling.toFixed(2)}`, + ` Coverage: ${result.components.coverage.toFixed(2)}`, + ` Blast radius: ${result.components.blastRadius.toFixed(2)}`, + "", + "Top Risk Files", + ].filter(Boolean); + + for (const file of result.hotspots.slice(0, 10)) { + lines.push( + ` ${file.file}`, + ` risk=${file.riskScore.toFixed(2)} maintainability=${file.maintainabilityIndex.toFixed(2)} crap=${file.crapScore.toFixed(2)} coverage=${file.coverage.toFixed(0)}%`, + ` ${file.evidence.join(", ")}`, + ); + } + + if (result.actions.length > 0) { + lines.push("", "Next", ...result.actions.map((action) => ` ${action.command} # ${action.reason}`)); + } + + lines.push("", result.summary); + 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 578131e..1b358b5 100644 --- a/src/operations/index.ts +++ b/src/operations/index.ts @@ -22,6 +22,7 @@ import { } from "../core/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"; @@ -36,6 +37,7 @@ import { formatFileContextText, formatForcesText, formatGroupsText, + formatHealthText, formatHighwaysText, formatHotspotsText, formatImpactText, @@ -67,6 +69,7 @@ export const operationNames = [ "processes", "codebaseMap", "contentDrift", + "health", "highways", "clusters", ] as const; @@ -180,6 +183,11 @@ const contentDriftInputShape = { minScore: z.number().min(0).max(100).optional().describe("Minimum drift score to report (default: 35)"), } satisfies z.ZodRawShape; const contentDriftInputSchema = z.object(contentDriftInputShape).strict(); +const healthInputShape = { + minScore: z.number().min(0).max(100).optional().describe("Minimum health score before the result fails (default: 70)"), + score: z.boolean().optional().describe("Request compact score output on the CLI"), +} satisfies z.ZodRawShape; +const healthInputSchema = z.object(healthInputShape).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"), @@ -211,6 +219,7 @@ type RenameInput = z.infer; type ProcessesInput = z.infer; export type CodebaseMapInput = z.infer; type ContentDriftInput = z.infer; +type HealthInput = z.infer; type HighwaysInput = z.infer; type ClustersInput = z.infer; @@ -398,6 +407,16 @@ export const operations = { run: (graph: CodebaseGraph, input: ContentDriftInput) => computeContentDrift(graph, input), formatText: formatContentDriftText, } satisfies Operation>, + health: { + name: "health", + cliCommand: "health", + mcpTool: "get_health_score", + description: "Compute a CI-gateable codebase health score with per-file maintainability, CRAP, coverage reachability, and risk hotspots. Use when: gating PR quality, finding files that combine complexity/churn/coupling/size, or tracking maintainability over time. Not for: route discipline (use analyze_highways) or naming drift (use detect_content_drift)", + inputShape: healthInputShape, + inputSchema: healthInputSchema, + run: (graph: CodebaseGraph, input: HealthInput, context) => computeHealth(graph, input, context), + formatText: formatHealthText, + } satisfies Operation>, highways: { name: "highways", cliCommand: "highways", diff --git a/tests/health.e2e.test.ts b/tests/health.e2e.test.ts new file mode 100644 index 0000000..c044737 --- /dev/null +++ b/tests/health.e2e.test.ts @@ -0,0 +1,290 @@ +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"; + +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; +} + +interface HealthFilePayload { + file: string; + loc: number; + maintainabilityIndex: number; + crapScore: number; + riskScore: number; + coverage: number; + coverageSource: string; + metrics: Record; + evidence: string[]; +} + +interface HealthPayload { + score: number; + minScore: number; + verdict: "pass" | "fail"; + summary: string; + components: Record; + coverage: { + source: string; + coveredFiles: number; + totalFiles: number; + warning?: string; + }; + files: HealthFilePayload[]; + hotspots: HealthFilePayload[]; + actions: Array<{ command: string; reason: string }>; + 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 isHealthFilePayload(value: unknown): value is HealthFilePayload { + return isRecord(value) + && typeof value.file === "string" + && typeof value.maintainabilityIndex === "number" + && typeof value.crapScore === "number" + && typeof value.riskScore === "number" + && typeof value.coverage === "number" + && Array.isArray(value.evidence); +} + +function isHealthPayload(value: unknown): value is HealthPayload { + return isRecord(value) + && typeof value.score === "number" + && typeof value.minScore === "number" + && (value.verdict === "pass" || value.verdict === "fail") + && isRecord(value.components) + && isRecord(value.coverage) + && Array.isArray(value.files) + && value.files.every(isHealthFilePayload) + && Array.isArray(value.hotspots) + && value.hotspots.every(isHealthFilePayload); +} + +function parsePayload(stdout: string): HealthPayload { + const parsed: unknown = JSON.parse(stdout); + if (!isHealthPayload(parsed)) throw new Error("Expected health JSON object"); + return parsed; +} + +function textPayload(result: unknown): Record { + 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"); + const parsed: unknown = JSON.parse(first.text); + if (!isRecord(parsed)) throw new Error("MCP text content was not a JSON object"); + return parsed; +} + +function withoutRuntimeFields(payload: HealthPayload): Omit { + 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: "Health Test", + GIT_AUTHOR_EMAIL: "32437578+bntvllnt@users.noreply.github.com", + GIT_COMMITTER_NAME: "Health 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 createHealthFixture(): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "cbi-health-")); + + writeFile(root, "tsconfig.json", JSON.stringify({ compilerOptions: { target: "ES2022" } }, null, 2)); + writeFile(root, "src/shared/math.ts", "export function add(left: number, right: number): number {\n return left + right;\n}\n"); + writeFile(root, "src/shared/math.test.ts", "import { add } from \"./math\";\n\nexport const result = add(1, 2);\n"); + writeFile(root, "src/risky/process.ts", [ + "export interface OrderInput {", + " items: number[];", + " vip: boolean;", + " blocked: boolean;", + " retries: number;", + "}", + "", + "export function processOrder(input: OrderInput): number {", + " let score = 0;", + " if (input.blocked) return -1;", + " for (const item of input.items) {", + " if (item > 50) score += item;", + " else if (item > 20) score += 10;", + " else score += 1;", + " if (input.vip && item % 2 === 0) score += 5;", + " if (!input.vip && item < 0) score -= 10;", + " }", + " if (input.retries > 3) score -= 20;", + " if (score > 100) return 100;", + " if (score < 0) return 0;", + " return score;", + "}", + "", + "export const riskVersion = 0;", + "", + ].join("\n")); + writeFile(root, "src/entry-a.ts", "import { processOrder } from \"./risky/process\";\n\nexport const a = processOrder({ items: [1], vip: false, blocked: false, retries: 0 });\n"); + writeFile(root, "src/entry-b.ts", "import { processOrder } from \"./risky/process\";\n\nexport const b = processOrder({ items: [2], vip: true, blocked: false, retries: 1 });\n"); + writeFile(root, "src/entry-c.ts", "import { processOrder } from \"./risky/process\";\n\nexport const c = processOrder({ items: [3], vip: false, blocked: false, retries: 2 });\n"); + + git(root, ["init"]); + git(root, ["config", "user.email", "32437578+bntvllnt@users.noreply.github.com"]); + git(root, ["config", "user.name", "Health Test"]); + commitAll(root, "initial"); + + for (const version of [1, 2, 3]) { + const processPath = path.join(root, "src/risky/process.ts"); + const next = fs.readFileSync(processPath, "utf-8").replace(/riskVersion = \d+/, `riskVersion = ${version}`); + fs.writeFileSync(processPath, next); + commitAll(root, `risk churn ${version}`); + } + + fs.mkdirSync(path.join(root, "coverage"), { recursive: true }); + const coverage = { + [path.join(root, "src/shared/math.ts")]: { s: { "0": 1, "1": 1 } }, + [path.join(root, "src/risky/process.ts")]: { s: { "0": 1, "1": 0, "2": 0, "3": 0 } }, + }; + fs.writeFileSync(path.join(root, "coverage/coverage-final.json"), JSON.stringify(coverage, null, 2)); + + return root; +} + +describe("CH-P2-05 health score chain", () => { + it("scores maintainability, CRAP, risk hotspots, stable exits, and MCP parity", async () => { + const root = createHealthFixture(); + try { + const passRun = await run(["health", root, "--min-score", "0", "--json", "--force"]); + expect(passRun.status).toBe(0); + expect(passRun.stderr).toContain("Parsed"); + + const cliPayload = parsePayload(passRun.stdout); + expect(cliPayload.verdict).toBe("pass"); + expect(cliPayload.minScore).toBe(0); + expect(cliPayload.coverage.source).toBe("istanbul"); + expect(cliPayload.hotspots[0]?.file).toBe("src/risky/process.ts"); + + const risky = cliPayload.files.find((file) => file.file === "src/risky/process.ts"); + const stable = cliPayload.files.find((file) => file.file === "src/shared/math.ts"); + if (!risky || !stable) throw new Error("Expected risky and stable files"); + const riskyComplexity = risky.metrics.complexity; + if (typeof riskyComplexity !== "number") throw new Error("Expected numeric complexity"); + expect(risky.maintainabilityIndex).toBeLessThan(stable.maintainabilityIndex); + expect(risky.crapScore).toBeGreaterThan(riskyComplexity); + expect(risky.riskScore).toBeGreaterThan(stable.riskScore); + expect(risky.coverageSource).toBe("istanbul"); + + const failRun = await run(["health", root, "--min-score", "100", "--json"]); + expect(failRun.status).toBe(1); + expect(parsePayload(failRun.stdout).verdict).toBe("fail"); + + const scoreRun = await run(["health", root, "--score", "--min-score", "0"]); + expect(scoreRun.status).toBe(0); + expect(scoreRun.stdout).toContain("Health Score:"); + + const riskRun = await run(["hotspots", root, "--metric", "risk", "--limit", "1", "--json"]); + expect(riskRun.status).toBe(0); + const riskPayload: unknown = JSON.parse(riskRun.stdout); + if (!isRecord(riskPayload) || !Array.isArray(riskPayload.hotspots)) throw new Error("Expected hotspots payload"); + expect(riskPayload.hotspots[0]).toMatchObject({ path: "src/risky/process.ts" }); + + const transport = new StdioClientTransport({ + command: "node", + args: [cli, root, "--force"], + cwd: repoRoot, + stderr: "pipe", + }); + const client = new Client({ name: "health-e2e", version: "0.1.0" }); + await client.connect(transport); + try { + const result = await client.callTool({ + name: "get_health_score", + arguments: { minScore: 0 }, + }); + const mcpPayloadRecord = textPayload(result); + if (!isHealthPayload(mcpPayloadRecord)) throw new Error("Expected MCP health payload"); + expect(withoutRuntimeFields(mcpPayloadRecord)).toEqual(withoutRuntimeFields(cliPayload)); + expect(mcpPayloadRecord).toHaveProperty("nextSteps"); + } finally { + await client.close(); + await transport.close(); + } + } 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 f61d7eb..5da240d 100644 --- a/tests/mcp-tools.test.ts +++ b/tests/mcp-tools.test.ts @@ -37,6 +37,14 @@ function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } +function recordArray(value: unknown, label: string): Record[] { + expect(Array.isArray(value)).toBe(true); + if (!Array.isArray(value)) throw new Error(`Expected ${label} array`); + const records = value.filter(isRecord); + expect(records).toHaveLength(value.length); + return records; +} + describe("Tool 1: codebase_overview", () => { it("returns totalFiles, modules, topDependedFiles, metrics, nextSteps", async () => { const r = await callTool("codebase_overview"); @@ -153,7 +161,7 @@ describe("Tool 3: get_dependents", () => { describe("Tool 4: find_hotspots", () => { const metrics = [ "coupling", "pagerank", "fan_in", "fan_out", "betweenness", - "tension", "churn", "complexity", "blast_radius", "coverage", + "tension", "churn", "complexity", "blast_radius", "coverage", "risk", ] as const; for (const metric of metrics) { @@ -496,7 +504,23 @@ describe("Tool 19: detect_content_drift", () => { }); }); -describe("Tool 20: analyze_highways", () => { +describe("Tool 20: get_health_score", () => { + it("returns a gateable health score with file evidence", async () => { + const r = await callTool("get_health_score", { minScore: 0 }); + expect(r).toHaveProperty("score"); + expect(r).toHaveProperty("verdict", "pass"); + expect(r).toHaveProperty("components"); + expect(r).toHaveProperty("files"); + expect(r).toHaveProperty("hotspots"); + expect(r).toHaveProperty("nextSteps"); + const files = recordArray(r.files, "health files"); + expect(files[0]).toHaveProperty("maintainabilityIndex"); + expect(files[0]).toHaveProperty("crapScore"); + expect(files[0]).toHaveProperty("riskScore"); + }); +}); + +describe("Tool 21: analyze_highways", () => { it("returns highway opportunities envelope", async () => { const r = await callTool("analyze_highways", { operation: "get", minRoutes: 2 }); expect(r).toHaveProperty("totalRoutes"); @@ -508,7 +532,7 @@ describe("Tool 20: analyze_highways", () => { }); }); -describe("Tool 21: get_clusters", () => { +describe("Tool 22: get_clusters", () => { it("returns community-detected clusters", async () => { const r = await callTool("get_clusters"); expect(r).toHaveProperty("clusters"); @@ -590,6 +614,7 @@ describe("MCP Resources", () => { expect(availableTools).toContain("get_scope_graph"); expect(availableTools).toContain("get_context_pack"); expect(availableTools).toContain("detect_content_drift"); + expect(availableTools).toContain("get_health_score"); 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 e38180d..77ad718 100644 --- a/tests/operation-registry.e2e.test.ts +++ b/tests/operation-registry.e2e.test.ts @@ -343,6 +343,14 @@ describe("operation registry chained parity", () => { {}, cachedRun, ); + expectCliMatchesRegistry( + operations.health, + { minScore: 0, score: false }, + ["health", getFixtureSrcPath(), "--min-score", "0"], + codebaseGraph, + { rootDir: getFixtureSrcPath() }, + cachedRun, + ); expectCliMatchesRegistry( operations.highways, { operation: "get", minRoutes: 2 }, @@ -489,6 +497,14 @@ describe("operation registry chained parity", () => { {}, cachedRun, ); + expectCliTextMatchesFormatter( + operations.health, + { minScore: 0, score: true }, + ["health", getFixtureSrcPath(), "--score", "--min-score", "0"], + codebaseGraph, + { rootDir: getFixtureSrcPath() }, + cachedRun, + ); expectCliTextMatchesFormatter( operations.highways, { operation: "get", minRoutes: 2 }, @@ -771,6 +787,14 @@ describe("operation registry chained parity", () => { codebaseGraph, mcp, ); + await expectMcpMatchesRegistry( + operations.health, + { minScore: 0 }, + { minScore: 0 }, + codebaseGraph, + mcp, + { rootDir: getFixtureSrcPath() }, + ); await expectMcpMatchesRegistry( operations.highways, { operation: "get", minRoutes: 2 }, diff --git a/tests/operation-registry.test.ts b/tests/operation-registry.test.ts index a8cc724..4cb4b56 100644 --- a/tests/operation-registry.test.ts +++ b/tests/operation-registry.test.ts @@ -41,6 +41,7 @@ const expectedOperations: Array<{ { name: "processes", cliCommand: "processes", mcpTool: "get_processes", inputKeys: ["entryPoint", "limit"], sampleInput: { entryPoint: "main" } }, { 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: "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 } }, ]; @@ -115,7 +116,7 @@ describe("operation registry", () => { type: "object", properties: { metric: { - enum: expect.arrayContaining(["coupling", "blast_radius"]), + enum: expect.arrayContaining(["coupling", "blast_radius", "risk"]), }, limit: { type: "integer", diff --git a/tools/verify-cli-real-codebases.mjs b/tools/verify-cli-real-codebases.mjs index 3f95995..e8b84c5 100644 --- a/tools/verify-cli-real-codebases.mjs +++ b/tools/verify-cli-real-codebases.mjs @@ -279,6 +279,7 @@ function findBannedPath(value, keyPath = []) { if (typeof value === "string") { const normalized = value.replaceAll("\\", "/"); if (normalized.includes("package.json:")) return ""; + if (!normalized.includes("/") && !normalized.startsWith(".")) return ""; const segments = normalized.split("/"); if (segments.includes(".claude") && segments.includes("worktrees")) return value; for (const segment of segments) { @@ -372,12 +373,14 @@ for (const inputTarget of targets) { return `${result.totalAffected} affected`; }); - for (const command of ["modules", "forces", "dead-exports", "opportunities", "groups", "processes", "map", "drift", "highways", "clusters"]) { + for (const command of ["modules", "forces", "dead-exports", "opportunities", "groups", "processes", "map", "drift", "health", "highways", "clusters"]) { record(`${target.name}: ${command}`, () => { const args = command === "highways" ? [command, target.path, "--operation", "get", "--min-routes", "3"] : command === "map" ? [command, target.path, "--focus", target.symbol, "--context-budget", "600"] + : command === "health" + ? [command, target.path, "--min-score", "0"] : [command, target.path]; const result = json(args); if (!result || typeof result !== "object") throw new Error("invalid JSON object"); @@ -387,6 +390,9 @@ for (const inputTarget of targets) { if (command === "drift" && (result.mode !== "report-only" || !Array.isArray(result.findings))) { throw new Error("bad drift payload"); } + if (command === "health" && (typeof result.score !== "number" || !Array.isArray(result.hotspots))) { + throw new Error("bad health payload"); + } return "json ok"; }); }