fix(resolution): clean up processed refs by row id so batch boundaries can't drop sibling call sites (#1269) (#1270)

Post-batch cleanup deleted resolved refs (and parked failed ones) by
(from_node_id, reference_name, reference_kind) — no line/col. When one
caller had several call sites to the same callee and a batch boundary
split them, the first batch's cleanup removed every row with that key,
including later-batch siblings that were never attempted — their edges
were silently never created. On nlohmann/json this ate 422 real call
edges (write_cbor's 38 to_char_type calls indexed as 11).

Refs loaded from unresolved_refs now carry their row id through
resolution, and all three persist paths (sync resolveAndPersist, the
yielding retry pass, the batched drain loop) delete / mark-failed by
exactly that id. The key-tuple methods remain only as the fallback for
hand-built refs from the public API. Failed-parking gains the same
precision: outcome can differ per call site (receiver inference reads
the ref's line), so a sibling must not inherit another row's failure.

Also untracks the zz-scratch local test files that slipped into #1268
and gitignores the pattern.

Validation: red-green regression test (5 sites, batch size 2 — old code
kept 2 edges, fix keeps 5); nlohmann/json re-index is a strict superset
of the previous edge set (0 lost, 422 recovered, spot-checked against
source).

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-07-12 20:09:03 -05:00
committed by GitHub
co-authored by Claude Fable 5
parent 6103f5e228
commit e871c49a31
11 changed files with 277 additions and 145 deletions
+43
View File
@@ -1827,6 +1827,7 @@ export class QueryBuilder {
candidates: row.candidates ? safeJsonParse(row.candidates, undefined) : undefined,
filePath: row.file_path,
language: row.language as Language,
rowId: row.id,
}));
}
@@ -1844,6 +1845,7 @@ export class QueryBuilder {
candidates: row.candidates ? safeJsonParse(row.candidates, undefined) : undefined,
filePath: row.file_path,
language: row.language as Language,
rowId: row.id,
}));
}
@@ -1886,6 +1888,7 @@ export class QueryBuilder {
candidates: row.candidates ? safeJsonParse(row.candidates, undefined) : undefined,
filePath: row.file_path,
language: row.language as Language,
rowId: row.id,
}));
}
@@ -1954,6 +1957,7 @@ export class QueryBuilder {
candidates: row.candidates ? safeJsonParse(row.candidates, undefined) : undefined,
filePath: row.file_path,
language: row.language as Language,
rowId: row.id,
}));
}
@@ -1998,6 +2002,23 @@ export class QueryBuilder {
deleteMany(refs);
}
/**
* Delete unresolved-ref rows by row id — the precise cleanup for refs a
* resolution pass actually processed. The key-tuple variant above also
* deletes SIBLING rows (same caller calling the same callee at other lines)
* that a later batch hasn't attempted yet, so when a batch boundary split a
* caller's same-named call sites, the later sites' edges were silently never
* created (#1269).
*/
deleteReferencesByRowIds(rowIds: number[]): void {
if (rowIds.length === 0) return;
for (let i = 0; i < rowIds.length; i += SQLITE_PARAM_CHUNK_SIZE) {
const chunk = rowIds.slice(i, i + SQLITE_PARAM_CHUNK_SIZE);
const placeholders = chunk.map(() => '?').join(',');
this.db.prepare(`DELETE FROM unresolved_refs WHERE id IN (${placeholders})`).run(...chunk);
}
}
/**
* Mark refs a completed resolution pass could not resolve as status='failed'
* instead of deleting them (#1240). Failed rows are invisible to the pending
@@ -2020,6 +2041,27 @@ export class QueryBuilder {
markMany(refs);
}
/**
* Park refs as status='failed' by row id — the precise counterpart of
* markReferencesFailed, for the same reason as deleteReferencesByRowIds:
* the key-tuple variant also flips same-key sibling rows in later batches
* to 'failed' before they were ever attempted (#1269). Resolution outcome
* can differ per call site (receiver-type inference reads the ref's line),
* so a sibling must not inherit this row's failure.
*/
markReferencesFailedByRowIds(refs: Array<{ rowId: number; referenceName: string }>): void {
if (refs.length === 0) return;
const stmt = this.db.prepare(
"UPDATE unresolved_refs SET status = 'failed', name_tail = ? WHERE id = ?"
);
const markMany = this.db.transaction((items: typeof refs) => {
for (const ref of items) {
stmt.run(referenceNameTail(ref.referenceName), ref.rowId);
}
});
markMany(refs);
}
/**
* Failed refs whose name tail matches one of the given symbol names — the
* candidates a sync should retry after files carrying those names changed
@@ -2068,6 +2110,7 @@ export class QueryBuilder {
candidates: row.candidates ? safeJsonParse(row.candidates, undefined) : undefined,
filePath: row.file_path,
language: row.language as Language,
rowId: row.id,
}));
}