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:
co-authored by
Claude Fable 5
parent
386bff0f84
commit
9d0cd3a7d1
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user