diff --git a/CHANGELOG.md b/CHANGELOG.md index e1136f89..69672d95 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## Unreleased + +### Added + +- **Opt-in SCC condensation maps** (#632): `graft map [dir] --scc` (also + `trail map`) and MCP `graft_repo_map { "scc": true }` show dependency clusters, + every member's definition location, and the complete directed links between + clusters. Single-dependency links are candidate abstraction boundaries; + `--scc --json` retains original dependencies for every link. This full view + requires one repository graph and rejects directory budgets. Default maps + and the main skill remain unchanged. + ## 0.23.0 ### Changed diff --git a/README.md b/README.md index 68a6a061..0adc4212 100644 --- a/README.md +++ b/README.md @@ -745,6 +745,22 @@ scripts/ 7 files · 7 symbols hubs: bump (graph-quality.mjs, 1←), hotspots: wiringPath · function · src/graph/write.ts:L20-L22 · 48← tmpRepo · function · test/helpers.ts:L30-L32 · 46← cmd · function · src/brand.ts:L63-L70 · 45← ... ``` +`graft map [dir] --scc` (also `trail map`) explicitly switches to the full +strongly connected component (SCC) condensation DAG. It lists every indexed +member with its definition location and every directed link between components, +including direct links with alternate paths. Links supported by exactly one +stored dependency are marked as candidate single-edge abstraction boundaries; +counts are stored dependencies, not call sites. `--scc --json` returns the direct +`{ components, edges }` result, including all original edge endpoints, relations, +and confidences for both single- and multi-edge links. + +MCP clients can request the same text view with `graft_repo_map { "scc": true }`. +This opt-in view may be large: it has no directory/token budget and cannot be +combined with `--max-dirs` / `max_dirs`. At a multi-repository workspace parent, +pass a child root to the CLI or start MCP in that child; SCCs cover one stored +repository graph. Ordinary maps and the main skill workflow are unchanged; +SCC analysis never runs implicitly. + ## Monorepos, submodules & multi-repo folders The graft map supports these layouts: diff --git a/src/cli.ts b/src/cli.ts index 8b7051e6..445042ff 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -30,6 +30,7 @@ import { openBrowser } from "./util/browser.js"; import { learningsAtBase, teammateEdits, withBaseLearnings } from "./check/learning-edits.js"; import { contextDirFor } from "./context/node-file.js"; import { loadGraphCached } from "./graph/load.js"; +import { buildCondensation, formatCondensation } from "./graph/scc.js"; import { ensureFreshChildren, ensureFreshGraph, refreshNote } from "./graph/refresh.js"; import { isWorkspaceBuildRoot, readWorkspace } from "./graph/workspace.js"; import { hasGraftIndex, nearestGraftRoot } from "./graph/root.js"; @@ -1074,16 +1075,43 @@ grouped(program.command("grep"), GROUP.find) grouped(program.command("map"), GROUP.find) .description( - "Token-budgeted repo orientation — directory clusters, per-directory hubs, and global hotspots from the wiring graph ($0, no LLM)", + "Token-budgeted repo orientation by default; --scc shows full dependency clusters and single-edge boundaries ($0, no LLM)", ) .argument(...DIR_ARG) .option("--max-dirs ", "max directory entries shown, rest counted into dropped (default 16)") + .option("--scc", "show the full SCC condensation DAG and single-edge boundaries") .option("--json", "output as JSON") .option(...NO_REFRESH_FLAG) - .action(async (dirArg: string | undefined, opts: { json?: boolean; maxDirs?: string; refresh?: boolean }) => { + .action(async (dirArg: string | undefined, opts: { json?: boolean; maxDirs?: string; refresh?: boolean; scc?: boolean }) => { const dir = noteQuery(queryRoot(dirArg)); const root = resolve(dir); const globalOpts = program.opts<{ dir?: string }>(); + if (opts.scc === true) { + if (opts.maxDirs !== undefined) { + console.error("✗ --scc cannot be combined with --max-dirs"); + process.exit(1); + return; + } + if (readWorkspace(root, globalOpts.dir)) { + console.error("✗ SCC view requires one repository graph — pass a workspace child directory"); + process.exit(1); + return; + } + await refreshBefore(dir, opts); + const graph = loadGraphCached(contextDirFor(root, globalOpts.dir)); + if (!graph) { + console.error(`✗ no graph — run ${cmd("graft build")} first`); + process.exit(1); + return; + } + const result = buildCondensation(graph); + if (opts.json) { + console.log(JSON.stringify(result, null, 2)); + } else { + process.stdout.write(formatCondensation(result)); + } + return; + } let maxDirsW: number | undefined; if (opts.maxDirs !== undefined) { const n = parseInt(opts.maxDirs, 10); diff --git a/src/graph/scc.ts b/src/graph/scc.ts new file mode 100644 index 00000000..c97b1500 --- /dev/null +++ b/src/graph/scc.ts @@ -0,0 +1,137 @@ +import type { EdgeV1, GraphV1, NodeV1 } from "./types.js"; +import { WALK_RELATIONS } from "./relations.js"; + +export type CondensationMember = Pick; + +export interface Condensation { + components: CondensationMember[][]; + edges: CondensationEdge[]; +} + +export interface CondensationEdge { + source: number; + target: number; + count: number; + originalEdges: EdgeV1[]; +} + +/** Dependency SCCs and every cross-component link; never mutates the cached graph. */ +export function buildCondensation(graph: GraphV1): Condensation { + const nodes = new Map(graph.nodes.map((node) => [node.id, node])); + const adjacency = new Map(); + for (const id of nodes.keys()) adjacency.set(id, []); + for (const edge of graph.edges) { + if (WALK_RELATIONS.has(edge.relation) && nodes.has(edge.source) && nodes.has(edge.target)) { + adjacency.get(edge.source)!.push(edge.target); + } + } + + const indices = new Map(); + const lowlinks = new Map(); + const onStack = new Set(); + const active: string[] = []; + const frames: { id: string; next: number }[] = []; + const groups: string[][] = []; + let index = 0; + const discover = (id: string) => { + indices.set(id, index); + lowlinks.set(id, index++); + active.push(id); + onStack.add(id); + frames.push({ id, next: 0 }); + }; + + // Explicit DFS frames keep long dependency paths off the JavaScript call stack. + for (const id of nodes.keys()) { + if (indices.has(id)) continue; + discover(id); + while (frames.length > 0) { + const frame = frames[frames.length - 1]!; + const neighbors = adjacency.get(frame.id)!; + if (frame.next < neighbors.length) { + const target = neighbors[frame.next++]!; + if (!indices.has(target)) { + discover(target); + } else if (onStack.has(target)) { + lowlinks.set(frame.id, Math.min(lowlinks.get(frame.id)!, indices.get(target)!)); + } + continue; + } + + frames.pop(); + if (lowlinks.get(frame.id) === indices.get(frame.id)) { + const members: string[] = []; + let member: string; + do { + member = active.pop()!; + onStack.delete(member); + members.push(member); + } while (member !== frame.id); + members.sort((a, b) => a.localeCompare(b)); + groups.push(members); + } + const parent = frames[frames.length - 1]; + if (parent) { + lowlinks.set(parent.id, Math.min(lowlinks.get(parent.id)!, lowlinks.get(frame.id)!)); + } + } + } + + groups.sort((a, b) => a[0]!.localeCompare(b[0]!)); + const componentOf = new Map(); + const components = groups.map((members, component) => members.map((id) => { + componentOf.set(id, component); + const node = nodes.get(id)!; + return { id, name: node.name, kind: node.kind, path: node.path, span: node.span }; + })); + const links = new Map(); + for (const edge of graph.edges) { + if (!WALK_RELATIONS.has(edge.relation)) continue; + const source = componentOf.get(edge.source); + const target = componentOf.get(edge.target); + if (source === undefined || target === undefined || source === target) continue; + const key = `${source},${target}`; + let link = links.get(key); + if (!link) { + link = { source, target, originalEdges: [] }; + links.set(key, link); + } + link.originalEdges.push(edge); + } + const edges = Array.from(links.values(), (link) => { + link.originalEdges.sort((a, b) => + a.source.localeCompare(b.source) || a.relation.localeCompare(b.relation) || + a.target.localeCompare(b.target) || a.confidence.localeCompare(b.confidence)); + return { ...link, count: link.originalEdges.length }; + }); + edges.sort((a, b) => a.source - b.source || a.target - b.target); + return { components, edges }; +} + +/** Full explicit view: locations are definition anchors, not call-site spans. */ +export function formatCondensation(condensation: Condensation): string { + const members = new Map(); + for (const component of condensation.components) { + for (const member of component) members.set(member.id, member); + } + const label = (member: CondensationMember) => `${member.name} (${member.path}:${member.span})`; + let boundaries = 0; + for (const edge of condensation.edges) { + if (edge.count === 1) boundaries++; + } + const lines = [`condensation DAG — ${condensation.components.length} components · ${condensation.edges.length} links · ${boundaries} single-edge boundaries`]; + for (let i = 0; i < condensation.components.length; i++) { + const labels: string[] = []; + for (const member of condensation.components[i]!) labels.push(label(member)); + lines.push(`C${i}: ${labels.join(", ")}`); + } + for (const edge of condensation.edges) { + let line = `C${edge.source} → C${edge.target} · ${edge.count} edges`; + if (edge.count === 1) { + const original = edge.originalEdges[0]!; + line += ` · single-edge boundary · ${label(members.get(original.source)!)} → ${label(members.get(original.target)!)} (${original.relation}, ${original.confidence})`; + } + lines.push(line); + } + return lines.join("\n") + "\n"; +} diff --git a/src/mcp/instructions.ts b/src/mcp/instructions.ts index c5f78878..0793c338 100644 --- a/src/mcp/instructions.ts +++ b/src/mcp/instructions.ts @@ -52,7 +52,7 @@ export function mcpInstructions(): string { '- graft_find_all — when you need EVERY occurrence; find_code is top-N and misses some.', '- graft_trace_calls — who calls it, what it calls, blast radius before a rename.', '- graft_file_api — a file\'s whole API in ~200 tokens.', - '- graft_repo_map — orientation in an unfamiliar repo.', + '- graft_repo_map — orientation; scc:true for SCC clusters and single-edge boundaries.', '', 'Results already reflect uncommitted edits — the graph refreshes before each query.', ].join('\n')); diff --git a/src/mcp/tools.ts b/src/mcp/tools.ts index 9543428f..34138420 100644 --- a/src/mcp/tools.ts +++ b/src/mcp/tools.ts @@ -16,6 +16,7 @@ import { sessionInputRate } from '../claude/session-metrics.js'; import { grepGraph } from '../search/grep.js'; import { formatGrepResult, zeroHitNote } from '../search/grep-cli.js'; import { buildRepoMap, formatRepoMap } from '../graph/map.js'; +import { buildCondensation, formatCondensation } from '../graph/scc.js'; import { federateAsk, federateCallers, @@ -116,11 +117,15 @@ export const TOOLS: ToolDef[] = [ { name: 'graft_repo_map', description: - 'Token-budgeted repo orientation — directory clusters, per-directory hubs, and global hotspots computed purely from the wiring graph ($0, no LLM). Use this to get oriented in an unfamiliar repo before diving into files.', + 'Token-budgeted repo orientation — directory clusters, per-directory hubs, and global hotspots computed purely from the wiring graph ($0, no LLM). Use this to get oriented in an unfamiliar repo before diving into files. Set scc:true for the complete SCC view and single-edge boundaries.', inputSchema: { type: 'object', properties: { max_dirs: { type: 'number', description: 'max directory entries shown, rest counted into dropped (default 16)' }, + scc: { + type: 'boolean', + description: 'return the full SCC condensation DAG with member definition locations and single-edge boundaries instead of the directory view (default false; may be large)', + }, }, }, }, @@ -223,6 +228,17 @@ export async function callTool( try { const name = canonicalToolName(requestedName); const ws = readWorkspace(root, dirOverride); + if (name === 'graft_repo_map' && args.scc === true) { + if (args.max_dirs !== undefined) { + return { text: 'SCC view cannot be combined with max_dirs', isError: true }; + } + if (ws) { + return { + text: `SCC view requires one repository graph; run ${cmd('graft map')} --scc or start the MCP server in that child`, + isError: true, + }; + } + } // Freshness first: an answer that cites file:line has to be about the code as // it is right now, including edits nobody has committed (or even saved through // this agent). ~3ms when nothing moved; a structural, $0 rebuild when it did. @@ -320,6 +336,10 @@ async function callSingleTool( case 'graft_repo_map': { const w = loadGraphCached(contextDirFor(root, dirOverride)); if (!w) return { text: noGraph(), isError: true }; + if (args.scc === true) { + const result = buildCondensation(w); + return { text: formatCondensation(result), isError: false }; + } const maxDirs = typeof args.max_dirs === 'number' && Number.isFinite(args.max_dirs) && args.max_dirs > 0 ? args.max_dirs : undefined; const map = buildRepoMap(w, { maxDirs }); return { text: formatRepoMap(map), isError: false }; diff --git a/test/graph-scc.test.ts b/test/graph-scc.test.ts new file mode 100644 index 00000000..c5322d68 --- /dev/null +++ b/test/graph-scc.test.ts @@ -0,0 +1,89 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { buildCondensation } from "../src/graph/scc.js"; +import type { EdgeV1, GraphV1, NodeV1, Relation } from "../src/graph/types.js"; + +function fileNode(path: string): NodeV1 { + return { + id: path, name: path.slice(path.lastIndexOf("/") + 1), kind: "file", path, + span: "L1-L1", signature: null, exported: true, origin: "ast", body_hash: "h", + summary_state: "pending", summary: null, crux: null, + }; +} + +function symNode(path: string, name: string, span = "L2-L3"): NodeV1 { + return { ...fileNode(path), id: `${path}#${name}`, name, kind: "function", span }; +} + +function edge(source: string, target: string, relation: Relation = "calls", confidence: EdgeV1["confidence"] = "extracted"): EdgeV1 { + return { source, target, relation, confidence }; +} + +function graphOf(nodes: NodeV1[], edges: EdgeV1[]): GraphV1 { + return { meta: { version: 1, nodeCount: nodes.length, edgeCount: edges.length, languages: [] }, nodes, edges }; +} + +test("condensation partitions every vertex, retains dependency support, and is deterministic without mutation", () => { + const a1 = symNode("a/a.ts", "a1"); + const a2 = symNode("a/a.ts", "a2", "L5-L6"); + // Ordinal IDs must remain exact, not collapse onto the unsuffixed definition. + a2.id += "~2"; + const b1 = symNode("b/b.ts", "b1"); + const b2 = symNode("b/b.ts", "b2", "L5-L6"); + const c = symNode("c/c.ts", "c"); + const isolated = symNode("c/c.ts", "isolated", "L8-L9"); + const files = [fileNode("a/a.ts"), fileNode("b/b.ts"), fileNode("c/c.ts")]; + const nodes = [...files, a1, a2, b1, b2, c, isolated]; + const ab = [edge(a1.id, b1.id), edge(a2.id, b2.id, "references")]; + const ac = edge(a2.id, c.id); + const bc = edge(b2.id, c.id, "calls", "inferred"); + const edges = [ + edge(a1.id, a2.id), edge(a2.id, a1.id), edge(b1.id, b2.id), edge(b2.id, b1.id), edge(c.id, c.id), + ...ab, bc, ac, + ...[a1, a2, b1, b2, c, isolated].map((n) => edge(n.path, n.id, "contains")), + edge("c/c.ts", "node:fs", "imports"), edge(c.id, "ExternalBase", "extends"), + edge(c.id, "ExternalContract", "implements"), edge(c.id, "ExternalAnnotation", "references"), + edge("missing.ts#ghost", a1.id), + ]; + const graph = graphOf(nodes, edges); + const before = structuredClone(graph); + const result = buildCondensation(graph); + const project = (n: NodeV1) => ({ id: n.id, name: n.name, kind: n.kind, path: n.path, span: n.span }); + const expectedGroups = [[files[0]!], [a1, a2], [files[1]!], [b1, b2], [files[2]!], [c], [isolated]]; + assert.deepEqual(result.components, expectedGroups.map((group) => group.map(project))); + assert.deepEqual(result.components.flat().map((n) => n.id).sort(), nodes.map((n) => n.id).sort()); + assert.deepEqual(result.edges, [ + { source: 1, target: 3, count: 2, originalEdges: ab }, + { source: 1, target: 5, count: 1, originalEdges: [ac] }, + { source: 3, target: 5, count: 1, originalEdges: [bc] }, + ]); + for (const link of result.edges) { + for (const original of link.originalEdges) assert.ok(edges.includes(original), "retains original edge references"); + } + assert.deepEqual(graph, before); + const reversed = graphOf([...nodes].reverse(), [...edges].reverse()); + const reversedBefore = structuredClone(reversed); + assert.deepEqual(buildCondensation(reversed), result); + assert.deepEqual(reversed, reversedBefore); +}); + +test("condensation of an empty graph is empty", () => { + assert.deepEqual(buildCondensation(graphOf([], [])), { components: [], edges: [] }); +}); + +test("condensation handles a 20,000-vertex dependency path with exact directed provenance", () => { + const nodes = Array.from({ length: 20_000 }, (_, i) => symNode("path.ts", `n${String(i).padStart(5, "0")}`)); + const edges = nodes.slice(1).map((n, i) => edge(nodes[i]!.id, n.id)); + const result = buildCondensation(graphOf(nodes, edges)); + assert.equal(result.components.length, nodes.length); + assert.equal(result.edges.length, edges.length); + for (let i = 0; i < nodes.length; i++) { + assert.deepEqual(result.components[i], [{ + id: nodes[i]!.id, name: nodes[i]!.name, kind: "function", path: "path.ts", span: "L2-L3", + }]); + if (i < edges.length) { + assert.deepEqual(result.edges[i], { source: i, target: i + 1, count: 1, originalEdges: [edges[i]!] }); + assert.equal(result.edges[i]!.originalEdges[0], edges[i]); + } + } +}); diff --git a/test/mcp-tools.test.ts b/test/mcp-tools.test.ts index a3ccf690..75dd0352 100644 --- a/test/mcp-tools.test.ts +++ b/test/mcp-tools.test.ts @@ -1,10 +1,12 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs'; +import { appendFileSync, mkdtempSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { execFileSync } from 'node:child_process'; import { TOOLS, callTool, canonicalToolName } from '../src/mcp/tools.js'; +import { contextDirFor } from '../src/context/node-file.js'; +import { wiringPath } from '../src/graph/write.js'; function builtRepo(): string { const d = mkdtempSync(join(tmpdir(), 'graft-mcptools-')); @@ -15,6 +17,28 @@ function builtRepo(): string { return d; } +test('SCC map rejects conflicting budgets before refresh and valid requests see source edits', async () => { + const dir = builtRepo(); + const wiring = wiringPath(contextDirFor(dir)); + const before = readFileSync(wiring); + appendFileSync(join(dir, 'src', 'math.ts'), '\nexport function afterSccEdit() { return 1; }\n'); + const noRefresh = process.env.GRAFT_NO_REFRESH; + delete process.env.GRAFT_NO_REFRESH; + try { + const rejected = await callTool(dir, 'graft_repo_map', { scc: true, max_dirs: 1 }); + assert.equal(rejected.isError, true); + assert.deepEqual(readFileSync(wiring), before); + + const result = await callTool(dir, 'graft_repo_map', { scc: true }); + assert.equal(result.isError, false); + assert.match(result.text, /afterSccEdit \(src\/math\.ts:L\d+-L\d+\)/); + assert.notDeepEqual(readFileSync(wiring), before); + } finally { + if (noRefresh === undefined) delete process.env.GRAFT_NO_REFRESH; + else process.env.GRAFT_NO_REFRESH = noRefresh; + } +}); + /** Three-deep call chain (compute -> sub -> add) so a `--depth`/`depth` param * has something to distinguish: depth 1 from `add` reaches only `sub`, the * default depth (2) also reaches `compute`. Same fixture shape as