GitNexus/gitnexus/test/unit/scope-resolution/cpp/cpp-qualified-ns-index.test.ts
Gergő Magyar 74409a37f6
perf(cpp): index qualified namespace members once per pipeline run (#2788) (#2794)
* perf(cpp): index qualified namespace members once per pipeline run (#2788)

`resolveCppQualifiedNamespaceMember` walked every parsed file — rebuilding a
per-file `scopesById` map each time — once per qualified `ns::member()` call
site, so the scope-resolution emit phase cost O(callsites x scopes). On a
1,473-file C++ repo that was 25.3 min of a 33-min analyze, with 75% of total
self-time in this one function. Its inner `findMemberInNamespaceTransitive`
compounded it: each recursion step filtered `scopesById.values()` by parent,
O(scopes^2) per file on its own.

This is the same bug #1990 fixed in the sibling ADL path (`pickCppAdlCandidates`
-> `AdlCandidateIndex`), so it gets the same fix: a `QualifiedNsMemberIndex`
(receiver simple name -> member simple name -> callable defs) built lazily once
per `parsedFiles` identity and reset by `clearCppInlineNamespaces`, which runs
from `cppScopeResolver.loadResolutionConfig` at the start of every pass. Per
call site the work drops to two Map lookups.

Ordering is preserved exactly — file-major, `parsed.scopes` declaration order,
a namespace's own `ownedDefs` before its inline-namespace children, depth-first
— because the caller takes `allHits[0]` for the single-hit case and
`narrowOverloadCandidates` is first-wins. Non-inline nested namespaces are
still not descended into, and same-name hits across inline children still
report `'ambiguous'` (#1564).

Measured with `PROF_SCOPE_RESOLUTION=1 analyze --force --index-only` on a
synthetic corpus (`namespace ns_i { inline namespace v1 { ... } }` plus 20
`ns_j::fn()` call sites per file):

| files | emit before | emit after |
|-------|-------------|------------|
| 100   | 153ms       | 16ms       |
| 200   | 704ms       | 24ms       |
| 400   | 3,293ms     | 42ms       |
| 800   | 16,898ms    | 78ms       |

Before, doubling the file count quadrupled emit; now it doubles. At 800 files
total scope resolution goes 17.2s -> 394ms.

Output is unchanged, verified rather than assumed: a full graph dump (sorted
nodes + relationships) from a baseline build at the parent commit and from this
one are byte-identical on all 134 `cpp-*` fixtures merged into a single repo
(1573 nodes / 1997 relationships) and on the 400-file synthetic corpus.
`test/integration/resolvers/cpp.test.ts` passes 334/334.

#1990 shipped its ADL fix without a scaling gate, which is how the bug class
came straight back here, so this adds one: `bench/cpp-qualified-ns` measures
`(t_large/t_small)/(1600/400)` — 0.93-1.21 indexed versus 3.45 for the old
per-call-site scan — alongside a fingerprint over every
`receiver::member -> outcome` the corpus resolves, and CI runs it with
`--check`. `test/unit/cpp-qualified-ns-index.test.ts` covers the cache
invalidation the index introduces.

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

* fix(cpp): address tri-review findings on the qualified-namespace index (#2788)

Multi-engine review of this PR (Claude swarm + ce-code-review, Codex
gpt-5.6-sol swarm + ce + adversarial) returned two P1s and five smaller
findings. All are fixed here.

P1 — the index defeated the pipeline's post-language memory release.
`scope-resolution/pipeline/phase.ts` evicts each language's files and then
calls `forceGc()`, on a stated premise that "This language's ParsedFiles are
now unreachable", sizing C/C++ at ~17-20GB on the Linux kernel. The
module-level `let qualifiedNsIndexSource` falsified that: it pinned the whole
`parsedFiles` array, and the index held defs reaching into those files'
scopes, until the *next* C++ pass cleared it — which in a single analyze never
comes. C++ is 7th of 16 in SCOPE_RESOLVERS, so the set survived nine later
language passes plus emit. Replaced with a
`WeakMap<readonly ParsedFile[], QualifiedNsMemberIndex>`, the pattern already
used by `moduleScopeIndexByPass` in `cpp/file-local-linkage.ts`.
`clearCppInlineNamespaces` still swaps in a fresh WeakMap, because the index
has a second input (`inlineNamespaceScopeIds`) the key cannot observe.
Measured with `--expose-gc`: 61.2MB retained after the caller drops the array
before, 0.1MB after.

The ADL twin (`adl.ts`) has the same pattern, so the hazard predates this PR —
but `pickCppAdlCandidates` returns early before `ensureAdlIndex` on
`noAdlSites`/empty `argInfoBySite`, so it rarely arms, whereas a qualified
`ns::member()` index arms on almost every C++ workspace. Moving the ADL twin
to a WeakMap is left as a follow-up.

P1 — the new bench could not see the regression class it exists to gate.
`callSites()` drew every receiver from `ns_${...}`, so the receiver lookup
never missed; production is the opposite, since Case 1.5 in
`receiver-bound-calls.ts` is reached by every plain-identifier receiver call
and misses on most. A rescan reintroduced only on the receiver-bucket-absent
path scored 1.279 and PASSED the old bench. The corpus now mirrors production
(~1 in 5 receivers name a declared namespace) and adds a namespace reopened
across files, a same-name inline nest, a member declared at both namespace and
inline-child level, and call sites carrying a real `Callsite` so
`narrowOverloadCandidates`/`cppConversionRank`/
`isOverloadAmbiguousAfterNormalization` are inside the fingerprinted surface at
all. That same rescan now measures 4.538 and FAILS; defeating the dedup now
fails the fingerprint arm where it previously passed byte-identical. The
fingerprint moved once, deliberately, for the corpus expansion — recorded in
`_rebaseline_2788_review`, explicitly not precedent.

Also fixed:

- Unbounded recursion aborted analyze. `collectNamespaceMembers` recursed per
  inline child with no bound and threw an uncontained `RangeError` at inline
  depth 8000 (`phase.ts`'s try has a `finally`, no `catch`), and a receiver
  *miss* paid full recursion where the deleted walker skipped on a name
  mismatch. An explicit work-stack alone would only have converted that into
  an OOM at depth 6000, because the eager table was quadratic in memory too:
  for a depth-D chain it legitimately holds D(D+1)/2 entries, since `v2::foo()`
  is a valid receiver at every level. Replaced with a lazily-queried node graph
  (per-scope own-member buckets plus direct child links, resolved on demand and
  memoized per receiver+member). Build is now linear; depth 100000 costs 133ms
  where 8000 previously threw.
- "#1990 shipped without a scaling gate" was false. #1990 did ship
  `test/integration/cpp-adl-benchmark.test.ts` (f1b843838). Corrected in the
  bench header and the CI step comment. The accurate point is narrower and
  stronger: that bench asserts `callsResolved === 0`, so it never drives the
  qualified-receiver path, and it is `skipIf(!GITNEXUS_BENCH)` while the only
  step setting that variable lists neither C++ bench — so it has never run in
  CI. Wiring it in is a follow-up.
- "Ordering is load-bearing" was not a live property: `allHits[0]` is only
  reached at length 1, and both tail branches return `'ambiguous'`. Order is
  still preserved for byte-identity with the pre-#2788 walker; the comment now
  says that instead, and the test named for ordering is renamed to the
  parent/inline-child visibility it actually asserts.
- "Same contract as `ensureAdlIndex`" overstated parity — the sibling ships a
  `validateAdlSeqCoverage` guard because it reads
  `seqByNodeId.get(...) ?? 0`, which can silently collapse candidates. This
  index has no analogous defaulting read, so no guard is added; the comment now
  says why.
- Two coverage gaps closed, both mutation-verified: cross-file merge of one
  namespace reopened in two files (no existing test covered it — confirmed by
  making each file clobber the previous and watching only the new test fail),
  and the same-name inline nest whose dedup, when defeated, flips a resolved
  def to `'ambiguous'`.
- `resolveCppQualifiedNamespaceMember`'s JSDoc now names both production call
  sites, including the callsite-less `resolveAdlCandidates` path.
- Memoized candidate buckets are frozen, so a future in-place sort in
  `overload-narrowing.ts` throws instead of silently corrupting later
  resolutions now that the array is shared across call sites.
- `_scaling_note`'s "measured 0.93-1.21" band did not reproduce; it is now the
  honestly observed 1.28-1.45, with the small arm widened to ~14ms (halves the
  spread) and a triage line saying a scaling failure is a timing signal to
  re-run, unlike the deterministic fingerprint arm.

Verification: 840,000 differential probes against the pre-#2788 walker
extracted from base, 0 mismatches, plus 48,000 candidate-order comparisons,
0 mismatches. cpp resolver integration suite 334/334. Unit suite 10/10.
tsc, eslint, prettier clean. Bench --check PASS.

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

* fix(bench): escape the NUL separator so measure.mjs stays a text file

The fingerprint key separator was written as a literal NUL byte instead of the
`\u0000` escape. Git classifies any file containing a NUL as binary, so the
whole bench showed as `Bin` with no diff on GitHub and could not be reviewed —
the same defect this branch already fixed once before the tri-review.

Escaping it is byte-for-byte equivalent at runtime (both produce U+0000), so
the committed fingerprint is unchanged and `--check` still passes.

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

* refactor(cpp): quality cleanups from a four-angle review of the #2788 series

Reuse, simplification, efficiency and altitude passes over the diff. No
resolution behaviour changes: the bench fingerprint is unchanged and the C++
resolver integration suite still passes in full.

Efficiency

- The `Object.freeze` added in the review-fix commit to close an aliasing
  residual costs 4.6x on the narrowing path — V8 moves frozen arrays to
  PACKED_FROZEN_ELEMENTS, off the fast path for the `.filter`/`.map`/`.some`
  runs `narrowOverloadCandidates` does at every multi-candidate call site.
  The hazard it guards is already a compile error (both the memo and the
  parameter are `readonly SymbolDefinition[]`), so it is now a dev-only
  tripwire. Gated on `isSemanticModelValidatorEnabled()` — the repo's opt-IN
  form used by `phase.ts` and `validate-bindings-immutability.ts` — not
  `adl.ts`'s opt-out `NODE_ENV !== 'production'`, which would keep paying the
  cost in CLI runs where `NODE_ENV` is unset. Large bench arm 100.3 -> 70.6ms.
- The index build scanned every scope in every file twice; pass 1 now collects
  `[scope, node]` pairs for pass 2 to iterate. 800k scopes 14.40 -> 8.21ms.
- `simpleNameOf` uses `lastIndexOf('.')` + `slice` instead of
  `split('.').pop()` (83 -> 24 ns/call), semantics verified byte-equivalent
  over 12 edge cases including `undefined`, `''`, `'a.'`, `'.b'` and `'a..b'`.
- The per-call `hookCtx` literal is hoisted to a module const.
- The bench's fingerprint pass resolved 960k call sites to produce 5,800
  distinct outcomes; it now dedups on the key it already builds. Bench wall
  time 2.4 -> 1.9s, fingerprint byte-identical.

Reuse

- `bucketOwnMembers` used an inlined `Function | Method | Constructor` compare;
  it now calls the canonical `isOverloadableCallable`. `graph-bridge/ids.ts`
  already carries a note that inlining this list recreated twin-list drift once.

Altitude

- `adl.ts` had the same retention defect this series just fixed next door: a
  module-level `let adlIndex` + `let adlIndexSource` strongly pinning the whole
  `parsedFiles` array until the next C++ pass, which in a single analyze never
  comes. Converted to the same `WeakMap` shape. Measured with `--expose-gc`:
  89.11MB retained after the caller drops the array before, 0.17MB after. Six
  file-local helpers now take the index as a parameter; no exported signature
  changed.
- The index's freshness depended on `clearCppInlineNamespaces()` being called
  from another file, guarded only by a warning paragraph. An epoch bumped in
  both `populateCppInlineNamespaceScopes` and the clear is now stored with the
  memo, so a missed clear degrades to a rebuild instead of a stale answer —
  confirmed by driving inline state mid-pass without the clear. Roughly line
  neutral, since it replaces most of the paragraph.
- `test/integration/cpp-adl-benchmark.test.ts` is wired into the
  `GITNEXUS_BENCH` step. It is `skipIf`-gated and was absent from that step's
  explicit file list, so #1990's ADL emit-scaling guard had never executed in
  CI. It passes; ~50s added to a 25-minute job. `cpp-pipeline-benchmark.test.ts`
  is deliberately NOT wired: it costs 115s for guards covering generic
  per-language pipeline scaling that nothing here touches.

Simplification

- Deleted a comment referencing a `inlineChildrenByParent` map that only ever
  existed inside this branch's own first commit, so "the legacy map" pointed a
  reader at code that never shipped.
- Dropped three unreachable `undefined` guards (`strict: false`, no
  `noUncheckedIndexedAccess`), keeping the load-bearing `visited` check.
- Compressed the `rootsByReceiver` doc from 17 lines to 7 — it was the longest
  comment in the file and guarded the least consequential property — the
  `validateAdlSeqCoverage` paragraph from 7 lines to 3, and turned three
  restatements of the uncaught-throw and dedup arguments into pointers.
- Test fixtures: dropped the dead `'Module'` union arm, added a one-line `ns()`
  builder for the nine hand-written scope literals, and moved the file to
  `test/unit/scope-resolution/cpp/` where every other C++ scope-resolution unit
  test lives. 403 -> 337 lines, same 10 tests, and the cross-file mutation check
  still fails exactly one test.

Not done, and why: merging this index into `AdlCandidateIndex` (they key on
different names with different inline-transparency depth — a refactor with
correctness risk, not a cleanup); `ScopeTree.getChildren` (trades in-memory
`parsed.scopes` for store hits on a hot path); a shared `cpp/` util for the five
pre-existing `simpleName` copies; sharing fixtures across the bench/test
boundary (no precedent in this repo); and an exact-count arm for the bench,
which is a gate redesign worth its own change.

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

---------

Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 15:52:33 +01:00

337 lines
13 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* #2788 — `resolveCppQualifiedNamespaceMember` serves qualified `ns::member()`
* lookups from a per-pipeline index instead of rescanning every parsed file per
* call site. These tests pin the properties the index must not lose:
*
* 1. Transitive inline-namespace collection — parent- and child-level member
* visibility through one receiver — and same-name ambiguity (#1564): the
* semantics the old linear scan provided.
* 2. Cross-file accumulation — C++ namespaces are open, so one receiver's
* members are spread over however many files reopen it. The legacy scan
* re-derived its hits per call site and got this for free; the index has
* to merge across the whole `parsedFiles` array to match it.
* 3. Cache invalidation — a new `parsedFiles` array, or a
* `clearCppInlineNamespaces()` between passes, must not serve stale hits.
* This is the failure mode the index introduces; nothing else covers it.
*/
import type {
ParsedFile,
ScopeId,
ScopeResolutionIndexes,
SymbolDefinition,
} from 'gitnexus-shared';
import { beforeEach, describe, expect, it } from 'vitest';
import {
clearCppInlineNamespaces,
markCppInlineNamespaceRange,
populateCppInlineNamespaceScopes,
resolveCppQualifiedNamespaceMember,
} from '../../../../src/core/ingestion/languages/cpp/inline-namespaces.js';
const NO_SCOPES = {} as unknown as ScopeResolutionIndexes;
interface ScopeSpec {
readonly id: string;
readonly parent: string | null;
readonly defs: readonly SymbolDefinition[];
/** Distinguishes each scope's range so inline marking targets exactly one. */
readonly line: number;
}
function def(nodeId: string, type: string, qualifiedName: string): SymbolDefinition {
return { nodeId, type, qualifiedName } as unknown as SymbolDefinition;
}
function nsDef(nodeId: string, qualifiedName: string): SymbolDefinition {
return def(nodeId, 'Namespace', qualifiedName);
}
function fnDef(nodeId: string, qualifiedName: string): SymbolDefinition {
return def(nodeId, 'Function', qualifiedName);
}
function ns(
id: string,
parent: string | null,
defs: readonly SymbolDefinition[],
line: number,
): ScopeSpec {
return { id, parent, defs, line };
}
function range(line: number): {
startLine: number;
startCol: number;
endLine: number;
endCol: number;
} {
return { startLine: line, startCol: 0, endLine: line + 1, endCol: 0 };
}
/** Build one `ParsedFile` from scope specs, marking the scopes named in
* `inlineIds` as inline namespaces (capture-time range mark +
* `populateOwners`-time scope-id resolution, same order as the pipeline).
* Separate from {@link makeParsedFiles} so a test can compose a genuinely
* MULTI-file `parsedFiles` array, which is the only way to exercise the
* index's cross-file accumulation. */
function makeParsedFile(
filePath: string,
specs: readonly ScopeSpec[],
inlineIds: readonly string[],
): ParsedFile {
const parsed = {
filePath,
scopes: specs.map((s) => ({
id: s.id as unknown as ScopeId,
kind: 'Namespace',
parent: s.parent as unknown as ScopeId | null,
ownedDefs: s.defs,
range: range(s.line),
})),
} as unknown as ParsedFile;
markInline(parsed, specs, inlineIds);
return parsed;
}
/** Single-file `parsedFiles` array — the shape most of these tests need. */
function makeParsedFiles(
filePath: string,
specs: readonly ScopeSpec[],
inlineIds: readonly string[],
): readonly ParsedFile[] {
return [makeParsedFile(filePath, specs, inlineIds)];
}
/** Capture-time inline marking + `populateOwners`-time scope-id resolution,
* in the same order the pipeline runs them. Spec lookup is a Map, not
* `Array.find`, so marking a deep chain stays linear in `specs`. */
function markInline(
parsed: ParsedFile,
specs: readonly ScopeSpec[],
inlineIds: readonly string[],
): void {
const byId = new Map(specs.map((s) => [s.id, s]));
for (const id of inlineIds) {
const spec = byId.get(id);
if (spec === undefined) throw new Error(`inline scope ${id} must exist`);
markCppInlineNamespaceRange(parsed.filePath, range(spec.line));
}
populateCppInlineNamespaceScopes(parsed);
}
/** `namespace outer { <ownDefs> inline namespace v1 { <inlineDefs> } }` */
function outerWithInlineChild(
filePath: string,
ownDefs: readonly SymbolDefinition[],
inlineDefs: readonly SymbolDefinition[],
): readonly ParsedFile[] {
return makeParsedFiles(
filePath,
[
ns('sc:outer', null, [nsDef('n:outer', 'outer'), ...ownDefs], 1),
ns('sc:v1', 'sc:outer', [nsDef('n:v1', 'outer.v1'), ...inlineDefs], 10),
],
['sc:v1'],
);
}
/** `namespace n0 { inline namespace n1 { … inline namespace n<depth-1> {
* void leaf(); } … } }` — one function, declared in the innermost namespace,
* reachable by qualified lookup from every level above it. */
function inlineChain(filePath: string, depth: number): readonly ParsedFile[] {
const specs: ScopeSpec[] = [];
for (let d = 0; d < depth; d++) {
const self = nsDef(`n:ns${d}`, `n${d}`);
specs.push(
ns(
`sc:n${d}`,
d === 0 ? null : `sc:n${d - 1}`,
d === depth - 1 ? [self, fnDef('n:leaf', `n${d}.leaf`)] : [self],
d * 2 + 1,
),
);
}
// Every level below the outermost is `inline`, so the whole chain is one
// transitively-visible run of namespaces.
return makeParsedFiles(
filePath,
specs,
specs.slice(1).map((s) => s.id),
);
}
describe('C++ qualified-namespace member index (#2788)', () => {
beforeEach(() => {
clearCppInlineNamespaces();
});
it('resolves outer::foo through an inline-namespace child', () => {
const files = outerWithInlineChild('a.cpp', [], [fnDef('n:foo@v1', 'outer.v1.foo')]);
expect(resolveCppQualifiedNamespaceMember('outer', 'foo', files, NO_SCOPES)).toMatchObject({
nodeId: 'n:foo@v1',
});
});
it('returns undefined for an unknown namespace or member', () => {
const files = outerWithInlineChild('a.cpp', [], [fnDef('n:foo@v1', 'outer.v1.foo')]);
expect(resolveCppQualifiedNamespaceMember('nope', 'foo', files, NO_SCOPES)).toBeUndefined();
expect(resolveCppQualifiedNamespaceMember('outer', 'nope', files, NO_SCOPES)).toBeUndefined();
});
it('does not descend into a non-inline nested namespace', () => {
const files = makeParsedFiles(
'a.cpp',
[
ns('sc:outer', null, [nsDef('n:outer', 'outer')], 1),
ns(
'sc:nested',
'sc:outer',
[nsDef('n:nested', 'outer.nested'), fnDef('n:foo@nested', 'outer.nested.foo')],
10,
),
],
[],
);
expect(resolveCppQualifiedNamespaceMember('outer', 'foo', files, NO_SCOPES)).toBeUndefined();
expect(resolveCppQualifiedNamespaceMember('nested', 'foo', files, NO_SCOPES)).toMatchObject({
nodeId: 'n:foo@nested',
});
});
it('reports same-name hits across two inline children as ambiguous (#1564)', () => {
const files = makeParsedFiles(
'a.cpp',
[
ns('sc:outer', null, [nsDef('n:outer', 'outer')], 1),
ns('sc:v1', 'sc:outer', [nsDef('n:v1', 'outer.v1'), fnDef('n:foo@v1', 'outer.v1.foo')], 10),
ns('sc:v2', 'sc:outer', [nsDef('n:v2', 'outer.v2'), fnDef('n:foo@v2', 'outer.v2.foo')], 20),
],
['sc:v1', 'sc:v2'],
);
expect(resolveCppQualifiedNamespaceMember('outer', 'foo', files, NO_SCOPES)).toBe('ambiguous');
});
it('resolves through an inline namespace that reuses its parents name', () => {
// Pins the `visited` dedup: `namespace ns { inline namespace ns { … } }`
// registers both scopes under receiver `ns`, and without dedup the one
// `foo` is collected twice and degrades to 'ambiguous', dropping the CALLS
// edge. Why node identity is the right dedup key: see
// `gatherQualifiedNsMember` in `inline-namespaces.ts`.
const files = makeParsedFiles(
'a.cpp',
[
ns('sc:ns@outer', null, [nsDef('n:ns@outer', 'ns')], 1),
ns(
'sc:ns@inner',
'sc:ns@outer',
[nsDef('n:ns@inner', 'ns.ns'), fnDef('n:foo', 'ns.ns.foo')],
10,
),
],
['sc:ns@inner'],
);
expect(resolveCppQualifiedNamespaceMember('ns', 'foo', files, NO_SCOPES)).toMatchObject({
nodeId: 'n:foo',
});
});
it('finds both a namespace-owned member and its inline child member', () => {
// Both levels are visible through the same receiver: `foo` is owned by
// `outer` itself, `bar` only by its inline child `v1`. Distinct member
// names per level on purpose — two same-named candidates with no call-site
// info collapse to 'ambiguous', which would assert nothing about either.
const files = outerWithInlineChild(
'a.cpp',
[fnDef('n:foo@outer', 'outer.foo')],
[fnDef('n:bar@v1', 'outer.v1.bar')],
);
expect(resolveCppQualifiedNamespaceMember('outer', 'foo', files, NO_SCOPES)).toMatchObject({
nodeId: 'n:foo@outer',
});
expect(resolveCppQualifiedNamespaceMember('outer', 'bar', files, NO_SCOPES)).toMatchObject({
nodeId: 'n:bar@v1',
});
});
it('resolves through a 20,000-deep inline chain without exhausting the stack', () => {
// Pins that a deep chain neither overflows the stack nor blows the heap —
// the two failure modes a recursive build and an eager member table had.
// Why an uncaught throw here aborts the whole `analyze`: see
// `QualifiedNsMemberIndex` in `inline-namespaces.ts`. 20,000 is ~2.5x the
// depth that overflowed in this same runner, for margin over per-platform
// stack sizes.
const files = inlineChain('deep.cpp', 20_000);
// From the outermost namespace: the full chain is one transitive walk.
expect(resolveCppQualifiedNamespaceMember('n0', 'leaf', files, NO_SCOPES)).toMatchObject({
nodeId: 'n:leaf',
});
// Every inline namespace is a legal qualified receiver of its own, so
// mid-chain and innermost receivers must resolve too.
expect(resolveCppQualifiedNamespaceMember('n10000', 'leaf', files, NO_SCOPES)).toMatchObject({
nodeId: 'n:leaf',
});
expect(resolveCppQualifiedNamespaceMember('n19999', 'leaf', files, NO_SCOPES)).toMatchObject({
nodeId: 'n:leaf',
});
// A miss down the same chain stays a miss rather than becoming a throw.
expect(resolveCppQualifiedNamespaceMember('n0', 'nosuch', files, NO_SCOPES)).toBeUndefined();
});
it('merges one namespaces members across every file that reopens it', () => {
// C++ namespaces are open: `namespace outer { void a(); }` in one file and
// `namespace outer { void b(); }` in another are the SAME namespace, and
// `outer::a()` / `outer::b()` must both resolve. The legacy scan re-derived
// its hits from all of `parsedFiles` per call site; the index accumulates
// once, so the per-receiver state has to outlive the file loop. Distinct
// scope ids per file, as the real pipeline mints them.
const files: readonly ParsedFile[] = [
makeParsedFile(
'a.cpp',
[ns('sc:outer@a', null, [nsDef('n:outer@a', 'outer'), fnDef('n:a', 'outer.a')], 1)],
[],
),
makeParsedFile(
'b.cpp',
[ns('sc:outer@b', null, [nsDef('n:outer@b', 'outer'), fnDef('n:b', 'outer.b')], 1)],
[],
),
];
expect(resolveCppQualifiedNamespaceMember('outer', 'a', files, NO_SCOPES)).toMatchObject({
nodeId: 'n:a',
});
expect(resolveCppQualifiedNamespaceMember('outer', 'b', files, NO_SCOPES)).toMatchObject({
nodeId: 'n:b',
});
});
it('does not serve one parsedFiles arrays index to another', () => {
const first = outerWithInlineChild('a.cpp', [], [fnDef('n:foo@a', 'outer.v1.foo')]);
expect(resolveCppQualifiedNamespaceMember('outer', 'foo', first, NO_SCOPES)).toMatchObject({
nodeId: 'n:foo@a',
});
const second = outerWithInlineChild('b.cpp', [], [fnDef('n:foo@b', 'outer.v1.foo')]);
expect(resolveCppQualifiedNamespaceMember('outer', 'foo', second, NO_SCOPES)).toMatchObject({
nodeId: 'n:foo@b',
});
});
it('rebuilds after clearCppInlineNamespaces even when parsedFiles is reused', () => {
// Pass 1: `v1` is inline, so `outer::foo` reaches through it.
const specs: readonly ScopeSpec[] = [
ns('sc:outer', null, [nsDef('n:outer', 'outer')], 1),
ns('sc:v1', 'sc:outer', [nsDef('n:v1', 'outer.v1'), fnDef('n:foo@v1', 'outer.v1.foo')], 10),
];
const files = makeParsedFiles('a.cpp', specs, ['sc:v1']);
expect(resolveCppQualifiedNamespaceMember('outer', 'foo', files, NO_SCOPES)).toMatchObject({
nodeId: 'n:foo@v1',
});
// Pass 2: SAME `parsedFiles` reference (so identity alone would serve the
// cached index), but `v1` is no longer inline. Without the index reset in
// `clearCppInlineNamespaces` the stale pass-1 hit survives.
clearCppInlineNamespaces();
markInline(files[0], specs, []);
expect(resolveCppQualifiedNamespaceMember('outer', 'foo', files, NO_SCOPES)).toBeUndefined();
});
});