Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
32 changes: 30 additions & 2 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 <n>", "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);
Expand Down
137 changes: 137 additions & 0 deletions src/graph/scc.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import type { EdgeV1, GraphV1, NodeV1 } from "./types.js";
import { WALK_RELATIONS } from "./relations.js";

export type CondensationMember = Pick<NodeV1, "id" | "name" | "kind" | "path" | "span">;

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<string, string[]>();
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<string, number>();
const lowlinks = new Map<string, number>();
const onStack = new Set<string>();
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<string, number>();
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<string, { source: number; target: number; originalEdges: EdgeV1[] }>();
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<string, CondensationMember>();
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";
}
2 changes: 1 addition & 1 deletion src/mcp/instructions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'));
Expand Down
22 changes: 21 additions & 1 deletion src/mcp/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)',
},
},
},
},
Expand Down Expand Up @@ -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')} <child-root> --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.
Expand Down Expand Up @@ -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 };
Expand Down
89 changes: 89 additions & 0 deletions test/graph-scc.test.ts
Original file line number Diff line number Diff line change
@@ -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]);
}
}
});
Loading
Loading