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
+46
-7
@@ -664,6 +664,15 @@ export class CodeGraph {
|
||||
// (regex over *.module.ts only).
|
||||
if (result.filesAdded > 0 || result.filesModified > 0) {
|
||||
this.resolver.runPostExtract();
|
||||
} else if (result.filesRemoved > 0) {
|
||||
// A pure-removal sync still resolves refs below — the deletion path
|
||||
// resurrects the removed file's incoming edges as pending refs
|
||||
// (#1240 removal case) and the orphan sweep consumes them. In a
|
||||
// long-lived process (daemon) the resolver's name caches were
|
||||
// warmed against the pre-removal graph; drop them so resolution
|
||||
// sees the post-removal state. (runPostExtract above clears caches
|
||||
// itself, so the changed-files branch is already covered.)
|
||||
this.resolver.clearCaches();
|
||||
}
|
||||
|
||||
// Resolve references if files were updated
|
||||
@@ -688,6 +697,34 @@ export class CodeGraph {
|
||||
total,
|
||||
});
|
||||
});
|
||||
|
||||
// Retry previously-failed refs the changed files may now satisfy
|
||||
// (#1240). Scoped resolution above only re-resolves refs FROM the
|
||||
// changed files — but when a changed file gains an export/symbol,
|
||||
// refs in UNCHANGED files that failed against the old graph can
|
||||
// now resolve, and nothing else ever revisits them (their rows
|
||||
// were parked as status='failed' by an earlier completed pass).
|
||||
// Look them up by the symbol names the changed files now carry
|
||||
// and re-resolve just that set. On a sync where no failed ref
|
||||
// matches, this is one indexed lookup.
|
||||
const tRetry = Date.now();
|
||||
const retryable = this.queries.getRetryableFailedReferences(
|
||||
this.queries.getNodeNamesByFiles(result.changedFilePaths)
|
||||
);
|
||||
if (retryable.length > 0) {
|
||||
options.onProgress?.({
|
||||
phase: 'resolving',
|
||||
current: 0,
|
||||
total: retryable.length,
|
||||
});
|
||||
await this.resolver.resolveAndPersistListYielding(retryable);
|
||||
options.onProgress?.({
|
||||
phase: 'resolving',
|
||||
current: retryable.length,
|
||||
total: retryable.length,
|
||||
});
|
||||
}
|
||||
if (process.env.CODEGRAPH_SYNTH_TIMINGS) console.error(`[phase-timing] sync-failed-ref-retry: ${Date.now() - tRetry}ms (${retryable.length} refs)`);
|
||||
} else {
|
||||
// No git info — use batched resolution to avoid OOM
|
||||
const unresolvedCount = this.queries.getUnresolvedReferencesCount();
|
||||
@@ -714,9 +751,10 @@ export class CodeGraph {
|
||||
// path above never revisits them (it reads only the changed files'
|
||||
// rows). Those files' call edges were then missing PERMANENTLY, with
|
||||
// nothing to see except a too-small blast radius, until a full
|
||||
// re-index. A completed pass deletes every row it processed (resolved
|
||||
// or not), so any row still present now is such an orphan — or a row
|
||||
// parked by an older engine whose scoped pass kept unresolvable refs.
|
||||
// re-index. A completed pass takes every row it processed out of the
|
||||
// PENDING set (resolved rows are deleted, unresolvable ones parked as
|
||||
// status='failed' for the #1240 retry above), so any pending row now
|
||||
// is such an orphan — or a row from an older engine's scoped pass.
|
||||
// Grind them down with the batched resolver; this also makes a bare
|
||||
// `codegraph sync` the recovery command for a wedged index. On a
|
||||
// healthy index this is one COUNT query.
|
||||
@@ -968,10 +1006,11 @@ export class CodeGraph {
|
||||
}
|
||||
|
||||
/**
|
||||
* References extracted but not yet resolved into edges. Zero on a healthy
|
||||
* index — a completed resolution pass consumes every row. Non-zero at rest
|
||||
* means a pass was interrupted mid-run (killed indexer, crash — #1187), so
|
||||
* some files' call edges are missing; the next `sync` sweeps them.
|
||||
* References extracted but never attempted by a resolution pass. Zero on a
|
||||
* healthy index — a completed pass consumes every pending row (resolving it
|
||||
* or parking it as failed, #1240). Non-zero at rest means a pass was
|
||||
* interrupted mid-run (killed indexer, crash — #1187), so some files' call
|
||||
* edges are missing; the next `sync` sweeps them.
|
||||
*/
|
||||
getPendingReferenceCount(): number {
|
||||
return this.queries.getUnresolvedReferencesCount();
|
||||
|
||||
Reference in New Issue
Block a user