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
+70 -4
View File
@@ -16,6 +16,8 @@ import {
ExtractionResult,
ExtractionError,
Edge,
UnresolvedReference,
ReferenceKind,
} from '../types';
import { QueryBuilder } from '../db/queries';
import { extractFromSource } from './tree-sitter';
@@ -1360,6 +1362,38 @@ function scanDirectoryWalk(
return files;
}
/**
* Resurrect a resolution edge that is about to be dropped (its target symbol
* was removed, renamed, or its whole file deleted) as the ORIGINAL unresolved
* reference that created it, read from the refName/refKind stamp
* `createEdges` writes into edge metadata. Inserted as status='pending', the
* ref is consumed by the same sync's resolution sweep: it rebinds to an
* alternative definition if one exists, or parks as status='failed' where the
* #1240 retry finds it if the symbol later reappears.
*
* Returns null — drop silently, the pre-#1240 behavior — for edges without a
* refName stamp (created before the stamp existed, or synthesized): rebuilding
* a ref from the target's plain node name would strip the receiver/qualifier
* context the original text carried (`h.greet` → `greet`) and could rebind
* somewhere a full re-index never would. Silent beats wrong.
*/
function resurrectRefFromDroppedEdge(
e: Edge & { sourceFilePath: string; sourceLanguage: Language }
): UnresolvedReference | null {
const refName = e.metadata?.refName;
if (typeof refName !== 'string' || refName.length === 0) return null;
const refKind = typeof e.metadata?.refKind === 'string' ? (e.metadata.refKind as ReferenceKind) : e.kind;
return {
fromNodeId: e.source,
referenceName: refName,
referenceKind: refKind,
line: e.line ?? 0,
column: e.column ?? 0,
filePath: e.sourceFilePath,
language: e.sourceLanguage,
};
}
/**
* Extraction orchestrator
*/
@@ -2193,25 +2227,41 @@ export class ExtractionOrchestrator {
// (filePath, kind, name). Node ids include the source line, so any line
// shift in the callee file (e.g. a docstring-only edit above the symbol)
// changes every target id and a naive re-insert by old id would drop them
// all. `insertEdges` still filters to endpoints that exist, so edges whose
// caller (source) was deleted, or whose callee (target) was renamed/removed
// during the re-index (no match in `newTargetIds`), are dropped. This
// closes the #899 edge-drop on `sync`.
// all. `insertEdges` still filters to endpoints that exist. This closes
// the #899 edge-drop on `sync`.
//
// Edges whose callee (target) was renamed/removed during the re-index (no
// match in `newNodesByKindName`) are not silently dropped anymore: each is
// resurrected as its ORIGINAL unresolved ref (stamped on the edge as
// metadata.refName/refKind at creation) so the same sync's resolution
// sweep can rebind it to an alternative definition elsewhere, or park it
// as status='failed' to be retried when the symbol reappears — the
// removal-side counterpart of #1240. Edges without refName (built before
// the stamp existed, or synthesized) still drop silently: reconstructing
// a ref from the target's plain name would strip receiver/qualifier
// context and risk a rebind a full re-index would never make.
if (crossFileIncomingEdges.length > 0) {
const newNodesByKindName = new Map<string, string>();
for (const n of validNodes) {
newNodesByKindName.set(`${n.kind}\0${n.name}`, n.id);
}
const reinserted: Edge[] = [];
const resurrected: UnresolvedReference[] = [];
for (const e of crossFileIncomingEdges) {
const newTargetId = newNodesByKindName.get(`${e.targetKind}\0${e.targetName}`);
if (newTargetId) {
reinserted.push({ source: e.source, target: newTargetId, kind: e.kind, metadata: e.metadata, line: e.line, column: e.column, provenance: e.provenance });
} else {
const ref = resurrectRefFromDroppedEdge(e);
if (ref) resurrected.push(ref);
}
}
if (reinserted.length > 0) {
this.queries.insertEdges(reinserted);
}
if (resurrected.length > 0) {
this.queries.insertUnresolvedRefsBatch(resurrected);
}
}
// Insert unresolved references in batch with denormalized filePath/language
@@ -2300,6 +2350,22 @@ export class ExtractionOrchestrator {
let reconcileChecks = 0;
for (const tracked of trackedFiles) {
if (!currentSet.has(tracked.path) || !fs.existsSync(path.join(this.rootDir, tracked.path))) {
// Before the cascade deletes them, resurrect incoming cross-file
// resolution edges as their original refs (#1240 removal case): the
// callers live in files this sync will NOT revisit, so this is their
// only chance to rebind to an alternative definition — or to park as
// failed until the symbol reappears somewhere. (A deleted file whose
// CALLERS are also being deleted is fine: their nodes cascade later
// in this loop and take the resurrected rows with them.)
const incoming = this.queries.getCrossFileIncomingEdgesWithTarget(tracked.path);
if (incoming.length > 0) {
const resurrected = incoming
.map((e) => resurrectRefFromDroppedEdge(e))
.filter((r): r is UnresolvedReference => r !== null);
if (resurrected.length > 0) {
this.queries.insertUnresolvedRefsBatch(resurrected);
}
}
this.queries.deleteFile(tracked.path);
filesRemoved++;
}