From 77a7fbd6beceb4ba6e3c369be6e0b2b7c7a149c9 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Thu, 16 Apr 2026 17:08:44 -0400 Subject: [PATCH] fix(graphify): fall back to graph.links when graph.edges is absent (#2323) Closes #2301 ## Root cause graphify's JSON output uses the key `links` for edges, but graphify.cjs reads `graph.edges` at four sites (buildAdjacencyMap, status edge_count, diff currentEdgeMap/snapshotEdgeMap, snapshot writer). Any graph produced by graphify itself therefore reported edge_count: 0 and adjacency maps with no entries. ## Fix Added `|| graph.links` fallback at all four read sites so both key names are accepted. The snapshot writer now also normalises to `edges` when saving, ensuring round-trips through the snapshot path use a consistent key. ## Test Added LINKS-01/02/03 regression tests covering buildAdjacencyMap, graphifyStatus edge_count, and graphifyDiff edge change detection with links-keyed input graphs. Co-authored-by: Claude Sonnet 4.6 --- get-shit-done/bin/lib/graphify.cjs | 10 +++--- tests/graphify.test.cjs | 53 ++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 5 deletions(-) diff --git a/get-shit-done/bin/lib/graphify.cjs b/get-shit-done/bin/lib/graphify.cjs index 8dc640839..5a7fa2a87 100644 --- a/get-shit-done/bin/lib/graphify.cjs +++ b/get-shit-done/bin/lib/graphify.cjs @@ -165,7 +165,7 @@ function buildAdjacencyMap(graph) { for (const node of (graph.nodes || [])) { adj[node.id] = []; } - for (const edge of (graph.edges || [])) { + for (const edge of (graph.edges || graph.links || [])) { if (!adj[edge.source]) adj[edge.source] = []; if (!adj[edge.target]) adj[edge.target] = []; adj[edge.source].push({ target: edge.target, edge }); @@ -337,7 +337,7 @@ function graphifyStatus(cwd) { exists: true, last_build: stat.mtime.toISOString(), node_count: (graph.nodes || []).length, - edge_count: (graph.edges || []).length, + edge_count: (graph.edges || graph.links || []).length, hyperedge_count: (graph.hyperedges || []).length, stale: age > STALE_MS, age_hours: Math.round(age / (60 * 60 * 1000)), @@ -384,8 +384,8 @@ function graphifyDiff(cwd) { // Diff edges (keyed by source+target+relation) const edgeKey = (e) => `${e.source}::${e.target}::${e.relation || e.label || ''}`; - const currentEdgeMap = Object.fromEntries((current.edges || []).map(e => [edgeKey(e), e])); - const snapshotEdgeMap = Object.fromEntries((snapshot.edges || []).map(e => [edgeKey(e), e])); + const currentEdgeMap = Object.fromEntries((current.edges || current.links || []).map(e => [edgeKey(e), e])); + const snapshotEdgeMap = Object.fromEntries((snapshot.edges || snapshot.links || []).map(e => [edgeKey(e), e])); const edgesAdded = Object.keys(currentEdgeMap).filter(k => !snapshotEdgeMap[k]); const edgesRemoved = Object.keys(snapshotEdgeMap).filter(k => !currentEdgeMap[k]); @@ -454,7 +454,7 @@ function writeSnapshot(cwd) { version: 1, timestamp: new Date().toISOString(), nodes: graph.nodes || [], - edges: graph.edges || [], + edges: graph.edges || graph.links || [], }; const snapshotPath = path.join(cwd, '.planning', 'graphs', '.last-build-snapshot.json'); diff --git a/tests/graphify.test.cjs b/tests/graphify.test.cjs index 7c5d6ac99..988c7c0c8 100644 --- a/tests/graphify.test.cjs +++ b/tests/graphify.test.cjs @@ -465,6 +465,17 @@ describe('buildAdjacencyMap', () => { assert.strictEqual(entry.edge.label, 'reads_from'); assert.strictEqual(entry.edge.confidence, 'EXTRACTED'); }); + + // LINKS-01: graphify emits 'links' key; reader must fall back to it + test('falls back to graph.links when graph.edges is absent (LINKS-01)', () => { + const graphWithLinks = { + nodes: SAMPLE_GRAPH.nodes, + links: SAMPLE_GRAPH.edges, + }; + const adj = buildAdjacencyMap(graphWithLinks); + assert.ok(adj['n1'].some(e => e.target === 'n2'), 'adjacency must traverse links'); + assert.ok(adj['n2'].some(e => e.target === 'n1'), 'reverse adjacency must work'); + }); }); // ─── seedAndExpand (TEST-01) ─────────────────────────────────────────────── @@ -678,6 +689,19 @@ describe('graphifyStatus', () => { const result = graphifyStatus(tmpDir); assert.strictEqual(result.hyperedge_count, 1); }); + + // LINKS-02: status edge_count must read graph.links when graph.edges is absent + test('reports correct edge_count when graph uses links key (LINKS-02)', () => { + enableGraphify(planningDir); + const graphWithLinks = { + nodes: SAMPLE_GRAPH.nodes, + links: SAMPLE_GRAPH.edges, + hyperedges: [], + }; + writeGraphJson(planningDir, graphWithLinks); + const result = graphifyStatus(tmpDir); + assert.strictEqual(result.edge_count, 5, 'edge_count must equal links array length'); + }); }); // ─── graphifyDiff (DIFF-01, DIFF-02) ────────────────────────────────────── @@ -770,6 +794,35 @@ describe('graphifyDiff', () => { assert.strictEqual(result.nodes.changed, 1, 'n1 label changed'); assert.strictEqual(result.edges.changed, 1, 'edge confidence changed'); }); + + // LINKS-03: diff must handle links key in both current and snapshot (LINKS-03) + test('detects edge changes when graphs use links key (LINKS-03)', () => { + enableGraphify(planningDir); + const snapshot = { + nodes: [ + { id: 'n1', label: 'AuthService', description: 'Auth', type: 'service' }, + { id: 'n2', label: 'UserModel', description: 'User', type: 'model' }, + ], + links: [ + { source: 'n1', target: 'n2', label: 'reads_from', confidence: 'INFERRED' }, + ], + }; + const current = { + nodes: [ + { id: 'n1', label: 'AuthService', description: 'Auth', type: 'service' }, + { id: 'n2', label: 'UserModel', description: 'User', type: 'model' }, + ], + links: [ + { source: 'n1', target: 'n2', label: 'reads_from', confidence: 'EXTRACTED' }, + ], + }; + writeSnapshotJson(planningDir, snapshot); + writeGraphJson(planningDir, current); + const result = graphifyDiff(tmpDir); + assert.strictEqual(result.edges.changed, 1, 'edge confidence change must be detected via links key'); + assert.strictEqual(result.edges.added, 0); + assert.strictEqual(result.edges.removed, 0); + }); }); // ─── graphifyBuild (BUILD-01, BUILD-02, TEST-02) ────────────────────────────