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
+81 -18
View File
@@ -974,6 +974,18 @@ export class ReferenceResolver {
metadata: {
confidence: ref.confidence,
resolvedBy: ref.resolvedBy,
// The ORIGINAL reference text (and kind, when edge-kind promotion
// rewrote it — calls→instantiates, extends→implements,
// function_ref→references). If this edge's target is later removed
// by a re-index, the edge is resurrected as exactly this ref and
// re-resolved (#1240 removal case) — a faithful resurrection, so
// re-resolution can never bind anywhere a full re-index wouldn't.
// Reconstruction from the target node's name instead would strip
// receiver/qualifier context (`h.greet` → `greet`) and risk a
// wrong rebind; edges without refName (pre-#1240, synthesized) are
// deliberately NOT resurrected for the same reason.
refName: ref.original.referenceName,
...(ref.original.referenceKind !== kind ? { refKind: ref.original.referenceKind } : {}),
// Uniform marker for function-as-value edges (#756), regardless of
// which strategy resolved them (import vs matchFunctionRef) — lets
// tooling label "callback registration" and lets validation diff
@@ -1012,15 +1024,17 @@ export class ReferenceResolver {
);
}
// Delete unresolvable refs too — parity with resolveAndPersistBatched.
// Keeping them bought nothing: a ref is only ever retried when its file
// is re-extracted, which cascade-deletes and re-inserts its rows anyway.
// And it broke the #1187 orphan sweep's invariant — after a COMPLETED
// pass the table must hold nothing that pass processed, so that any row
// still present belongs to an interrupted run and the sweep can key off
// a bare row count.
// Park unresolvable refs as status='failed' — parity with
// resolveAndPersistBatched. Deleting them was wrong (#1240): a ref whose
// own file never changes is otherwise gone forever, so when a DIFFERENT
// file later gains the export/symbol that would satisfy it, no sync can
// recreate the edge — only a full re-index. Failed rows are excluded from
// the pending readers, which preserves the #1187 orphan sweep's
// invariant in status form: after a COMPLETED pass nothing it processed
// is still 'pending', so any pending row at rest belongs to an
// interrupted run and the sweep can key off the pending count.
if (result.unresolved.length > 0) {
this.queries.deleteSpecificResolvedReferences(
this.queries.markReferencesFailed(
result.unresolved.map((r) => ({
fromNodeId: r.fromNodeId,
referenceName: r.referenceName,
@@ -1032,6 +1046,49 @@ export class ReferenceResolver {
return result;
}
/**
* Yielding counterpart of {@link resolveAndPersist} for a caller-supplied
* ref list — used by sync's failed-ref retry pass (#1240). Same persistence
* semantics: resolved refs become edges and their rows are deleted;
* still-unresolvable refs are (re-)marked failed (a no-op for rows already
* in that status). Yields per-ref because sync can run on the daemon's
* liveness-watchdog thread (#850/#1091) and a retry set is unbounded when
* a large edit lands many popular symbol names at once.
*/
async resolveAndPersistListYielding(refs: UnresolvedReference[]): Promise<ResolutionResult> {
const maybeYield = createYielder();
const result = await this.resolveBatchYielding(refs, maybeYield);
const PERSIST_CHUNK = 1000;
const edges = this.createEdges(result.resolved);
for (let i = 0; i < edges.length; i += PERSIST_CHUNK) {
this.queries.insertEdges(edges.slice(i, i + PERSIST_CHUNK));
await maybeYield();
}
const resolvedKeys = result.resolved.map((r) => ({
fromNodeId: r.original.fromNodeId,
referenceName: r.original.referenceName,
referenceKind: r.original.referenceKind,
}));
for (let i = 0; i < resolvedKeys.length; i += PERSIST_CHUNK) {
this.queries.deleteSpecificResolvedReferences(resolvedKeys.slice(i, i + PERSIST_CHUNK));
await maybeYield();
}
const unresolvedKeys = result.unresolved.map((r) => ({
fromNodeId: r.fromNodeId,
referenceName: r.referenceName,
referenceKind: r.referenceKind,
}));
for (let i = 0; i < unresolvedKeys.length; i += PERSIST_CHUNK) {
this.queries.markReferencesFailed(unresolvedKeys.slice(i, i + PERSIST_CHUNK));
await maybeYield();
}
return result;
}
/**
* Second resolution pass for chained static-factory / fluent calls whose
* chained method is defined on a SUPERTYPE the receiver's type conforms to —
@@ -1162,8 +1219,10 @@ export class ReferenceResolver {
byMethod: {} as Record<string, number>,
};
// Process in batches. We always read from offset 0 because resolved refs
// are deleted after each batch, shifting the remaining rows forward.
// Process in batches. We always read from offset 0 because every ref the
// batch processed leaves the pending set (resolved rows are deleted,
// unresolvable ones flip to status='failed'), shifting the remaining
// pending rows forward.
let prevRemaining = Number.POSITIVE_INFINITY;
while (true) {
const batch = this.queries.getUnresolvedReferencesBatch(0, batchSize);
@@ -1198,14 +1257,17 @@ export class ReferenceResolver {
await maybeYield();
}
// Delete unresolvable refs from this batch to avoid re-processing them
// Park unresolvable refs from this batch as status='failed' so they
// leave the pending set (the batch reader and non-progress guard below
// only see pending rows) but stay retryable when a later sync adds a
// symbol that could satisfy them (#1240).
const unresolvedKeys = result.unresolved.map((r) => ({
fromNodeId: r.fromNodeId,
referenceName: r.referenceName,
referenceKind: r.referenceKind,
}));
for (let i = 0; i < unresolvedKeys.length; i += PERSIST_CHUNK) {
this.queries.deleteSpecificResolvedReferences(unresolvedKeys.slice(i, i + PERSIST_CHUNK));
this.queries.markReferencesFailed(unresolvedKeys.slice(i, i + PERSIST_CHUNK));
await maybeYield();
}
@@ -1232,13 +1294,14 @@ export class ReferenceResolver {
// The count-based guard below catches the true no-progress case.
// Non-progress guard (defense-in-depth). Because we re-read from offset 0
// each pass, the unresolved_refs table MUST shrink every iteration — both
// resolved and unresolved refs are deleted above. If it didn't shrink, a
// each pass, the PENDING population MUST shrink every iteration — resolved
// refs are deleted and unresolvable ones are marked failed above, and both
// leave the pending set the batch reader sees. If it didn't shrink, a
// resolver returned a match whose `original.referenceName` differs from the
// stored row, so the keyed delete no-ops, and we'd re-read + re-resolve +
// re-insert the same rows forever (the runaway that grew a 99-file repo to
// 5M edges / 1.4 GB before the Go-fallback fix). Stop rather than grow the
// graph without bound.
// stored row, so the keyed delete/update no-ops, and we'd re-read +
// re-resolve + re-insert the same rows forever (the runaway that grew a
// 99-file repo to 5M edges / 1.4 GB before the Go-fallback fix). Stop
// rather than grow the graph without bound.
const remaining = this.queries.getUnresolvedReferencesCount();
if (remaining >= prevRemaining) break;
prevRemaining = remaining;