From f0817412b3228babba091cdfae0466d87bbcb06f Mon Sep 17 00:00:00 2001 From: bntvllnt <32437578+bntvllnt@users.noreply.github.com> Date: Wed, 1 Jul 2026 11:58:19 +0200 Subject: [PATCH] feat: add highway synthesis proposals --- README.md | 4 +- docs/architecture.md | 4 +- docs/cli-reference.md | 2 +- docs/data-model.md | 21 ++- docs/mcp-tools.md | 2 +- llms-full.txt | 6 +- llms.txt | 2 +- roadmap.md | 4 +- src/highways/index.ts | 274 ++++++++++++++++++++++++++++++++++- src/operations/formatters.ts | 7 + tests/highways.e2e.test.ts | 156 ++++++++++++++++++++ 11 files changed, 462 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index a1bc96a..df2b84c 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,7 @@ 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 +- **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 @@ -111,7 +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 | +| `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 | | `init` | Set up AI agents to use CI — writes per-agent instruction files (skill opt-in via `--skill`) | diff --git a/docs/architecture.md b/docs/architecture.md index 53b8838..0446f1f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -47,7 +47,7 @@ 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) - highways/index.ts <- Repeated route convergence + bypass/cowpath evidence + highways/index.ts <- Repeated route convergence + bypass/cowpath/synthesis 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 @@ -97,7 +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. +- **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. - **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 9b4f45c..77e7674 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -176,7 +176,7 @@ Find repeated routes that should converge on one canonical operation path. 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. +**Output:** route opportunities with `bypass`, `cowpath`, or `synthesis` kind, operation, shape, sink, canonical/proposed node, route chains, bypass routes, duplicated callees when present, evidence, blast radius, recommendation, context pack, and optional synthesis proposal (`name`, `file`, `signature`, `skeleton`, `reroutePlan`, `cycleSafety`). ### clusters diff --git a/docs/data-model.md b/docs/data-model.md index b75381f..77d4429 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -194,14 +194,30 @@ HighwaysResult { HighwayOpportunity { id: string // hwy-- - kind: "bypass" | "cowpath" + kind: "bypass" | "cowpath" | "synthesis" operation: string shape?: string sink: HighwayStep - canonicalNode: HighwayStep + canonicalNode: HighwayStep // proposed=true for synthesis findings routes: HighwayRoute[] // all routes reaching the sink bypassRoutes: HighwayRoute[] // routes missing canonicalNode duplicatedCallees?: HighwayStep[] // cowpath overlap with canonical node callees + proposal?: { + name: string + file: string + signature: string + skeleton: string + reroutePlan: Array<{ + entryPoint: string + replaceSteps: string[] + call: string + }> + cycleSafety: { + safe: boolean + checkedEdges: string[] + reason: string + } + } evidence: string[] blastRadius: number recommendation: string @@ -230,6 +246,7 @@ HighwayStep { id: string file: string symbol: string + proposed?: boolean } ``` diff --git a/docs/mcp-tools.md b/docs/mcp-tools.md index 4852ad3..e9ff6d6 100644 --- a/docs/mcp-tools.md +++ b/docs/mcp-tools.md @@ -174,7 +174,7 @@ Trace execution flows from entry points through the call graph. 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 +**Returns:** totalRoutes, totalSinks, totalOpportunities, opportunities[] (id, kind, operation, shape, sink, canonicalNode, routes[], bypassRoutes[], duplicatedCallees?, proposal?, evidence[], blastRadius, recommendation, contextPack), optional trace. With `propose: true`, no-canonical route groups can return `kind: "synthesis"` with proposed name/file/signature/skeleton/reroutePlan/cycleSafety. **Use when:** Enforcing dataflow discipline, finding ad-hoc routes, or planning canonical shared paths. **Not for:** Raw execution flow listing (use get_processes). diff --git a/llms-full.txt b/llms-full.txt index 2bec7c0..1b1422d 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -51,7 +51,7 @@ 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 - highways/index.ts <- Repeated route convergence + bypass/cowpath evidence + highways/index.ts <- Repeated route convergence + bypass/cowpath/synthesis 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 @@ -313,7 +313,7 @@ Reference finder for rename planning. Input: `{ oldName: string, newName: string Entry point execution flows. Input: `{ entryPoint?: string, limit?: number }`. Returns: processes with steps and depth. ## 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. +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 Community-detected file clusters. Input: `{ minFiles?: number }`. Returns: clusters with cohesion. @@ -454,7 +454,7 @@ Entry point execution flows through the call graph. ```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. +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. With `--propose`, no-canonical route groups can return `synthesis` findings with proposed name, file, signature, skeleton, reroute plan, and cycle-safety check. ### clusters ```bash diff --git a/llms.txt b/llms.txt index 40530c0..3c6c49d 100644 --- a/llms.txt +++ b/llms.txt @@ -35,7 +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 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 codebase-intelligence init . # make AI agents use CI diff --git a/roadmap.md b/roadmap.md index 4d58d27..8da74eb 100644 --- a/roadmap.md +++ b/roadmap.md @@ -397,9 +397,9 @@ entry ─► transform ─► validate ─► sink **Remaining:** -- H2: add type-shape grouping once Type/Shape layer lands. +- H2 shipped: add type-shape grouping once Type/Shape layer lands. +- H2 shipped: synthesize new highway proposals: name, location, signature, skeleton, cycle-safe reroute plan. - 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`. ### Scope Graph + Codebase Map diff --git a/src/highways/index.ts b/src/highways/index.ts index 55c2c33..cfcc989 100644 --- a/src/highways/index.ts +++ b/src/highways/index.ts @@ -2,12 +2,13 @@ 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 type HighwayOpportunityKind = "bypass" | "cowpath" | "synthesis"; export interface HighwayStep { id: string; file: string; symbol: string; + proposed?: boolean; } export interface HighwayRoute { @@ -30,6 +31,27 @@ export interface HighwayContextPack { nextSafeCommand: string; } +export interface HighwayReroutePlan { + entryPoint: string; + replaceSteps: string[]; + call: string; +} + +export interface HighwayCycleSafety { + safe: boolean; + checkedEdges: string[]; + reason: string; +} + +export interface HighwayProposal { + name: string; + file: string; + signature: string; + skeleton: string; + reroutePlan: HighwayReroutePlan[]; + cycleSafety: HighwayCycleSafety; +} + export interface HighwayOpportunity { id: string; kind: HighwayOpportunityKind; @@ -40,6 +62,7 @@ export interface HighwayOpportunity { routes: HighwayRoute[]; bypassRoutes: HighwayRoute[]; duplicatedCallees?: HighwayStep[]; + proposal?: HighwayProposal; evidence: string[]; blastRadius: number; recommendation: string; @@ -338,6 +361,206 @@ function uniqueSteps(steps: readonly HighwayStep[]): HighwayStep[] { return [...byId.values()].sort((left, right) => left.file.localeCompare(right.file) || left.symbol.localeCompare(right.symbol)); } +function capitalize(value: string): string { + return `${value[0].toUpperCase()}${value.slice(1)}`; +} + +function pascalWords(value: string): string { + return splitWords(value).map(capitalize).join(""); +} + +function kebabWords(value: string): string { + return splitWords(value).join("-"); +} + +function commonDirectory(files: readonly string[]): string { + const directories = files.map((file) => file.includes("/") ? file.slice(0, file.lastIndexOf("/")) : ""); + const first = directories[0] ?? ""; + return directories.every((directory) => directory === first) ? first : ""; +} + +function proposedHighwayName(operation: string, shape: string | undefined, sink: HighwayStep): string { + const suffix = shape ? pascalWords(shape) : pascalWords(sink.symbol.replace(/^(insert|save|write|publish)/i, "")); + return `${operation}${suffix || pascalWords(sink.symbol)}`; +} + +function proposedHighwayFileForEntries(name: string, entryFiles: readonly string[]): string { + const directory = commonDirectory(entryFiles); + const filename = `${kebabWords(name)}.highway.ts`; + return directory ? `${directory}/${filename}` : filename; +} + +function variableNameForStep(symbol: string, fallbackIndex: number): string { + const words = splitWords(symbol); + if (words.includes("normalize")) return "normalized"; + if (words.includes("validate")) return "valid"; + if (words.includes("parse")) return "parsed"; + if (words.includes("format")) return "formatted"; + if (words.includes("build")) return "built"; + if (words.includes("transform")) return "transformed"; + if (words.includes("load")) return "loaded"; + if (words.includes("fetch")) return "fetched"; + return `step${fallbackIndex}`; +} + +function proposalSignature(name: string, inputType: string, outputType: string): string { + return `${name}(input: ${inputType}): ${outputType}`; +} + +function inferInputType(routes: readonly HighwayRoute[], symbolsById: Map, shape: string | undefined): string { + if (shape) return shape; + for (const route of routes) { + const entrySymbol = symbolsById.get(route.entryPoint.id); + const parameterType = entrySymbol?.typeFacts?.parameters[0]?.type; + if (parameterType) return parameterType; + } + return "unknown"; +} + +function inferOutputType(sink: HighwayStep, routes: readonly HighwayRoute[], symbolsById: Map): string { + const sinkReturnType = symbolsById.get(sink.id)?.typeFacts?.returnType; + if (sinkReturnType) return sinkReturnType; + for (const route of routes) { + const entryReturnType = symbolsById.get(route.entryPoint.id)?.typeFacts?.returnType; + if (entryReturnType) return entryReturnType; + } + return "unknown"; +} + +function proposalStepRank(step: HighwayStep, sink: HighwayStep): number { + if (step.id === sink.id) return 100; + const words = splitWords(step.symbol); + if (words.includes("parse")) return 10; + if (words.includes("normalize")) return 20; + if (words.includes("transform")) return 30; + if (words.includes("validate")) return 40; + if (words.includes("insert") || words.includes("save") || words.includes("write") || words.includes("publish")) return 100; + return 60; +} + +function commonDirectCalleesForProposal( + routes: readonly HighwayRoute[], + sink: HighwayStep, + outEdges: Map, + symbolsById: Map, +): HighwayStep[] { + const [firstRoute] = routes; + const firstCallees = directCallees(outEdges, symbolsById, firstRoute.entryPoint.id); + return firstCallees + .filter((step) => routes.every((route) => + directCallees(outEdges, symbolsById, route.entryPoint.id).some((callee) => callee.id === step.id) + )) + .sort((left, right) => + proposalStepRank(left, sink) - proposalStepRank(right, sink) + || left.file.localeCompare(right.file) + || left.symbol.localeCompare(right.symbol) + ); +} + +function proposalStepsForRoutes( + routes: readonly HighwayRoute[], + sink: HighwayStep, + outEdges: Map, + symbolsById: Map, +): HighwayStep[] { + const sharedSteps = commonDirectCalleesForProposal(routes, sink, outEdges, symbolsById); + return sharedSteps.some((step) => step.id === sink.id) ? sharedSteps : [...sharedSteps, sink]; +} + +function skeletonForProposal(name: string, inputType: string, outputType: string, steps: readonly HighwayStep[]): string { + const lines = [`export function ${name}(input: ${inputType}): ${outputType} {`]; + let currentValue = "input"; + for (const [index, step] of steps.slice(0, -1).entries()) { + const nextValue = variableNameForStep(step.symbol, index + 1); + lines.push(` const ${nextValue} = ${step.symbol}(${currentValue});`); + currentValue = nextValue; + } + const sink = steps[steps.length - 1]; + lines.push(` return ${sink.symbol}(${currentValue});`); + lines.push("}"); + return lines.join("\n"); +} + +function reachableFrom(startId: string, targetId: string, outEdges: Map): boolean { + const queue = [startId]; + const visited = new Set(); + while (queue.length > 0) { + const current = queue.shift(); + if (!current || visited.has(current)) continue; + if (current === targetId) return true; + visited.add(current); + for (const edge of outEdges.get(current) ?? []) { + if (!visited.has(edge.target)) queue.push(edge.target); + } + } + return false; +} + +function cycleSafetyForProposal( + proposalName: string, + routes: readonly HighwayRoute[], + sink: HighwayStep, + outEdges: Map, +): HighwayCycleSafety { + const entryPoints = [...new Set(routes.map((route) => route.entryPoint))].sort((left, right) => left.symbol.localeCompare(right.symbol)); + const checkedEdges = [ + ...entryPoints.map((entryPoint) => `${entryPoint.symbol} -> ${proposalName}`), + `${proposalName} -> ${sink.symbol}`, + ]; + const unsafeEntry = entryPoints.find((entryPoint) => reachableFrom(sink.id, entryPoint.id, outEdges)); + return { + safe: !unsafeEntry, + checkedEdges, + reason: unsafeEntry + ? `${sink.symbol} already reaches ${unsafeEntry.symbol}; rerouting through ${proposalName} could create a cycle.` + : `${proposalName} is a new node and ${sink.symbol} does not reach the affected entry points.`, + }; +} + +function createProposal( + operation: string, + shape: string | undefined, + sink: HighwayStep, + routes: readonly HighwayRoute[], + symbolsById: Map, + outEdges: Map, +): HighwayProposal { + const name = proposedHighwayName(operation, shape, sink); + const inputType = inferInputType(routes, symbolsById, shape); + const outputType = inferOutputType(sink, routes, symbolsById); + const sharedSteps = proposalStepsForRoutes(routes, sink, outEdges, symbolsById); + return { + name, + file: proposedHighwayFileForEntries(name, routes.map((route) => route.entryPoint.file)), + signature: proposalSignature(name, inputType, outputType), + skeleton: skeletonForProposal(name, inputType, outputType, sharedSteps), + reroutePlan: routes + .map((route) => ({ + entryPoint: route.entryPoint.symbol, + replaceSteps: sharedSteps.map((step) => step.symbol), + call: name, + })) + .sort((left, right) => left.entryPoint.localeCompare(right.entryPoint)), + cycleSafety: cycleSafetyForProposal(name, routes, sink, outEdges), + }; +} + +function createProposedStep( + operation: string, + shape: string | undefined, + sink: HighwayStep, + routes: readonly RouteCandidate[], +): HighwayStep { + const name = proposedHighwayName(operation, shape, sink); + const file = proposedHighwayFileForEntries(name, routes.map((route) => route.entryPoint.file)); + return { + id: `proposed::${hashId([operation, shape ?? "", sink.id, file, name])}`, + file, + symbol: name, + proposed: true, + }; +} + function createContextPack( kind: HighwayOpportunityKind, operation: string, @@ -346,6 +569,7 @@ function createContextPack( bypassRoutes: readonly HighwayRoute[], evidence: readonly string[], blastRadius: number, + nextSafeCommand?: string, ): HighwayContextPack { const affectedRoutes = [...new Set(bypassRoutes.map((route) => route.entryPoint.symbol))].sort(); return { @@ -354,7 +578,7 @@ function createContextPack( evidence: [...evidence], blastRadius, proposedCanonicalNode: canonicalNode, - nextSafeCommand: `codebase-intelligence impact . ${canonicalNode.symbol}`, + nextSafeCommand: nextSafeCommand ?? `codebase-intelligence impact . ${canonicalNode.symbol}`, }; } @@ -367,6 +591,7 @@ function createOpportunity( routes: HighwayRoute[], bypassRoutes: HighwayRoute[], duplicatedCallees?: HighwayStep[], + proposal?: HighwayProposal, ): HighwayOpportunity { const routeFiles = new Set(routes.flatMap((route) => route.steps.map((step) => step.file))); const blastRadius = routeFiles.size; @@ -380,6 +605,8 @@ function createOpportunity( duplicatedCallees && duplicatedCallees.length > 0 ? `duplicatedCallees=${duplicatedCallees.map((step) => step.symbol).join(",")}` : "", + proposal ? `proposal=${proposal.file}::${proposal.name}` : "", + proposal ? `cycleSafe=${String(proposal.cycleSafety.safe)}` : "", ].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(); @@ -394,10 +621,20 @@ function createOpportunity( routes, bypassRoutes, duplicatedCallees, + proposal, evidence, blastRadius, recommendation, - contextPack: createContextPack(kind, operation, sink, canonicalNode, bypassRoutes, evidence, blastRadius), + contextPack: createContextPack( + kind, + operation, + sink, + canonicalNode, + bypassRoutes, + evidence, + blastRadius, + proposal ? `codebase-intelligence impact . ${sink.symbol}` : undefined, + ), }; } @@ -405,7 +642,12 @@ 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[] { +function createOpportunities( + graph: CodebaseGraph, + routes: readonly RouteCandidate[], + minRoutes: number, + propose: boolean, +): HighwayOpportunity[] { const routesByGroup = new Map(); for (const route of routes) { const sinkRoutes = routesByGroup.get(routeGroupKey(route)) ?? []; @@ -421,7 +663,27 @@ function createOpportunities(graph: CodebaseGraph, routes: readonly RouteCandida if (sinkRoutes.length < minRoutes) continue; const operation = sinkRoutes[0]?.operation ?? "unknown"; const canonicalNode = selectCanonicalNode(sinkRoutes, operation); - if (!canonicalNode) continue; + if (!canonicalNode) { + if (!propose) continue; + const sink = sinkRoutes[0]?.sink; + const shape = sinkRoutes.find((route) => route.shape)?.shape; + const proposedNode = createProposedStep(operation, shape, sink, sinkRoutes); + const routesForSink = sinkRoutes.map((route) => toHighwayRoute(route, proposedNode)); + const proposal = createProposal(operation, shape, sink, routesForSink, symbolsById, outEdges); + const duplicatedCallees = commonDirectCalleesForProposal(routesForSink, sink, outEdges, symbolsById); + opportunities.push(createOpportunity( + "synthesis", + operation, + shape, + sink, + proposedNode, + routesForSink, + routesForSink, + duplicatedCallees, + proposal, + )); + continue; + } const routesForSink = sinkRoutes.map((route) => toHighwayRoute(route, canonicalNode)); const bypassRoutes = routesForSink.filter((route) => !route.includesCanonical); if (bypassRoutes.length === 0) continue; @@ -462,7 +724,7 @@ export function computeHighways(graph: CodebaseGraph, options: HighwaysOptions = minRoutes, }; const routes = enumerateRoutes(graph, normalizedOptions); - const opportunities = createOpportunities(graph, routes, minRoutes); + const opportunities = createOpportunities(graph, routes, minRoutes, Boolean(normalizedOptions.propose)); const sinks = new Set(routes.map((route) => route.sink.id)); const trace = normalizedOptions.trace ? { diff --git a/src/operations/formatters.ts b/src/operations/formatters.ts index 279093e..bbb4877 100644 --- a/src/operations/formatters.ts +++ b/src/operations/formatters.ts @@ -620,6 +620,13 @@ export function formatHighwaysText(result: HighwaysResult): string { if (opportunity.duplicatedCallees && opportunity.duplicatedCallees.length > 0) { lines.push(` Duplicated callees: ${opportunity.duplicatedCallees.map((step) => step.symbol).join(", ")}`); } + if (opportunity.proposal) { + lines.push( + ` Proposal: ${opportunity.proposal.name} in ${opportunity.proposal.file}`, + ` Signature: ${opportunity.proposal.signature}`, + ` Cycle safety: ${opportunity.proposal.cycleSafety.safe ? "safe" : "unsafe"} — ${opportunity.proposal.cycleSafety.reason}`, + ); + } } return text(lines); diff --git a/tests/highways.e2e.test.ts b/tests/highways.e2e.test.ts index 0befabc..7c9617e 100644 --- a/tests/highways.e2e.test.ts +++ b/tests/highways.e2e.test.ts @@ -23,6 +23,7 @@ interface RunResult { interface HighwayRouteStep { file: string; symbol: string; + proposed?: boolean; } interface HighwayRoute { @@ -31,6 +32,23 @@ interface HighwayRoute { includesCanonical: boolean; } +interface HighwayProposal { + name: string; + file: string; + signature: string; + skeleton: string; + reroutePlan: Array<{ + entryPoint: string; + replaceSteps: string[]; + call: string; + }>; + cycleSafety: { + safe: boolean; + checkedEdges: string[]; + reason: string; + }; +} + interface HighwayOpportunity { id: string; kind: string; @@ -40,6 +58,7 @@ interface HighwayOpportunity { canonicalNode: HighwayRouteStep; routes: HighwayRoute[]; duplicatedCallees?: HighwayRouteStep[]; + proposal?: HighwayProposal; contextPack: { proposedCanonicalNode: HighwayRouteStep; affectedRoutes: string[]; @@ -205,6 +224,62 @@ function makeHighwaysProject(): string { return path.join(dir, "src"); } +function makeHighwaySynthesisProject(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "cbi-highways-synthesis-")); + 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/rest.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 createUserFromRest(draft: UserDraft): User {", + " const normalized = normalizeUserDraft(draft);", + " const valid = validateUserDraft(normalized);", + " return insertUser(valid);", + "}", + "", + ].join("\n"), + "src/webhook.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 createUserFromWebhook(draft: UserDraft): User {", + " const normalized = normalizeUserDraft(draft);", + " const valid = validateUserDraft(normalized);", + " return insertUser(valid);", + "}", + "", + ].join("\n"), + "src/admin.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 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 @@ -290,3 +365,84 @@ describe("CH-P2-01 highways reroute", () => { expect(stderr()).toContain("Parsed"); }, 120_000); }); + +describe("CH-P2-02 highway synthesis", () => { + it("proposes a canonical highway when no shared node exists and traces the proposal", async () => { + const src = makeHighwaySynthesisProject(); + + const cliRun = await run(["highways", src, "--operation", "create", "--shape", "UserDraft", "--min-routes", "3", "--propose", "--json", "--force"]); + expect(cliRun.status).toBe(0); + const cliPayload = parsePayload(cliRun.stdout); + const synthesis = cliPayload.opportunities.find((item) => item.kind === "synthesis" && item.sink.symbol === "insertUser"); + if (!synthesis?.proposal) throw new Error("Expected synthesis proposal for insertUser"); + + expect(synthesis.id).toMatch(/^hwy-synthesis-[a-f0-9]{10}$/); + expect(synthesis.canonicalNode).toMatchObject({ + file: "create-user-draft.highway.ts", + symbol: "createUserDraft", + proposed: true, + }); + expect(synthesis.proposal).toMatchObject({ + name: "createUserDraft", + file: "create-user-draft.highway.ts", + signature: "createUserDraft(input: UserDraft): User", + cycleSafety: { + safe: true, + checkedEdges: [ + "createUserFromAdmin -> createUserDraft", + "createUserFromRest -> createUserDraft", + "createUserFromWebhook -> createUserDraft", + "createUserDraft -> insertUser", + ], + }, + }); + expect(synthesis.proposal.skeleton).toContain("export function createUserDraft(input: UserDraft): User"); + expect(synthesis.proposal.skeleton).toContain("return insertUser(valid);"); + expect(synthesis.proposal.reroutePlan).toEqual([ + { + entryPoint: "createUserFromAdmin", + replaceSteps: ["normalizeUserDraft", "validateUserDraft", "insertUser"], + call: "createUserDraft", + }, + { + entryPoint: "createUserFromRest", + replaceSteps: ["normalizeUserDraft", "validateUserDraft", "insertUser"], + call: "createUserDraft", + }, + { + entryPoint: "createUserFromWebhook", + replaceSteps: ["normalizeUserDraft", "validateUserDraft", "insertUser"], + call: "createUserDraft", + }, + ]); + expect(synthesis.contextPack.nextSafeCommand).toContain("codebase-intelligence impact"); + expect(synthesis.contextPack.affectedRoutes).toEqual(["createUserFromAdmin", "createUserFromRest", "createUserFromWebhook"]); + + const traceRun = await run(["highways", src, "--operation", "create", "--shape", "UserDraft", "--min-routes", "3", "--propose", "--trace", synthesis.id, "--json"]); + expect(traceRun.status).toBe(0); + const tracePayload = parsePayload(traceRun.stdout); + expect(tracePayload.trace).toMatchObject({ id: synthesis.id, found: true }); + expect(tracePayload.trace?.opportunity?.proposal?.name).toBe("createUserDraft"); + + const transport = new StdioClientTransport({ + command: "node", + args: [cli, src, "--force"], + cwd: repoRoot, + stderr: "pipe", + }); + const client = new Client({ name: "highways-synthesis-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, propose: true }, + }); + const mcpPayloadRecord = textPayload(result); + if (!isHighwaysPayload(mcpPayloadRecord)) throw new Error("Expected MCP highways payload"); + expect(comparable(mcpPayloadRecord)).toEqual(comparable(cliPayload)); + } finally { + await client.close(); + await transport.close(); + } + }, 120_000); +});