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
+70
-4
@@ -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++;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user