/** * Orphaned unresolved-refs sweep (#1187) * * A resolution pass that dies mid-run (watchdog SIGKILL, Ctrl-C, crash) * leaves the refs it never reached in unresolved_refs. The git-scoped sync * fast path only ever reads the changed files' rows, so those orphans — and * the call edges they represent — used to be missing permanently until a * full re-index. Field report: a Spring monorepo where blast radius showed * 3 of 10 caller files for a method behind @Resource field injection. * * These tests pin the healing behavior: a completed pass consumes every row * it processes (resolved or not), and sync sweeps any leftovers even when * no files changed. */ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; import CodeGraph from '../src/index'; import { createDatabase } from '../src/db/sqlite-adapter'; import type { ReferenceResolver } from '../src/resolution'; describe('Orphaned refs sweep (#1187)', () => { let testDir: string; let cg: CodeGraph; beforeEach(() => { testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-orphan-sweep-')); }); afterEach(() => { if (cg) { cg.destroy(); } if (fs.existsSync(testDir)) { fs.rmSync(testDir, { recursive: true, force: true }); } }); /** Distinct files with a `calls` edge into the node. */ function callerFiles(target: { id: string }): string[] { return [...new Set(cg.getCallers(target.id).map((c) => c.node.filePath))].sort(); } /** * Leave `relPath` in the exact on-disk state a resolution pass killed * mid-run leaves behind: content re-extracted (nodes + refs re-inserted, * old edges cascade-deleted, content hash stamped current) but resolution * never run. The content tweak is needed because re-extraction of * byte-identical content is a no-op; the hash stamp means a later sync * sees NO changed files. */ async function interruptAfterExtraction(relPath: string): Promise { fs.appendFileSync(path.join(testDir, relPath), '\n// interrupted-run edit\n'); await cg.indexFiles([relPath]); } function findMethod(name: string) { const hit = cg .searchNodes(name) .find((r) => (r.node.kind === 'method' || r.node.kind === 'function') && r.node.name === name); expect(hit, `expected an indexed definition of ${name}`).toBeDefined(); return hit!.node; } // Compare call sites and resolution evidence, not just edge counts: a // recovery can also silently downgrade confidence without losing a row. function graphSnapshot() { const { db } = createDatabase(path.join(testDir, '.codegraph', 'codegraph.db'), { readOnly: true }); try { const sorted = (sql: string) => db.prepare(sql).all().map((row) => JSON.stringify(row)).sort(); return { nodes: sorted('SELECT id, kind, name, qualified_name, file_path FROM nodes'), edges: sorted('SELECT source, target, kind, line, col, metadata, provenance FROM edges'), refs: sorted('SELECT from_node_id, reference_name, reference_kind, line, col, file_path, language, status FROM unresolved_refs'), }; } finally { db.close(); } } describe('recovery has clean-index resolution parity (#1577)', () => { it('persists prerequisites before calls even when the orphan order is reversed', async () => { fs.writeFileSync(path.join(testDir, 'aTypes.java'), [ 'class Base { void draw() {} }', 'class Child extends Base {}', 'class Decoy { void draw() {} }', ].join('\n')); // Put the caller beyond the first clean-index batch. Recovery below // queues that same caller FIRST and its inheritance prerequisite LAST. fs.writeFileSync(path.join(testDir, 'bPadding.java'), 'class Padding { void noop() {\n' + 'externalCall();\n'.repeat(5100) + '} }\n'); fs.writeFileSync(path.join(testDir, 'zCaller.java'), 'class Caller { void run(Child child) { child.draw(); } }\n'); cg = CodeGraph.initSync(testDir); await cg.indexAll(); const target = cg.getNodesByKind('method').find((n) => n.qualifiedName === 'Base::draw')!; expect(callerFiles(target)).toEqual(['zCaller.java']); const clean = graphSnapshot(); for (const file of ['zCaller.java', 'bPadding.java', 'aTypes.java']) { await interruptAfterExtraction(file); } cg.destroy(); cg = CodeGraph.openSync(testDir); expect(cg.getPendingReferenceCount()).toBeGreaterThan(5000); const recovered = await cg.sync(); expect(recovered.filesAdded + recovered.filesModified + recovered.filesRemoved).toBe(0); expect(cg.getPendingReferenceCount()).toBe(0); expect(callerFiles(target)).toEqual(['zCaller.java']); expect(graphSnapshot()).toEqual(clean); await cg.sync(); expect(graphSnapshot()).toEqual(clean); }, 15000); it('recovers inherited callbacks when the process restarts before the deferred pass', async () => { fs.writeFileSync(path.join(testDir, 'form.ts'), [ 'class Base { handleSubmit() {} }', 'class Unrelated { missingHandler() {} }', 'class Form extends Base {', ' wire() { bus.on("submit", this.handleSubmit); }', ' save() { bus.on("save", this.handleSubmit); }', ' confirm() { bus.on("confirm", this.handleSubmit); }', ' missing() { bus.on("missing", this.missingHandler); }', '}', ].join('\n')); cg = CodeGraph.initSync(testDir); await cg.indexAll(); const target = findMethod('handleSubmit'); expect(cg.getIncomingEdges(target.id).filter((e) => e.kind === 'references')).toHaveLength(3); const clean = graphSnapshot(); await interruptAfterExtraction('form.ts'); // Stop after the final batch has persisted, before the deferred // inherited-member pass runs. There are no later batches to hide the // bug: failed rows plus a lost in-memory queue used to look healthy. // One ref per batch also exercises consecutive all-deferred batches: // their intentionally pending rows must not trip the non-progress guard. const resolver = (cg as unknown as { resolver: ReferenceResolver }).resolver; await expect(resolver.resolveAndPersistBatched((current, total) => { if (current === total) throw new Error('interrupted before deferred resolution'); }, 1)).rejects.toThrow('interrupted before deferred resolution'); cg.destroy(); cg = CodeGraph.openSync(testDir); await cg.sync(); expect(cg.getIncomingEdges(target.id).filter((e) => e.kind === 'references')).toHaveLength(3); expect(cg.getIncomingEdges(findMethod('missingHandler').id).filter((e) => e.kind === 'references')).toEqual([]); expect(cg.getPendingReferenceCount()).toBe(0); expect(graphSnapshot()).toEqual(clean); await cg.sync(); expect(graphSnapshot()).toEqual(clean); }); }); describe('sync() heals an interrupted resolution run', () => { beforeEach(async () => { // The #1187 shape: a concrete @Component class called through Spring // @Resource field injection from another package. const supportDir = path.join(testDir, 'src', 'support'); const notifyDir = path.join(testDir, 'src', 'notify'); fs.mkdirSync(supportDir, { recursive: true }); fs.mkdirSync(notifyDir, { recursive: true }); fs.writeFileSync( path.join(supportDir, 'MemberDescriptionSupport.java'), [ 'package com.demo.support;', '', 'public class MemberDescriptionSupport {', ' public String getSuperVipName() {', ' return "SVIP";', ' }', '}', '', ].join('\n') ); fs.writeFileSync( path.join(notifyDir, 'NotifyBuilder.java'), [ 'package com.demo.notify;', '', 'import com.demo.support.MemberDescriptionSupport;', '', 'public class NotifyBuilder {', ' private MemberDescriptionSupport memberDescriptionSupport;', '', ' public String buildParams() {', ' return memberDescriptionSupport.getSuperVipName();', ' }', '}', '', ].join('\n') ); cg = CodeGraph.initSync(testDir); await cg.indexAll(); }); it('resolves leftover refs on a sync with NO file changes', async () => { const target = findMethod('getSuperVipName'); // Healthy baseline: the caller edge exists, no refs pending. expect(callerFiles(target)).toContain('src/notify/NotifyBuilder.java'); expect(cg.getPendingReferenceCount()).toBe(0); // Simulate the interrupted run: re-extract the caller (cascade-deleting // its old nodes and edges, re-inserting its refs) and stop before // resolution — exactly the state a killed "Resolving refs" phase // leaves behind. await interruptAfterExtraction('src/notify/NotifyBuilder.java'); expect(cg.getPendingReferenceCount()).toBeGreaterThan(0); expect(callerFiles(target)).not.toContain('src/notify/NotifyBuilder.java'); // The file on disk is unchanged, so this sync re-extracts nothing — // pre-fix it returned without touching resolution and the edge stayed // missing forever. const result = await cg.sync(); expect(result.filesAdded).toBe(0); expect(result.filesModified).toBe(0); expect(cg.getPendingReferenceCount()).toBe(0); expect(callerFiles(target)).toContain('src/notify/NotifyBuilder.java'); }); it('is idempotent: a second no-change sync stays clean', async () => { await interruptAfterExtraction('src/notify/NotifyBuilder.java'); await cg.sync(); const target = findMethod('getSuperVipName'); const healed = callerFiles(target); const again = await cg.sync(); expect(again.filesAdded + again.filesModified + again.filesRemoved).toBe(0); expect(cg.getPendingReferenceCount()).toBe(0); expect(callerFiles(target)).toEqual(healed); }); }); describe('completed passes consume every processed row', () => { it('resolveReferences() deletes unresolvable rows (parity with the batched path)', async () => { const srcDir = path.join(testDir, 'src'); fs.mkdirSync(srcDir, { recursive: true }); fs.writeFileSync( path.join(srcDir, 'app.ts'), [ 'export function helper() { return 1; }', 'export function main() {', ' helper();', ' totallyUndefinedCall();', // resolves to nothing anywhere '}', '', ].join('\n') ); cg = CodeGraph.initSync(testDir); await cg.indexAll(); expect(cg.getPendingReferenceCount()).toBe(0); // Re-extract without resolving: both the resolvable helper() ref and // the unresolvable one are back in the table. await interruptAfterExtraction('src/app.ts'); expect(cg.getPendingReferenceCount()).toBeGreaterThan(0); // The non-batched full pass (which also backs the git-scoped sync // path) must consume BOTH: pre-fix it deleted only resolved rows, so // unresolvable ones parked forever and defeated the orphan sweep's // "non-empty table means interrupted run" invariant. cg.resolveReferences(); expect(cg.getPendingReferenceCount()).toBe(0); }); it('batched resolution does not stop at an all-unresolvable batch', async () => { const srcDir = path.join(testDir, 'src'); fs.mkdirSync(srcDir, { recursive: true }); // File A: only unresolvable refs. Extracted first, so its rows sort // first and fill the whole first batch. fs.writeFileSync( path.join(srcDir, 'a.ts'), [ 'export function a() {', ' ghostOne();', ' ghostTwo();', ' ghostThree();', '}', '', ].join('\n') ); // File B: a resolvable ref whose rows sort after A's. fs.writeFileSync( path.join(srcDir, 'b.ts'), [ "import { target } from './c';", 'export function b() { target(); }', '', ].join('\n') ); fs.writeFileSync( path.join(srcDir, 'c.ts'), 'export function target() { return 2; }\n' ); cg = CodeGraph.initSync(testDir); await cg.indexAll(); // Re-queue A's refs then B's, in that order. await interruptAfterExtraction('src/a.ts'); await interruptAfterExtraction('src/b.ts'); expect(cg.getPendingReferenceCount()).toBeGreaterThan(0); // Batch size 2 puts only A's unresolvable refs in the first batch. // The old early break ended the whole run there, leaving B's ref an // orphan even though the batch's rows WERE consumed (progress). const resolver = (cg as unknown as { resolver: { resolveAndPersistBatched(p?: unknown, b?: number): Promise } }).resolver; await resolver.resolveAndPersistBatched(undefined, 2); expect(cg.getPendingReferenceCount()).toBe(0); const target = findMethod('target'); expect(callerFiles(target)).toContain('src/b.ts'); }); }); });