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
+121
View File
@@ -0,0 +1,121 @@
/**
* Batched resolution cleanup precision (#1269)
*
* Post-batch cleanup used to delete resolved refs (and park failed ones) by
* (from_node_id, reference_name, reference_kind) — no line/col. When one
* caller contains several call sites to the SAME callee and a batch boundary
* splits them, resolving the first batch's sites deleted every row with that
* key, including sibling rows in later batches that were never attempted —
* their edges were silently never created. Observed on nlohmann/json:
* `write_cbor` calls `to_char_type` at 11 lines; the batch boundary
* deterministically dropped the last site's edge.
*
* Cleanup now targets the exact `unresolved_refs.id` for DB-loaded refs, with
* the key-tuple delete kept only as the fallback for hand-built refs (public
* resolveAndPersist API) that carry no row id.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { DatabaseConnection } from '../src/db';
import { QueryBuilder } from '../src/db/queries';
import { createResolver } from '../src/resolution';
import { Node, UnresolvedReference } from '../src/types';
function makeNode(id: string, name: string, kind: Node['kind'], filePath: string, startLine: number): Node {
return {
id,
kind,
name,
qualifiedName: name,
filePath,
language: 'typescript',
startLine,
endLine: startLine + 2,
startColumn: 0,
endColumn: 0,
updatedAt: Date.now(),
};
}
function makeRef(fromNodeId: string, name: string, line: number): UnresolvedReference {
return {
fromNodeId,
referenceName: name,
referenceKind: 'calls',
line,
column: 2,
filePath: 'caller.ts',
language: 'typescript',
};
}
describe('Batched ref cleanup precision (#1269)', () => {
let dir: string;
let db: DatabaseConnection;
let q: QueryBuilder;
beforeEach(() => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-refcleanup-'));
db = DatabaseConnection.initialize(path.join(dir, 'test.db'));
q = new QueryBuilder(db.getDb());
// The files the refs/nodes point at must exist for resolution context.
fs.writeFileSync(path.join(dir, 'caller.ts'), 'callee();\ncallee();\ncallee();\ncallee();\ncallee();\n');
fs.writeFileSync(path.join(dir, 'callee.ts'), 'export function callee() {}\n');
q.insertNode(makeNode('fn:caller', 'caller', 'function', 'caller.ts', 1));
q.insertNode(makeNode('fn:callee', 'callee', 'function', 'callee.ts', 1));
});
afterEach(() => {
db.close();
fs.rmSync(dir, { recursive: true, force: true });
});
it('creates an edge for EVERY same-named call site when the sites straddle a batch boundary', async () => {
// 5 call sites, batch size 2 → boundaries after sites 2 and 4. With the
// key-tuple delete, batch 1's cleanup removed ALL five rows and only 2
// edges ever existed.
const lines = [1, 2, 3, 4, 5];
q.insertUnresolvedRefsBatch(lines.map((line) => makeRef('fn:caller', 'callee', line)));
expect(q.getUnresolvedReferencesCount()).toBe(5);
const resolver = createResolver(dir, q);
await resolver.resolveAndPersistBatched(undefined, 2);
const edges = q
.getOutgoingEdges('fn:caller')
.filter((e) => e.kind === 'calls' && e.target === 'fn:callee');
expect(edges.map((e) => e.line).sort()).toEqual(lines);
// Every processed row left the pending set (drain terminated normally).
expect(q.getUnresolvedReferencesCount()).toBe(0);
});
it('parks EVERY unresolvable same-named site as failed only after its own attempt', async () => {
// 4 sites calling a name with no definition, batch size 2. Both halves
// must drain to status='failed' (previously batch 1's key-tuple update
// also flipped batch 2's rows before they were attempted — same outcome
// here, but the loop must still terminate and leave nothing pending).
q.insertUnresolvedRefsBatch([1, 2, 3, 4].map((line) => makeRef('fn:caller', 'missingCallee', line)));
const resolver = createResolver(dir, q);
await resolver.resolveAndPersistBatched(undefined, 2);
expect(q.getUnresolvedReferencesCount()).toBe(0); // nothing pending
const failed = q.getUnresolvedReferences().filter((r) => r.referenceName === 'missingCallee');
expect(failed).toHaveLength(4); // all parked, none deleted
});
it('hand-built refs without a rowId still clean up through the key fallback', () => {
// Public resolveAndPersist API: refs built in memory (no rowId) that also
// exist as DB rows — the legacy key-tuple delete must still clear them.
q.insertUnresolvedRefsBatch([makeRef('fn:caller', 'callee', 1)]);
const resolver = createResolver(dir, q);
const inMemory = makeRef('fn:caller', 'callee', 1); // no rowId
const result = resolver.resolveAndPersist([inMemory]);
expect(result.resolved).toHaveLength(1);
expect(q.getUnresolvedReferencesCount()).toBe(0);
});
});
-43
View File
@@ -1,43 +0,0 @@
import { describe, it, beforeAll } from 'vitest';
import { extractFromSource } from '../src/extraction';
import { initGrammars, loadAllGrammars, getParser } from '../src/extraction/grammars';
beforeAll(async () => {
await initGrammars();
await loadAllGrammars();
});
const CODE = `struct V {
int x;
V operator+(const V& o) const { return V{x + o.x}; }
V operator[](int i) const { return V{x + i}; }
int get() const { return x; }
};
int plainCaller(const V& a) { return a.get(); }
V explicitCaller(const V& a, const V& b) { return a.operator+(b); }
`;
describe('scratch', () => {
it('dumps', async () => {
const parser: any = getParser('cpp' as any);
const tree = parser.parse(CODE);
const dump = (n: any, d = 0) => {
let out = `${' '.repeat(d)}${n.type} [${JSON.stringify(n.text.slice(0, 40))}]\n`;
for (let i = 0; i < n.childCount; i++) {
const c = n.child(i);
const f = n.fieldNameForChild ? n.fieldNameForChild(i) : null;
out += `${' '.repeat(d + 1)}${f ? f + ': ' : ''}`.trimEnd() ? '' : '';
out += dump(c, d + 1);
}
return out;
};
// just dump the explicitCaller subtree
console.log(dump(tree.rootNode));
const result = extractFromSource('optest.cpp', CODE);
console.log('NODES', result.nodes.map((n: any) => `${n.kind}:${n.name}`));
console.log('KEYS', Object.keys(result));
console.log('UREFS', JSON.stringify((result as any).unresolvedReferences, null, 1));
});
});
-41
View File
@@ -1,41 +0,0 @@
import { describe, it, beforeAll } from 'vitest';
import { extractFromSource } from '../src/extraction';
import { initGrammars, loadAllGrammars, getParser } from '../src/extraction/grammars';
beforeAll(async () => { await initGrammars(); await loadAllGrammars(); });
const CASES: Record<string,string> = {
dot_plus: 'V f(V a, V b) { return a.operator+(b); }',
arrow_plus: 'V f(V* a, V b) { return a->operator+(b); }',
dot_sub: 'V f(V a) { return a.operator[](3); }',
dot_call: 'V f(V a) { return a.operator()(3); }',
dot_eq: 'bool f(V a, V b) { return a.operator==(b); }',
dot_bool: 'bool f(V a) { return a.operator bool(); }',
qualified: 'V f(V a, V b) { return V::operator+(a, b); }',
free_op: 'V f(V a, V b) { return operator+(a, b); }',
this_op: 'struct V { V g(V b) { return this->operator+(b); } };',
member_op: 'struct V { V x; V g(V b) { return x.operator+(b); } };',
arrow_deref: 'V f(V a) { return a.operator->(); }',
dot_notop: 'bool f(V a) { return a.operator!(); }',
};
describe('dump', () => {
it('all', () => {
const p: any = getParser('cpp' as any);
for (const [k, code] of Object.entries(CASES)) {
const tree = p.parse(code);
const dump = (n: any, d = 0): string => {
let out = `${' '.repeat(d)}${n.type}${n.childCount === 0 ? ' ' + JSON.stringify(n.text) : ''}\n`;
for (let i = 0; i < n.childCount; i++) out += dump(n.child(i), d + 1);
return out;
};
const call = (function find(n: any): any {
if (n.type === 'call_expression') return n;
for (let i = 0; i < n.childCount; i++) { const r = find(n.child(i)); if (r) return r; }
return null;
})(tree.rootNode);
const refs = extractFromSource('t.cpp', code).unresolvedReferences.filter((r: any) => r.referenceKind === 'calls');
console.log(`\n=== ${k}: ${code}\n${call ? dump(call) : '(no call_expression)'}refs: ${JSON.stringify(refs.map((r: any) => r.referenceName))} hasError=${tree.rootNode.hasError}`);
}
});
});
-14
View File
@@ -1,14 +0,0 @@
import { describe, it, beforeAll } from 'vitest';
import { extractFromSource } from '../src/extraction';
import { initGrammars, loadAllGrammars } from '../src/extraction/grammars';
beforeAll(async () => { await initGrammars(); await loadAllGrammars(); });
describe('d', () => { it('n', () => {
const code = `struct V {
int x;
operator bool() const { return x != 0; }
V operator+(const V& o) const { return V{x+o.x}; }
V& operator=(const V& o) { x = o.x; return *this; }
};`;
const r = extractFromSource('t.cpp', code);
console.log('NODES', r.nodes.map((n: any) => `${n.kind}:${JSON.stringify(n.name)}`));
}); });