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:
co-authored by
Claude Fable 5
parent
6103f5e228
commit
e871c49a31
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
@@ -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}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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)}`));
|
||||
}); });
|
||||
Reference in New Issue
Block a user