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
parent 455175da67
commit e28ac71048
4 changed files with 418 additions and 71 deletions
+1
View File
@@ -164,6 +164,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- A CodeGraph session that queried another project through `projectPath` no longer keeps that project locked for as long as it runs: after 10 minutes without a query it lets the project go, so the project's own session and `codegraph index` can take over again (tune with `CODEGRAPH_PROJECT_IDLE_TIMEOUT_MS`, `0` keeps it open). Thanks @danusha2345, and @bompus for the report. (#2087)
- Claude Code now shows the model the full text of `codegraph_status` and of explore's stale-index refusal. Both results also carried `structuredContent`, and Claude Code shows a result's `structuredContent` in place of its text, so the model saw only a freshness JSON object: no refusal message, no retry guidance and no status report. Both results are now text only. Thanks @bompus. (#2088)
- When an agent asks CodeGraph about a separate git repository nested inside an indexed project, one the project's index leaves out (for example because the parent's `.gitignore` excludes it), it now gets the usual "isn't indexed" guidance instead of answers from the parent project's code. Nested repositories the parent does index, such as submodules, work as before. Thanks @wstczyw for the report. (#2110)
- Editing a file that defines the same name more than once, like two classes that each have an `execute` method or a method's overloads, no longer moves every caller from other files onto one of them during `codegraph sync`. A sync interrupted partway through a file also no longer loses those callers until a full re-index. Thanks @ijbranch for the report. (#2276)
## [1.6.1] - 2026-09-29
+223
View File
@@ -12,6 +12,7 @@ import * as path from 'path';
import * as os from 'os';
import { execFileSync } from 'child_process';
import CodeGraph, { LockUnavailableError } from '../src/index';
import { QueryBuilder } from '../src/db/queries';
import { __emitWatchEventForTests } from '../src/sync/watcher';
describe('Sync Module', () => {
@@ -833,6 +834,228 @@ describe('Sync Module', () => {
});
});
describe('Same-named symbols keep their own cross-file callers across a re-index (#2276)', () => {
const dirs: string[] = [];
const graphs: CodeGraph[] = [];
afterEach(() => {
for (const cg of graphs.splice(0)) cg.destroy();
for (const dir of dirs.splice(0)) fs.rmSync(dir, { recursive: true, force: true });
});
async function indexProject(files: Record<string, string>, include: string[]) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-2276-'));
dirs.push(dir);
for (const [rel, content] of Object.entries(files)) {
fs.mkdirSync(path.dirname(path.join(dir, rel)), { recursive: true });
fs.writeFileSync(path.join(dir, rel), content);
}
const cg = CodeGraph.initSync(dir, { config: { include, exclude: [] } });
graphs.push(cg);
await cg.indexAll();
return { dir, cg };
}
/** Every method named `name`, in file/line order, with its callers. */
function callersOf(cg: CodeGraph, name: string): string[] {
return cg
.getNodesByName(name)
.filter((n) => n.kind === 'method')
.sort((a, b) => a.filePath.localeCompare(b.filePath) || a.startLine - b.startLine)
.map((n) => `${n.qualifiedName}: ${cg.getCallers(n.id).map((c) => c.node.name).sort().join(',')}`);
}
/**
* Index `files`, rewrite one of them, sync, and return the callers of `name`
* before the edit, after the sync, and from a fresh index of the edited tree
* — the answer a synced index must agree with.
*/
async function editAndSync(
files: Record<string, string>,
include: string[],
editPath: string,
edited: string,
name: string
) {
const { dir, cg } = await indexProject(files, include);
const before = callersOf(cg, name);
fs.writeFileSync(path.join(dir, editPath), edited);
const result = await cg.sync();
expect(result.filesModified).toBe(1);
const fresh = await indexProject({ ...files, [editPath]: edited }, include);
return { before, synced: callersOf(cg, name), fresh: callersOf(fresh.cg, name) };
}
const tsOps = [
`export class Foo {`,
` execute(): number {`,
` return 1;`,
` }`,
`}`,
``,
`export class Bar {`,
` execute(): number {`,
` return 2;`,
` }`,
`}`,
``,
].join('\n');
const tsFiles = {
'ops.ts': tsOps,
'callers.ts': [
`import { Foo, Bar } from './ops';`,
``,
`export function runFoo(): number {`,
` const foo = new Foo();`,
` return foo.execute();`,
`}`,
``,
`export function runBar(): number {`,
` const bar = new Bar();`,
` return bar.execute();`,
`}`,
``,
].join('\n'),
};
it.each([
['a body-only edit', tsOps.replace('return 1;', 'return 10;')],
['an edit that shifts the second class', tsOps.replace('return 1;', 'const n = 1;\n return n;')],
])('two classes defining `execute`: %s keeps each method its own callers', async (_label, edited) => {
const { before, synced, fresh } = await editAndSync(tsFiles, ['**/*.ts'], 'ops.ts', edited, 'execute');
expect(before).toEqual(['Foo::execute: runFoo', 'Bar::execute: runBar']);
expect(synced).toEqual(before);
expect(synced).toEqual(fresh);
});
it('a re-store that fails part-way leaves the old rows and their incoming edges in place', async () => {
const { dir, cg } = await indexProject(tsFiles, ['**/*.ts']);
const before = callersOf(cg, 'execute');
fs.writeFileSync(path.join(dir, 'ops.ts'), tsOps.replace('return 1;', 'return 10;'));
const store = vi.spyOn(QueryBuilder.prototype, 'storeFileBundle').mockImplementationOnce(() => {
throw new Error('simulated crash mid-store');
});
try {
await expect(cg.sync()).rejects.toThrow('simulated crash mid-store');
} finally {
store.mockRestore();
}
// Rolled back to the pre-edit file, so the retry still sees a modified
// file — one it snapshots — rather than a new one with no history.
expect(callersOf(cg, 'execute')).toEqual(before);
const retry = await cg.sync();
expect(retry.filesModified).toBe(1);
expect(callersOf(cg, 'execute')).toEqual(before);
});
it('a caller of a removed twin is re-resolved, not handed to the survivor', async () => {
const edited = tsOps.slice(tsOps.indexOf('export class Bar'));
const { synced, fresh } = await editAndSync(tsFiles, ['**/*.ts'], 'ops.ts', edited, 'execute');
expect(synced).toEqual(fresh);
});
const javaFactory = [
`package app;`,
``,
`public class Factory {`,
` public static Widget create(int size) {`,
` return new Widget();`,
` }`,
``,
` public static Widget create(String name) {`,
` return new Widget();`,
` }`,
`}`,
``,
].join('\n');
const javaFiles = {
'src/app/Factory.java': javaFactory,
'src/app/Widget.java': `package app;\n\npublic class Widget {}\n`,
'src/app/Client.java': [
`package app;`,
``,
`public class Client {`,
` public Widget small() {`,
` return Factory.create(1);`,
` }`,
``,
` public Widget named() {`,
` return Factory.create("x");`,
` }`,
`}`,
``,
].join('\n'),
};
it('Java overloads: a body edit leaves each overload the callers it had', async () => {
const edited = javaFactory.replace(
`create(int size) {\n return new Widget();`,
`create(int size) {\n Widget w = new Widget();\n return w;`
);
const { before, synced, fresh } = await editAndSync(javaFiles, ['**/*.java'], 'src/app/Factory.java', edited, 'create');
expect(before.some((line) => /: .+/.test(line))).toBe(true);
expect(synced).toEqual(before);
expect(synced).toEqual(fresh);
});
// C# records no signature, so its overloads differ only by position.
const csFactory = [
`namespace App {`,
` public class Factory {`,
` public static Widget Create(int size) {`,
` return new Widget();`,
` }`,
``,
` public static Widget Create(string name) {`,
` return new Widget();`,
` }`,
` }`,
``,
` public class Widget {}`,
`}`,
``,
].join('\n');
const csFiles = {
'Factory.cs': csFactory,
'Client.cs': [
`namespace App {`,
` public class Client {`,
` public Widget Small() {`,
` return Factory.Create(1);`,
` }`,
``,
` public Widget Named() {`,
` return Factory.Create("x");`,
` }`,
` }`,
`}`,
``,
].join('\n'),
};
it('C# overloads with no signature: a body edit pairs them by position', async () => {
const edited = csFactory.replace(
`Create(int size) {\n return new Widget();`,
`Create(int size) {\n var w = new Widget();\n return w;`
);
const { before, synced, fresh } = await editAndSync(csFiles, ['**/*.cs'], 'Factory.cs', edited, 'Create');
expect(before.some((line) => /: .+/.test(line))).toBe(true);
expect(synced).toEqual(before);
expect(synced).toEqual(fresh);
});
it('C# overloads: a new indistinguishable overload re-resolves the callers instead of guessing', async () => {
const edited = csFactory.replace(
` public class Factory {\n`,
` public class Factory {\n public static Widget Create(bool flag) {\n return new Widget();\n }\n\n`
);
const { synced, fresh } = await editAndSync(csFiles, ['**/*.cs'], 'Factory.cs', edited, 'Create');
expect(synced).toEqual(fresh);
});
});
describe('Scoped sync parity (#watcher-scoped)', () => {
let testDir: string;
let cg: CodeGraph;
+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
+142 -64
View File
@@ -15,12 +15,11 @@ import {
FileRecord,
ExtractionResult,
ExtractionError,
Node,
Edge,
UnresolvedReference,
ReferenceKind,
} from '../types';
import { QueryBuilder } from '../db/queries';
import { QueryBuilder, NodeIdentity } from '../db/queries';
import { extractFromSource } from './tree-sitter';
import { ParseWorkerPool, resolveParsePoolSize, resolveParseTimeoutMs } from './parse-pool';
import { StoreWriter, StoreBundle, finalizeStoreBundle } from './store-writer';
@@ -1780,6 +1779,71 @@ function resurrectRefFromDroppedEdge(
};
}
/**
* What tells a file's nodes apart across a re-index, coarsest first. (kind,
* name) is the #899 key and settles every name a file defines once; a name it
* defines more than once — the same method on two classes, a method's
* overloads — is split further by qualified name, then by signature (#2276).
*/
const REINDEX_IDENTITY_TIERS: ReadonlyArray<(n: NodeIdentity) => string> = [
(n) => `${n.kind}\0${n.name}`,
(n) => n.qualifiedName,
(n) => n.signature ?? '',
];
function groupNodeIdentities(
nodes: readonly NodeIdentity[],
key: (n: NodeIdentity) => string
): Map<string, NodeIdentity[]> {
const groups = new Map<string, NodeIdentity[]>();
for (const n of nodes) {
const k = key(n);
const group = groups.get(k);
if (group) group.push(n);
else groups.set(k, [n]);
}
return groups;
}
/**
* Pair each node a file had before a re-index with the node that replaces it,
* old id → new id. Ids embed the start line, so they can't be compared across
* an edit. Each tier of {@link REINDEX_IDENTITY_TIERS} splits the groups the
* one before left ambiguous, and a group down to one node on each side is a
* pair. Nodes still identical after every tier (overloads in a language that
* records no signature) pair by position, but only when both sides have the
* same number of them. Anything else stays unpaired: the caller re-resolves
* its edges from their original reference instead of handing them all to
* whichever same-named node happens to come last (#2276).
*/
function pairReindexedNodes(
prior: readonly NodeIdentity[],
next: readonly NodeIdentity[]
): Map<string, string> {
const pairs = new Map<string, string>();
const byPosition = (a: NodeIdentity, b: NodeIdentity) =>
a.startLine - b.startLine || a.startColumn - b.startColumn;
const pairGroups = (before: readonly NodeIdentity[], after: readonly NodeIdentity[], tier: number): void => {
const key = REINDEX_IDENTITY_TIERS[tier]!;
const afterGroups = groupNodeIdentities(after, key);
for (const [k, group] of groupNodeIdentities(before, key)) {
const match = afterGroups.get(k);
if (!match) continue;
if (group.length === 1 && match.length === 1) {
pairs.set(group[0]!.id, match[0]!.id);
} else if (tier + 1 < REINDEX_IDENTITY_TIERS.length) {
pairGroups(group, match, tier + 1);
} else if (group.length === match.length) {
const a = [...group].sort(byPosition);
const b = [...match].sort(byPosition);
for (let i = 0; i < a.length; i++) pairs.set(a[i]!.id, b[i]!.id);
}
}
};
pairGroups(prior, next, 0);
return pairs;
}
/**
* Extraction orchestrator
*/
@@ -2854,9 +2918,9 @@ export class ExtractionOrchestrator {
// Bulk inserts run in bounded sub-transactions with a yield between, so a
// giant generated file (tens of thousands of symbols) can't block the
// event loop — and the #850 watchdog heartbeat — for the whole store.
// The file was NEVER one atomic transaction (each insert call has its
// own), and the files-table record still lands last, so crash recovery
// is unchanged: a partially-stored file has no record and re-indexes.
// That chunked path is not one atomic transaction (each insert call has
// its own), and the files-table record still lands last, so a
// partially-stored file has no record and re-indexes.
const STORE_CHUNK = 2000;
const contentHash = hashContent(content);
@@ -2890,21 +2954,21 @@ export class ExtractionOrchestrator {
// `references` edges from callers that import it via module-attribute
// access (`pkg.mod.fn(...)`).
//
// We snapshot the edge plus the target node's (name, kind) so we can
// re-resolve to the re-indexed target's NEW id. Node ids are
// `sha256(filePath:kind:name: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 silently drop every edge. Matching by
// (filePath, kind, name) is stable across line shifts; if the symbol was
// renamed/removed, no match is found and the edge stays dropped (correct).
// We snapshot the edges plus the identity of every node the file had, so
// each edge can follow its old target to the re-indexed node that replaces
// it. Node ids are `sha256(filePath:kind:name: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 silently drop
// every edge. Pairing by (kind, name) — then qualified name, signature and
// position when the file defines a name more than once (#2276) — is stable
// across line shifts; if the symbol was renamed/removed, no pair is found
// and the edge is re-resolved from its original reference (see below).
const crossFileIncomingEdges = existingFile
? this.queries.getCrossFileIncomingEdgesWithTarget(filePath)
: [];
// Delete existing data for this file
if (existingFile) {
this.queries.deleteFile(filePath);
}
const priorNodes = crossFileIncomingEdges.length > 0
? this.queries.getNodeIdentitiesByFile(filePath)
: [];
// Filter out nodes with missing required fields before insertion.
// This prevents FK violations when edges reference nodes that would
@@ -2923,38 +2987,52 @@ export class ExtractionOrchestrator {
}));
// Fast path for the common case (everything fits one chunk): the whole
// file — nodes, edges, refs, file record — lands in ONE transaction with
// no event-loop yields in between. Giant generated files keep the chunked
// + yielding path below so the #850 watchdog heartbeat stays serviced.
// re-store — deleting the old rows, the new nodes, edges, refs and file
// record, and the re-attached incoming edges — lands in ONE transaction
// with no event-loop yields in between. Committed separately, a crash
// after the delete left the file looking new (no snapshot next time) and
// a crash before the re-attach left it looking current: either way other
// files' edges into it were lost for good. Giant generated files keep the
// chunked + yielding path below so the #850 watchdog heartbeat stays
// serviced.
const fitsOneChunk =
validNodes.length <= STORE_CHUNK &&
validEdges.length <= STORE_CHUNK &&
validRefs.length <= STORE_CHUNK;
if (fitsOneChunk) {
// Snapshot/re-resolution of cross-file incoming edges (below) still runs
// for the sync path; on a fresh bulk index crossFileIncomingEdges is [].
this.queries.storeFileBundle({
nodes: validNodes,
edges: validEdges,
refs: validRefs,
file: {
path: filePath,
contentHash,
language,
size: stats.size,
modifiedAt: stats.mtimeMs,
indexedAt: Date.now(),
nodeCount: result.nodes.length,
errors: result.errors.length > 0 ? result.errors : undefined,
generated,
},
this.queries.runInTransaction(() => {
if (existingFile) {
this.queries.deleteFile(filePath);
}
this.queries.storeFileBundle({
nodes: validNodes,
edges: validEdges,
refs: validRefs,
file: {
path: filePath,
contentHash,
language,
size: stats.size,
modifiedAt: stats.mtimeMs,
indexedAt: Date.now(),
nodeCount: result.nodes.length,
errors: result.errors.length > 0 ? result.errors : undefined,
generated,
},
});
// On a fresh bulk index crossFileIncomingEdges is [].
if (crossFileIncomingEdges.length > 0) {
this.reattachCrossFileEdges(crossFileIncomingEdges, priorNodes, validNodes);
}
});
if (crossFileIncomingEdges.length > 0) {
this.reattachCrossFileEdges(crossFileIncomingEdges, validNodes);
}
return;
}
// Delete existing data for this file
if (existingFile) {
this.queries.deleteFile(filePath);
}
// Insert nodes (chunked — see STORE_CHUNK above)
for (let i = 0; i < validNodes.length; i += STORE_CHUNK) {
this.queries.insertNodes(validNodes.slice(i, i + STORE_CHUNK));
@@ -2970,25 +3048,26 @@ export class ExtractionOrchestrator {
}
// Re-insert cross-file incoming edges snapshotted before the delete,
// re-resolving each edge's target to the re-indexed node's new id by
// (filePath, kind, name). Node ids include the source line, so any line
// moving each edge's target to the re-indexed node that replaces it (see
// pairReindexedNodes). 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. 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.
// Edges whose callee (target) was renamed/removed during the re-index, or
// can't be told apart from a same-named sibling (#2276), are not silently
// dropped or guessed at: 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 the right definition
// here or an alternative one 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) {
this.reattachCrossFileEdges(crossFileIncomingEdges, validNodes);
this.reattachCrossFileEdges(crossFileIncomingEdges, priorNodes, validNodes);
}
// Insert unresolved references in batch with denormalized filePath/language
@@ -3059,23 +3138,22 @@ export class ExtractionOrchestrator {
/**
* Re-attach cross-file incoming edges snapshotted before a re-index delete
* (#899): re-resolve each edge's target to the re-indexed node's new id by
* (kind, name); targets that vanished are resurrected as their original
* unresolved ref (#1240's removal-side counterpart) when the edge carries
* its refName stamp.
* (#899): move each edge's target to the re-indexed node that replaces it
* ({@link pairReindexedNodes}). Targets that vanished, or that can't be
* told apart from a same-named sibling (#2276), are resurrected as their
* original unresolved ref (#1240's removal-side counterpart) when the edge
* carries its refName stamp, so resolution picks the target afresh.
*/
private reattachCrossFileEdges(
crossFileIncomingEdges: Array<Edge & { targetKind: string; targetName: string; sourceFilePath: string; sourceLanguage: Language }>,
validNodes: Node[]
crossFileIncomingEdges: Array<Edge & { sourceFilePath: string; sourceLanguage: Language }>,
priorNodes: readonly NodeIdentity[],
validNodes: readonly NodeIdentity[]
): void {
const newNodesByKindName = new Map<string, string>();
for (const n of validNodes) {
newNodesByKindName.set(`${n.kind}\0${n.name}`, n.id);
}
const replacementOf = pairReindexedNodes(priorNodes, validNodes);
const reinserted: Edge[] = [];
const resurrected: UnresolvedReference[] = [];
for (const e of crossFileIncomingEdges) {
const newTargetId = newNodesByKindName.get(`${e.targetKind}\0${e.targetName}`);
const newTargetId = replacementOf.get(e.target);
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 {