GitNexus/gitnexus/test/unit/scope-resolution/cpp/cpp-adl-seq-coverage.test.ts
Gergő Magyar f1b8438388
perf(cpp): index ADL candidates once instead of per-site rescans (#1990)
* perf(cpp): index ADL candidates once instead of per-site rescans

C++ scope-resolution `emit` dominated large-repo analysis (~6.76h on a
5,969-file repo — ~70% of the total run). `pickCppAdlCandidates` ran once
per unresolved ADL-eligible call site and each time:

- rescanned every parsed file (rebuilding a per-file scope map per call),
- scanned every workspace def (`findCppClassDefBySimpleName`), and
- used an O(scopes²) child-scope walk for hidden friends.

That is O(unresolved sites × files); with hundreds of thousands of
unresolved C++ sites the emit phase went super-linear. `resolve` (registry
lookup) was only 3.5s — the cost was entirely in fallback edge emission.

Build an `AdlCandidateIndex` once per run (lazy, guarded by `parsedFiles`
identity, reset in `clearCppAdlState`) and query it per site:

- `classDefsBySimple` — preserves `defs.byId` order so first-match /
  ambiguous semantics are identical to the legacy linear scan.
- `nsCandidates` — namespace-owned callables, with inline-namespace
  transparency.
- `friendCandidates` — hidden-friend + class-member callables; a
  parent→children scope index replaces the O(scopes²) walk.
- `nsFunctionsByQName` / `nsFunctionsBySimple` — function-reference ADL path.

A monotonic `seqByNodeId` (file-major; namespace defs before friend/member
defs within a file) lets the per-site query merge candidates across
associated namespaces, dedup by nodeId, and sort — reproducing the exact
legacy candidate set and order.

Per-site cost drops from O(sites × files) to O(associated namespaces); the
emit phase goes from linear-in-sites to flat. Benchmark (files=80): emit at
1000 sites 232ms → 9ms, 2000 sites flat at 17ms; the eliminated term scales
with file count, so the speedup is ~1000×+ on the real 5,969-file repo.

Behavior is unchanged: synthetic candidate output is byte-identical
before/after, all 270 C++ integration resolver tests and 4/4
resolver-parity-expected-failures pass, and tsc + eslint are clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(cpp): correct ADL state-lifecycle and cache-guard comments

The header lifecycle block listed three module-level maps and named
clearFileLocalNames as the reset caller; both became inaccurate when the
candidate index was added. Enumerate all five state pieces, name the real
caller (loadResolutionConfig), and document that ensureAdlIndex's staleness
guard keys on parsedFiles identity while the index also depends on scopes
and classToNamespaceQualifiedName.

Addresses PR #1990 tri-review (U1, U3). Doc-only; no behavior change.

* test(cpp): guard the ADL seq-coverage invariant in dev/test

pickCppAdlCandidates sorts merged candidates by seqByNodeId with a `?? 0`
fallback. That fallback is unreachable today (every bucketed def is
seq-assigned in the same build block), but a future regression could break
it and silently collapse two seq-0 candidates, dropping a CALLS edge with no
error. Add validateAdlSeqCoverage and run it from buildAdlIndex under the
resolver's opt-in validation gate (NODE_ENV!=production && VALIDATE_SEMANTIC_MODEL!=0),
so a broken invariant throws loudly in dev/CI instead. Production behavior
and the hot path are unchanged. Unit-tested; 270/270 cpp integration tests
pass with the guard active.

Addresses PR #1990 tri-review (U2).

* test(cpp): parity fixture for ADL hidden-friend + namespace-callable merge

pickCppAdlCandidates merges friendCandidates (hidden friends of associated
classes) and nsCandidates (namespace-owned callables) for a single associated
namespace. The byte-identical-parity claim rested only on an uncommitted
harness. Add a fixture that reaches one callable through each bucket — combine
only via a hidden friend, process only via a namespace member — so dropping
either bucket from the merge fails the suite. Candidate order is not observable
(narrowing resolves a unique survivor or suppresses), so the guard is on the set.

Addresses PR #1990 tri-review (U4).

* test(cpp): add ADL emit-scaling benchmark

Guards the PR #1990 optimization against reintroducing the O(sites x files)
ADL candidate scan. Generates many UNRESOLVED ADL sites (class-typed arg +
a callee declared nowhere) and co-scales files and sites with N, so the old
cost is O(N^2) and the new cost O(N). Isolates the scope-resolution emit ms
from parse-dominated wall time via the logger test destination (capture
verified) and asserts the end-to-end emit ratio stays under fileRatio^1.5.
Gated by GITNEXUS_BENCH=1; runs build-free (workerPoolSize: 0).

Addresses the benchmark request alongside PR #1990 (U5).

* test(cpp): add cpp pipeline file-count benchmark

Fills the one missing per-language pipeline benchmark (cobol/csharp/go/php/
ruby/rust already have one); modeled on cobol-pipeline-benchmark.test.ts.
Generates synthetic C++ with constant per-file work and constant header
fan-out, sweeps file count through the full pipeline, and guards linearity
with a coarse time-ratio bound plus a deterministic node-ratio bound (the
non-flaky guard against reintroducing O(fileCount^2) work). Gated by
GITNEXUS_BENCH=1; runs build-free (workerPoolSize: 0).

Addresses the benchmark request alongside PR #1990 (U6).

* style(cpp): prettier-format adl benchmark

* test(cpp): rebaseline scope-capture fingerprint for new ADL fixture

The U4 parity fixture (cpp-adl-ns-plus-hidden-friend-same-name) lives under
test/fixtures/lang-resolution/cpp-*, so its lib.h + app.cpp join the cpp
scope-capture bench corpus (bench/scope-capture/measure.mjs). That is pure
fixture-corpus growth — no scope-extractor change, existing fixtures' captures
byte-identical — so the cpp fingerprint legitimately drifts (fixture_count
265->267). Rebaseline cpp to match, as #1965/#1975 did for earlier fixture
additions. Verified: --check PASS for all 14 languages.

Addresses PR #1990 tri-review (U4 follow-on).

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 12:25:42 +01:00

79 lines
3 KiB
TypeScript

/**
* Unit tests for the C++ ADL seq-coverage invariant guard.
*
* `pickCppAdlCandidates` sorts merged candidates by `seqByNodeId`, falling back
* to `?? 0` if a bucketed def has no seq. That fallback is provably unreachable
* (every def pushed into `nsCandidates`/`friendCandidates` is seq-assigned in the
* same build block), but a future regression could break the invariant and
* silently collapse two seq-0 candidates into one. `validateAdlSeqCoverage`
* detects that break; `buildAdlIndex` runs it under the dev/test validation gate
* so a regression fails loudly in CI rather than dropping a CALLS edge in prod.
*/
import { describe, it, expect } from 'vitest';
import {
validateAdlSeqCoverage,
type AdlCandidateIndex,
} from '../../../../src/core/ingestion/languages/cpp/adl.js';
import type { SymbolDefinition } from 'gitnexus-shared';
function def(nodeId: string): SymbolDefinition {
return { nodeId } as unknown as SymbolDefinition;
}
function makeIndex(
nsCandidates: Map<string, Map<string, SymbolDefinition[]>>,
friendCandidates: Map<string, Map<string, SymbolDefinition[]>>,
seqByNodeId: Map<string, number>,
): AdlCandidateIndex {
return {
classDefsBySimple: new Map(),
nsCandidates,
friendCandidates,
nsFunctionsByQName: new Map(),
nsFunctionsBySimple: new Map(),
seqByNodeId,
};
}
describe('validateAdlSeqCoverage', () => {
it('returns no missing ids when every bucketed def has a seq', () => {
const ns = new Map([['lib', new Map([['act', [def('A')]]])]]);
const friend = new Map([['lib', new Map([['swap', [def('B')]]])]]);
const seq = new Map([
['A', 0],
['B', 1],
]);
expect(validateAdlSeqCoverage(makeIndex(ns, friend, seq))).toEqual([]);
});
it('flags a namespace-candidate def missing from seqByNodeId', () => {
const ns = new Map([['lib', new Map([['act', [def('A'), def('C')]]])]]);
const friend = new Map<string, Map<string, SymbolDefinition[]>>();
const seq = new Map([['A', 0]]); // 'C' missing
expect(validateAdlSeqCoverage(makeIndex(ns, friend, seq))).toEqual(['C']);
});
it('flags a friend-candidate def missing from seqByNodeId', () => {
const ns = new Map<string, Map<string, SymbolDefinition[]>>();
const friend = new Map([['lib', new Map([['swap', [def('D')]]])]]);
const seq = new Map<string, number>(); // 'D' missing
expect(validateAdlSeqCoverage(makeIndex(ns, friend, seq))).toEqual(['D']);
});
it('reports each missing nodeId once even when bucketed under multiple keys', () => {
// Inline-namespace transparency registers the same def under its own and
// its parent QName; a missing seq should surface as a single entry.
const inner = new Map([['act', [def('E')]]]);
const ns = new Map([
['lib', inner],
['lib.inline', inner],
]);
const friend = new Map<string, Map<string, SymbolDefinition[]>>();
const seq = new Map<string, number>(); // 'E' missing
expect(validateAdlSeqCoverage(makeIndex(ns, friend, seq))).toEqual(['E']);
});
});