fix(sync): resolve cross-file refs when an edit adds or removes the satisfying symbol (#1240) (#1249)

* chore: ignore .kommandr/ directory

* fix(sync): resolve cross-file refs when an edit adds or removes the satisfying symbol (#1240)

Incremental sync scoped reference resolution to the changed files' own
refs, and a completed pass deleted every ref it failed to resolve — so
a symbol change in one file could never repair references in UNCHANGED
files, in either direction, until a full re-index:

- New-export case: a.ts imports/calls `greet` before b.ts defines it.
  The failed refs were deleted at index time; when b.ts later gained
  `greet`, nothing revisited a.ts — the calls/imports edges stayed
  missing while status reported a clean index.
- Removal case: when a re-index (or file deletion) dropped a symbol,
  the incoming edges cascade-deleted and the callers — whose resolved
  refs had been consumed — never got a chance to rebind to an
  alternative definition or reconnect when the symbol returned.

Fix, sharing one lifecycle:

- Schema v8: unresolved_refs gains status ('pending'/'failed') and
  name_tail (last dotted segment, so `h.greet` is findable by `greet`).
  Both resolver persist paths now park unresolvable refs as failed
  instead of deleting them. All pending-work readers (batched drain,
  non-progress guard, #1187 orphan sweep, status pendingRefs) filter to
  pending, preserving their invariants and keeping status honest.
- Sync retry: after scoped resolution, failed refs whose name tail
  matches a symbol name now present in the changed files are re-resolved
  through a per-ref-yielding path (watchdog-safe, #1091 class). Names
  matching >500 failed refs are skipped as external/builtin noise (#999
  rationale).
- Removal side: createEdges stamps each resolution edge with its
  originating reference (metadata.refName, + refKind when kind promotion
  rewrote it). When the #899 restore misses a target or sync deletes a
  file, the dropped edge is resurrected as exactly that ref — re-resolved
  in the same sync (rebinding to an alternative definition) or parked
  failed until the symbol reappears. Edges without the stamp (pre-upgrade,
  synthesized) still drop silently: reconstructing from the target's plain
  name would strip receiver context and risk a rebind a full re-index
  would never make.
- Pure-removal syncs clear resolver caches so a long-lived daemon can't
  resolve resurrected refs against the pre-removal graph.

Validated: issue repro now yields a graph byte-identical to a full
re-index; move/remove-readd/file-deletion scenarios all rebind or heal;
baseline-vs-new A/B on express and gin shows identical node/edge counts
and no timing regression (DB grows ~25% from the parked ref rows — pure
cache, reset by any full re-index). 8 regression tests added.

Fixes #1240

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-07-10 12:19:08 -05:00
committed by GitHub
co-authored by Claude Fable 5
parent 386bff0f84
commit 9d0cd3a7d1
12 changed files with 570 additions and 43 deletions
+1 -1
View File
@@ -344,7 +344,7 @@ describe('migration v6: dedup edges + add identity index on upgrade (#1034)', ()
runMigrations(raw, 5);
expect(count()).toBe(2); // duplicate collapsed, the distinct `calls` edge kept
expect(getCurrentVersion(raw)).toBe(7);
expect(getCurrentVersion(raw)).toBe(8);
const idx = raw
.prepare("SELECT name FROM sqlite_master WHERE type='index' AND name='idx_edges_identity'")
.get();
+1 -1
View File
@@ -370,7 +370,7 @@ describe('Database Connection', () => {
const version = db.getSchemaVersion();
expect(version).not.toBeNull();
expect(version?.version).toBe(7);
expect(version?.version).toBe(8);
db.close();
});
+1 -1
View File
@@ -299,7 +299,7 @@ describe('Best-Candidate Resolution', () => {
describe('Schema v2 Migration', () => {
it.skipIf(!HAS_SQLITE)('should have correct current schema version', async () => {
const { CURRENT_SCHEMA_VERSION } = await import('../src/db/migrations');
expect(CURRENT_SCHEMA_VERSION).toBe(7);
expect(CURRENT_SCHEMA_VERSION).toBe(8);
});
it.skipIf(!HAS_SQLITE)('should have migration for version 2', async () => {
+194
View File
@@ -429,6 +429,200 @@ describe('Sync Module', () => {
});
});
// Incremental sync used to scope resolution to the CHANGED files' refs, and
// a completed pass deleted every ref it failed to resolve — so when a changed
// file introduced an export/symbol that would satisfy a previously-failed ref
// in an UNCHANGED file, nothing ever revisited it: the cross-file edge stayed
// missing (with status reporting a clean index) until a full re-index. Failed
// refs are now parked as status='failed' and retried when a sync lands files
// carrying a matching symbol name. (#1240)
describe('Sync resolves refs satisfied by a new export in another file (#1240)', () => {
let testDir: string;
let cg: CodeGraph;
function write(rel: string, content: string) {
fs.writeFileSync(path.join(testDir, rel), content);
}
function callersOf(fnName: string, kind: string = 'function'): string[] {
const results = cg.searchNodes(fnName);
const def = results.map((r) => r.node).find((n) => n.kind === kind && n.name === fnName);
if (!def) return [];
return cg.getCallers(def.id).map((c) => c.node.name);
}
beforeEach(async () => {
testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-1240-'));
// a.ts references `greet`, which does not exist anywhere yet — the ref
// fails resolution during the initial index.
write('a.ts', `import { greet } from './b';\n\nexport function run(): number {\n return greet();\n}\n`);
write('b.ts', `export function other(): number {\n return 1;\n}\n`);
cg = CodeGraph.initSync(testDir, {
config: { include: ['**/*.ts'], exclude: [] },
});
await cg.indexAll();
});
afterEach(() => {
if (cg) cg.destroy();
if (fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true, force: true });
});
it('baseline: the unresolvable ref creates no edge and does not report as pending', () => {
expect(callersOf('greet')).toHaveLength(0);
// Failed refs are parked, not pending — status must keep reporting a
// healthy index, or every repo with external-library imports would
// permanently warn about an "interrupted run".
expect(cg.getPendingReferenceCount()).toBe(0);
});
it('creates the cross-file calls edge from the UNCHANGED file after sync', async () => {
write('b.ts', `export function greet(): number {\n return 42;\n}\n`);
const result = await cg.sync();
expect(result.filesModified).toBe(1);
// The ref lives in a.ts, which did NOT change — only the retry of the
// parked failed ref can create this edge.
expect(callersOf('greet')).toContain('run');
expect(cg.getPendingReferenceCount()).toBe(0);
});
it('the synced graph matches a full re-index (the issue\'s exact complaint)', async () => {
write('b.ts', `export function greet(): number {\n return 42;\n}\n`);
await cg.sync();
const synced = cg.getStats();
await cg.indexAll();
const reindexed = cg.getStats();
expect(synced.edgeCount).toBe(reindexed.edgeCount);
expect(synced.nodeCount).toBe(reindexed.nodeCount);
});
it('a second sync is a no-op and does not duplicate edges', async () => {
write('b.ts', `export function greet(): number {\n return 42;\n}\n`);
await cg.sync();
const afterFirst = cg.getStats();
const second = await cg.sync();
expect(second.filesModified).toBe(0);
expect(cg.getStats().edgeCount).toBe(afterFirst.edgeCount);
expect(callersOf('greet')).toContain('run');
});
it('retries dotted method refs via the name tail when a class gains the method', async () => {
// `h.greet()` is stored as reference_name 'h.greet'; the retry lookup
// must match it through name_tail ('greet') when Helper gains greet.
write('use.ts', `import { Helper } from './helper';\n\nexport function useHelper(): number {\n const h = new Helper();\n return h.greet();\n}\n`);
write('helper.ts', `export class Helper {\n other(): number {\n return 1;\n }\n}\n`);
await cg.sync();
expect(callersOf('greet', 'method')).toHaveLength(0);
write('helper.ts', `export class Helper {\n other(): number {\n return 1;\n }\n greet(): number {\n return 42;\n }\n}\n`);
const result = await cg.sync();
expect(result.filesModified).toBe(1);
expect(callersOf('greet', 'method')).toContain('useHelper');
});
});
// The removal-side counterpart of #1240: when a re-index (or file deletion)
// drops a symbol other files had resolved edges to, those edges cascade away
// and the referencing files — which did not change — were never given a
// chance to re-resolve, so they could not rebind to an alternative
// definition the way a full re-index would. Resolution edges now carry their
// originating reference (metadata.refName), and a dropped edge is
// resurrected as that exact ref: re-resolved in the same sync, or parked as
// failed until the symbol reappears.
describe('Sync rebinds or parks refs when a resolved symbol is removed (#1240 removal case)', () => {
let testDir: string;
let cg: CodeGraph;
function write(rel: string, content: string) {
fs.writeFileSync(path.join(testDir, rel), content);
}
function greetDef(): { id: string; filePath: string } | undefined {
const results = cg.searchNodes('greet');
const def = results.map((r) => r.node).find((n) => n.kind === 'function' && n.name === 'greet');
return def ? { id: def.id, filePath: def.filePath } : undefined;
}
function greetCallers(): string[] {
const def = greetDef();
return def ? cg.getCallers(def.id).map((c) => c.node.name) : [];
}
beforeEach(async () => {
testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-1240-removal-'));
// No import — cross-file name matching, so the caller can legitimately
// rebind to a definition in ANY file, which is what a full re-index does.
write('a.ts', `export function run(): number {\n return greet();\n}\n`);
write('b.ts', `export function greet(): number {\n return 42;\n}\n`);
cg = CodeGraph.initSync(testDir, {
config: { include: ['**/*.ts'], exclude: [] },
});
await cg.indexAll();
// Baseline: the call resolved into b.ts.
expect(greetCallers()).toContain('run');
});
afterEach(() => {
if (cg) cg.destroy();
if (fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true, force: true });
});
it('rebinds the unchanged caller when the symbol moves to another file', async () => {
write('b.ts', `export function other(): number {\n return 1;\n}\n`);
write('d.ts', `export function greet(): number {\n return 42;\n}\n`);
await cg.sync();
const def = greetDef();
expect(def?.filePath).toBe('d.ts');
expect(greetCallers()).toContain('run');
// Parity with a full re-index — the issue's contract.
const synced = cg.getStats();
await cg.indexAll();
expect(cg.getStats().edgeCount).toBe(synced.edgeCount);
});
it('drops the edge on removal and restores it when the symbol returns', async () => {
write('b.ts', `export function other(): number {\n return 1;\n}\n`);
await cg.sync();
// Removed with no alternative: the edge must be gone (not preserved
// against a nonexistent symbol) and status must stay clean while the
// ref waits parked.
expect(greetDef()).toBeUndefined();
expect(cg.getPendingReferenceCount()).toBe(0);
write('b.ts', `export function other(): number {\n return 1;\n}\nexport function greet(): number {\n return 42;\n}\n`);
await cg.sync();
expect(greetCallers()).toContain('run');
});
it('handles whole-file deletion: parks the ref, then rebinds when the symbol reappears elsewhere', async () => {
fs.unlinkSync(path.join(testDir, 'b.ts'));
const removal = await cg.sync();
expect(removal.filesRemoved).toBe(1);
expect(greetDef()).toBeUndefined();
expect(cg.getPendingReferenceCount()).toBe(0);
write('d.ts', `export function greet(): number {\n return 99;\n}\n`);
await cg.sync();
expect(greetDef()?.filePath).toBe('d.ts');
expect(greetCallers()).toContain('run');
});
});
describe('Cross-file module-attribute caller edges survive callee re-index (#899)', () => {
let testDir: string;
let cg: CodeGraph;