| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326 |
- /**
- * 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<void> {
- 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<unknown> } }).resolver;
- await resolver.resolveAndPersistBatched(undefined, 2);
- expect(cg.getPendingReferenceCount()).toBe(0);
- const target = findMethod('target');
- expect(callerFiles(target)).toContain('src/b.ts');
- });
- });
- });
|