GitNexus/gitnexus/test/unit/scope-resolution/cpp/cpp-qualified-ns-index.test.ts
Abhigyan Patwari a09f76be39
fix(cpp): type receivers declared or produced through qualified names (#3656)
* fix(cpp): resolve qualified receiver types by namespace, not by tail

Cause: a C++ receiver declared with a qualified type (`rp::C x = ...`,
`void f(a::Config c)`, `rp::Box<int> b`) typed nothing. The capture keeps
the qualifier, but the shared receiver lookups read a qualifier only as a
`.` namespace-import handle, and C++ has neither: `#include` binds no
handle and the index keys `rp::C` under `C`. The two places that did type
qualified spellings — the `auto x = ns::T{}` brace-init rule and qualified
member fields — reduced them to their tail, which binds the NEAREST
same-named class: `auto x = rp::C{}` reached a global `C` through another
include, and `a::Config` inside a `namespace b` declaring its own Config
reached `b::Config` (both confident wrong CALLS edges).

Fix: a ScopeResolver opt-in, `resolveQualifiedTypeSpellings`, that C++
sets. When a qualified declared or return type binds nothing,
`resolveClassBindingForName` and the compound resolver's return-type lookup
fall back to `resolveQualifiedInheritanceBase` — the route qualified base
classes and qualified type references already take: the qualifier is
matched against the candidate's namespace and a tie resolves nothing. It
runs after every existing lookup and after the template-argument match, so
it adds edges and never retargets one. The brace-init rule now captures the
whole qualified_identifier, and non-generic qualified member fields keep
their qualifier, so both resolve through the same route; generic members
stay tail-reduced because specialization matching keys on the bare base
name.

Corpus A/B (lang-resolution, all languages): no edge outside the new
fixture moved; drops unchanged. bench/scope-capture cpp re-baselined (one
brace-init capture text, plus the new fixture).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(cpp): bind qualified declarations that have no initializer

Cause: `rp::C x;` and `rp::C* x = nullptr;` emitted no type binding at
all. The plain-declaration annotation rules accept only a bare
`type_identifier` or `template_type`, and tree-sitter-cpp parses a
qualified type as a `qualified_identifier` wrapping one of those, so the
receiver of `x.m()` was untyped (no edge, and no drop either). The
initialized form `rp::C x = ...` was already captured by the `type: (_)`
assignment rule.

Fix: two @type-binding.annotation rules on the OUTER qualified_identifier
(plain declarator, and pointer inside an init_declarator) — the qualified
twins of the bare rules, one pattern per declarator shape like the
qualified member-field rules, with no node-type alternation. The spelling
is kept as written and resolved by the qualifier-aware route from the
previous commit.

Capture diff over the cpp corpus: 29 added @type-binding.annotation
matches on qualified locals, nothing removed or changed. Corpus A/B: no
edge or drop outside the new fixture moved. bench/scope-capture cpp
re-baselined.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(cpp): record return types of header function prototypes

Cause: only function DEFINITIONS (and in-class method prototypes)
recorded a return type. A free-function prototype — `D makeD();`,
`namespace rp { C make(); }` — recorded none, and the usual layout is
"header declares, .cpp defines": the .cpp is never included, so an
includer saw no return fact at all and `auto x = makeD(); x.m()` /
`makeD().m()` typed nothing (the inline form recorded a drop, the bound
form not even that).

Fix: @type-binding.return rules for a prototype with a plain or pointer
return. In a function body the same syntax declares a block-scope
function (the most vexing parse, `D x();`), whose binding would be
hoisted to the module and type every undeclared `x` in the file, so
captures.ts keeps a prototype match only at namespace scope — the
translation unit or a namespace / extern "C" body, through preprocessor
conditionals (include guards), single-declaration extern "C" and
template prefixes. A prototype declared in a namespace keeps its binding
in that namespace instead of the module: the binding's scope is also
where its type is looked up, and from the module a global `Widget` in
the same header won over `rp::Widget`. Callers read namespace members by
definition identity, which joins in any scope, and namespace members are
never mirrored to includers by bare name anyway.

A reference return (`D& f();`) gets no rule: such a function is not
declared as a Function at all (no declaration pattern), so a return fact
would have nothing to attach to.

Capture diff over the cpp corpus: one added match outside the new
fixture (cpp-return-type's header prototype, same type as its
definition); the gate drops only the fixture's deliberate `E y();`.
Corpus A/B: no edge or drop outside the fixture moved.
bench/scope-capture cpp re-baselined.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(scope): type an inline call receiver from its callee's own return type

Cause: the compound resolver's free-call branch (`make().m()`) read the
callee's return type BY NAME from the caller's scope chain, while the
chain follow that types the bound form (`auto x = make(); x.m()`) reads
it BY DEFINITION IDENTITY (`producerTypeRef`). A callee that arrives by a
channel nothing mirrors a return binding through — a C++ using-directive
— therefore typed the bound form and dropped the inline one.

Fix: when the name read finds nothing, fall back to the return type the
callee's definition declares, via `producerTypeRef` (now exported from
imported-return-types.ts; the only change there). Only for a callee the
name actually BINDS at the call: `findExportedDefByName` falls back to the
first workspace callable of that name, and reading a return type off that
guess is not evidence. Measured: without the binding check the corpus
gained 15 non-C++ edges (Go range/chain factories, Swift call results),
all reached through that workspace-name guess and one of them provably
wrong (swift-return-type's `user.save()` bound to another fixture's
User); with it, no edge outside the new fixture moves.

