GitNexus/gitnexus/test/integration/cpp-qualified-receiver-boundaries.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

61 lines
2.4 KiB
TypeScript

import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js';
describe('C++ qualified receiver boundaries', () => {
let root: string;
let result: Awaited<ReturnType<typeof runPipelineFromRepo>>;
beforeAll(async () => {
root = await fs.mkdtemp(path.join(os.tmpdir(), 'cpp-receiver-boundaries-'));
await fs.copyFile(
new URL('../fixtures/cpp-qualified-receiver-boundaries.cpp', import.meta.url),
path.join(root, 'main.cpp'),
);
result = await runPipelineFromRepo(root, () => {}, {});
}, 120000);
afterAll(async () => {
if (root) await fs.rm(root, { recursive: true, force: true });
});
function receiverOwners(caller: string, ids = false): string[] {
const edges = [...result.graph.iterRelationships()];
return edges
.filter(
(e) =>
e.type === 'CALLS' &&
result.graph.getNode(e.sourceId)?.properties.name === caller &&
result.graph.getNode(e.targetId)?.properties.name === 'm',
)
.map((e) => {
const owner = edges.find((x) => x.type === 'HAS_METHOD' && x.targetId === e.targetId);
return ids
? String(owner?.sourceId)
: String(owner && result.graph.getNode(owner.sourceId)?.properties.name);
})
.sort();
}
it.each(['alias_field', 'alias_brace'])('%s resolves the namespace alias', (caller) => {
expect(receiverOwners(caller)).toEqual(['AliasC']);
});
it('keeps the method return separate from a same-name free prototype', () => {
expect(receiverOwners('prototype_collision')).toEqual(['A']);
});
it('does not select a competing overload without call evidence', () => {
expect(receiverOwners('using_overload')).toEqual([]);
});
it.each(['dependent_bound', 'dependent_inline', 'dependent_outer'])(
'%s does not borrow a namespace factory',
(caller) => {
expect(receiverOwners(caller)).toEqual([]);
},
);
it('alias_shadowed cannot borrow the namespace alias through a local class', () => {
const namespaceTarget = receiverOwners('alias_field', true);
expect(namespaceTarget).toHaveLength(1);
expect(receiverOwners('alias_shadowed', true)).not.toContain(namespaceTarget[0]);
});
it('keeps the concrete namespace factory', () => {
expect(receiverOwners('qualified_positive')).toEqual(['A']);
});
});