diff --git a/src/graph/runtime.ts b/src/graph/runtime.ts index 2d0fbf65..da654844 100644 --- a/src/graph/runtime.ts +++ b/src/graph/runtime.ts @@ -310,6 +310,13 @@ export function persistMovedGroundings( const scaffoldFile = relative(config.projectRoot, filePath).replaceAll("\\", "/"); let dirty = false; const pendingMoves = new Map(); + // Per-node migration is atomic (#128): a node grounded both in + // grounds_to and as an inline mex:// anchor must migrate through ONE + // resolution. The grounds_to pass records its resolution per old id so + // the anchor pass can follow it even when the store has already lost + // the old node's rows (no baseline, no fingerprint, no alias) — instead + // of skipping the anchor and leaving GROUNDING_GONE behind. + const migratedNodes = new Map(); for (const grounding of groundings) { const aliasedNode = runtime.graph.getNode(grounding.node); if (aliasedNode) { @@ -320,6 +327,7 @@ export function persistMovedGroundings( const fingerprint = runtime.reconciler.getFingerprint(aliasedNode.id); if (fingerprint) grounding.fingerprint = serializeFingerprint(fingerprint); moveGroundingBaseline(scaffoldFile, oldId, grounding.node, runtime, pendingMoves, grounding); + migratedNodes.set(oldId, grounding.node); dirty = true; moved += 1; continue; @@ -336,6 +344,7 @@ export function persistMovedGroundings( const fingerprint = runtime.reconciler.getFingerprint(resolution.nodeId); if (fingerprint) grounding.fingerprint = serializeFingerprint(fingerprint); moveGroundingBaseline(scaffoldFile, oldId, grounding.node, runtime, pendingMoves, grounding); + migratedNodes.set(oldId, grounding.node); dirty = true; moved += 1; } @@ -343,6 +352,13 @@ export function persistMovedGroundings( let anchoredContent = groundedContent; const anchors = findMexAnchors(anchoredContent); for (const anchor of [...anchors].reverse()) { + const migratedId = migratedNodes.get(anchor.nodeId); + if (migratedId !== undefined) { + if (migratedId === anchor.nodeId) continue; + anchoredContent = rewriteMexAnchor(anchoredContent, anchor, migratedId); + moved += 1; + continue; + } const aliasedNode = runtime.graph.getNode(anchor.nodeId); if (aliasedNode) { if (aliasedNode.id === anchor.nodeId) continue; diff --git a/test/graph-integration.test.ts b/test/graph-integration.test.ts index d175d96c..a8beffe9 100644 --- a/test/graph-integration.test.ts +++ b/test/graph-integration.test.ts @@ -404,6 +404,107 @@ describe("code-graph grounding integration", () => { ]); }, 20_000); + it("migrates an inline anchor atomically when grounds_to and the anchor share a moved node (#128)", async () => { + const { root, scaffold, config } = fixture(); + const source = join(root, "src", "service.ts"); + writeFileSync(source, "export function calculateTotal(items: number[]): number {\n return items.reduce((a, b) => a + b, 0);\n}\n"); + + const engine = createGraphEngine({ rootDir: root }); + await engine.build(); + const node = engine.searchNodes("calculateTotal").find((entry) => entry.kind === "function")!; + engine.close(); + + // Ground the node BOTH in frontmatter and as an inline anchor — the shape + // the stack.md template encourages. + let runtime = await loadGroundingRuntime(config); + const fingerprint = runtime!.reconciler.getFingerprint(node.id); + writeFileSync(scaffold, writeGroundings(readFileSync(scaffold, "utf-8"), [{ + node: node.id, + fingerprint: serializeFingerprint(fingerprint!), + }]) + `\n[\`calculateTotal()\`](mex://${node.id})\n`); + refreshGroundingBaselines(config, [scaffold], runtime!); + runtime!.close(); + + // Move the function to another file with an identical body, replace the + // original, and rebuild: the old node id vanishes from the store entirely. + const movedSource = join(root, "src", "moved.ts"); + writeFileSync(movedSource, readFileSync(source, "utf-8")); + writeFileSync(source, "export const unrelated = 1;\n"); + const rebuilt = createGraphEngine({ rootDir: root }); + await rebuilt.build(); + const movedNode = rebuilt.searchNodes("calculateTotal").find((entry) => entry.kind === "function")!; + expect(movedNode.id).not.toBe(node.id); + rebuilt.close(); + + runtime = await loadGroundingRuntime(config); + const moved = persistMovedGroundings(config, [scaffold], runtime!); + runtime!.close(); + + expect(moved).toBe(2); // grounds_to entry + inline anchor, one resolution + const persistedContent = readFileSync(scaffold, "utf-8"); + expect(persistedContent).not.toContain(`mex://${node.id}`); + expect(persistedContent).toContain(`mex://${movedNode.id}`); + expect(extractGroundings(persistedContent)[0].node).toBe(movedNode.id); + + // The next check must not report the anchor as gone. + const report = await runDriftCheckWithGraphStatus(config, { graphWarning: () => {} }); + expect(report.issues.filter((issue) => issue.code === "GROUNDING_GONE")).toHaveLength(0); + }, 20_000); + + it("migrates the inline anchor from the grounds_to resolution even when the store lost the old node's rows (#128)", async () => { + const { root, scaffold, config } = fixture(); + const source = join(root, "src", "service.ts"); + writeFileSync(source, "export function calculateTotal(items: number[]): number {\n return items.reduce((a, b) => a + b, 0);\n}\n"); + + const engine = createGraphEngine({ rootDir: root }); + await engine.build(); + const node = engine.searchNodes("calculateTotal").find((entry) => entry.kind === "function")!; + engine.close(); + + let runtime = await loadGroundingRuntime(config); + const fingerprint = runtime!.reconciler.getFingerprint(node.id); + writeFileSync(scaffold, writeGroundings(readFileSync(scaffold, "utf-8"), [{ + node: node.id, + fingerprint: serializeFingerprint(fingerprint!), + }]) + `\n[\`calculateTotal()\`](mex://${node.id})\n`); + refreshGroundingBaselines(config, [scaffold], runtime!); + runtime!.close(); + + const movedSource = join(root, "src", "moved.ts"); + writeFileSync(movedSource, readFileSync(source, "utf-8")); + writeFileSync(source, "export const unrelated = 1;\n"); + const rebuilt = createGraphEngine({ rootDir: root }); + await rebuilt.build(); + const movedNode = rebuilt.searchNodes("calculateTotal").find((entry) => entry.kind === "function")!; + rebuilt.close(); + + // A store where the old node's rows are already gone (the reporter's + // post-move state): no grounded-source row, no fingerprint row, and no + // compatibility alias. Without per-node atomic migration the anchor pass + // finds no baseline and silently skips, leaving GROUNDING_GONE behind. + const db = openSqlite(join(root, ".mex", "graph.db")); + try { + db.prepare("DELETE FROM _mex_grounded_source WHERE node_id = ?").run(node.id); + db.prepare("DELETE FROM node_fingerprints WHERE node_id = ?").run(node.id); + db.prepare("DELETE FROM node_aliases WHERE alias_id = ?").run(node.id); + } finally { + db.close(); + } + + runtime = await loadGroundingRuntime(config); + const moved = persistMovedGroundings(config, [scaffold], runtime!); + runtime!.close(); + + expect(moved).toBe(2); + const persistedContent = readFileSync(scaffold, "utf-8"); + expect(persistedContent).not.toContain(`mex://${node.id}`); + expect(persistedContent).toContain(`mex://${movedNode.id}`); + expect(extractGroundings(persistedContent)[0].node).toBe(movedNode.id); + + const report = await runDriftCheckWithGraphStatus(config, { graphWarning: () => {} }); + expect(report.issues.filter((issue) => issue.code === "GROUNDING_GONE")).toHaveLength(0); + }, 20_000); + it("keeps legacy checks running when the graph engine fails to load", async () => { const { scaffold, config } = fixture(); writeFileSync(scaffold, writeGroundings(readFileSync(scaffold, "utf-8"), [{