Corpus A/B: edges and drops outside the fixture identical to main.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(cpp): type receivers produced by a qualified factory callee

Cause: `auto x = rp::make(); x.m()` bound `x` to the literal type `auto`
— the bare `auto x = f()` constructor rule matches only an identifier
callee, so the generic assignment rule captured the placeholder — and
`rp::make().m()` reached the compound resolver as the free call
`rp::make`, whose name binds nothing. Neither had any route from the
qualified callee to its definition, so both typed nothing (`auto x =
rp::C()` likewise).

Fix:
- captures.ts promotes `auto x = ns::f()` / `auto x = ns::T()` (a
  qualified_identifier whose innermost name is a plain identifier) to
  @type-binding.constructor with the whole callee as the type, the
  qualified twin of the bare rule; template factories keep their own
  rule. interpret.ts drops any leftover `auto` / `decltype(auto)` binding,
  which named no type.
- New ScopeResolver hook `splitQualifiedCallee` maps a callee spelling onto
  the receiver/member pair the language's qualified CALL captures use (C++:
  innermost scope segment, `a:🅱️:make` -> `b` / `make`), so shared code
  never splits on `::`. The compound resolver's free-call branch, when the
  callee's name binds nothing, resolves the definition through the existing
  `resolveQualifiedReceiverMember` hook — the answer the call itself got —
  and reads that definition's own return type by identity
  (`producerTypeRef`). `auto x = rp::C()` resolves through the
  qualified-type route instead.

Capture diff over the cpp corpus: one changed match outside the new
fixture (cpp-method-enrichment `auto category = Animal::classify(...)`
now binds the callee instead of `auto`; a class static method, which the
namespace lookup does not answer, so it stays untyped as before). Corpus
A/B: no edge or drop outside the fixture moved. bench/scope-capture cpp
re-baselined. The new cpp-captures rows also pin the namespace-scope gate
on prototype return types from the earlier commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(cpp): bind qualified receiver owners

Preserve complete namespace qualifiers and declaration-owned return facts. Resolve inline namespace and chained alias types without borrowing unrelated functions or classes; cover review collision and header-order cases.

* fix(cpp): honor qualified type visibility

Carry header alias activation positions through include chains. Refuse unresolved type-owned qualifiers without rejecting value names, and align the lexical-ownership fixture with the scope-index contract.

* style(cpp): format inline namespace lookup

* fix(cpp): preserve factory lookup position

Keep later local type declarations from hiding an earlier namespace
factory. Name overload and class-factory recall limits explicitly.

Refs #3656

* fix(cpp): expand factory return aliases

Apply the provider qualified-type hook in producer-return lookup.
Keep authoritative refusals and the original declaration context.

Refs #3656

---------

Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 08:21:37 +00:00

349 lines
14 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.

import type { ScopeResolutionIndexes } from '../../../../src/core/ingestion/model/scope-resolution-indexes.js';
/**
* #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, SymbolDefinition } from 'gitnexus-shared';
import { beforeEach, describe, expect, it } from 'vitest';
import {
clearCppInlineNamespaces,
markCppInlineNamespaceRange,
populateCppInlineNamespaceScopes,
resolveCppQualifiedNamespaceMember,
splitCppQualifiedCallee,
} 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 parent’s 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 namespace’s 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 array’s 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();
});
});
describe('splitCppQualifiedCallee', () => {
// Keep the written namespace path so a factory result cannot borrow a
// same-named nested namespace from another outer namespace.
it.each<[string, { receiver: string; member: string } | undefined]>([
['rp::make', { receiver: 'rp', member: 'make' }],
['a::b::make', { receiver: 'a::b', member: 'make' }],
['ns::Box<a::B>::make', { receiver: 'ns::Box<a::B>', member: 'make' }],
['make', undefined],
['::make', undefined],
['Box<a::B>', undefined],
])('%s -> %j', (callee, expected) => {
expect(splitCppQualifiedCallee(callee)).toEqual(expected);
});
});