diff --git a/README.md b/README.md index e10fa60..a1bc96a 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 -- **19 CLI commands** for architecture analysis, dependency impact, duplicate families, improvement opportunities, dead code detection, search, CI rules, and agent setup +- **20 CLI commands** for architecture analysis, dependency impact, duplicate families, 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,9 +65,10 @@ 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 +- **Highways analysis** — find repeated routes that bypass canonical dataflow paths - **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 18 MCP tools, 2 prompts, and 3 resources +- **MCP parity (secondary)** — same analysis and rules gate available as 19 MCP tools, 2 prompts, and 3 resources ## Installation @@ -110,6 +111,7 @@ codebase-intelligence [options] | `impact` | Symbol-level blast radius | | `rename` | Reference discovery for rename planning | | `processes` | Entry-point execution flow tracing | +| `highways` | Repeated route convergence and canonical path opportunities | | `clusters` | Community-detected file clusters | | `check` | Rules-engine gate for CI, including opt-in dead-code and dependency gates | | `init` | Set up AI agents to use CI — writes per-agent instruction files (skill opt-in via `--skill`) | @@ -127,6 +129,8 @@ 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 | +| `--operation ` | Focus `highways` on one operation verb | +| `--shape ` | Focus `highways` on one type/DTO shape | The scanner always excludes common generated and agent-workspace directories such as `.codebase-intelligence/`, legacy `.code-visualizer/`, `.next/`, `dist/`, `coverage/`, `.worktrees/`, and `.claude/worktrees/`. diff --git a/docs/architecture.md b/docs/architecture.md index 0bc73c7..53b8838 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) - | 18 tools, 2 prompts, | 19 commands with text + JSON + | 19 tools, 2 prompts, | 20 commands with text + JSON | 3 resources for LLMs | output for humans and CI ``` @@ -47,7 +47,8 @@ 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) - mcp/index.ts <- 18 MCP tools for LLM integration + highways/index.ts <- Repeated route convergence + bypass/cowpath evidence + mcp/index.ts <- 19 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 @@ -82,7 +83,7 @@ analyzeGraph(builtGraph, parsedFiles) } startMcpServer(codebaseGraph) - -> stdio MCP server with 18 tools, 2 prompts, 3 resources + -> stdio MCP server with 19 tools, 2 prompts, 3 resources runOperation(operation, codebaseGraph, input, context) -> { ok: true, data } | { ok: false, error, data? } @@ -96,6 +97,7 @@ runOperation(operation, codebaseGraph, input, context) - **Duplication families**: Parser stores deterministic function-body token streams on symbols. `duplicates` / `find_duplicates` groups symbols into strict, renamed, and near-miss clone families, with optional trace evidence for AI agents before refactors. - **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**: `highways` / `analyze_highways` enumerates entry-to-sink call routes, groups repeated routes by sink, detects bypasses and cowpaths around an existing canonical node, and emits route chains, evidence, blast radius, recommendations, and a context pack for agents. - **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 c0dabf5..9b4f45c 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -1,6 +1,6 @@ # CLI Reference -19 commands for terminal and CI use. The 17 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. +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. ## Commands @@ -168,6 +168,16 @@ codebase-intelligence processes [--entry ] [--limit ] [--json] [ **Output:** processes with entry point, steps, depth, modules touched. +### highways + +Find repeated routes that should converge on one canonical operation path. + +```bash +codebase-intelligence highways [--operation ] [--shape ] [--min-routes ] [--propose] [--trace ] [--json] [--force] +``` + +**Output:** route opportunities with `bypass` / `cowpath` kind, operation, shape, sink, canonical node, route chains, bypass routes, duplicated callees when present, evidence, blast radius, recommendation, and context pack. + ### clusters Community-detected file clusters (Louvain algorithm). @@ -226,7 +236,7 @@ codebase-intelligence init [path] [--agents ] [--all] [--skill] [--gitigno | `--mode ` | duplicates | Clone mode: strict, mild, weak | | `--min-tokens ` | duplicates | Minimum function body token count (default: 30) | | `--skip-local` | duplicates | Ignore families confined to one file | -| `--trace ` | duplicates | Return token evidence for one family id | +| `--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) | | `--cohesion ` | forces | Min cohesion threshold (default: 0.6) | @@ -234,6 +244,10 @@ codebase-intelligence init [path] [--agents ] [--all] [--skill] [--gitigno | `--escape ` | forces | Min escape velocity threshold (default: 0.5) | | `--module ` | dead-exports | Filter by module path | | `--entry ` | processes | Filter by entry point name | +| `--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 | diff --git a/docs/data-model.md b/docs/data-model.md index 09937cf..b75381f 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -1,6 +1,6 @@ # Data Model -All types defined in `src/types/index.ts`. This is the single source of truth. +Core graph types are defined in `src/types/index.ts`. Feature result envelopes live beside their analyzer modules. ## Parser Output @@ -171,6 +171,68 @@ CacheFacts { } ``` +## Highways Result + +`highways --json` and MCP `analyze_highways` return deterministic route-convergence opportunities. + +```typescript +HighwaysResult { + totalRoutes: number + totalSinks: number + totalOpportunities: number + operation?: string + shape?: string + minRoutes: number + opportunities: HighwayOpportunity[] + trace?: { + id: string + found: boolean + opportunity?: HighwayOpportunity + } + summary: string +} + +HighwayOpportunity { + id: string // hwy-- + kind: "bypass" | "cowpath" + operation: string + shape?: string + sink: HighwayStep + canonicalNode: HighwayStep + routes: HighwayRoute[] // all routes reaching the sink + bypassRoutes: HighwayRoute[] // routes missing canonicalNode + duplicatedCallees?: HighwayStep[] // cowpath overlap with canonical node callees + evidence: string[] + blastRadius: number + recommendation: string + contextPack: { + summary: string + affectedRoutes: string[] + evidence: string[] + blastRadius: number + proposedCanonicalNode: HighwayStep + nextSafeCommand: string + } +} + +HighwayRoute { + id: string + operation: string + shape?: string + entryPoint: HighwayStep + sink: HighwayStep + steps: HighwayStep[] + includesCanonical: boolean + confidence: "type-resolved" | "text-inferred" +} + +HighwayStep { + id: string + file: string + symbol: string +} +``` + ## Check Findings `check --json` and MCP `check` return deterministic findings plus a suppression diff --git a/docs/mcp-tools.md b/docs/mcp-tools.md index 5ef73ca..4852ad3 100644 --- a/docs/mcp-tools.md +++ b/docs/mcp-tools.md @@ -1,6 +1,6 @@ # MCP Tools Reference -18 tools available via MCP stdio. +19 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,17 @@ 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. get_clusters +## 17. analyze_highways + +Detect repeated entry-to-sink routes that should converge on one canonical operation path. + +**Input:** `{ operation?: string, shape?: string, minRoutes?: number, propose?: boolean, trace?: string }` +**Returns:** totalRoutes, totalSinks, totalOpportunities, opportunities[] (id, kind, operation, shape, sink, canonicalNode, routes[], bypassRoutes[], duplicatedCallees?, evidence[], blastRadius, recommendation, contextPack), optional trace + +**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 Community-detected clusters of related files. @@ -179,7 +189,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). -## 18. check +## 19. check Run the configurable rules engine and gate on findings. @@ -227,6 +237,7 @@ 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` | +| "Which routes bypass canonical dataflow?" | `analyze_highways` | | "What files naturally belong together?" | `get_clusters` | | "What are the main areas?" | `get_groups` | | "What rule violations exist? Lint this." | `check` | diff --git a/llms-full.txt b/llms-full.txt index c73a0da..2bec7c0 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: 18 tools, 2 prompts, 3 resources for LLM agents - | CLI: 19 commands with formatted + JSON output for humans/CI + | MCP: 19 tools, 2 prompts, 3 resources for LLM agents + | CLI: 20 commands with formatted + JSON output for humans/CI ``` ## Module Map @@ -51,7 +51,8 @@ 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 - mcp/index.ts <- 18 MCP tools for LLM integration + highways/index.ts <- Repeated route convergence + bypass/cowpath evidence + mcp/index.ts <- 19 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 @@ -81,7 +82,7 @@ analyzeGraph(builtGraph, parsedFiles) -> CodebaseGraph { nodes, edges, symbolNodes, callEdges, symbolMetrics, fileMetrics, moduleMetrics, forceAnalysis, stats, - groups, processes, clusters + groups, processes, highways, clusters } runOperation(operation, codebaseGraph, input, context) @@ -261,7 +262,7 @@ The most dangerous files have: high churn + high coupling + low coverage. # MCP Tools Reference -18 tools available via MCP stdio. +19 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. @@ -311,10 +312,13 @@ 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. get_clusters +## 17. analyze_highways +Repeated route convergence. Input: `{ operation?: string, shape?: string, minRoutes?: number, propose?: boolean, trace?: string }`. Returns: bypass/cowpath opportunities with route chains, evidence, blast radius, proposed canonical node, and context pack. + +## 18. get_clusters Community-detected file clusters. Input: `{ minFiles?: number }`. Returns: clusters with cohesion. -## 18. check +## 19. 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 @@ -338,6 +342,7 @@ 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 | +| Which routes bypass canonical dataflow? | analyze_highways | | What files naturally belong together? | get_clusters | | What rule violations exist? | check | @@ -345,7 +350,7 @@ Rules-engine gate. Input: `{}`. Returns: pass/warn/fail verdict, findings, suppr # CLI Reference -19 commands — 17 analysis commands (full parity with MCP tools), `check` for CI rules, and `init` for agent adoption. +20 commands — 18 analysis commands (full parity with MCP tools), `check` for CI rules, and `init` for agent adoption. ## Commands @@ -445,6 +450,12 @@ codebase-intelligence processes [--entry ] [--limit ] [--json] [ ``` Entry point execution flows through the call graph. +### highways +```bash +codebase-intelligence highways [--operation ] [--shape ] [--min-routes ] [--propose] [--trace ] [--json] [--force] +``` +Find repeated routes that should converge on one canonical operation path. Returns bypass/cowpath findings with route chains, canonical node, evidence, blast radius, recommendation, and context pack. + ### clusters ```bash codebase-intelligence clusters [--min-files ] [--json] [--force] diff --git a/llms.txt b/llms.txt index bfee888..40530c0 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): 18 MCP tools — inputs, outputs, use cases, selection guide +- [MCP Tools](docs/mcp-tools.md): 19 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) — 19 commands (17 analysis with MCP parity + `check` + `init`): +CLI mode (humans/CI) — 20 commands (18 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 highways ./src --operation create --min-routes 3 --json codebase-intelligence clusters ./src --min-files 3 codebase-intelligence check ./src # rules gate for CI; JSON includes findings plus active/stale suppressions codebase-intelligence init . # make AI agents use CI diff --git a/roadmap.md b/roadmap.md index fb4bca2..4d58d27 100644 --- a/roadmap.md +++ b/roadmap.md @@ -387,17 +387,20 @@ entry ─► transform ─► validate ─► sink | `bypass` | Route that reaches a sink while skipping an existing canonical node | | `highway` | Approved canonical route for one `(operation, shape, sink)` inside a bounded scope | -**To do:** +**H1 slice:** + +- H1 shipped: classify operation verbs, enumerate entry-to-sink routes, detect cowpaths and bypasses. +- H1 shipped: propose reroutes to existing canonical nodes with route chains, blast radius, evidence, and context packs. +- MCP shipped: add `analyze_highways`. +- CLI shipped: add `highways ` with `--operation`, `--shape`, `--min-routes`, `--propose`, `--trace`, `--json`. +- H1 shipped: every opportunity emits a token-budgeted context pack: summary, affected routes, evidence, blast radius, proposed canonical node, next safe command. + +**Remaining:** -- H1: classify operation verbs, enumerate entry-to-sink routes, detect cowpaths and bypasses. -- H1: propose reroutes to existing canonical nodes. - H2: add type-shape grouping once Type/Shape layer lands. - H2: detect shape drift and near-duplicate intermediate steps. - H2: synthesize new highway proposals: name, location, signature, skeleton, cycle-safe reroute plan. - H3: add reuse hotspot metrics and cross-link opportunities into `forces` / `hotspots`. -- MCP: add `analyze_highways`. -- CLI: add `highways ` with `--operation`, `--shape`, `--min-routes`, `--propose`, `--trace`, `--json`. -- Every opportunity should emit a token-budgeted context pack: summary, affected routes, evidence, blast radius, proposed canonical node, next safe command. ### Scope Graph + Codebase Map diff --git a/src/cli.ts b/src/cli.ts index 44acf12..a89e0ea 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -202,6 +202,14 @@ interface ProcessesOptions extends CliCommandOptions { limit?: string; } +interface HighwaysOptions extends CliCommandOptions { + operation?: string; + shape?: string; + minRoutes?: string; + propose?: boolean; + trace?: string; +} + interface ClustersOptions extends CliCommandOptions { minFiles?: string; } @@ -643,6 +651,38 @@ program outputOperationText(operations.processes, result, input); }); +// ── Subcommand: highways ─────────────────────────────────── + +program + .command("highways") + .description("Find repeated routes that should converge on one canonical path") + .argument("", "Path to TypeScript codebase") + .option("--operation ", "Operation verb to focus on, such as create or update") + .option("--shape ", "Type/DTO shape to focus on") + .option("--min-routes ", "Minimum routes reaching a sink before reporting (default: 2)") + .option("--propose", "Include reroute proposal metadata") + .option("--trace ", "Return route evidence for one highway opportunity id") + .option("--json", "Output as JSON") + .option("--force", "Re-index even if HEAD unchanged") + .action((targetPath: string, options: HighwaysOptions) => { + const input = parseCliOperationInput(operations.highways, { + operation: options.operation, + shape: options.shape, + minRoutes: optionalIntegerInput(options.minRoutes), + propose: options.propose, + trace: options.trace, + }); + const { graph } = loadGraph(targetPath, forceOption(options)); + const result = runCliOperation(operations.highways, graph, input); + + if (options.json) { + outputJson(result); + return; + } + + outputOperationText(operations.highways, result, input); + }); + // ── Subcommand: clusters ─────────────────────────────────── program diff --git a/src/highways/index.ts b/src/highways/index.ts new file mode 100644 index 0000000..55c2c33 --- /dev/null +++ b/src/highways/index.ts @@ -0,0 +1,490 @@ +import { createHash } from "node:crypto"; +import { detectEntryPoints } from "../process/index.js"; +import type { CallConfidence, CodebaseGraph, SymbolNode } from "../types/index.js"; + +export type HighwayOpportunityKind = "bypass" | "cowpath"; + +export interface HighwayStep { + id: string; + file: string; + symbol: string; +} + +export interface HighwayRoute { + id: string; + operation: string; + shape?: string; + entryPoint: HighwayStep; + sink: HighwayStep; + steps: HighwayStep[]; + includesCanonical: boolean; + confidence: CallConfidence; +} + +export interface HighwayContextPack { + summary: string; + affectedRoutes: string[]; + evidence: string[]; + blastRadius: number; + proposedCanonicalNode: HighwayStep; + nextSafeCommand: string; +} + +export interface HighwayOpportunity { + id: string; + kind: HighwayOpportunityKind; + operation: string; + shape?: string; + sink: HighwayStep; + canonicalNode: HighwayStep; + routes: HighwayRoute[]; + bypassRoutes: HighwayRoute[]; + duplicatedCallees?: HighwayStep[]; + evidence: string[]; + blastRadius: number; + recommendation: string; + contextPack: HighwayContextPack; +} + +export interface HighwaysOptions { + operation?: string; + shape?: string; + minRoutes?: number; + propose?: boolean; + trace?: string; +} + +export interface HighwaysTrace { + id: string; + found: boolean; + opportunity?: HighwayOpportunity; +} + +export interface HighwaysResult { + totalRoutes: number; + totalSinks: number; + totalOpportunities: number; + operation?: string; + shape?: string; + minRoutes: number; + opportunities: HighwayOpportunity[]; + trace?: HighwaysTrace; + summary: string; +} + +interface RouteCandidate { + operation: string; + shape?: string; + entryPoint: HighwayStep; + sink: HighwayStep; + steps: HighwayStep[]; + confidence: CallConfidence; +} + +interface EdgeTarget { + target: string; + confidence: CallConfidence; +} + +const KNOWN_OPERATION_VERBS = [ + "create", + "update", + "delete", + "get", + "list", + "validate", + "authenticate", + "send", + "publish", + "save", + "load", + "fetch", + "parse", + "format", + "render", + "handle", + "process", + "normalize", + "calculate", + "build", + "run", + "write", + "read", +] as const; + +const PRIMITIVE_TYPES = new Set([ + "Array", + "Boolean", + "Date", + "Error", + "Map", + "Number", + "Promise", + "Record", + "Set", + "String", + "boolean", + "never", + "null", + "number", + "string", + "undefined", + "unknown", + "void", +]); + +function hashId(parts: readonly string[]): string { + return createHash("sha1").update(parts.join("\0")).digest("hex").slice(0, 10); +} + +function splitWords(value: string): string[] { + return value + .replace(/^[^.]+\./, "") + .replace(/([a-z0-9])([A-Z])/g, "$1 $2") + .replace(/[^A-Za-z0-9]+/g, " ") + .trim() + .split(/\s+/) + .filter(Boolean) + .map((word) => word.toLowerCase()); +} + +function normalizedOptional(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + return trimmed ? trimmed.toLowerCase() : undefined; +} + +function symbolHasOperation(symbol: string, operation: string): boolean { + return splitWords(symbol).includes(operation.toLowerCase()); +} + +function inferOperation(steps: readonly HighwayStep[], requested?: string): string { + if (requested) return requested; + for (const step of steps) { + const words = splitWords(step.symbol); + const verb = KNOWN_OPERATION_VERBS.find((candidate) => words.includes(candidate)); + if (verb) return verb; + } + return "unknown"; +} + +function symbolStep(symbol: SymbolNode): HighwayStep { + return { id: symbol.id, file: symbol.file, symbol: symbol.name }; +} + +function collectTypeNames(symbols: readonly SymbolNode[]): string[] { + const names = new Set(); + for (const symbol of symbols) { + const facts = symbol.typeFacts; + if (!facts) continue; + for (const name of [...facts.consumes, ...facts.produces]) { + if (!PRIMITIVE_TYPES.has(name)) names.add(name); + } + } + return [...names].sort(); +} + +function inferShape(symbols: readonly SymbolNode[], requested?: string): string | undefined { + if (requested) return requested; + return collectTypeNames(symbols)[0]; +} + +function routeMatchesShape(symbols: readonly SymbolNode[], requestedShape: string | undefined): boolean { + if (!requestedShape) return true; + return collectTypeNames(symbols).includes(requestedShape); +} + +function maxConfidence(left: CallConfidence, right: CallConfidence): CallConfidence { + return left === "type-resolved" || right === "type-resolved" ? "type-resolved" : "text-inferred"; +} + +function buildSymbolMap(graph: CodebaseGraph): Map { + return new Map(graph.symbolNodes.map((symbol) => [symbol.id, symbol])); +} + +function buildOutEdges(graph: CodebaseGraph): Map { + const outEdges = new Map(); + for (const edge of graph.callEdges) { + const targets = outEdges.get(edge.source) ?? []; + targets.push({ target: edge.target, confidence: edge.confidence }); + outEdges.set(edge.source, targets); + } + for (const targets of outEdges.values()) { + targets.sort((left, right) => left.target.localeCompare(right.target)); + } + return outEdges; +} + +function routeId(steps: readonly HighwayStep[]): string { + return `route-${hashId(steps.map((step) => `${step.file}::${step.symbol}`))}`; +} + +function toHighwayRoute(candidate: RouteCandidate, canonicalNode: HighwayStep): HighwayRoute { + return { + id: routeId(candidate.steps), + operation: candidate.operation, + shape: candidate.shape, + entryPoint: candidate.entryPoint, + sink: candidate.sink, + steps: candidate.steps, + includesCanonical: candidate.steps.some((step) => step.id === canonicalNode.id), + confidence: candidate.confidence, + }; +} + +function enumerateRoutes(graph: CodebaseGraph, options: HighwaysOptions): RouteCandidate[] { + const requestedOperation = normalizedOptional(options.operation); + const requestedShape = options.shape?.trim(); + const symbolsById = buildSymbolMap(graph); + const outEdges = buildOutEdges(graph); + const routes: RouteCandidate[] = []; + const maxDepth = 8; + + for (const entryPoint of detectEntryPoints(graph)) { + const entrySymbol = symbolsById.get(entryPoint.symbolId); + if (!entrySymbol) continue; + + const visit = ( + currentId: string, + currentSteps: SymbolNode[], + visited: ReadonlySet, + confidence: CallConfidence, + ): void => { + const targets = outEdges.get(currentId) ?? []; + const nextTargets = targets.filter((target) => !visited.has(target.target) && symbolsById.has(target.target)); + if (nextTargets.length === 0 || currentSteps.length >= maxDepth) { + const steps = currentSteps.map(symbolStep); + const operation = inferOperation(steps, requestedOperation); + if (requestedOperation && !steps.some((step) => symbolHasOperation(step.symbol, requestedOperation))) return; + if (!routeMatchesShape(currentSteps, requestedShape)) return; + const sink = steps[steps.length - 1]; + routes.push({ + operation, + shape: inferShape(currentSteps, requestedShape), + entryPoint: steps[0], + sink, + steps, + confidence, + }); + return; + } + + for (const target of nextTargets) { + const targetSymbol = symbolsById.get(target.target); + if (!targetSymbol) continue; + const nextVisited = new Set(visited); + nextVisited.add(target.target); + visit( + target.target, + [...currentSteps, targetSymbol], + nextVisited, + maxConfidence(confidence, target.confidence), + ); + } + }; + + visit(entryPoint.symbolId, [entrySymbol], new Set([entryPoint.symbolId]), "text-inferred"); + } + + return routes.sort((left, right) => + left.sink.file.localeCompare(right.sink.file) + || left.sink.symbol.localeCompare(right.sink.symbol) + || left.entryPoint.symbol.localeCompare(right.entryPoint.symbol) + || routeId(left.steps).localeCompare(routeId(right.steps)) + ); +} + +function selectCanonicalNode(routes: readonly RouteCandidate[], operation: string): HighwayStep | undefined { + const candidates = new Map(); + for (const route of routes) { + const seenInRoute = new Set(); + for (const step of route.steps.slice(1, -1)) { + if (seenInRoute.has(step.id)) continue; + seenInRoute.add(step.id); + const existing = candidates.get(step.id); + const operationMatch = symbolHasOperation(step.symbol, operation); + if (existing) { + candidates.set(step.id, { + step, + routeCount: existing.routeCount + 1, + operationMatch: existing.operationMatch || operationMatch, + }); + } else { + candidates.set(step.id, { step, routeCount: 1, operationMatch }); + } + } + } + + return [...candidates.values()] + .filter((candidate) => candidate.routeCount < routes.length) + .sort((left, right) => + Number(right.operationMatch) - Number(left.operationMatch) + || right.routeCount - left.routeCount + || left.step.file.localeCompare(right.step.file) + || left.step.symbol.localeCompare(right.step.symbol) + )[0]?.step; +} + +function directCallees(outEdges: Map, symbolsById: Map, symbolId: string): HighwayStep[] { + return (outEdges.get(symbolId) ?? []) + .map((edge) => symbolsById.get(edge.target)) + .filter((symbol): symbol is SymbolNode => Boolean(symbol)) + .map(symbolStep) + .sort((left, right) => left.file.localeCompare(right.file) || left.symbol.localeCompare(right.symbol)); +} + +function uniqueSteps(steps: readonly HighwayStep[]): HighwayStep[] { + const byId = new Map(); + for (const step of steps) byId.set(step.id, step); + return [...byId.values()].sort((left, right) => left.file.localeCompare(right.file) || left.symbol.localeCompare(right.symbol)); +} + +function createContextPack( + kind: HighwayOpportunityKind, + operation: string, + sink: HighwayStep, + canonicalNode: HighwayStep, + bypassRoutes: readonly HighwayRoute[], + evidence: readonly string[], + blastRadius: number, +): HighwayContextPack { + const affectedRoutes = [...new Set(bypassRoutes.map((route) => route.entryPoint.symbol))].sort(); + return { + summary: `${kind} routes for ${operation} reach ${sink.symbol} without ${canonicalNode.symbol}.`, + affectedRoutes, + evidence: [...evidence], + blastRadius, + proposedCanonicalNode: canonicalNode, + nextSafeCommand: `codebase-intelligence impact . ${canonicalNode.symbol}`, + }; +} + +function createOpportunity( + kind: HighwayOpportunityKind, + operation: string, + shape: string | undefined, + sink: HighwayStep, + canonicalNode: HighwayStep, + routes: HighwayRoute[], + bypassRoutes: HighwayRoute[], + duplicatedCallees?: HighwayStep[], +): HighwayOpportunity { + const routeFiles = new Set(routes.flatMap((route) => route.steps.map((step) => step.file))); + const blastRadius = routeFiles.size; + const evidence = [ + `operation=${operation}`, + shape ? `shape=${shape}` : "", + `sink=${sink.file}::${sink.symbol}`, + `canonical=${canonicalNode.file}::${canonicalNode.symbol}`, + `routes=${routes.length}`, + `bypasses=${bypassRoutes.length}`, + duplicatedCallees && duplicatedCallees.length > 0 + ? `duplicatedCallees=${duplicatedCallees.map((step) => step.symbol).join(",")}` + : "", + ].filter(Boolean); + const id = `hwy-${kind}-${hashId([kind, operation, shape ?? "", sink.id, canonicalNode.id])}`; + const routeNames = [...new Set(bypassRoutes.map((route) => route.entryPoint.symbol))].sort(); + const recommendation = `Route ${routeNames.join(", ")} through ${canonicalNode.symbol} before ${sink.symbol}.`; + return { + id, + kind, + operation, + shape, + sink, + canonicalNode, + routes, + bypassRoutes, + duplicatedCallees, + evidence, + blastRadius, + recommendation, + contextPack: createContextPack(kind, operation, sink, canonicalNode, bypassRoutes, evidence, blastRadius), + }; +} + +function routeGroupKey(route: RouteCandidate): string { + return [route.sink.id, route.operation, route.shape ?? ""].join("\0"); +} + +function createOpportunities(graph: CodebaseGraph, routes: readonly RouteCandidate[], minRoutes: number): HighwayOpportunity[] { + const routesByGroup = new Map(); + for (const route of routes) { + const sinkRoutes = routesByGroup.get(routeGroupKey(route)) ?? []; + sinkRoutes.push(route); + routesByGroup.set(routeGroupKey(route), sinkRoutes); + } + + const symbolsById = buildSymbolMap(graph); + const outEdges = buildOutEdges(graph); + const opportunities: HighwayOpportunity[] = []; + + for (const sinkRoutes of routesByGroup.values()) { + if (sinkRoutes.length < minRoutes) continue; + const operation = sinkRoutes[0]?.operation ?? "unknown"; + const canonicalNode = selectCanonicalNode(sinkRoutes, operation); + if (!canonicalNode) continue; + const routesForSink = sinkRoutes.map((route) => toHighwayRoute(route, canonicalNode)); + const bypassRoutes = routesForSink.filter((route) => !route.includesCanonical); + if (bypassRoutes.length === 0) continue; + const sink = routesForSink[0]?.sink; + const shape = sinkRoutes.find((route) => route.shape)?.shape; + + opportunities.push(createOpportunity("bypass", operation, shape, sink, canonicalNode, routesForSink, bypassRoutes)); + + const canonicalCallees = directCallees(outEdges, symbolsById, canonicalNode.id); + const canonicalCalleeIds = new Set(canonicalCallees.map((step) => step.id)); + const duplicated = uniqueSteps( + bypassRoutes.flatMap((route) => directCallees(outEdges, symbolsById, route.entryPoint.id)) + .filter((step) => canonicalCalleeIds.has(step.id)), + ); + if (duplicated.length >= 2) { + opportunities.push(createOpportunity("cowpath", operation, shape, sink, canonicalNode, routesForSink, bypassRoutes, duplicated)); + } + } + + return opportunities.sort((left, right) => + right.bypassRoutes.length - left.bypassRoutes.length + || right.blastRadius - left.blastRadius + || left.sink.file.localeCompare(right.sink.file) + || left.sink.symbol.localeCompare(right.sink.symbol) + || left.kind.localeCompare(right.kind) + ); +} + +/** + * Analyze repeated call routes that should converge on one canonical operation path. + */ +export function computeHighways(graph: CodebaseGraph, options: HighwaysOptions = {}): HighwaysResult { + const minRoutes = options.minRoutes ?? 2; + const normalizedOptions = { + ...options, + operation: normalizedOptional(options.operation), + shape: options.shape?.trim(), + minRoutes, + }; + const routes = enumerateRoutes(graph, normalizedOptions); + const opportunities = createOpportunities(graph, routes, minRoutes); + const sinks = new Set(routes.map((route) => route.sink.id)); + const trace = normalizedOptions.trace + ? { + id: normalizedOptions.trace, + found: opportunities.some((opportunity) => opportunity.id === normalizedOptions.trace), + opportunity: opportunities.find((opportunity) => opportunity.id === normalizedOptions.trace), + } + : undefined; + + const visibleOpportunities = trace?.found && trace.opportunity ? [trace.opportunity] : opportunities; + + return { + totalRoutes: routes.length, + totalSinks: sinks.size, + totalOpportunities: opportunities.length, + operation: normalizedOptions.operation, + shape: normalizedOptions.shape, + minRoutes, + opportunities: visibleOpportunities, + trace, + summary: opportunities.length > 0 + ? `${opportunities.length} highway opportunities across ${sinks.size} sinks.` + : "No highway opportunities found.", + }; +} diff --git a/src/mcp/hints.ts b/src/mcp/hints.ts index c42170d..af50f49 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", ], + highways: [ + "Use impact_analysis on the proposed canonical node before editing", + "Use symbol_context on bypass route entry points to inspect call chains", + "Use get_processes to compare raw execution flows against highway findings", + ], clusters: [ "Use file_context on files within a cluster for detailed metrics", "Use get_module_structure to compare clusters against directory structure", diff --git a/src/mcp/index.ts b/src/mcp/index.ts index 8a2a72a..8a0e7ac 100644 --- a/src/mcp/index.ts +++ b/src/mcp/index.ts @@ -115,6 +115,7 @@ 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.highways); registerOperationTool(server, graph, operations.clusters); // MCP Prompts diff --git a/src/operations/formatters.ts b/src/operations/formatters.ts index 9ff5612..279093e 100644 --- a/src/operations/formatters.ts +++ b/src/operations/formatters.ts @@ -19,6 +19,7 @@ import type { SymbolContextError, SymbolContextResult, } from "../core/index.js"; +import type { HighwaysResult } from "../highways/index.js"; import type { ImpactResult, RenameResult } from "../impact/index.js"; function text(lines: readonly string[]): string { @@ -586,6 +587,44 @@ export function formatProcessesText(result: ProcessesResult): string { return text(lines); } +/** + * Format a highways operation result for human CLI output. + */ +export function formatHighwaysText(result: HighwaysResult): string { + const lines = [ + `Highways (${result.opportunities.length} of ${result.totalOpportunities})`, + "─".repeat(30), + `Routes: ${result.totalRoutes}`, + `Sinks: ${result.totalSinks}`, + `Min routes: ${result.minRoutes}`, + result.operation ? `Operation: ${result.operation}` : "", + result.shape ? `Shape: ${result.shape}` : "", + "", + result.summary, + ].filter(Boolean); + + if (result.trace) { + lines.push("", `Trace: ${result.trace.id} (${result.trace.found ? "found" : "not found"})`); + } + + for (const opportunity of result.opportunities) { + lines.push( + "", + `${opportunity.id} [${opportunity.kind}] ${opportunity.operation} → ${opportunity.sink.symbol}`, + ` Canonical: ${opportunity.canonicalNode.file}::${opportunity.canonicalNode.symbol}`, + ` Bypass routes: ${opportunity.bypassRoutes.map((route) => route.entryPoint.symbol).join(", ")}`, + ` Blast radius: ${opportunity.blastRadius}`, + ` Evidence: ${opportunity.evidence.join("; ")}`, + ` Next: ${opportunity.contextPack.nextSafeCommand}`, + ); + if (opportunity.duplicatedCallees && opportunity.duplicatedCallees.length > 0) { + lines.push(` Duplicated callees: ${opportunity.duplicatedCallees.map((step) => step.symbol).join(", ")}`); + } + } + + return text(lines); +} + /** * Format a clusters operation result for human CLI output. */ diff --git a/src/operations/index.ts b/src/operations/index.ts index deee03a..c79644e 100644 --- a/src/operations/index.ts +++ b/src/operations/index.ts @@ -21,6 +21,7 @@ import { renameSymbol, } from "../core/index.js"; import { DUPLICATION_MODES } from "../duplication/index.js"; +import { computeHighways } from "../highways/index.js"; import type { CodebaseGraph } from "../types/index.js"; import { formatChangesText, @@ -31,6 +32,7 @@ import { formatFileContextText, formatForcesText, formatGroupsText, + formatHighwaysText, formatHotspotsText, formatImpactText, formatModuleStructureText, @@ -59,6 +61,7 @@ export const operationNames = [ "impact", "rename", "processes", + "highways", "clusters", ] as const; @@ -157,6 +160,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 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"), + minRoutes: z.number().int().positive().optional().describe("Minimum routes reaching a sink before reporting (default: 2)"), + propose: z.boolean().optional().describe("Include reroute proposal metadata (default: false)"), + trace: z.string().min(1).optional().describe("Return route evidence for one highway opportunity id"), +} satisfies z.ZodRawShape; +const highwaysInputSchema = z.object(highwaysInputShape).strict(); const clustersInputShape = { minFiles: z.number().int().positive().optional().describe("Filter clusters with at least N files (default: 0)"), } satisfies z.ZodRawShape; @@ -178,6 +189,7 @@ type ChangesInput = z.infer; type ImpactInput = z.infer; type RenameInput = z.infer; type ProcessesInput = z.infer; +type HighwaysInput = z.infer; type ClustersInput = z.infer; export const operations = { @@ -344,6 +356,16 @@ export const operations = { run: (graph: CodebaseGraph, input: ProcessesInput) => computeProcesses(graph, input.entryPoint, input.limit), formatText: formatProcessesText, } satisfies Operation>, + highways: { + name: "highways", + cliCommand: "highways", + mcpTool: "analyze_highways", + description: "Detect repeated entry-to-sink routes that should converge on one canonical operation path. Finds bypass and cowpath routes with route chains, blast radius, evidence, and a safe reroute context pack. Use when: enforcing dataflow discipline, finding ad-hoc routes, or planning canonical shared paths. Not for: generic process traces (use get_processes)", + inputShape: highwaysInputShape, + inputSchema: highwaysInputSchema, + run: (graph: CodebaseGraph, input: HighwaysInput) => computeHighways(graph, input), + formatText: formatHighwaysText, + } satisfies Operation>, clusters: { name: "clusters", cliCommand: "clusters", diff --git a/tests/highways.e2e.test.ts b/tests/highways.e2e.test.ts new file mode 100644 index 0000000..0befabc --- /dev/null +++ b/tests/highways.e2e.test.ts @@ -0,0 +1,292 @@ +import { execFile, execSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import type { Stream } from "node:stream"; +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 { afterEach, 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 HighwayRouteStep { + file: string; + symbol: string; +} + +interface HighwayRoute { + entryPoint: HighwayRouteStep; + steps: HighwayRouteStep[]; + includesCanonical: boolean; +} + +interface HighwayOpportunity { + id: string; + kind: string; + operation: string; + shape?: string; + sink: HighwayRouteStep; + canonicalNode: HighwayRouteStep; + routes: HighwayRoute[]; + duplicatedCallees?: HighwayRouteStep[]; + contextPack: { + proposedCanonicalNode: HighwayRouteStep; + affectedRoutes: string[]; + nextSafeCommand: string; + }; +} + +interface HighwaysPayload { + totalRoutes: number; + totalSinks: number; + totalOpportunities: number; + opportunities: HighwayOpportunity[]; + trace?: { + id: string; + found: boolean; + opportunity?: HighwayOpportunity; + }; + cache?: unknown; + nextSteps?: unknown; +} + +const created: string[] = []; + +beforeAll(() => { + execSync("pnpm build", { cwd: repoRoot, stdio: "inherit" }); +}, 120_000); + +afterEach(() => { + for (const dir of created.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +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 isRouteStep(value: unknown): value is HighwayRouteStep { + return isRecord(value) && typeof value.file === "string" && typeof value.symbol === "string"; +} + +function isHighwaysPayload(value: unknown): value is HighwaysPayload { + if (!isRecord(value)) return false; + if (typeof value.totalRoutes !== "number") return false; + if (typeof value.totalSinks !== "number") return false; + if (typeof value.totalOpportunities !== "number") return false; + if (!Array.isArray(value.opportunities)) return false; + return value.opportunities.every((item) => { + if (!isRecord(item)) return false; + if (typeof item.id !== "string" || typeof item.kind !== "string" || typeof item.operation !== "string") return false; + if (!isRouteStep(item.sink) || !isRouteStep(item.canonicalNode)) return false; + return Array.isArray(item.routes); + }); +} + +function collectStream(stream: Stream | null): () => string { + let output = ""; + stream?.on("data", (chunk: Buffer | string) => { + output += chunk.toString(); + }); + return () => output; +} + +function parsePayload(stdout: string): HighwaysPayload { + const parsed: unknown = JSON.parse(stdout); + if (!isHighwaysPayload(parsed)) throw new Error("Expected highways JSON object"); + return parsed; +} + +function comparable(payload: HighwaysPayload): 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 : {}; + const code = err.code; + const stdout = err.stdout; + const stderr = err.stderr; + return { + status: typeof code === "number" ? code : 1, + stdout: typeof stdout === "string" ? stdout : "", + stderr: typeof stderr === "string" ? stderr : "", + }; + } +} + +function makeHighwaysProject(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "cbi-highways-")); + created.push(dir); + + const files: Record = { + "src/types.ts": "export interface UserDraft { email: string; name: string; }\nexport interface User { id: string; email: string; name: string; }\n", + "src/normalize.ts": "import type { UserDraft } from './types.js';\nexport function normalizeUserDraft(draft: UserDraft): UserDraft { return { ...draft, email: draft.email.trim().toLowerCase() }; }\n", + "src/validation.ts": "import type { UserDraft } from './types.js';\nexport function validateUserDraft(draft: UserDraft): UserDraft { if (!draft.email) throw new Error('email required'); return draft; }\n", + "src/repository.ts": "import type { User, UserDraft } from './types.js';\nexport function insertUser(draft: UserDraft): User { return { id: 'u_1', email: draft.email, name: draft.name }; }\n", + "src/service.ts": [ + "import type { User, UserDraft } from './types.js';", + "import { normalizeUserDraft } from './normalize.js';", + "import { validateUserDraft } from './validation.js';", + "import { insertUser } from './repository.js';", + "export function createUser(draft: UserDraft): User {", + " const normalized = normalizeUserDraft(draft);", + " const valid = validateUserDraft(normalized);", + " return insertUser(valid);", + "}", + "", + ].join("\n"), + "src/routes.ts": [ + "import type { User, UserDraft } from './types.js';", + "import { normalizeUserDraft } from './normalize.js';", + "import { validateUserDraft } from './validation.js';", + "import { insertUser } from './repository.js';", + "import { createUser } from './service.js';", + "export function createUserFromRest(draft: UserDraft): User {", + " return createUser(draft);", + "}", + "export function createUserFromWebhook(draft: UserDraft): User {", + " const normalized = normalizeUserDraft(draft);", + " const valid = validateUserDraft(normalized);", + " return insertUser(valid);", + "}", + "export function createUserFromAdmin(draft: UserDraft): User {", + " const normalized = normalizeUserDraft(draft);", + " const valid = validateUserDraft(normalized);", + " return insertUser(valid);", + "}", + "", + ].join("\n"), + }; + + for (const [rel, content] of Object.entries(files)) { + const full = path.join(dir, rel); + fs.mkdirSync(path.dirname(full), { recursive: true }); + fs.writeFileSync(full, content); + } + + return path.join(dir, "src"); +} + +function findOpportunity(payload: HighwaysPayload, kind: string): HighwayOpportunity { + const opportunity = payload.opportunities.find((item) => + item.kind === kind + && item.sink.symbol === "insertUser" + && item.canonicalNode.symbol === "createUser" + ); + if (!opportunity) throw new Error(`Expected ${kind} opportunity for createUser -> insertUser`); + return opportunity; +} + +describe("CH-P2-01 highways reroute", () => { + it("detects bypass and cowpath routes through CLI JSON, trace, and MCP parity", async () => { + const src = makeHighwaysProject(); + + const cliRun = await run(["highways", src, "--operation", "create", "--shape", "UserDraft", "--min-routes", "3", "--json", "--force"]); + expect(cliRun.status).toBe(0); + expect(cliRun.stderr).toContain("Parsed"); + const cliPayload = parsePayload(cliRun.stdout); + + expect(cliPayload.totalRoutes).toBeGreaterThanOrEqual(3); + expect(cliPayload.totalSinks).toBeGreaterThanOrEqual(1); + expect(cliPayload.totalOpportunities).toBeGreaterThanOrEqual(2); + + const bypass = findOpportunity(cliPayload, "bypass"); + expect(bypass.id).toMatch(/^hwy-bypass-[a-f0-9]{10}$/); + expect(bypass.operation).toBe("create"); + expect(bypass.shape).toBe("UserDraft"); + expect(bypass.routes.map((route) => route.entryPoint.symbol).sort()).toEqual([ + "createUserFromAdmin", + "createUserFromRest", + "createUserFromWebhook", + ]); + expect(bypass.routes.filter((route) => !route.includesCanonical).map((route) => route.entryPoint.symbol).sort()).toEqual([ + "createUserFromAdmin", + "createUserFromWebhook", + ]); + expect(bypass.contextPack.proposedCanonicalNode.symbol).toBe("createUser"); + expect(bypass.contextPack.affectedRoutes).toContain("createUserFromWebhook"); + expect(bypass.contextPack.nextSafeCommand).toContain("codebase-intelligence impact"); + + const cowpath = findOpportunity(cliPayload, "cowpath"); + expect(cowpath.id).toMatch(/^hwy-cowpath-[a-f0-9]{10}$/); + expect(cowpath.duplicatedCallees?.map((step) => step.symbol).sort()).toEqual([ + "insertUser", + "normalizeUserDraft", + "validateUserDraft", + ]); + + const stableIdRun = await run(["highways", src, "--operation", "create", "--shape", "UserDraft", "--min-routes", "3", "--json", "--force"]); + expect(stableIdRun.status).toBe(0); + const stableIdPayload = parsePayload(stableIdRun.stdout); + expect(stableIdPayload.opportunities.map((item) => item.id)).toEqual(cliPayload.opportunities.map((item) => item.id)); + + const traceRun = await run(["highways", src, "--operation", "create", "--shape", "UserDraft", "--trace", bypass.id, "--json"]); + expect(traceRun.status).toBe(0); + const tracePayload = parsePayload(traceRun.stdout); + expect(tracePayload.trace).toMatchObject({ id: bypass.id, found: true }); + expect(tracePayload.trace?.opportunity?.routes[0]?.steps.length).toBeGreaterThanOrEqual(2); + + const transport = new StdioClientTransport({ + command: "node", + args: [cli, src, "--force"], + cwd: repoRoot, + stderr: "pipe", + }); + const stderr = collectStream(transport.stderr); + const client = new Client({ name: "highways-e2e", version: "0.1.0" }); + await client.connect(transport); + try { + const result = await client.callTool({ + name: "analyze_highways", + arguments: { operation: "create", shape: "UserDraft", minRoutes: 3 }, + }); + const mcpPayloadRecord = textPayload(result); + if (!isHighwaysPayload(mcpPayloadRecord)) throw new Error("Expected MCP highways payload"); + const mcpPayload = mcpPayloadRecord; + expect(mcpPayload.nextSteps).toBeDefined(); + expect(comparable(mcpPayload)).toEqual(comparable(cliPayload)); + } finally { + await client.close(); + await transport.close(); + } + expect(stderr()).toContain("Parsed"); + }, 120_000); +}); diff --git a/tests/mcp-tools.test.ts b/tests/mcp-tools.test.ts index 8c9a42f..63052a6 100644 --- a/tests/mcp-tools.test.ts +++ b/tests/mcp-tools.test.ts @@ -411,7 +411,19 @@ describe("Tool 15: get_processes", () => { }); }); -describe("Tool 16: get_clusters", () => { +describe("Tool 16: analyze_highways", () => { + it("returns highway opportunities envelope", async () => { + const r = await callTool("analyze_highways", { operation: "get", minRoutes: 2 }); + expect(r).toHaveProperty("totalRoutes"); + expect(r).toHaveProperty("totalSinks"); + expect(r).toHaveProperty("totalOpportunities"); + expect(r).toHaveProperty("opportunities"); + expect(r).toHaveProperty("nextSteps"); + expect(Array.isArray(r.opportunities)).toBe(true); + }); +}); + +describe("Tool 17: get_clusters", () => { it("returns community-detected clusters", async () => { const r = await callTool("get_clusters"); expect(r).toHaveProperty("clusters"); @@ -486,6 +498,7 @@ describe("MCP Resources", () => { 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"); }); }); diff --git a/tests/operation-registry.e2e.test.ts b/tests/operation-registry.e2e.test.ts index 9631351..30369cb 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.highways, + { operation: "get", minRoutes: 2 }, + ["highways", getFixtureSrcPath(), "--operation", "get", "--min-routes", "2"], + codebaseGraph, + {}, + cachedRun, + ); expectCliMatchesRegistry( operations.clusters, { minFiles: 2 }, @@ -449,6 +457,14 @@ describe("operation registry chained parity", () => { {}, cachedRun, ); + expectCliTextMatchesFormatter( + operations.highways, + { operation: "get", minRoutes: 2 }, + ["highways", getFixtureSrcPath(), "--operation", "get", "--min-routes", "2"], + codebaseGraph, + {}, + cachedRun, + ); expectCliTextMatchesFormatter( operations.clusters, { minFiles: 2 }, @@ -709,6 +725,13 @@ describe("operation registry chained parity", () => { codebaseGraph, mcp, ); + await expectMcpMatchesRegistry( + operations.highways, + { operation: "get", minRoutes: 2 }, + { operation: "get", minRoutes: 2 }, + codebaseGraph, + mcp, + ); await expectMcpMatchesRegistry( operations.clusters, { minFiles: 2 }, diff --git a/tests/operation-registry.test.ts b/tests/operation-registry.test.ts index f468147..ab995c8 100644 --- a/tests/operation-registry.test.ts +++ b/tests/operation-registry.test.ts @@ -38,6 +38,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: "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 } }, ]; diff --git a/tools/verify-cli-real-codebases.mjs b/tools/verify-cli-real-codebases.mjs index ac5b250..362e935 100644 --- a/tools/verify-cli-real-codebases.mjs +++ b/tools/verify-cli-real-codebases.mjs @@ -372,9 +372,12 @@ for (const inputTarget of targets) { return `${result.totalAffected} affected`; }); - for (const command of ["modules", "forces", "dead-exports", "opportunities", "groups", "processes", "clusters"]) { + for (const command of ["modules", "forces", "dead-exports", "opportunities", "groups", "processes", "highways", "clusters"]) { record(`${target.name}: ${command}`, () => { - const result = json([command, target.path]); + const args = command === "highways" + ? [command, target.path, "--operation", "get", "--min-routes", "3"] + : [command, target.path]; + const result = json(args); if (!result || typeof result !== "object") throw new Error("invalid JSON object"); return "json ok"; });