Skip to content
Merged
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
16 changes: 16 additions & 0 deletions src/graph/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,13 @@ export function persistMovedGroundings(
const scaffoldFile = relative(config.projectRoot, filePath).replaceAll("\\", "/");
let dirty = false;
const pendingMoves = new Map<string, { previous: GroundedSource; newId: string }>();
// 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<string, string>();
for (const grounding of groundings) {
const aliasedNode = runtime.graph.getNode(grounding.node);
if (aliasedNode) {
Expand All @@ -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;
Expand All @@ -336,13 +344,21 @@ 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;
}
const groundedContent = dirty ? writeGroundings(content, groundings) : content;
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;
Expand Down
101 changes: 101 additions & 0 deletions test/graph-integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"), [{
Expand Down
Loading