From a10626fcab51763dc0a8670ec1ca28bc485911a2 Mon Sep 17 00:00:00 2001 From: bntvllnt <32437578+bntvllnt@users.noreply.github.com> Date: Wed, 1 Jul 2026 13:07:11 +0200 Subject: [PATCH] feat: add codebase map context packs --- README.md | 9 +- docs/architecture.md | 8 +- docs/cli-reference.md | 22 +- docs/data-model.md | 66 +++ docs/mcp-tools.md | 41 +- llms-full.txt | 81 ++- llms.txt | 5 +- roadmap.md | 11 +- src/cli.ts | 40 ++ src/map/index.ts | 723 +++++++++++++++++++++++++++ src/mcp/hints.ts | 5 + src/mcp/index.ts | 80 ++- src/operations/formatters.ts | 97 ++++ src/operations/index.ts | 22 + tests/map.e2e.test.ts | 222 ++++++++ tests/mcp-tools.test.ts | 94 +++- tests/operation-registry.e2e.test.ts | 23 + tests/operation-registry.test.ts | 4 +- tools/verify-cli-real-codebases.mjs | 9 +- 19 files changed, 1525 insertions(+), 37 deletions(-) create mode 100644 src/map/index.ts create mode 100644 tests/map.e2e.test.ts diff --git a/README.md b/README.md index df2b84c..d8b5db3 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,7 @@ claude mcp add -s user -t stdio codebase-intelligence -- npx -y codebase-intelli ## Features -- **20 CLI commands** for architecture analysis, dependency impact, duplicate families, Highways convergence, improvement opportunities, dead code detection, search, CI rules, and agent setup +- **21 CLI commands** for architecture analysis, dependency impact, duplicate families, focused map/context packs, 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[]`) @@ -65,10 +65,11 @@ claude mcp add -s user -t stdio codebase-intelligence -- npx -y codebase-intelli - **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 - **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 19 MCP tools, 2 prompts, and 3 resources +- **MCP parity (secondary)** — same analysis and rules gate available as 22 MCP tools, 2 prompts, and 3 resources ## Installation @@ -111,6 +112,7 @@ codebase-intelligence [options] | `impact` | Symbol-level blast radius | | `rename` | Reference discovery for rename planning | | `processes` | Entry-point execution flow tracing | +| `map` | Focused codebase graph + token-bounded context pack | | `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 | @@ -129,6 +131,9 @@ codebase-intelligence [options] | `--min-tokens ` | Minimum duplicate token size for `duplicates` | | `--skip-local` | Ignore duplicate families confined to one file | | `--trace ` | Return token evidence for one duplicate family | +| `--focus ` | Focus `map` on one symbol, file, or scope | +| `--context-budget ` | Bound `map` context pack size | +| `--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 0446f1f..b5b5274 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) - | 19 tools, 2 prompts, | 20 commands with text + JSON + | 22 tools, 2 prompts, | 21 commands with text + JSON | 3 resources for LLMs | output for humans and CI ``` @@ -47,8 +47,9 @@ src/ duplication/index.ts <- Duplicate family detection + trace evidence config/index.ts <- Config discovery + zod validation rules/index.ts <- Rules engine + registry (check command + MCP check tool) + map/index.ts <- Focused codebase maps + token-bounded context packs highways/index.ts <- Repeated route convergence + bypass/cowpath/synthesis evidence - mcp/index.ts <- 19 MCP tools for LLM integration + mcp/index.ts <- 22 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 @@ -83,7 +84,7 @@ analyzeGraph(builtGraph, parsedFiles) } startMcpServer(codebaseGraph) - -> stdio MCP server with 19 tools, 2 prompts, 3 resources + -> stdio MCP server with 22 tools, 2 prompts, 3 resources runOperation(operation, codebaseGraph, input, context) -> { ok: true, data } | { ok: false, error, data? } @@ -98,6 +99,7 @@ runOperation(operation, codebaseGraph, input, context) - **Dead-code gates**: `check` can opt into unused-file, unused-type, unused-member, and dependency hygiene rules. These rules use graph metrics plus local TypeScript AST facts and emit confidence/evidence in JSON and SARIF; dependency hygiene is scoped to the nearest package/workspace manifest. - **Suppression hygiene**: `check` reports active and stale `ci-ignore-*` / `@expected-unused` suppressions, emits stale suppression findings via `no-stale-suppressions`, and treats `@public` exported type declarations as intentional public API while `@internal` stays checkable. - **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. - **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 77e7674..7f26e57 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -1,6 +1,6 @@ # CLI Reference -20 commands for terminal and CI use. The 18 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. +21 commands for terminal and CI use. The 19 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 @@ -168,6 +168,18 @@ codebase-intelligence processes [--entry ] [--limit ] [--json] [ **Output:** processes with entry point, steps, depth, modules touched. +### map + +Focused codebase graph plus token-bounded context pack for agents. + +```bash +codebase-intelligence map [--focus ] [--scope ] [--depth ] [--format ] [--context-budget ] [--json] [--force] +``` + +**Formats:** `markdown` (default), `json`, `dot`, `graphml`. + +**Output:** overview, focus node, nodes, edges, evidence, contextPack (ranked files, symbols, tests, token estimate/budget), and summary. JSON mode includes stable evidence IDs (`evidence-*`) and edge IDs (`edge-*`) for agent-safe references. + ### highways Find repeated routes that should converge on one canonical operation path. @@ -237,20 +249,22 @@ codebase-intelligence init [path] [--agents ] [--all] [--skill] [--gitigno | `--min-tokens ` | duplicates | Minimum function body token count (default: 30) | | `--skip-local` | duplicates | Ignore families confined to one file | | `--trace ` | duplicates, highways | Return evidence for one family/opportunity id | -| `--scope ` | changes | Git diff scope: staged, unstaged, all | -| `--depth ` | dependents | Max traversal depth (default: 2) | +| `--scope ` | changes, map | Git diff scope for `changes`; directory/module scope for `map` | +| `--depth ` | dependents, map | Max traversal depth | | `--cohesion ` | forces | Min cohesion threshold (default: 0.6) | | `--tension ` | forces | Min tension threshold (default: 0.3) | | `--escape ` | forces | Min escape velocity threshold (default: 0.5) | | `--module ` | dead-exports | Filter by module path | | `--entry ` | processes | Filter by entry point name | +| `--focus ` | map | Focus on one symbol, file, or scope | +| `--context-budget ` | map | Approximate token budget for context pack | | `--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 | | `--propose` | highways | Include reroute proposal metadata | | `--min-files ` | clusters | Min files per cluster | | `--no-dry-run` | rename | Actually perform the rename (default: dry run) | -| `--format ` | check | Output format: text, json, sarif | +| `--format ` | map, check | Map export: markdown, json, dot, graphml. Check output: text, json, sarif | | `--fail-on ` | check | Severity that fails the gate: error, warn, never | | `--gate ` | check | Gate mode: all, new-only | | `--base ` | check | Base git ref for new-only gating | diff --git a/docs/data-model.md b/docs/data-model.md index 77d4429..16b3ca6 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -171,6 +171,72 @@ CacheFacts { } ``` +## Codebase Map Result + +`map --json`, MCP `get_codebase_map`, `get_scope_graph`, and `get_context_pack` +return deterministic graph/context data with stable evidence IDs. + +```typescript +CodebaseMapResult { + overview: { + focus?: string + scope?: string + depth: number + contextBudget: number + totalNodes: number + totalEdges: number + totalEvidence: number + } + focus?: CodebaseMapNode + nodes: CodebaseMapNode[] + edges: CodebaseMapEdge[] + evidence: CodebaseMapEvidence[] + contextPack: CodebaseContextPack + summary: string +} + +CodebaseMapNode { + id: string + kind: "file" | "symbol" | "test" | "scope" + label: string + file?: string + symbol?: string + type?: string + loc?: number + module?: string + score: number + evidenceIds: string[] +} + +CodebaseMapEdge { + id: string // edge- + kind: "calls" | "contains" | "imports" | "tests" + from: string + to: string + label: string + weight: number + evidenceIds: string[] +} + +CodebaseMapEvidence { + id: string // evidence- + kind: "focus" | "metric" | "call" | "contains" | "import" | "test" | "scope" + summary: string + file?: string + symbol?: string +} + +CodebaseContextPack { + tokenBudget: number + tokenEstimate: number + rankedFiles: Array<{ path: string; rank: number; reason: string; tokenEstimate: number; evidenceIds: string[] }> + rankedSymbols: Array<{ file: string; symbol: string; rank: number; reason: string; tokenEstimate: number; evidenceIds: string[] }> + tests: Array<{ path: string; covers: string; rank: number; reason: string; tokenEstimate: number; evidenceIds: string[] }> + evidenceIds: string[] + nextCommands: 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 e9ff6d6..1c69a94 100644 --- a/docs/mcp-tools.md +++ b/docs/mcp-tools.md @@ -1,6 +1,6 @@ # MCP Tools Reference -19 tools available via MCP stdio. +22 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. @@ -169,7 +169,37 @@ Trace execution flows from entry points through the call graph. **Use when:** "How does this app start?" "Trace request flow." "What are the entry points?" **Not for:** Static file dependencies (use get_dependents). -## 17. analyze_highways +## 17. get_codebase_map + +Build a deterministic focused graph plus context pack. + +**Input:** `{ focus?: string, scope?: string, depth?: number, format?: "json" | "markdown" | "dot" | "graphml", contextBudget?: number }` +**Returns:** overview, focus node, nodes, edges, evidence, contextPack, summary + +**Use when:** Preparing an LLM task context, visualizing symbol/file neighborhoods, or exporting a structured codebase map. +**Not for:** Raw execution flow listing (use get_processes) or route convergence analysis (use analyze_highways). + +## 18. get_scope_graph + +File/scope/test view derived from `get_codebase_map`. + +**Input:** same as `get_codebase_map` +**Returns:** overview, focus node, file/test/scope nodes, imports/tests edges, evidence, summary + +**Use when:** An agent needs topology without symbol-level detail. +**Not for:** Full context selection (use get_context_pack). + +## 19. get_context_pack + +Token-bounded context pack derived from `get_codebase_map`. + +**Input:** same as `get_codebase_map` +**Returns:** tokenBudget, tokenEstimate, rankedFiles, rankedSymbols, tests, evidenceIds, nextCommands + +**Use when:** Passing compact, ranked code context to an LLM. +**Not for:** Visual graph export (use get_codebase_map). + +## 20. analyze_highways Detect repeated entry-to-sink routes that should converge on one canonical operation path. @@ -179,7 +209,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). -## 18. get_clusters +## 21. get_clusters Community-detected clusters of related files. @@ -189,7 +219,7 @@ 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). -## 19. check +## 22. check Run the configurable rules engine and gate on findings. @@ -237,6 +267,9 @@ Rules: `no-comments` (off by default), `no-circular-deps` (error), `no-dead-expo | "What changed?" | `detect_changes` | | "Find all references to X" | `rename_symbol` | | "How does data flow through the app?" | `get_processes` | +| "What context should I give an LLM for this task?" | `get_context_pack` | +| "Show a focused codebase graph" | `get_codebase_map` | +| "Show only file/scope topology" | `get_scope_graph` | | "Which routes bypass canonical dataflow?" | `analyze_highways` | | "What files naturally belong together?" | `get_clusters` | | "What are the main areas?" | `get_groups` | diff --git a/llms-full.txt b/llms-full.txt index 1b1422d..e1edc6f 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: 19 tools, 2 prompts, 3 resources for LLM agents - | CLI: 20 commands with formatted + JSON output for humans/CI + | MCP: 22 tools, 2 prompts, 3 resources for LLM agents + | CLI: 21 commands with formatted + JSON output for humans/CI ``` ## Module Map @@ -51,8 +51,9 @@ src/ operations/formatters.ts <- Result-object text formatters for CLI commands parser/duplication.ts <- Function-body clone token extraction duplication/index.ts <- Duplicate family detection + trace evidence + map/index.ts <- Focused codebase maps + token-bounded context packs highways/index.ts <- Repeated route convergence + bypass/cowpath/synthesis evidence - mcp/index.ts <- 19 MCP tools for LLM integration + mcp/index.ts <- 22 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 @@ -213,6 +214,53 @@ ModuleMetrics { Graph stats include `analysisMode` (`full-program` or `ast-only`), `callGraphPrecision` (`type-resolved` or `syntax-only`), and `fullProgramFileLimit`. In `ast-only` mode, file/import/export/dependency metrics remain available while type-resolved call graph detail is reduced. +## Codebase Map Result + +`map --json`, MCP `get_codebase_map`, `get_scope_graph`, and `get_context_pack` +return deterministic graph/context data with stable IDs. + +```typescript +CodebaseMapResult { + overview: { focus?: string; scope?: string; depth: number; contextBudget: number; totalNodes: number; totalEdges: number; totalEvidence: number } + focus?: CodebaseMapNode + nodes: CodebaseMapNode[] + edges: CodebaseMapEdge[] + evidence: CodebaseMapEvidence[] + contextPack: CodebaseContextPack + summary: string +} + +CodebaseMapNode { + id: string + kind: "file" | "symbol" | "test" | "scope" + label: string + file?: string + symbol?: string + score: number + evidenceIds: string[] +} + +CodebaseMapEdge { + id: string + kind: "calls" | "contains" | "imports" | "tests" + from: string + to: string + label: string + weight: number + evidenceIds: string[] +} + +CodebaseContextPack { + tokenBudget: number + tokenEstimate: number + rankedFiles: Array<{ path: string; rank: number; reason: string; tokenEstimate: number; evidenceIds: string[] }> + rankedSymbols: Array<{ file: string; symbol: string; rank: number; reason: string; tokenEstimate: number; evidenceIds: string[] }> + tests: Array<{ path: string; covers: string; rank: number; reason: string; tokenEstimate: number; evidenceIds: string[] }> + evidenceIds: string[] + nextCommands: string[] +} +``` + --- # Metrics Reference @@ -262,7 +310,7 @@ The most dangerous files have: high churn + high coupling + low coverage. # MCP Tools Reference -19 tools available via MCP stdio. +22 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. @@ -312,13 +360,22 @@ Reference finder for rename planning. Input: `{ oldName: string, newName: string ## 16. get_processes Entry point execution flows. Input: `{ entryPoint?: string, limit?: number }`. Returns: processes with steps and depth. -## 17. analyze_highways +## 17. get_codebase_map +Focused codebase graph. Input: `{ focus?: string, scope?: string, depth?: number, format?: "json" | "markdown" | "dot" | "graphml", contextBudget?: number }`. Returns: overview, focus node, nodes, edges, evidence, contextPack, summary. + +## 18. get_scope_graph +File/scope/test topology from the same map result. Input: same as `get_codebase_map`. Returns: overview, focus node, file/test/scope nodes, imports/tests edges, evidence, summary. + +## 19. get_context_pack +Token-bounded context pack from the same map result. Input: same as `get_codebase_map`. Returns: tokenBudget, tokenEstimate, rankedFiles, rankedSymbols, tests, evidenceIds, nextCommands. + +## 20. 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. -## 18. get_clusters +## 21. get_clusters Community-detected file clusters. Input: `{ minFiles?: number }`. Returns: clusters with cohesion. -## 19. check +## 22. 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 @@ -342,6 +399,8 @@ Rules-engine gate. Input: `{}`. Returns: pass/warn/fail verdict, findings, suppr | What changed? | detect_changes | | Find all references to X | rename_symbol | | How does data flow? | get_processes | +| What context should I give an LLM for this task? | get_context_pack | +| Show a focused codebase graph | get_codebase_map | | Which routes bypass canonical dataflow? | analyze_highways | | What files naturally belong together? | get_clusters | | What rule violations exist? | check | @@ -350,7 +409,7 @@ Rules-engine gate. Input: `{}`. Returns: pass/warn/fail verdict, findings, suppr # CLI Reference -20 commands — 18 analysis commands (full parity with MCP tools), `check` for CI rules, and `init` for agent adoption. +21 commands — 19 analysis commands (full parity with MCP tools), `check` for CI rules, and `init` for agent adoption. ## Commands @@ -450,6 +509,12 @@ codebase-intelligence processes [--entry ] [--limit ] [--json] [ ``` Entry point execution flows through the call graph. +### map +```bash +codebase-intelligence map [--focus ] [--scope ] [--depth ] [--format ] [--context-budget ] [--json] [--force] +``` +Focused codebase graph plus token-bounded context pack. Formats: markdown, json, dot, graphml. + ### highways ```bash codebase-intelligence highways [--operation ] [--shape ] [--min-routes ] [--propose] [--trace ] [--json] [--force] diff --git a/llms.txt b/llms.txt index 3c6c49d..12233b0 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): 19 MCP tools — inputs, outputs, use cases, selection guide +- [MCP Tools](docs/mcp-tools.md): 22 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) — 20 commands (18 analysis with MCP parity + `check` + `init`): +CLI mode (humans/CI) — 21 commands (19 analysis with MCP parity + `check` + `init`): ```bash codebase-intelligence overview ./src codebase-intelligence hotspots ./src --metric coupling @@ -35,6 +35,7 @@ codebase-intelligence symbol ./src parseCodebase codebase-intelligence impact ./src getUserById codebase-intelligence rename ./src oldName newName codebase-intelligence processes ./src --entry main +codebase-intelligence map ./src --focus getUserById --context-budget 800 --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 8da74eb..8c8a309 100644 --- a/roadmap.md +++ b/roadmap.md @@ -434,11 +434,18 @@ codebase graph | Test proximity | Important route has no proof nearby | | Runtime surface | User-facing entrypoint route maps | -**To do:** +**Status:** CH-P2-03 shipped in the canary train. + +**Shipped:** - CLI: add `map ` with `--focus`, `--scope`, `--depth`, `--format json|dot|graphml|markdown`, `--context-budget`, `--json`. - MCP: add `get_codebase_map`, `get_scope_graph`, `get_context_pack`. -- Emit `overview`, `focus`, `route`, `contextPack`, and `evidence` shapes. +- Emit deterministic `overview`, `focus`, `nodes`, `edges`, `contextPack`, `evidence`, and stable evidence/edge IDs. +- Add chained CLI + real stdio MCP coverage for focused symbol maps and token-bounded ranked files/symbols/tests. + +**Remaining:** + +- Add route/type/owner nodes after the upstream analyzers expose those facts as structured graph data. - Add optional 2D/3D graph export/viewer only after structured graph outputs are useful. ### Content Drift diff --git a/src/cli.ts b/src/cli.ts index a89e0ea..ea1184d 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -202,6 +202,14 @@ interface ProcessesOptions extends CliCommandOptions { limit?: string; } +interface CodebaseMapOptions extends CliCommandOptions { + focus?: string; + scope?: string; + depth?: string; + format?: string; + contextBudget?: string; +} + interface HighwaysOptions extends CliCommandOptions { operation?: string; shape?: string; @@ -651,6 +659,38 @@ program outputOperationText(operations.processes, result, input); }); +// ── Subcommand: map ──────────────────────────────────────── + +program + .command("map") + .description("Focused codebase graph plus token-bounded context pack") + .argument("", "Path to TypeScript codebase") + .option("--focus ", "Symbol, file, or scope to focus on") + .option("--scope ", "Directory/module scope to include") + .option("--depth ", "Graph traversal depth (default: 1)") + .option("--format ", "Output format: markdown, json, dot, or graphml") + .option("--context-budget ", "Approximate context pack token budget (default: 1200)") + .option("--json", "Output as JSON") + .option("--force", "Re-index even if HEAD unchanged") + .action((targetPath: string, options: CodebaseMapOptions) => { + const input = parseCliOperationInput(operations.codebaseMap, { + focus: options.focus, + scope: options.scope, + depth: optionalIntegerInput(options.depth), + format: options.format, + contextBudget: optionalIntegerInput(options.contextBudget), + }); + const { graph } = loadGraph(targetPath, forceOption(options)); + const result = runCliOperation(operations.codebaseMap, graph, input); + + if (options.json || input.format === "json") { + outputJson(result); + return; + } + + outputOperationText(operations.codebaseMap, result, input); + }); + // ── Subcommand: highways ─────────────────────────────────── program diff --git a/src/map/index.ts b/src/map/index.ts new file mode 100644 index 0000000..d8981d5 --- /dev/null +++ b/src/map/index.ts @@ -0,0 +1,723 @@ +import { createHash } from "node:crypto"; +import type { CallEdge, CodebaseGraph, FileMetrics, GraphEdge, SymbolNode } from "../types/index.js"; + +export const CODEBASE_MAP_FORMATS = ["json", "markdown", "dot", "graphml"] as const; + +export type CodebaseMapFormat = typeof CODEBASE_MAP_FORMATS[number]; +export type CodebaseMapNodeKind = "file" | "symbol" | "test" | "scope"; +export type CodebaseMapEdgeKind = "calls" | "contains" | "imports" | "tests"; +export type CodebaseMapEvidenceKind = "focus" | "metric" | "call" | "contains" | "import" | "test" | "scope"; + +export interface CodebaseMapOptions { + focus?: string; + scope?: string; + depth?: number; + format?: CodebaseMapFormat; + contextBudget?: number; +} + +export interface CodebaseMapEvidence { + id: string; + kind: CodebaseMapEvidenceKind; + summary: string; + file?: string; + symbol?: string; +} + +export interface CodebaseMapNode { + id: string; + kind: CodebaseMapNodeKind; + label: string; + file?: string; + symbol?: string; + type?: string; + loc?: number; + module?: string; + score: number; + evidenceIds: string[]; +} + +export interface CodebaseMapEdge { + id: string; + kind: CodebaseMapEdgeKind; + from: string; + to: string; + label: string; + weight: number; + evidenceIds: string[]; +} + +export interface CodebaseMapOverview { + focus?: string; + scope?: string; + depth: number; + contextBudget: number; + totalNodes: number; + totalEdges: number; + totalEvidence: number; +} + +export interface ContextPackFile { + path: string; + rank: number; + reason: string; + tokenEstimate: number; + evidenceIds: string[]; +} + +export interface ContextPackSymbol { + file: string; + symbol: string; + rank: number; + reason: string; + tokenEstimate: number; + evidenceIds: string[]; +} + +export interface ContextPackTest { + path: string; + covers: string; + rank: number; + reason: string; + tokenEstimate: number; + evidenceIds: string[]; +} + +export interface CodebaseContextPack { + tokenBudget: number; + tokenEstimate: number; + rankedFiles: ContextPackFile[]; + rankedSymbols: ContextPackSymbol[]; + tests: ContextPackTest[]; + evidenceIds: string[]; + nextCommands: string[]; +} + +export interface CodebaseMapResult { + overview: CodebaseMapOverview; + focus?: CodebaseMapNode; + nodes: CodebaseMapNode[]; + edges: CodebaseMapEdge[]; + evidence: CodebaseMapEvidence[]; + contextPack: CodebaseContextPack; + summary: string; +} + +interface EvidenceStore { + byId: Map; +} + +interface MapState { + graph: CodebaseGraph; + evidence: EvidenceStore; + nodes: Map; + edges: Map; +} + +interface FocusMatch { + kind: "symbol" | "file" | "scope"; + symbol?: SymbolNode; + file?: string; + scope?: string; +} + +interface ContextCandidate { + category: "file" | "symbol" | "test"; + priority: number; + tokenEstimate: number; + evidenceIds: string[]; + file?: string; + symbol?: string; + covers?: string; + reason: string; +} + +const DEFAULT_DEPTH = 1; +const DEFAULT_CONTEXT_BUDGET = 1200; + +function hashId(parts: readonly string[]): string { + return createHash("sha1").update(parts.join("\0")).digest("hex").slice(0, 10); +} + +function evidenceKey(kind: CodebaseMapEvidenceKind, summary: string, file?: string, symbol?: string): string { + return hashId([kind, summary, file ?? "", symbol ?? ""]); +} + +function addEvidence( + store: EvidenceStore, + kind: CodebaseMapEvidenceKind, + summary: string, + file?: string, + symbol?: string, +): string { + const id = `evidence-${evidenceKey(kind, summary, file, symbol)}`; + if (!store.byId.has(id)) { + store.byId.set(id, { id, kind, summary, file, symbol }); + } + return id; +} + +function addEvidenceId(ids: string[], id: string): string[] { + return ids.includes(id) ? ids : [...ids, id]; +} + +function fileNodeId(file: string): string { + return `file:${file}`; +} + +function symbolNodeId(symbol: SymbolNode): string { + return `symbol:${symbol.id}`; +} + +function scopeNodeId(scope: string): string { + return `scope:${scope}`; +} + +function edgeId(kind: CodebaseMapEdgeKind, from: string, to: string, label: string): string { + return `edge-${hashId([kind, from, to, label])}`; +} + +function normalizePath(value: string): string { + return value.replace(/\\/g, "/").replace(/^(src|lib|app)\//, ""); +} + +function normalizeScope(value: string): string { + const normalized = normalizePath(value).replace(/\/+$/, ""); + return normalized ? `${normalized}/` : ""; +} + +function graphFileNode(graph: CodebaseGraph, file: string): CodebaseGraph["nodes"][number] | undefined { + return graph.nodes.find((node) => node.type === "file" && node.id === file); +} + +function fileScore(metrics: FileMetrics | undefined, isFocus: boolean): number { + const base = metrics + ? (metrics.pageRank * 1000) + (metrics.fanIn * 20) + (metrics.fanOut * 10) + metrics.blastRadius + : 0; + return isFocus ? base + 10_000 : base; +} + +function symbolScore(symbol: SymbolNode, graph: CodebaseGraph, isFocus: boolean): number { + const metrics = graph.symbolMetrics.get(symbol.id); + const base = metrics + ? (metrics.pageRank * 1000) + (metrics.fanIn * 20) + (metrics.fanOut * 10) + metrics.betweenness + : symbol.loc; + return isFocus ? base + 9_000 : base; +} + +function addFileNode(state: MapState, file: string, evidenceId: string, isFocus = false): CodebaseMapNode { + const id = fileNodeId(file); + const existing = state.nodes.get(id); + if (existing) { + existing.evidenceIds = addEvidenceId(existing.evidenceIds, evidenceId); + existing.score = Math.max(existing.score, fileScore(state.graph.fileMetrics.get(file), isFocus)); + return existing; + } + + const metrics = state.graph.fileMetrics.get(file); + const graphNode = graphFileNode(state.graph, file); + const node: CodebaseMapNode = { + id, + kind: metrics?.isTestFile ? "test" : "file", + label: file, + file, + loc: graphNode?.loc, + module: graphNode?.module, + score: fileScore(metrics, isFocus), + evidenceIds: [evidenceId], + }; + state.nodes.set(id, node); + return node; +} + +function addSymbolNode(state: MapState, symbol: SymbolNode, evidenceId: string, isFocus = false): CodebaseMapNode { + const id = symbolNodeId(symbol); + const existing = state.nodes.get(id); + if (existing) { + existing.evidenceIds = addEvidenceId(existing.evidenceIds, evidenceId); + existing.score = Math.max(existing.score, symbolScore(symbol, state.graph, isFocus)); + return existing; + } + + const node: CodebaseMapNode = { + id, + kind: "symbol", + label: symbol.name, + file: symbol.file, + symbol: symbol.name, + type: symbol.type, + loc: symbol.loc, + score: symbolScore(symbol, state.graph, isFocus), + evidenceIds: [evidenceId], + }; + state.nodes.set(id, node); + return node; +} + +function addScopeNode(state: MapState, scope: string): CodebaseMapNode { + const id = scopeNodeId(scope); + const evidenceId = addEvidence(state.evidence, "scope", `Scope filter ${scope}`, scope); + const existing = state.nodes.get(id); + if (existing) { + existing.evidenceIds = addEvidenceId(existing.evidenceIds, evidenceId); + return existing; + } + const node: CodebaseMapNode = { + id, + kind: "scope", + label: scope, + file: scope, + score: 8_000, + evidenceIds: [evidenceId], + }; + state.nodes.set(id, node); + return node; +} + +function addEdge(state: MapState, kind: CodebaseMapEdgeKind, from: string, to: string, label: string, weight: number, evidenceId: string): void { + const id = edgeId(kind, from, to, label); + const existing = state.edges.get(id); + if (existing) { + existing.evidenceIds = addEvidenceId(existing.evidenceIds, evidenceId); + existing.weight = Math.max(existing.weight, weight); + return; + } + state.edges.set(id, { + id, + kind, + from, + to, + label, + weight, + evidenceIds: [evidenceId], + }); +} + +function findSymbol(graph: CodebaseGraph, focus: string): SymbolNode | undefined { + const normalizedFocus = normalizePath(focus); + const candidates = graph.symbolNodes + .filter((symbol) => + symbol.id === focus + || symbol.name === focus + || `${symbol.file}::${symbol.name}` === normalizedFocus + ) + .sort((left, right) => left.file.localeCompare(right.file) || left.name.localeCompare(right.name)); + return candidates[0]; +} + +function findFile(graph: CodebaseGraph, focus: string): string | undefined { + const normalizedFocus = normalizePath(focus); + if (graph.fileMetrics.has(normalizedFocus)) return normalizedFocus; + return [...graph.fileMetrics.keys()] + .filter((file) => file.endsWith(`/${normalizedFocus}`) || file === normalizedFocus) + .sort()[0]; +} + +function findFocus(graph: CodebaseGraph, options: Required>): FocusMatch | undefined { + if (options.focus) { + const symbol = findSymbol(graph, options.focus); + if (symbol) return { kind: "symbol", symbol }; + const file = findFile(graph, options.focus); + if (file) return { kind: "file", file }; + } + if (options.scope) return { kind: "scope", scope: normalizeScope(options.scope) }; + return undefined; +} + +function symbolsForFile(graph: CodebaseGraph, file: string): SymbolNode[] { + return graph.symbolNodes + .filter((symbol) => symbol.file === file) + .sort((left, right) => + Number(right.isExported === true) - Number(left.isExported === true) + || left.name.localeCompare(right.name) + ); +} + +function callEdgesForSymbol(graph: CodebaseGraph, symbolId: string): CallEdge[] { + return graph.callEdges + .filter((edge) => edge.source === symbolId || edge.target === symbolId) + .sort((left, right) => + left.source.localeCompare(right.source) + || left.target.localeCompare(right.target) + || left.callerSymbol.localeCompare(right.callerSymbol) + || left.calleeSymbol.localeCompare(right.calleeSymbol) + ); +} + +function includeSymbol(state: MapState, symbol: SymbolNode, reason: CodebaseMapEvidenceKind, isFocus = false): CodebaseMapNode { + const symbolEvidence = addEvidence(state.evidence, reason, `${symbol.name} in ${symbol.file}`, symbol.file, symbol.name); + const fileEvidence = addEvidence(state.evidence, "metric", `${symbol.file} contains ${symbol.name}`, symbol.file, symbol.name); + addFileNode(state, symbol.file, fileEvidence, isFocus); + return addSymbolNode(state, symbol, symbolEvidence, isFocus); +} + +function includeFile(state: MapState, file: string, reason: CodebaseMapEvidenceKind, isFocus = false): CodebaseMapNode { + const evidenceId = addEvidence(state.evidence, reason, `${file} selected for map`, file); + const node = addFileNode(state, file, evidenceId, isFocus); + for (const symbol of symbolsForFile(state.graph, file)) { + includeSymbol(state, symbol, reason); + } + return node; +} + +function includeRelatedTests(state: MapState): void { + const files = [...state.nodes.values()] + .filter((node) => node.kind === "file" && node.file) + .map((node) => node.file); + for (const file of files) { + if (!file) continue; + const metrics = state.graph.fileMetrics.get(file); + if (!metrics?.testFile) continue; + const testFile = metrics.testFile; + const evidenceId = addEvidence(state.evidence, "test", `${testFile} tests ${file}`, testFile); + addFileNode(state, testFile, evidenceId); + addEdge(state, "tests", fileNodeId(testFile), fileNodeId(file), "tests", 1, evidenceId); + } +} + +function includeSymbolNeighborhood(state: MapState, focusSymbol: SymbolNode, depth: number): CodebaseMapNode { + const focusNode = includeSymbol(state, focusSymbol, "focus", true); + const symbolsById = new Map(state.graph.symbolNodes.map((symbol) => [symbol.id, symbol])); + const queue: Array<{ symbolId: string; depth: number }> = [{ symbolId: focusSymbol.id, depth: 0 }]; + const visited = new Set(); + + while (queue.length > 0) { + const current = queue.shift(); + if (!current || visited.has(current.symbolId)) continue; + visited.add(current.symbolId); + if (current.depth >= depth) continue; + + for (const edge of callEdgesForSymbol(state.graph, current.symbolId)) { + const nextId = edge.source === current.symbolId ? edge.target : edge.source; + const nextSymbol = symbolsById.get(nextId); + if (!nextSymbol) continue; + includeSymbol(state, nextSymbol, "call"); + if (!visited.has(nextId)) queue.push({ symbolId: nextId, depth: current.depth + 1 }); + } + } + + return focusNode; +} + +function includeFileNeighborhood(state: MapState, focusFile: string, depth: number): CodebaseMapNode { + const focusNode = includeFile(state, focusFile, "focus", true); + const queue: Array<{ file: string; depth: number }> = [{ file: focusFile, depth: 0 }]; + const visited = new Set(); + while (queue.length > 0) { + const current = queue.shift(); + if (!current || visited.has(current.file)) continue; + visited.add(current.file); + if (current.depth >= depth) continue; + const neighbors = state.graph.edges + .filter((edge) => edge.source === current.file || edge.target === current.file) + .flatMap((edge) => [edge.source, edge.target]) + .filter((file) => file !== current.file && state.graph.fileMetrics.has(file)) + .sort(); + for (const file of neighbors) { + includeFile(state, file, "import"); + if (!visited.has(file)) queue.push({ file, depth: current.depth + 1 }); + } + } + return focusNode; +} + +function includeScope(state: MapState, scope: string): CodebaseMapNode { + const focusNode = addScopeNode(state, scope); + const files = [...state.graph.fileMetrics.keys()] + .filter((file) => file.startsWith(scope)) + .sort(); + for (const file of files) { + includeFile(state, file, "scope"); + } + return focusNode; +} + +function includeOverviewFallback(state: MapState): CodebaseMapNode | undefined { + const topFiles = [...state.graph.fileMetrics.entries()] + .filter(([, metrics]) => !metrics.isTestFile) + .sort(([leftFile, left], [rightFile, right]) => + right.pageRank - left.pageRank + || right.fanIn - left.fanIn + || leftFile.localeCompare(rightFile) + ) + .slice(0, 8); + for (const [file] of topFiles) { + includeFile(state, file, "metric"); + } + return [...state.nodes.values()].find((node) => node.kind === "file"); +} + +function addContainsEdges(state: MapState): void { + for (const symbol of state.graph.symbolNodes) { + const from = fileNodeId(symbol.file); + const to = symbolNodeId(symbol); + if (!state.nodes.has(from) || !state.nodes.has(to)) continue; + const evidenceId = addEvidence(state.evidence, "contains", `${symbol.file} contains ${symbol.name}`, symbol.file, symbol.name); + addEdge(state, "contains", from, to, "contains", 1, evidenceId); + } +} + +function addCallEdges(state: MapState): void { + for (const edge of state.graph.callEdges) { + const sourceSymbol = state.graph.symbolNodes.find((symbol) => symbol.id === edge.source); + const targetSymbol = state.graph.symbolNodes.find((symbol) => symbol.id === edge.target); + if (!sourceSymbol || !targetSymbol) continue; + const from = symbolNodeId(sourceSymbol); + const to = symbolNodeId(targetSymbol); + if (!state.nodes.has(from) || !state.nodes.has(to)) continue; + const evidenceId = addEvidence( + state.evidence, + "call", + `${edge.callerSymbol} calls ${edge.calleeSymbol}`, + sourceSymbol.file, + edge.callerSymbol, + ); + addEdge(state, "calls", from, to, edge.confidence, 1, evidenceId); + } +} + +function edgeLabel(edge: GraphEdge): string { + return edge.symbols.length > 0 ? edge.symbols.join(",") : "imports"; +} + +function addImportEdges(state: MapState): void { + for (const edge of state.graph.edges) { + const from = fileNodeId(edge.source); + const to = fileNodeId(edge.target); + if (!state.nodes.has(from) || !state.nodes.has(to)) continue; + const isTestEdge = edge.symbols.includes("tests"); + const kind: CodebaseMapEdgeKind = isTestEdge ? "tests" : "imports"; + const evidenceKind: CodebaseMapEvidenceKind = isTestEdge ? "test" : "import"; + const summary = isTestEdge + ? `${edge.source} tests ${edge.target}` + : `${edge.source} imports ${edge.target}`; + const evidenceId = addEvidence(state.evidence, evidenceKind, summary, edge.source); + addEdge(state, kind, from, to, edgeLabel(edge), edge.weight, evidenceId); + } +} + +function addGraphEdges(state: MapState): void { + addContainsEdges(state); + addCallEdges(state); + addImportEdges(state); +} + +function fileTokenEstimate(graph: CodebaseGraph, file: string): number { + const loc = graphFileNode(graph, file)?.loc ?? 20; + return Math.min(160, 80 + Math.ceil(loc / 2)); +} + +function symbolTokenEstimate(symbol: SymbolNode): number { + return Math.min(120, 50 + symbol.loc * 5); +} + +function evidenceIdsForNode(node: CodebaseMapNode): string[] { + return [...node.evidenceIds].sort(); +} + +function candidatePriority(node: CodebaseMapNode): number { + if (node.kind === "test") return node.score + 8_500; + return node.score; +} + +function contextCandidates(resultNodes: readonly CodebaseMapNode[], graph: CodebaseGraph): ContextCandidate[] { + const candidates: ContextCandidate[] = []; + for (const node of resultNodes) { + if (node.kind === "file" && node.file) { + candidates.push({ + category: "file", + priority: candidatePriority(node), + tokenEstimate: fileTokenEstimate(graph, node.file), + evidenceIds: evidenceIdsForNode(node), + file: node.file, + reason: node.score >= 10_000 ? "focus file" : "related file from focused graph", + }); + } + if (node.kind === "symbol" && node.file && node.symbol) { + const symbol = graph.symbolNodes.find((candidate) => candidate.file === node.file && candidate.name === node.symbol); + candidates.push({ + category: "symbol", + priority: candidatePriority(node), + tokenEstimate: symbol ? symbolTokenEstimate(symbol) : 80, + evidenceIds: evidenceIdsForNode(node), + file: node.file, + symbol: node.symbol, + reason: node.score >= 9_000 ? "focus symbol" : "caller/callee symbol from focused graph", + }); + } + if (node.kind === "test" && node.file) { + const testEdge = graph.edges.find((edge) => edge.source === node.file && edge.symbols.includes("tests")); + const coveredFile = testEdge?.target ?? [...graph.fileMetrics.entries()] + .find(([, metrics]) => metrics.testFile === node.file)?.[0] ?? ""; + candidates.push({ + category: "test", + priority: candidatePriority(node), + tokenEstimate: fileTokenEstimate(graph, node.file), + evidenceIds: evidenceIdsForNode(node), + file: node.file, + covers: coveredFile, + reason: coveredFile ? `tests ${coveredFile}` : "test file near focus", + }); + } + } + return candidates.sort((left, right) => + right.priority - left.priority + || left.category.localeCompare(right.category) + || (left.file ?? "").localeCompare(right.file ?? "") + || (left.symbol ?? "").localeCompare(right.symbol ?? "") + ); +} + +function buildContextPack( + nodes: readonly CodebaseMapNode[], + evidence: readonly CodebaseMapEvidence[], + graph: CodebaseGraph, + options: Required> & Pick, +): CodebaseContextPack { + const rankedFiles: ContextPackFile[] = []; + const rankedSymbols: ContextPackSymbol[] = []; + const tests: ContextPackTest[] = []; + const evidenceIds = new Set(); + let tokenEstimate = 0; + + for (const candidate of contextCandidates(nodes, graph)) { + if (tokenEstimate + candidate.tokenEstimate > options.contextBudget) continue; + tokenEstimate += candidate.tokenEstimate; + for (const id of candidate.evidenceIds) evidenceIds.add(id); + if (candidate.category === "file" && candidate.file) { + rankedFiles.push({ + path: candidate.file, + rank: rankedFiles.length + 1, + reason: candidate.reason, + tokenEstimate: candidate.tokenEstimate, + evidenceIds: candidate.evidenceIds, + }); + } + if (candidate.category === "symbol" && candidate.file && candidate.symbol) { + rankedSymbols.push({ + file: candidate.file, + symbol: candidate.symbol, + rank: rankedSymbols.length + 1, + reason: candidate.reason, + tokenEstimate: candidate.tokenEstimate, + evidenceIds: candidate.evidenceIds, + }); + } + if (candidate.category === "test" && candidate.file) { + tests.push({ + path: candidate.file, + covers: candidate.covers ?? "", + rank: tests.length + 1, + reason: candidate.reason, + tokenEstimate: candidate.tokenEstimate, + evidenceIds: candidate.evidenceIds, + }); + } + } + + const focusCommand = options.focus ? `codebase-intelligence map . --focus ${JSON.stringify(options.focus)} --json` : "codebase-intelligence map . --json"; + const symbolCommand = rankedSymbols[0] + ? `codebase-intelligence symbol . ${JSON.stringify(rankedSymbols[0].symbol)} --json` + : undefined; + const fileCommand = rankedFiles[0] + ? `codebase-intelligence file . ${JSON.stringify(rankedFiles[0].path)} --json` + : undefined; + return { + tokenBudget: options.contextBudget, + tokenEstimate, + rankedFiles, + rankedSymbols, + tests, + evidenceIds: [...evidenceIds].filter((id) => evidence.some((item) => item.id === id)).sort(), + nextCommands: [focusCommand, symbolCommand, fileCommand].filter((command): command is string => Boolean(command)), + }; +} + +function sortedNodes(nodes: Iterable): CodebaseMapNode[] { + return [...nodes].sort((left, right) => + right.score - left.score + || left.kind.localeCompare(right.kind) + || left.label.localeCompare(right.label) + || left.id.localeCompare(right.id) + ); +} + +function sortedEdges(edges: Iterable): CodebaseMapEdge[] { + return [...edges].sort((left, right) => + left.kind.localeCompare(right.kind) + || left.from.localeCompare(right.from) + || left.to.localeCompare(right.to) + || left.label.localeCompare(right.label) + ); +} + +function sortedEvidence(evidence: EvidenceStore): CodebaseMapEvidence[] { + return [...evidence.byId.values()].sort((left, right) => left.id.localeCompare(right.id)); +} + +function normalizeOptions(options: CodebaseMapOptions): Required> & Pick { + const focus = options.focus?.trim(); + const scope = options.scope?.trim(); + return { + focus: focus === "" ? undefined : focus, + scope: scope === "" ? undefined : scope, + depth: options.depth ?? DEFAULT_DEPTH, + contextBudget: options.contextBudget ?? DEFAULT_CONTEXT_BUDGET, + format: options.format ?? "markdown", + }; +} + +/** + * Build a deterministic focused codebase map plus a token-bounded context pack for agents. + */ +export function computeCodebaseMap(graph: CodebaseGraph, options: CodebaseMapOptions = {}): CodebaseMapResult { + const normalized = normalizeOptions(options); + const state: MapState = { + graph, + evidence: { byId: new Map() }, + nodes: new Map(), + edges: new Map(), + }; + + const match = findFocus(graph, { focus: normalized.focus ?? "", scope: normalized.scope ?? "" }); + let focusNode: CodebaseMapNode | undefined; + if (match?.kind === "symbol" && match.symbol) { + focusNode = includeSymbolNeighborhood(state, match.symbol, normalized.depth); + } else if (match?.kind === "file" && match.file) { + focusNode = includeFileNeighborhood(state, match.file, normalized.depth); + } else if (match?.kind === "scope" && match.scope !== undefined) { + focusNode = includeScope(state, match.scope); + } else { + focusNode = includeOverviewFallback(state); + } + + includeRelatedTests(state); + addGraphEdges(state); + + const nodes = sortedNodes(state.nodes.values()); + const edges = sortedEdges(state.edges.values()); + const evidence = sortedEvidence(state.evidence); + const contextPack = buildContextPack(nodes, evidence, graph, { + focus: normalized.focus, + contextBudget: normalized.contextBudget, + }); + + const summaryFocus = focusNode ? `${focusNode.kind} ${focusNode.label}` : "overview"; + return { + overview: { + focus: normalized.focus, + scope: normalized.scope, + depth: normalized.depth, + contextBudget: normalized.contextBudget, + totalNodes: nodes.length, + totalEdges: edges.length, + totalEvidence: evidence.length, + }, + focus: focusNode, + nodes, + edges, + evidence, + contextPack, + summary: `Map for ${summaryFocus}: ${nodes.length} nodes, ${edges.length} edges, ${contextPack.tokenEstimate}/${contextPack.tokenBudget} context tokens.`, + }; +} diff --git a/src/mcp/hints.ts b/src/mcp/hints.ts index af50f49..5629cf3 100644 --- a/src/mcp/hints.ts +++ b/src/mcp/hints.ts @@ -82,6 +82,11 @@ const OPERATION_HINTS: Record = { "Use file_context on files in the process steps for metrics", "Use get_module_structure to see how process crosses module boundaries", ], + codebaseMap: [ + "Use get_context_pack with the same focus when preparing an LLM prompt", + "Use get_scope_graph when you only need file/scope nodes and edges", + "Use symbol_context or file_context on top-ranked context entries before editing", + ], 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 8a0e7ac..741710e 100644 --- a/src/mcp/index.ts +++ b/src/mcp/index.ts @@ -8,6 +8,7 @@ import type { CodebaseGraph } from "../types/index.js"; const require = createRequire(import.meta.url); const pkg = require("../../package.json") as { version: string }; import { getHintsForOperation } from "./hints.js"; +import type { CodebaseMapEdge, CodebaseMapEvidence, CodebaseMapNode, CodebaseMapResult } from "../map/index.js"; import { getIndexedHead, getRoot } from "../server/graph-store.js"; import { runCheck } from "../rules/check.js"; import { formatJson } from "../rules/format.js"; @@ -19,6 +20,8 @@ import { type Operation, } from "../operations/index.js"; +export const extraMcpTools = ["get_scope_graph", "get_context_pack"] as const; + interface OperationToolOptions { successPayload?: (data: TResult) => unknown; errorNextSteps?: string[]; @@ -93,6 +96,66 @@ function registerOperationTool( ); } +function evidenceForMapSubset( + evidence: readonly CodebaseMapEvidence[], + nodes: readonly CodebaseMapNode[], + edges: readonly CodebaseMapEdge[], +): CodebaseMapEvidence[] { + const evidenceIds = new Set(); + for (const node of nodes) { + for (const id of node.evidenceIds) evidenceIds.add(id); + } + for (const edge of edges) { + for (const id of edge.evidenceIds) evidenceIds.add(id); + } + return evidence.filter((item) => evidenceIds.has(item.id)); +} + +function scopeGraphPayload(result: CodebaseMapResult): Record { + const nodes = result.nodes.filter((node) => node.kind === "file" || node.kind === "test" || node.kind === "scope"); + const nodeIds = new Set(nodes.map((node) => node.id)); + const edges = result.edges.filter((edge) => + (edge.kind === "imports" || edge.kind === "tests") + && nodeIds.has(edge.from) + && nodeIds.has(edge.to) + ); + return { + overview: result.overview, + focus: result.focus, + nodes, + edges, + evidence: evidenceForMapSubset(result.evidence, result.focus ? [...nodes, result.focus] : nodes, edges), + summary: `Scope graph for ${result.overview.focus ?? result.overview.scope ?? "overview"}: ${nodes.length} nodes, ${edges.length} edges.`, + }; +} + +function registerDerivedMapTool( + server: McpServer, + graph: CodebaseGraph, + name: typeof extraMcpTools[number], + description: string, + payload: (result: CodebaseMapResult) => unknown, +): void { + server.registerTool( + name, + { + description, + inputSchema: mcpInputSchema(operations.codebaseMap), + }, + async (rawInput) => { + const parsed = parseOperationInput(operations.codebaseMap, rawInput); + if (!parsed.ok) return jsonToolResult({ error: parsed.error }, true); + + const result = runOperation(operations.codebaseMap, graph, parsed.data, { rootDir: getRoot() }); + if (!result.ok) return jsonToolResult(errorPayload(result), true); + + const computed = payload(result.data); + if (!isRecord(computed)) return jsonToolResult(computed); + return jsonToolResult({ ...computed, nextSteps: getHintsForOperation("codebaseMap") }); + }, + ); +} + /** Register all MCP tools on a server instance. Shared by stdio and HTTP transports. */ export function registerTools(server: McpServer, graph: CodebaseGraph): void { registerOperationTool(server, graph, operations.overview); @@ -115,8 +178,23 @@ export function registerTools(server: McpServer, graph: CodebaseGraph): void { registerOperationTool(server, graph, operations.impact); registerOperationTool(server, graph, operations.rename); registerOperationTool(server, graph, operations.processes); + registerOperationTool(server, graph, operations.codebaseMap); registerOperationTool(server, graph, operations.highways); registerOperationTool(server, graph, operations.clusters); + registerDerivedMapTool( + server, + graph, + "get_scope_graph", + "Return the file/scope view from a focused codebase map: nodes, imports/tests edges, evidence, and next steps. Use when an agent needs graph topology without symbol details.", + scopeGraphPayload, + ); + registerDerivedMapTool( + server, + graph, + "get_context_pack", + "Return only the token-bounded context pack from a focused codebase map. Use before sending compact code context to an LLM.", + (result) => result.contextPack, + ); // MCP Prompts server.prompt( @@ -187,7 +265,7 @@ export function registerTools(server: McpServer, graph: CodebaseGraph): void { totalFiles: graph.stats.totalFiles, totalFunctions: graph.stats.totalFunctions, modules: [...graph.moduleMetrics.keys()], - availableTools: [...operationList.map((operation) => operation.mcpTool), "check"], + availableTools: [...operationList.map((operation) => operation.mcpTool), ...extraMcpTools, "check"], indexedHead, gettingStarted: [ "Call codebase_overview for a high-level map", diff --git a/src/operations/formatters.ts b/src/operations/formatters.ts index bbb4877..da30d9c 100644 --- a/src/operations/formatters.ts +++ b/src/operations/formatters.ts @@ -21,6 +21,7 @@ import type { } from "../core/index.js"; import type { HighwaysResult } from "../highways/index.js"; import type { ImpactResult, RenameResult } from "../impact/index.js"; +import type { CodebaseMapOptions, CodebaseMapResult } from "../map/index.js"; function text(lines: readonly string[]): string { return lines.join("\n"); @@ -30,6 +31,18 @@ function signatureSuffix(signature: string | undefined): string { return signature ? ` — ${signature}` : ""; } +function quoteDot(value: string): string { + return `"${value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}"`; +} + +function escapeXml(value: string): string { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + /** * Format an overview operation result for human CLI output. */ @@ -587,6 +600,90 @@ export function formatProcessesText(result: ProcessesResult): string { return text(lines); } +function formatCodebaseMapDot(result: CodebaseMapResult): string { + const lines = [ + "digraph CodebaseMap {", + " rankdir=LR;", + " node [shape=box];", + ...result.nodes.map((node) => + ` ${quoteDot(node.id)} [label=${quoteDot(`${node.kind}: ${node.label}`)}];` + ), + ...result.edges.map((edge) => + ` ${quoteDot(edge.from)} -> ${quoteDot(edge.to)} [label=${quoteDot(edge.kind)}];` + ), + "}", + ]; + return text(lines); +} + +function formatCodebaseMapGraphml(result: CodebaseMapResult): string { + const lines = [ + "", + "", + " ", + ...result.nodes.map((node) => + ` ${escapeXml(node.kind)}${escapeXml(node.label)}` + ), + ...result.edges.map((edge) => + ` ${escapeXml(edge.kind)}${escapeXml(edge.label)}` + ), + " ", + "", + ]; + return text(lines); +} + +/** + * Format a codebase map operation result for human CLI or graph export output. + */ +export function formatCodebaseMapText(result: CodebaseMapResult, input: CodebaseMapOptions = {}): string { + if (input.format === "dot") return formatCodebaseMapDot(result); + if (input.format === "graphml") return formatCodebaseMapGraphml(result); + + const lines = [ + "Codebase Map", + "────────────", + result.summary, + `Focus: ${result.overview.focus ?? "overview"}`, + result.overview.scope ? `Scope: ${result.overview.scope}` : "", + `Depth: ${result.overview.depth}`, + `Nodes: ${result.overview.totalNodes}`, + `Edges: ${result.overview.totalEdges}`, + `Evidence: ${result.overview.totalEvidence}`, + `Context: ${result.contextPack.tokenEstimate}/${result.contextPack.tokenBudget} tokens`, + ].filter(Boolean); + + if (result.contextPack.rankedFiles.length > 0) { + lines.push( + "", + "Files", + ...result.contextPack.rankedFiles.map((file) => ` ${file.rank}. ${file.path} — ${file.reason}`), + ); + } + + if (result.contextPack.rankedSymbols.length > 0) { + lines.push( + "", + "Symbols", + ...result.contextPack.rankedSymbols.map((symbol) => ` ${symbol.rank}. ${symbol.file}::${symbol.symbol} — ${symbol.reason}`), + ); + } + + if (result.contextPack.tests.length > 0) { + lines.push( + "", + "Tests", + ...result.contextPack.tests.map((test) => ` ${test.rank}. ${test.path} covers ${test.covers}`), + ); + } + + if (result.contextPack.nextCommands.length > 0) { + lines.push("", "Next", ...result.contextPack.nextCommands.map((command) => ` ${command}`)); + } + + 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 c79644e..71682c1 100644 --- a/src/operations/index.ts +++ b/src/operations/index.ts @@ -22,8 +22,10 @@ import { } from "../core/index.js"; import { DUPLICATION_MODES } from "../duplication/index.js"; import { computeHighways } from "../highways/index.js"; +import { CODEBASE_MAP_FORMATS, computeCodebaseMap } from "../map/index.js"; import type { CodebaseGraph } from "../types/index.js"; import { + formatCodebaseMapText, formatChangesText, formatClustersText, formatDeadExportsText, @@ -61,6 +63,7 @@ export const operationNames = [ "impact", "rename", "processes", + "codebaseMap", "highways", "clusters", ] as const; @@ -160,6 +163,14 @@ const processesInputShape = { limit: z.number().int().positive().optional().describe("Max processes to return (default: all)"), } satisfies z.ZodRawShape; const processesInputSchema = z.object(processesInputShape).strict(); +const codebaseMapInputShape = { + focus: z.string().min(1).optional().describe("Symbol, file, or scope to focus the map on"), + scope: z.string().min(1).optional().describe("Directory/module scope to include"), + depth: z.number().int().positive().optional().describe("Graph traversal depth (default: 1)"), + format: z.enum(CODEBASE_MAP_FORMATS).optional().describe("Export format: json, markdown, dot, or graphml"), + contextBudget: z.number().int().positive().optional().describe("Approximate token budget for the context pack (default: 1200)"), +} satisfies z.ZodRawShape; +const codebaseMapInputSchema = z.object(codebaseMapInputShape).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"), @@ -189,6 +200,7 @@ type ChangesInput = z.infer; type ImpactInput = z.infer; type RenameInput = z.infer; type ProcessesInput = z.infer; +export type CodebaseMapInput = z.infer; type HighwaysInput = z.infer; type ClustersInput = z.infer; @@ -356,6 +368,16 @@ export const operations = { run: (graph: CodebaseGraph, input: ProcessesInput) => computeProcesses(graph, input.entryPoint, input.limit), formatText: formatProcessesText, } satisfies Operation>, + codebaseMap: { + name: "codebaseMap", + cliCommand: "map", + mcpTool: "get_codebase_map", + description: "Build a deterministic focused graph plus a token-bounded context pack for AI agents. Use when: preparing a code context bundle, visualizing scope/symbol neighborhoods, or giving an LLM the smallest useful map. Not for: raw execution traces (use get_processes) or route convergence analysis (use analyze_highways)", + inputShape: codebaseMapInputShape, + inputSchema: codebaseMapInputSchema, + run: (graph: CodebaseGraph, input: CodebaseMapInput) => computeCodebaseMap(graph, input), + formatText: formatCodebaseMapText, + } satisfies Operation>, highways: { name: "highways", cliCommand: "highways", diff --git a/tests/map.e2e.test.ts b/tests/map.e2e.test.ts new file mode 100644 index 0000000..3f2c760 --- /dev/null +++ b/tests/map.e2e.test.ts @@ -0,0 +1,222 @@ +import { execFile, execSync } from "node:child_process"; +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 { getFixtureSrcPath } from "./helpers/pipeline.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; +} + +interface MapEvidence { + id: string; + kind: string; + summary: string; +} + +interface MapNode { + id: string; + kind: string; + label: string; + file?: string; + symbol?: string; + evidenceIds: string[]; +} + +interface MapEdge { + id: string; + kind: string; + from: string; + to: string; + evidenceIds: string[]; +} + +interface ContextPack { + tokenBudget: number; + tokenEstimate: number; + rankedFiles: Array<{ path: string; rank: number; tokenEstimate: number; evidenceIds: string[] }>; + rankedSymbols: Array<{ file: string; symbol: string; rank: number; tokenEstimate: number; evidenceIds: string[] }>; + tests: Array<{ path: string; covers: string; rank: number; tokenEstimate: number; evidenceIds: string[] }>; + evidenceIds: string[]; +} + +interface CodebaseMapPayload { + overview: { + focus?: string; + depth: number; + contextBudget: number; + }; + focus?: MapNode; + nodes: MapNode[]; + edges: MapEdge[]; + evidence: MapEvidence[]; + contextPack: ContextPack; + 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 isCodebaseMapPayload(value: unknown): value is CodebaseMapPayload { + return isRecord(value) + && isRecord(value.overview) + && Array.isArray(value.nodes) + && Array.isArray(value.edges) + && Array.isArray(value.evidence) + && isRecord(value.contextPack); +} + +function parsePayload(stdout: string): CodebaseMapPayload { + const parsed: unknown = JSON.parse(stdout); + if (!isCodebaseMapPayload(parsed)) throw new Error("Expected codebase map JSON object"); + return parsed; +} + +function withoutRuntimeFields(payload: CodebaseMapPayload): 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 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 assertEvidenceReferences(payload: CodebaseMapPayload): void { + const evidenceIds = new Set(payload.evidence.map((item) => item.id)); + expect(evidenceIds.size).toBe(payload.evidence.length); + for (const evidence of payload.evidence) { + expect(evidence.id).toMatch(/^evidence-[a-f0-9]{10}$/); + expect(evidence.summary.length).toBeGreaterThan(0); + } + for (const node of payload.nodes) { + expect(node.id.length).toBeGreaterThan(0); + expect(node.evidenceIds.length).toBeGreaterThan(0); + expect(node.evidenceIds.every((id) => evidenceIds.has(id))).toBe(true); + } + for (const edge of payload.edges) { + expect(edge.id).toMatch(/^edge-[a-f0-9]{10}$/); + expect(edge.evidenceIds.length).toBeGreaterThan(0); + expect(edge.evidenceIds.every((id) => evidenceIds.has(id))).toBe(true); + } + expect(payload.contextPack.evidenceIds.every((id) => evidenceIds.has(id))).toBe(true); +} + +describe("CH-P2-03 codebase map context pack", () => { + it("returns focused graph evidence and a token-bounded context pack through CLI and MCP", async () => { + const src = getFixtureSrcPath(); + const args = ["map", src, "--focus", "UserService.getUserById", "--depth", "1", "--context-budget", "420", "--json", "--force"]; + const cliRun = await run(args); + expect(cliRun.status).toBe(0); + expect(cliRun.stderr).toContain("Parsed"); + + const cliPayload = parsePayload(cliRun.stdout); + expect(cliPayload.overview).toMatchObject({ + focus: "UserService.getUserById", + depth: 1, + contextBudget: 420, + }); + expect(cliPayload.focus).toMatchObject({ + kind: "symbol", + label: "UserService.getUserById", + file: "users/user-service.ts", + symbol: "UserService.getUserById", + }); + expect(cliPayload.nodes.some((node) => node.kind === "file" && node.file === "users/user-service.ts")).toBe(true); + expect(cliPayload.nodes.some((node) => node.kind === "symbol" && node.symbol === "getUserById")).toBe(true); + expect(cliPayload.edges.some((edge) => edge.kind === "calls")).toBe(true); + expect(cliPayload.edges.some((edge) => edge.kind === "contains")).toBe(true); + assertEvidenceReferences(cliPayload); + + expect(cliPayload.contextPack.tokenEstimate).toBeLessThanOrEqual(cliPayload.contextPack.tokenBudget); + expect(cliPayload.contextPack.rankedFiles[0]).toMatchObject({ path: "users/user-service.ts", rank: 1 }); + expect(cliPayload.contextPack.rankedSymbols.some((item) => item.symbol === "UserService.getUserById")).toBe(true); + expect(cliPayload.contextPack.tests).toContainEqual(expect.objectContaining({ + path: "users/user-service.spec.ts", + covers: "users/user-service.ts", + })); + + const jsonFormatRun = await run(["map", src, "--focus", "UserService.getUserById", "--format", "json", "--force"]); + expect(jsonFormatRun.status).toBe(0); + expect(parsePayload(jsonFormatRun.stdout).overview.focus).toBe("UserService.getUserById"); + + const dotRun = await run(["map", src, "--focus", "UserService.getUserById", "--format", "dot", "--force"]); + expect(dotRun.status).toBe(0); + expect(dotRun.stdout).toContain("digraph CodebaseMap"); + expect(dotRun.stdout).toContain("->"); + + const graphmlRun = await run(["map", src, "--focus", "UserService.getUserById", "--format", "graphml", "--force"]); + expect(graphmlRun.status).toBe(0); + expect(graphmlRun.stdout).toContain(" = {}): Promi return JSON.parse(text) as Record; } +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + describe("Tool 1: codebase_overview", () => { it("returns totalFiles, modules, topDependedFiles, metrics, nextSteps", async () => { const r = await callTool("codebase_overview"); @@ -411,7 +415,75 @@ describe("Tool 15: get_processes", () => { }); }); -describe("Tool 16: analyze_highways", () => { +describe("Tool 16: get_codebase_map", () => { + it("returns focused graph evidence and context pack", async () => { + const r = await callTool("get_codebase_map", { + focus: "UserService.getUserById", + depth: 1, + contextBudget: 420, + }); + expect(r).toHaveProperty("overview"); + expect(r).toHaveProperty("focus"); + expect(r).toHaveProperty("nodes"); + expect(r).toHaveProperty("edges"); + expect(r).toHaveProperty("evidence"); + expect(r).toHaveProperty("contextPack"); + expect(r).toHaveProperty("nextSteps"); + + const contextPack = r.contextPack; + expect(isRecord(contextPack)).toBe(true); + if (!isRecord(contextPack)) throw new Error("Expected contextPack object"); + expect(contextPack).toHaveProperty("tokenBudget", 420); + expect(contextPack).toHaveProperty("rankedFiles"); + expect(contextPack).toHaveProperty("rankedSymbols"); + expect(contextPack).toHaveProperty("tests"); + }); +}); + +describe("Tool 17: get_scope_graph", () => { + it("returns only file/scope/test graph topology", async () => { + const r = await callTool("get_scope_graph", { + focus: "UserService.getUserById", + depth: 1, + }); + expect(r).toHaveProperty("nodes"); + expect(r).toHaveProperty("edges"); + expect(r).toHaveProperty("evidence"); + expect(r).toHaveProperty("nextSteps"); + + const nodesValue = r.nodes; + const edgesValue = r.edges; + expect(Array.isArray(nodesValue)).toBe(true); + expect(Array.isArray(edgesValue)).toBe(true); + if (!Array.isArray(nodesValue) || !Array.isArray(edgesValue)) throw new Error("Expected scope graph arrays"); + const nodes = nodesValue.filter(isRecord); + const edges = edgesValue.filter(isRecord); + expect(nodes).toHaveLength(nodesValue.length); + expect(edges).toHaveLength(edgesValue.length); + expect(nodes.length).toBeGreaterThan(0); + expect(nodes.every((node) => node.kind === "file" || node.kind === "test" || node.kind === "scope")).toBe(true); + expect(edges.every((edge) => edge.kind === "imports" || edge.kind === "tests")).toBe(true); + }); +}); + +describe("Tool 18: get_context_pack", () => { + it("returns a token-bounded context pack", async () => { + const r = await callTool("get_context_pack", { + focus: "UserService.getUserById", + depth: 1, + contextBudget: 420, + }); + expect(r).toHaveProperty("tokenBudget", 420); + expect(r).toHaveProperty("tokenEstimate"); + expect(r).toHaveProperty("rankedFiles"); + expect(r).toHaveProperty("rankedSymbols"); + expect(r).toHaveProperty("tests"); + expect(r).toHaveProperty("nextSteps"); + expect(Number(r.tokenEstimate)).toBeLessThanOrEqual(420); + }); +}); + +describe("Tool 19: analyze_highways", () => { it("returns highway opportunities envelope", async () => { const r = await callTool("analyze_highways", { operation: "get", minRoutes: 2 }); expect(r).toHaveProperty("totalRoutes"); @@ -423,7 +495,7 @@ describe("Tool 16: analyze_highways", () => { }); }); -describe("Tool 17: get_clusters", () => { +describe("Tool 20: get_clusters", () => { it("returns community-detected clusters", async () => { const r = await callTool("get_clusters"); expect(r).toHaveProperty("clusters"); @@ -495,10 +567,16 @@ describe("MCP Resources", () => { expect(setup).toHaveProperty("project", "codebase-intelligence"); expect(setup).toHaveProperty("indexedHead", "abc123-test"); expect(setup).toHaveProperty("availableTools"); - expect((setup.availableTools as string[]).length).toBe(operationList.length + 1); - expect(setup.availableTools as string[]).toContain("find_opportunities"); - expect(setup.availableTools as string[]).toContain("find_duplicates"); - expect(setup.availableTools as string[]).toContain("analyze_highways"); - expect(setup.availableTools as string[]).toContain("check"); + const availableTools = setup.availableTools; + expect(Array.isArray(availableTools)).toBe(true); + if (!Array.isArray(availableTools)) throw new Error("Expected availableTools array"); + expect(availableTools.length).toBe(operationList.length + extraMcpTools.length + 1); + expect(availableTools).toContain("find_opportunities"); + expect(availableTools).toContain("find_duplicates"); + expect(availableTools).toContain("get_codebase_map"); + expect(availableTools).toContain("get_scope_graph"); + expect(availableTools).toContain("get_context_pack"); + 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 30369cb..494e751 100644 --- a/tests/operation-registry.e2e.test.ts +++ b/tests/operation-registry.e2e.test.ts @@ -327,6 +327,14 @@ describe("operation registry chained parity", () => { {}, cachedRun, ); + expectCliMatchesRegistry( + operations.codebaseMap, + { focus: "UserService.getUserById", depth: 1, contextBudget: 420 }, + ["map", getFixtureSrcPath(), "--focus", "UserService.getUserById", "--depth", "1", "--context-budget", "420"], + codebaseGraph, + {}, + cachedRun, + ); expectCliMatchesRegistry( operations.highways, { operation: "get", minRoutes: 2 }, @@ -457,6 +465,14 @@ describe("operation registry chained parity", () => { {}, cachedRun, ); + expectCliTextMatchesFormatter( + operations.codebaseMap, + { focus: "UserService.getUserById", depth: 1, contextBudget: 420 }, + ["map", getFixtureSrcPath(), "--focus", "UserService.getUserById", "--depth", "1", "--context-budget", "420"], + codebaseGraph, + {}, + cachedRun, + ); expectCliTextMatchesFormatter( operations.highways, { operation: "get", minRoutes: 2 }, @@ -725,6 +741,13 @@ describe("operation registry chained parity", () => { codebaseGraph, mcp, ); + await expectMcpMatchesRegistry( + operations.codebaseMap, + { focus: "UserService.getUserById", depth: 1, contextBudget: 420 }, + { focus: "UserService.getUserById", depth: 1, contextBudget: 420 }, + 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 ab995c8..89ec912 100644 --- a/tests/operation-registry.test.ts +++ b/tests/operation-registry.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { computeOverview } from "../src/core/index.js"; import { getHints, getHintsForOperation } from "../src/mcp/hints.js"; +import { extraMcpTools } from "../src/mcp/index.js"; import { getOperation, getOperationByCliCommand, @@ -38,6 +39,7 @@ const expectedOperations: Array<{ { name: "impact", cliCommand: "impact", mcpTool: "impact_analysis", inputKeys: ["symbol"], sampleInput: { symbol: "getUserById" } }, { name: "rename", cliCommand: "rename", mcpTool: "rename_symbol", inputKeys: ["oldName", "newName", "dryRun"], sampleInput: { oldName: "getUserById", newName: "findUserById" } }, { 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: "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 } }, ]; @@ -100,7 +102,7 @@ describe("operation registry", () => { it("registers MCP tools from the operation registry plus check", async () => { const mcp = await createFixtureMcp(); - expect(await mcp.listTools()).toEqual([...operationList.map((operation) => operation.mcpTool), "check"]); + expect(await mcp.listTools()).toEqual([...operationList.map((operation) => operation.mcpTool), ...extraMcpTools, "check"]); }); it("keeps MCP operation input fields discoverable while registry validation owns errors", async () => { diff --git a/tools/verify-cli-real-codebases.mjs b/tools/verify-cli-real-codebases.mjs index 362e935..4438da3 100644 --- a/tools/verify-cli-real-codebases.mjs +++ b/tools/verify-cli-real-codebases.mjs @@ -372,13 +372,18 @@ for (const inputTarget of targets) { return `${result.totalAffected} affected`; }); - for (const command of ["modules", "forces", "dead-exports", "opportunities", "groups", "processes", "highways", "clusters"]) { + for (const command of ["modules", "forces", "dead-exports", "opportunities", "groups", "processes", "map", "highways", "clusters"]) { record(`${target.name}: ${command}`, () => { const args = command === "highways" ? [command, target.path, "--operation", "get", "--min-routes", "3"] - : [command, target.path]; + : command === "map" + ? [command, target.path, "--focus", target.symbol, "--context-budget", "600"] + : [command, target.path]; const result = json(args); if (!result || typeof result !== "object") throw new Error("invalid JSON object"); + if (command === "map" && (!result.contextPack || !Array.isArray(result.nodes))) { + throw new Error("bad map payload"); + } return "json ok"; }); }