fix(sync): same-named symbols keep their own cross-file callers across a re-index (#2276) (#2288)

* fix(sync): same-named symbols keep their own cross-file callers across a re-index (#2276)

Re-storing a file re-attaches the edges other files had into it by moving
each edge to the re-indexed node that replaces its target. That pairing
keyed new nodes by (kind, name) with the last one winning, so in a file
defining a name twice — the same method on two classes, a method's
overloads — any edit, even a body-only one, moved every incoming caller
of those symbols onto one of them. A body-only edit leaves the sync's
definition delta empty, so nothing re-resolved them afterwards.

The re-store now reads the file's old node identities alongside the edge
snapshot and pairs old nodes with new ones by (kind, name), then
qualified name, then signature, then position within a same-sized group
of otherwise identical nodes. A target no tier can pair one-to-one is
resurrected as its original unresolved reference, so resolution rebinds
it instead of the store guessing.

The common in-memory re-store path also now deletes the old rows, stores
the new ones and re-attaches incoming edges in one transaction. Committed
separately, a failure between the steps left the file looking new or
already current, and other files' edges into it were lost until a full
re-index.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(changelog): same-named symbols keep their callers across a sync (#2276)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-10-02 04:26:51 +00:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 455175da67
commit e28ac71048
4 changed files with 418 additions and 71 deletions
+52 -7
View File
@@ -90,6 +90,15 @@ const IS_INTERFACE_MEMBER = (alias: string): string => `EXISTS (
*/
export const DEPRIORITIZED_NAME_BONUS_SCALE = 0.75;
/**
* The fields that tell one node of a file from another across a re-index of
* that file, whose node ids change with every line shift (#2276).
*/
export type NodeIdentity = Pick<
Node,
'id' | 'kind' | 'name' | 'qualifiedName' | 'signature' | 'startLine' | 'startColumn'
>;
/**
* Database row types (snake_case from SQLite)
*/
@@ -290,6 +299,7 @@ export class QueryBuilder {
existingNodeIdsFull?: SqliteStatement;
getExportedNodesByFile?: SqliteStatement;
getNodesByFileAndName?: SqliteStatement;
getNodeIdentitiesByFile?: SqliteStatement;
getFileNodesByNamePrefix?: SqliteStatement;
getNodesByQualifiedNameExact?: SqliteStatement;
getNodesByLowerName?: SqliteStatement;
@@ -599,6 +609,15 @@ export class QueryBuilder {
})();
}
/**
* Run `fn` as one transaction. The write helpers called inside join it
* rather than committing on their own, so a crash part-way leaves none of
* `fn`'s writes behind.
*/
runInTransaction<T>(fn: () => T): T {
return this.db.transaction(fn)();
}
/**
* Store one file's whole extraction bundle — nodes, edges, unresolved refs,
* and the file record — in a SINGLE transaction. The bulk-index path calls
@@ -1033,6 +1052,31 @@ export class QueryBuilder {
return rows.map(rowToNode);
}
/**
* The {@link NodeIdentity} of every node in a file, without decoding whole
* nodes. Read before a re-index deletes the file, so each incoming
* cross-file edge can follow its target to the node that replaces it (#2276).
*/
getNodeIdentitiesByFile(filePath: string): NodeIdentity[] {
if (!this.stmts.getNodeIdentitiesByFile) {
this.stmts.getNodeIdentitiesByFile = this.db.prepare(
'SELECT id, kind, name, qualified_name, signature, start_line, start_column FROM nodes WHERE file_path = ?'
);
}
const rows = this.stmts.getNodeIdentitiesByFile.all(filePath) as Array<
Pick<NodeRow, 'id' | 'kind' | 'name' | 'qualified_name' | 'signature' | 'start_line' | 'start_column'>
>;
return rows.map((row) => ({
id: row.id,
kind: row.kind as NodeKind,
name: row.name,
qualifiedName: row.qualified_name,
signature: row.signature ?? undefined,
startLine: row.start_line,
startColumn: row.start_column,
}));
}
/**
* Get all nodes in several files at once — one chunked `IN` query rather than
* one {@link getNodesByFile} per file (#1975).
@@ -2920,13 +2964,14 @@ export class QueryBuilder {
/**
* Cross-file edges whose TARGET is a node in `filePath` and whose SOURCE is a
* node in a *different* file, paired with the target node's (name, kind) so a
* caller can re-resolve the edge to the re-indexed target's new ID (node IDs
* are `sha256(filePath:kind:name:line)`, so any line shift in the callee file
* changes target IDs and a naive re-insert by old ID silently drops them).
* Used by `storeExtractionResult` to preserve incoming edges across a file
* re-index (issue #899). Same edge-kind rules as
* {@link getDependentFilePaths}: all kinds except `contains`.
* node in a *different* file, paired with the target node's (name, kind).
* Node IDs are `sha256(filePath:kind:name:line)`, so any line shift in the
* callee file changes target IDs and a naive re-insert by old ID silently
* drops them; `storeExtractionResult` instead follows each old target to the
* re-indexed node that replaces it (see {@link getNodeIdentitiesByFile}) to
* preserve incoming edges across a file re-index (issue #899). Same
* edge-kind rules as {@link getDependentFilePaths}: all kinds except
* `contains`.
*/
getCrossFileIncomingEdgesWithTarget(
filePath: string