From a09f76be39cd8b106b21d815074ad032e272984d Mon Sep 17 00:00:00 2001 From: Abhigyan Patwari <126312502+abhigyanpatwari@users.noreply.github.com> Date: Thu, 22 Oct 2026 13:51:37 +0530 Subject: [PATCH] fix(cpp): type receivers declared or produced through qualified names (#3656) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 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 * 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 * 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 * 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 * 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::b::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 * 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 Co-authored-by: Claude Opus 5.5 --- ARCHITECTURE.md | 4 +- .../bench/receiver-resolution/BASELINE.md | 9 + gitnexus/bench/scope-capture/baselines.json | 14 +- .../core/ingestion/languages/cpp/captures.ts | 82 ++++++ .../languages/cpp/inline-namespaces.ts | 94 +++++- .../core/ingestion/languages/cpp/interpret.ts | 34 ++- .../languages/cpp/qualified-name-claims.ts | 42 +++ .../src/core/ingestion/languages/cpp/query.ts | 90 ++++-- .../ingestion/languages/cpp/scope-resolver.ts | 71 ++++- .../ingestion/languages/cpp/simple-hooks.ts | 16 +- .../ingestion/languages/cpp/using-bindings.ts | 268 +++++++++++++++++- .../contract/scope-resolver.ts | 65 ++++- .../passes/compound-receiver.ts | 139 +++++++-- .../passes/imported-return-types.ts | 36 ++- .../passes/receiver-bound-calls.ts | 46 ++- .../scope-resolution/scope/walkers.ts | 67 ++++- .../cpp-qualified-receiver-boundaries.cpp | 29 ++ .../cpp-qualified-receiver-typing/a.h | 10 + .../cpp-qualified-receiver-typing/callers.cpp | 58 ++++ .../cpp-qualified-receiver-typing/mod.cpp | 8 + .../cpp-qualified-receiver-typing/mod.h | 34 +++ .../cpp-qualified-receiver-typing/other.h | 9 + .../cpp-qualified-receiver-typing/using.cpp | 14 + .../cpp-header-alias-order.test.ts | 71 +++++ .../cpp-namespace-order-boundaries.test.ts | 55 ++++ .../cpp-qualified-alias-chain.test.ts | 55 ++++ .../cpp-qualified-callee-collisions.test.ts | 71 +++++ .../cpp-qualified-factory-review.test.ts | 99 +++++++ .../cpp-qualified-receiver-boundaries.test.ts | 61 ++++ .../cpp-qualified-template-review.test.ts | 84 ++++++ .../producer-return-disk-scope.test.ts | 71 +++++ .../cpp-qualified-receiver-typing.test.ts | 99 +++++++ .../scope-resolution/cpp/cpp-captures.test.ts | 36 +++ .../cpp/cpp-qualified-ns-index.test.ts | 16 ++ .../lexical-name-claims.test.ts | 4 +- 35 files changed, 1847 insertions(+), 114 deletions(-) create mode 100644 gitnexus/src/core/ingestion/languages/cpp/qualified-name-claims.ts create mode 100644 gitnexus/test/fixtures/cpp-qualified-receiver-boundaries.cpp create mode 100644 gitnexus/test/fixtures/lang-resolution/cpp-qualified-receiver-typing/a.h create mode 100644 gitnexus/test/fixtures/lang-resolution/cpp-qualified-receiver-typing/callers.cpp create mode 100644 gitnexus/test/fixtures/lang-resolution/cpp-qualified-receiver-typing/mod.cpp create mode 100644 gitnexus/test/fixtures/lang-resolution/cpp-qualified-receiver-typing/mod.h create mode 100644 gitnexus/test/fixtures/lang-resolution/cpp-qualified-receiver-typing/other.h create mode 100644 gitnexus/test/fixtures/lang-resolution/cpp-qualified-receiver-typing/using.cpp create mode 100644 gitnexus/test/integration/cpp-header-alias-order.test.ts create mode 100644 gitnexus/test/integration/cpp-namespace-order-boundaries.test.ts create mode 100644 gitnexus/test/integration/cpp-qualified-alias-chain.test.ts create mode 100644 gitnexus/test/integration/cpp-qualified-callee-collisions.test.ts create mode 100644 gitnexus/test/integration/cpp-qualified-factory-review.test.ts create mode 100644 gitnexus/test/integration/cpp-qualified-receiver-boundaries.test.ts create mode 100644 gitnexus/test/integration/cpp-qualified-template-review.test.ts create mode 100644 gitnexus/test/integration/producer-return-disk-scope.test.ts create mode 100644 gitnexus/test/integration/resolvers/cpp-qualified-receiver-typing.test.ts diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index bed040e68..387f94012 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -389,13 +389,15 @@ Single interface a language implements to plug into the pipeline. Contract fully | `fieldFallbackOnMethodLookup?` | Statically-typed languages turn this OFF — the heuristic over-connects (default on) | | `elementTypeOf?` | `(containerType, via: {kind:'index'} \| {kind:'accessor',name}) → elementType \| undefined` — element type of a container, reached by subscript (`repos[0]`) or by a property-style collection view (`data.Values`). ONE hook for both routes (it replaced the split `unwrapCollectionAccessor` / `unwrapCollectionElement`, where implementing one silently answered nothing for the other). Consulted only where the source actually performed the access — never as a general type-name normalizer | | `stripTypePreservingDecoration?` | `(typeName) → strippedName \| undefined` — strip ONE layer of TYPE-PRESERVING decoration (pointer, reference, `const`, nullable, borrow, sigil) so a receiver declared `*Host` still finds the `Host` binding (#2766). Never a container: unwrapping `Repo[]` here would fold `repos.find(x)` to `Repo.find` — that is `elementTypeOf`'s job, and only after a real subscript. Consulted only after every undecorated lookup fails, and only by receiver-chain base/step resolution — default off | -| `qualifiedTypeLookupSpelling?` | `(typeRef) → 'pkg.Type' \| undefined` — the namespace-qualified spelling of a declared type whose qualifier capture cut from `rawName` (`*models.User` stored as `User`), recovered from `declaredSpelling`. Receiver typing tries it first, grounded on the qualifier's import (no unique-name guess), and falls back to the bare `rawName` lookup on any miss, so two packages' same-named types stop colliding. The binding is not rewritten — default off | +| `qualifiedTypeLookupSpelling?` | `(typeRef, scopes?) → 'pkg.Type' \| null \| undefined` — the namespace-qualified spelling of a declared type whose qualifier capture cut from `rawName` (`*models.User` stored as `User`), recovered from `declaredSpelling`. Receiver typing tries it first, grounded on the qualifier's import (no unique-name guess). A failed written-qualified lookup stops instead of falling back to a bare type name. The optional scope indexes also let a provider expand a visible namespace alias; `null` refuses the written lookup and its fallback. The binding is not rewritten — default off | | `collapseMemberCallsByCallerTarget?` | One CALLS edge per (caller, target) instead of per-site — default off | | `populateNamespaceSiblings?` | Cross-file implicit visibility (compiler-implicit namespace sharing) — default off; ctx carries `treeCache` | | `hoistTypeBindingsToModule?` | Walk up to Module scope when looking up a method's return-type typeBinding — default off; enable only when bindings are stored at module level | | `hasFileLocalCallableLinkage?` | Precise internal-linkage predicate used only when joining callable declarations/prototypes to cross-file definitions; C/C++ use it for `static` free functions | | `constructorCallTargetsClass?` | A constructor-form call `Type(...)` links to the Class def rather than its explicit Constructor def — default off; Swift and Dart opt in | | `constructionSyntax?` | How the language spells construction, so an INLINE constructor receiver (`Service(db).m()`, `new Service(db).m()`, `Service.new.m()`) can be typed — `bare` / `keyword` / `selector`; default off, opt in per language only where measured to be needed (#2708) | +| `resolveQualifiedTypeSpellings?` | A receiver's declared or return type written QUALIFIED (`rp::C x`, `f(a::Config c)`) that binds nothing through the scope chain resolves through `resolveQualifiedInheritanceBase` — qualifier matched against the candidate's namespace, refuse on a tie — instead of by its tail, which binds the nearer same-named class. Runs after every existing lookup and after the template-argument match; default off, C++ opts in | +| `splitQualifiedCallee?` | `(callee, callerScope?, scopes?, position?) → { receiver, member } \| undefined` — map a qualified callee spelling (`rp::make`) onto a receiver/member pair that retains the full written namespace qualifier, so a factory result (`auto x = rp::make(); x.m()`, `rp::make().m()`) is typed from the definition `resolveQualifiedReceiverMember` returns and that definition's own declared return type. Caller scope, indexes and source position let a provider reject shadowed or aliased namespace qualifiers only when visible at the factory expression. Shared code never splits on a separator; default off, C++ implements it | ### Per-language registration diff --git a/gitnexus/bench/receiver-resolution/BASELINE.md b/gitnexus/bench/receiver-resolution/BASELINE.md index b45e0f14a..7b0e0eb8c 100644 --- a/gitnexus/bench/receiver-resolution/BASELINE.md +++ b/gitnexus/bench/receiver-resolution/BASELINE.md @@ -982,6 +982,15 @@ Shape states remain unchanged; counts are 133 call drops and 160 drops of all kinds (27 reads, 0 writes). The counts include both main and this branch's fixes. +### C++ qualified receivers after #3653 + +Main `2b4456553` is merged normally. The field lookup retains both the declared namespace purpose and provider-qualified type handling. Build and both TypeScript configurations pass, and all 59 focused C++ and CommonJS merge checks pass. The measured receiver baseline has 133 call drops, 160 total drops, 27 reads and 0 writes. ### After merging #3653 into the C# namespace branch Main `2b4456553` is merged normally. The measured baseline has 130 call drops and 156 across all kinds, with 26 reads and zero writes. Main has 133 calls, 27 reads and zero writes (160 total). Every shape state matches main. The fresh build and both TypeScript configurations pass, as do all 68 namespace-boundary, capture, hook and CommonJS merge checks. + +### C++ qualified receivers after #3655 + +Main `caacc3bac` is merged normally. The regenerated combined baseline has 130 call drops,156 total,26 reads and zero writes; all shape states match. These counts include the C# namespace fix from main. The broad C++ resolver run was interrupted for the prioritized focused merge checks and is not reported as a pass. Fresh exact-head CI remains required. + +The prioritized merge validation passes a fresh package build, both TypeScript configurations and 56 tests across six C++, CommonJS and C# namespace-boundary files. diff --git a/gitnexus/bench/scope-capture/baselines.json b/gitnexus/bench/scope-capture/baselines.json index 90649845d..d0873ef32 100644 --- a/gitnexus/bench/scope-capture/baselines.json +++ b/gitnexus/bench/scope-capture/baselines.json @@ -44,7 +44,12 @@ }, "cpp": { "_lexical_ownership_3532": "#3532: lexical ownership captures and fixture updates; fixture identities are relative to lang-resolution so serialized scope IDs are independent of checkout location. Full capture content remains hashed; scaling_budget is unchanged.", - "fingerprint": "dfd1de80f55d8e1bacde91a99951e7eee4e76ccdc8a7477c5a8ac98d804ce05a", + "fingerprint": "14dcc4038c95a874f8f7bd72cbd8797f2deb030ba080b0d124ebcb3117617d00", + "_rebaselined_qualified_receiver_typing_factory_callee": "Qualified factory callee: captures.ts now promotes `auto x = ns::f()` / `auto x = ns::T()` (a qualified_identifier callee whose innermost name is a plain identifier) from the `type: (_)` @type-binding.assignment match, which bound x to the literal type `auto`, to @type-binding.constructor with the whole qualified callee as @type-binding.type - the qualified twin of the bare `auto x = f()` rule. Template callees (`std::make_shared()`) keep their dedicated rule. Compared canonicalized captures of the pre-change and post-change emitters over the same corpus: exactly ONE match changed outside the new fixture - cpp-method-enrichment/app.cpp `auto category = Animal::classify(\"dog\");`, @type-binding.assignment/type `auto` -> @type-binding.constructor/type `Animal::classify` - and none was added or removed; synthetic DAO-20 unchanged. interpret.ts additionally drops any leftover `auto` / `decltype(auto)` binding (no capture effect; over the corpus no such leftover remained). The fixture gains its qualified-factory callers (+33 groups). capture_groups_fp 4793 -> 4826, fixture_count 285 unchanged. Corpus A/B: no edge or drop outside the fixture moved (the changed binding names a class's static method, which the qualified-namespace lookup does not answer, so category stays untyped as before). Prior 2242a6b8085b27478cc8bf6f37da3f7b9b07df85fd863a3f633d7b0887ab7ac8 -> 13bfad4a8197dd9e3d252e28c6b591e08bb51e799d1c1a839f8a485d11e549eb; scaling 1.198 < 1.5.", + "_rebaselined_qualified_receiver_typing_inline_fixture": "cpp-qualified-receiver-typing/using.cpp gains two inline-receiver callers (`make().m()` and `makeWidget().m()` under a using-directive) for the compound resolver's definition-identity return-type fallback. CORPUS GROWTH ONLY: the accompanying fix is resolution-time (compound-receiver.ts) and no emitter changed; the new fixture is the only file whose captures moved (+14 groups). capture_groups_fp 4779 -> 4793, fixture_count 285 unchanged. Prior b21abc71298d184dd484647e6d36237bb56e66c82597ef7a47452a74526856cd -> 2242a6b8085b27478cc8bf6f37da3f7b9b07df85fd863a3f633d7b0887ab7ac8; scaling 1.167 < 1.5.", + "_rebaselined_qualified_receiver_typing_prototype_returns": "Header prototype return types: two @type-binding.return rules on a free-function PROTOTYPE (`D makeD();`, `D* makeDp();`), anchored on the `declaration` node; captures.ts drops a match whose declaration is not at namespace scope (translation unit or declaration_list, through preprocessor conditionals, extern \"C\" and template prefixes), so a block-scope `D x();` (most vexing parse) binds nothing. Compared canonicalized captures of the pre-change and post-change emitters over the same corpus: exactly ONE match added outside the new fixture - cpp-return-type/user.h `User getUser(const char* name);` (the header prototype whose same-file definition already bound the identical type) - and nothing removed or changed; synthetic DAO-20 unchanged (no free prototypes). Over the whole cpp corpus the gate drops exactly one candidate, the fixture's deliberate `E y();` in a function body. The fixture gains its prototype shapes and using.cpp (140 -> 242 groups, fixture_count 284 -> 285). capture_groups_fp 4676 -> 4779. Corpus A/B: no edge or drop outside the fixture moved. Prior a50c2b2313f34e03f8da44d34007250830b66af7c62690d55b1716219eae481e -> b21abc71298d184dd484647e6d36237bb56e66c82597ef7a47452a74526856cd; scaling 1.226 < 1.5.", + "_rebaselined_qualified_receiver_typing_declared_ns": "Qualified declarations without an initializer: two @type-binding.annotation rules matching the OUTER qualified_identifier (`rp::C x;` and `rp::C* x = nullptr;`), the qualified twins of the bare `T x;` / `T* x = ...` rules, so the local binds `rp::C` as written for the qualifier-aware resolver. Compared canonicalized captures of the pre-change and post-change emitters over the same corpus: 29 matches ADDED outside the new fixture, every one a @type-binding.annotation on a qualified local (21 x `ns::T v;` - audit::Event, data::Pair, lib::T, alpha::Token, lib::Foo - across 16 cpp-adl-* / cpp-inline-namespace-adl-participation / cpp-phase5-u2-u3-adl-from-derived files, plus 8 std::vector / std::array / std::map locals in cpp-adl-template-args, cpp-template-cross-file and cpp-template-overload); zero matches removed or changed, no other tag moved, synthetic DAO-20 unchanged (it declares no qualified local). The new fixture gains 2 callers (127 -> 140 groups). capture_groups_fp 4634 -> 4676. Corpus A/B: no CALLS/ACCESSES edge or receiver drop outside the fixture moved (ADL arguments are typed from the capture-time AST, not typeBindings). Prior 83c1009cd6c88f6997a0b7aa8c188bc7c44c03e9126a2ed684bfb5a21bb9f5f3 -> a50c2b2313f34e03f8da44d34007250830b66af7c62690d55b1716219eae481e; scaling 1.255 < 1.5.", + "_rebaselined_qualified_receiver_typing_brace_init": "Qualified receiver typing (resolveQualifiedTypeSpellings): the `auto x = ns::T{}` brace-init @type-binding.constructor rule now captures the WHOLE qualified_identifier instead of its type_identifier tail, so the resolver can match the qualifier (the tail bound a nearer same-named class: a global C through another include, or the enclosing namespace's own Config). Compared canonicalized captures of the pre-change and post-change emitters over the same corpus: exactly ONE existing match changed - cpp-scoped-brace-init/main.cpp `client = ns::HttpClient{}`, @type-binding.type text HttpClient -> ns::HttpClient (range 4:22-4:32 -> 4:18-4:32); no tag was added or removed anywhere. The rest is CORPUS growth: the new cpp-qualified-receiver-typing fixture (5 files, 127 capture groups). capture_groups_fp 4507 -> 4634, fixture_count 279 -> 284. The member field change (non-generic qualified members keep their qualifier) is in interpret.ts and does not touch captures. Prior dfd1de80f55d8e1bacde91a99951e7eee4e76ccdc8a7477c5a8ac98d804ce05a -> 83c1009cd6c88f6997a0b7aa8c188bc7c44c03e9126a2ed684bfb5a21bb9f5f3; scaling 1.479 < 1.5 on a host at load ~14.", "scaling_budget": 1.5, "_rebaselined_2833_qualified_member_fields": "#2833 follow-up: the six per-qualifier-depth `field_declaration` type-binding rules for a QUALIFIED generic member are replaced by three depth-agnostic ones that match the outer `qualified_identifier` itself, with the qualifier reduced to its top-level tail in `interpret.ts` (`cppQualifiedTail`). This is a CAPTURE-LOGIC change and it moves the fingerprint in two places at once. (1) A qualified NON-generic member (`ns::Address addr;`, `std::string name;`) was captured by nothing at all and now binds — that is the whole +24 on the fixture corpus, every one of them a `std::string` member. (2) Qualifier depth is no longer enumerated, so `a::b::c::Repo` (depth 3+) is captured where the old rules stopped at 2. Capture-name histogram, cpp-* corpus (278 files): `@type-binding.field` 8 -> 32, `@type-binding.name` and `@type-binding.type` 401 -> 425; synthetic DAO-20: `@type-binding.field` 40 -> 60, `@type-binding.name` and `@type-binding.type` 61 -> 81 (= 20 entities x the one `std::string name;` member the DAO unit already declared). NO OTHER TAG MOVED in either set — not one `@declaration.*`, `@scope.*` or `@reference.*` count — which is the property that says three rules replaced six without widening what a field_declaration matches. Measured over the 13 cpp-* fixture repos whose sources gained a binding, the distinct CALLS edge set is byte-identical before and after (32 edges): a reduced tail that names no workspace class binds nothing. Prior bd47c82d09a83cbf0ac857f41876fa31d22304043735582e913bccde06cf2c1a -> db1156d81b3e3341faf5e938a4a34417f4fd246588b6150b4686481823262529; scaling 1.04 < 1.5.", "_rebaselined_2833_generic_member_fields": "#2833 review follow-up: the cpp DAO generator's unit gains two GENERIC member fields — `Repo repo;` (bare template_type) and `std::vector items;` (qualified_identifier wrapping a template_type) — plus the header declaring `template class Repo`. CORPUS CHANGE, NOT A CAPTURE-LOGIC CHANGE: no extractor edit accompanies it. It exists because the corpus had ZERO template-typed member fields and, across 279 cpp-* fixtures, not one qualified generic member either, so BOTH rounds of new `field_declaration` type-binding rules landed with a byte-identical cpp fingerprint — the gate was structurally blind to the exact thing being changed. Measured under the new corpus, the three states now differ: pre-#2833 query 0e7cbda71360b7ff35dd76091c77f288d6af6a5cfa9185ad85a372aae8c85191 (4521 groups) -> the three template_type field rules de07d8b5300ed867b460918e16b4d80259c7eb6efc1034d32bebe9ff7cab126d (4541) -> the six qualified rules bd47c82d09a83cbf0ac857f41876fa31d22304043735582e913bccde06cf2c1a (4561); under the OLD corpus all three were 856d02f3f9d22cb973877211100aee8e052d4bc545922f78704b1a21ce49ddcc. Capture-name histogram over the synthetic DAO-20: `@type-binding.field` 0 -> 40, `@declaration.field` 40 -> 80, `@type-binding.type`/`@type-binding.name` 20 -> 61, `@declaration.name` 104 -> 147 — 40 = 20 entities x 2 fields, with the residual +1/+2/+3 attributable to the one-off header declaration; every `@reference.*` count is unchanged. Prior 856d02f3f9d22cb973877211100aee8e052d4bc545922f78704b1a21ce49ddcc -> bd47c82d09a83cbf0ac857f41876fa31d22304043735582e913bccde06cf2c1a; scaling 1.058 < 1.5. `c` is unaffected (3418cded..., unchanged).", @@ -62,10 +67,11 @@ "_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|…` instead of `1|…`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior 7e27aea46f3e17f33c41babbe0ddd982d1ab5920f143864763e0a1c6aef882a5 -> 856d02f3f9d22cb973877211100aee8e052d4bc545922f78704b1a21ce49ddcc.", "capture_groups_small": 5021, "capture_groups_large": 16021, - "capture_groups_fp": 4601, - "fixture_count": 279, + "capture_groups_fp": 4826, + "fixture_count": 285, "_rebaselined_1432_member_call_callee_name": "#1432 (Zig): the shared callable-flow reader no longer names a callee by simple name for a MEMBER call (`@callable-flow.direct-callee-name` requires a direct designator: `f(x)`, `ns.f(x)`), and a member call is a field-stored-callable invoke only when a MEMBER store (`o.f = handler`) or a declared callable-typed field is visible - a same-named plain binding no longer gates it. CAPTURE-EMISSION CHANGE, not fixture growth (fixture_count unchanged). Only drift: `choice.select(1)` / `choice.select(1.5)` (cpp-deleted-overload/main.cpp) no longer mint an INDIRECT `@callable-flow.invoke` (callee-kind binding) plus its synthetic `@reference.call.free` - that invoke was gated only by the same-named free `select` binding while the site is a genuine method call already captured as `@reference.call.member`, so the free-call duplicate is gone. capture_groups_fp 4605 -> 4601 (-4: 2 invokes + 2 synthetic call.free). Prior bf3587674267be1759e7c45abef143c3b81fe8629cfd17da5f8af40e83cc39ec -> 3aaee42523f02718795ba348782d78ec1418f35eff2e1b602422b192544fed96.", - "_rebaselined_3486_semantic_rename": "#3486: Compared actual origin/main (191a954b), committed dd3bf and frozen provider source over the same unchanged corpus. 9 existing read matches gain receiver chains; 9 synthetic operator names now use their written operator-token range rather than the translation-unit range. Group counts are unchanged. Fixture entries 279; changed entries 10; capture groups 4507 -> 4507. Fingerprint regenerated from the runner's canonicalized capture records. Scaling budgets unchanged." + "_rebaselined_3486_semantic_rename": "#3486: Compared actual origin/main (191a954b), committed dd3bf and frozen provider source over the same unchanged corpus. 9 existing read matches gain receiver chains; 9 synthetic operator names now use their written operator-token range rather than the translation-unit range. Group counts are unchanged. Fixture entries 279; changed entries 10; capture groups 4507 -> 4507. Fingerprint regenerated from the runner's canonicalized capture records. Scaling budgets unchanged.", + "_rebaselined_3498_review": "Qualified callable returns retain namespace spelling. Function-template definitions publish their own parameter list, including pointer-returning declarators. Only C++ capture fingerprint changes; all sixteen unchanged scaling budgets pass. Native boundaries cover alias chains, namespace/class collisions, overloaded prototypes and independent callable return owners." }, "csharp": { "_rebaselined": "#1956 synth-widening: + csharp-qualified-base fixture; the synth now walks record_declaration + struct_declaration base_lists and handles alias_qualified_name (matching the #1940 legacy leg), so record/struct heritage now emits. csharp-record-base gains a record inherits capture. (record->record SAME-namespace EXTENDS is a separate registry resolution gap, tracked as follow-up.) Linear (~1.00). (Earlier #1956: heritage-bearing scale source.) | #942: scope-resolution-only cleanup reworded fixture comments; capture byte-positions shift, capture LOGIC unchanged. | #1924 F16: record primary-constructor base bindings now exclude constructor arguments; capture fingerprint changes, scaling remains linear. | #2036 review follow-up: csharp-record-base now exercises primary-constructor base dispatch end to end; +2 capture groups, scaling remains linear.", diff --git a/gitnexus/src/core/ingestion/languages/cpp/captures.ts b/gitnexus/src/core/ingestion/languages/cpp/captures.ts index c8dd4075d..7e69f9fb8 100644 --- a/gitnexus/src/core/ingestion/languages/cpp/captures.ts +++ b/gitnexus/src/core/ingestion/languages/cpp/captures.ts @@ -224,6 +224,11 @@ export function emitCppScopeCaptures( } if (Object.keys(grouped).length === 0) continue; + if (grouped['@import.namespace-alias'] !== undefined) { + recordCppUsingDeclaration(filePath, grouped); + continue; + } + // ── Handle #include statements ────────────────────────────────── // `@import.statement` is captured directly on the `preproc_include` node. if (grouped['@import.statement'] !== undefined) { @@ -237,6 +242,19 @@ export function emitCppScopeCaptures( } } + const callable = nodeIfType( + nodeMap['@declaration.function'] ?? nodeMap['@declaration.method'], + 'function_definition', + ); + if (callable?.parent?.type === 'template_declaration') { + const parameters = callable.parent.childForFieldName('parameters'); + if (parameters !== null) + grouped['@declaration.type-parameters'] = nodeToCapture( + '@declaration.type-parameters', + parameters, + ); + } + // ── Handle using declarations (using namespace / using name) ──── // `@import.using-decl` is captured directly on the `using_declaration` node. if (grouped['@import.using-decl'] !== undefined) { @@ -606,6 +624,17 @@ export function emitCppScopeCaptures( grouped['@type-binding.type'] = nodeToCapture('@type-binding.type', fieldNode); delete grouped['@type-binding.assignment']; } + } else if (fnNode !== null && isCppQualifiedCallee(fnNode)) { + // auto x = rp::make() / auto x = rp::C() → bind the QUALIFIED + // callee, as `auto x = make()` binds `make` (the constructor rule + // in query.ts matches only a bare identifier callee). Left alone, + // the match bound `x` to the literal type `auto`. The resolver + // types `x` from the class the spelling names, or from the + // return type the callee's definition declares + // (ScopeResolver.splitQualifiedCallee). + grouped['@type-binding.constructor'] = anchor; + grouped['@type-binding.type'] = nodeToCapture('@type-binding.type', fnNode); + delete grouped['@type-binding.assignment']; } } } @@ -613,6 +642,17 @@ export function emitCppScopeCaptures( } } + // A prototype's return type (`D makeD();`) is a module-level fact only at + // namespace scope. In a function body the same syntax declares a + // block-scope function — the most vexing parse, `D x();` — and its binding + // would be hoisted to the module and type every undeclared `x` in the file. + // Only the prototype rules anchor `@type-binding.return` on a `declaration` + // (definitions anchor on `function_definition`, in-class prototypes on + // `field_declaration`), and the match carries nothing else, so it is dropped + // whole. + const prototypeReturn = nodeIfType(nodeMap['@type-binding.return'], 'declaration'); + if (prototypeReturn !== null && !isCppNamespaceScopeDeclaration(prototypeReturn)) continue; + const parameterNode = nodeMap['@type-binding.parameter']; const parameterCapture = grouped['@type-binding.parameter']; if (parameterNode !== undefined && parameterCapture !== undefined) { @@ -655,6 +695,48 @@ export function emitCppScopeCaptures( return out; } +/** + * A `qualified_identifier` callee naming a plain function or class — `rp::make`, + * `a::b::make`, `rp::C` — as opposed to a template call (`std::make_shared`, + * typed by its own rule in query.ts), an operator or a destructor. + */ +function isCppQualifiedCallee(node: SyntaxNode): boolean { + if (node.type !== 'qualified_identifier') return false; + let name = node.childForFieldName('name'); + while (name !== null && name.type === 'qualified_identifier') { + name = name.childForFieldName('name'); + } + return name !== null && name.type === 'identifier'; +} + +/** Nodes a namespace-scope declaration may sit inside without leaving namespace + * scope: preprocessor conditionals (include guards nest every declaration of a + * guarded header in one), single-declaration `extern "C"`, and a `template` + * prefix. */ +const CPP_NAMESPACE_SCOPE_WRAPPERS: ReadonlySet = new Set([ + 'preproc_if', + 'preproc_ifdef', + 'preproc_else', + 'preproc_elif', + 'preproc_elifdef', + 'linkage_specification', + 'template_declaration', +]); + +/** + * Is `node` declared at namespace scope — directly in the translation unit or in + * a namespace / `extern "C" {}` body (`declaration_list`), through any number of + * {@link CPP_NAMESPACE_SCOPE_WRAPPERS}? A function body, class body, friend + * declaration or anything else on the way up answers no. + */ +function isCppNamespaceScopeDeclaration(node: SyntaxNode): boolean { + for (let parent = node.parent; parent !== null; parent = parent.parent) { + if (parent.type === 'translation_unit' || parent.type === 'declaration_list') return true; + if (!CPP_NAMESPACE_SCOPE_WRAPPERS.has(parent.type)) return false; + } + return false; +} + /** Prototype and nested function-pointer parameter names do not bind the body. */ function isCppLexicalParameter(node: SyntaxNode): boolean { for (let parent = node.parent; parent !== null; parent = parent.parent) { diff --git a/gitnexus/src/core/ingestion/languages/cpp/inline-namespaces.ts b/gitnexus/src/core/ingestion/languages/cpp/inline-namespaces.ts index 47f76d845..5c02d84fa 100644 --- a/gitnexus/src/core/ingestion/languages/cpp/inline-namespaces.ts +++ b/gitnexus/src/core/ingestion/languages/cpp/inline-namespaces.ts @@ -29,7 +29,8 @@ * declaration transparently. */ -import type { Callsite, ParsedFile, ScopeId, SymbolDefinition } from 'gitnexus-shared'; +import type { Callsite, ParsedFile, ScopeId, SymbolDefinition, TypeRef } from 'gitnexus-shared'; +import { isCppNamespaceQualifierShadowed } from './qualified-name-claims.js'; import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js'; import { isOverloadAmbiguousAfterNormalization, @@ -182,6 +183,7 @@ interface QualifiedNsMemberIndex { * for are ever materialized, which is what keeps the deep-chain case linear. */ readonly membersByReceiver: Map>; + readonly ownerScopeByDefId: ReadonlyMap; } /** One `Namespace` scope as the qualified-lookup walk sees it. */ @@ -271,6 +273,7 @@ interface MemoizedIndex { * pairs it already found. */ function buildQualifiedNsMemberIndex(parsedFiles: readonly ParsedFile[]): QualifiedNsMemberIndex { const rootsByReceiver = new Map(); + const ownerScopeByDefId = new Map(); for (const parsed of parsedFiles) { const nodesByScope = new Map(); @@ -278,6 +281,8 @@ function buildQualifiedNsMemberIndex(parsedFiles: readonly ParsedFile[]): Qualif for (const sc of parsed.scopes) { if (sc.kind !== 'Namespace') continue; const node: MutableNsNode = { ownMembers: bucketOwnMembers(sc), inlineChildren: [] }; + for (const definitions of node.ownMembers.values()) + for (const definition of definitions) ownerScopeByDefId.set(definition.nodeId, sc.id); nodesByScope.set(sc.id, node); namespaces.push([sc, node]); } @@ -299,7 +304,7 @@ function buildQualifiedNsMemberIndex(parsedFiles: readonly ParsedFile[]): Qualif roots.push(node); } } - return { rootsByReceiver, membersByReceiver: new Map() }; + return { rootsByReceiver, membersByReceiver: new Map(), ownerScopeByDefId }; } /** Bucket a namespace scope's own callable `ownedDefs` by member simple name. @@ -450,10 +455,50 @@ export function resolveCppQualifiedNamespaceMember( receiverName: string, memberName: string, parsedFiles: readonly ParsedFile[], - _scopes: ScopeResolutionIndexes, + scopes: ScopeResolutionIndexes, callsite?: Callsite, + callerScope?: ScopeId, ): SymbolDefinition | 'ambiguous' | undefined { - const allHits = qualifiedNsMembers(qualifiedNsMemberIndex(parsedFiles), receiverName, memberName); + const index = qualifiedNsMemberIndex(parsedFiles); + const qualifier = receiverName.replace(/^::/, '').replace(/::/g, '.'); + const qualified = receiverName.includes('::'); + const receiver = qualifier.slice(qualifier.lastIndexOf('.') + 1); + let allHits = qualifiedNsMembers(index, receiver, memberName); + if (qualified) { + const prefixes: string[] = []; + let cursor = callerScope === undefined ? undefined : scopes.scopeTree.getScope(callerScope); + const visited = new Set(); + while (cursor !== undefined && !visited.has(cursor.id)) { + visited.add(cursor.id); + if (cursor.kind === 'Namespace') { + const definition = findNamespaceDefInScope(cursor); + if (definition !== undefined) prefixes.unshift(simpleNameOf(definition)); + } + cursor = cursor.parent === null ? undefined : scopes.scopeTree.getScope(cursor.parent); + } + const keys = new Set([qualifier]); + if (!receiverName.startsWith('::')) + for (let length = prefixes.length; length > 0; length--) + keys.add([...prefixes.slice(0, length), qualifier].join('.')); + allHits = allHits.filter((definition) => { + if (definition.namespacePrefix !== undefined && keys.has(definition.namespacePrefix)) + return true; + const transparent: string[] = []; + let id = index.ownerScopeByDefId.get(definition.nodeId); + const seen = new Set(); + while (id !== undefined && !seen.has(id)) { + seen.add(id); + const scope = scopes.scopeTree.getScope(id); + if (scope === undefined) break; + if (scope.kind === 'Namespace' && !isCppInlineNamespaceScope(id)) { + const namespace = findNamespaceDefInScope(scope); + if (namespace !== undefined) transparent.unshift(simpleNameOf(namespace)); + } + id = scope.parent ?? undefined; + } + return keys.has(transparent.join('.')); + }); + } if (allHits.length === 0) return undefined; if (allHits.length === 1) return allHits[0]; @@ -476,6 +521,47 @@ export function resolveCppQualifiedNamespaceMember( return 'ambiguous'; } +/** + * `ScopeResolver.splitQualifiedCallee` for C++: the receiver/member pair the + * qualified-call captures produce for a callee spelling, so + * {@link resolveCppQualifiedNamespaceMember} answers it exactly as it answers + * the call. The full written qualifier is retained + * (`a::b::make()` → `a::b` / `make`) so another outer namespace cannot supply + * the result type. Separators inside template + * arguments are not split points (`ns::Box::make` → `ns::Box` / `make`, + * which names no namespace and resolves nothing). Unqualified → `undefined`. + */ +export function splitCppQualifiedCallee( + callee: string, + callerScope?: ScopeId, + scopes?: ScopeResolutionIndexes, + position?: TypeRef['lookupPosition'], +): { readonly receiver: string; readonly member: string } | undefined { + if (callerScope !== undefined && scopes !== undefined && !/^\s*::/.test(callee)) { + const root = callee.trim().split('::')[0]?.trim(); + if (root !== undefined) { + if (isCppNamespaceQualifierShadowed(root, callerScope, scopes, position)) return undefined; + } + } + const separators: number[] = []; + let angleDepth = 0; + for (let i = 0; i < callee.length; i++) { + const ch = callee[i]; + if (ch === '<') angleDepth++; + else if (ch === '>') { + if (angleDepth > 0) angleDepth--; + } else if (angleDepth === 0 && ch === ':' && callee[i + 1] === ':') { + separators.push(i); + i++; + } + } + const last = separators[separators.length - 1]; + if (last === undefined) return undefined; + const receiver = callee.slice(0, last).trim(); + const member = callee.slice(last + 2).trim(); + return receiver.length > 0 && member.length > 0 ? { receiver, member } : undefined; +} + function findNamespaceDefInScope(scope: { readonly ownedDefs: readonly SymbolDefinition[]; }): SymbolDefinition | undefined { diff --git a/gitnexus/src/core/ingestion/languages/cpp/interpret.ts b/gitnexus/src/core/ingestion/languages/cpp/interpret.ts index 774080965..7e68f16d8 100644 --- a/gitnexus/src/core/ingestion/languages/cpp/interpret.ts +++ b/gitnexus/src/core/ingestion/languages/cpp/interpret.ts @@ -35,6 +35,9 @@ export function interpretCppImport(captures: CaptureMatch): ParsedImport | null return { kind: 'wildcard', targetRaw: source, isSystem }; } +/** Placeholder type specifiers: the declared type is deduced, never named. */ +const CPP_PLACEHOLDER_TYPES: ReadonlySet = new Set(['auto', 'decltype(auto)']); + /** * Interpret a C++ type-binding capture into a ParsedTypeBinding. * @@ -51,6 +54,12 @@ export function interpretCppTypeBinding(captures: CaptureMatch): ParsedTypeBindi const name = captures['@type-binding.name']?.text; const type = captures['@type-binding.type']?.text; if (name === undefined || type === undefined) return null; + // `auto` names no type: the deduced type is whatever the initializer yields, + // which the specific rules (constructor, alias, member access, qualified + // callee) capture directly. What reaches here as `auto` is a leftover — + // `auto n = 5;`, `[](auto v)` — and binding it records the literal type name + // `auto`, which types nothing and hides the variable from every other route. + if (CPP_PLACEHOLDER_TYPES.has(type.trim())) return null; let source: TypeRef['source'] = 'annotation'; @@ -85,17 +94,24 @@ export function interpretCppTypeBinding(captures: CaptureMatch): ParsedTypeBindi // A member field's type is captured AS WRITTEN, qualifier and all // (`ns::Repo`), because the query matches the outer // `qualified_identifier` — one depth-agnostic pattern per declarator shape - // instead of one per qualifier depth. The qualifier is dropped HERE; see the - // "Field type, QUALIFIED" block in query.ts for why the qualified spelling - // resolves to nothing and the tail resolves like the bare one. + // instead of one per qualifier depth. // - // FIELDS ONLY. `@type-binding.parameter` and `@type-binding.assignment` also - // capture qualified spellings (their patterns use `type: (_)`), and reducing - // THOSE would newly bind every qualified local and parameter in the workspace - // — a far wider change than the member-field miss this closes, and not one - // anything here has measured. + // A NON-GENERIC qualified member (`a::Config cfg;`) keeps its qualifier, like + // a qualified local or parameter: the resolver matches the qualifier against + // the candidate's namespace (`ScopeResolver.resolveQualifiedTypeSpellings`). + // Its tail bound the NEAREST `Config` — inside a `namespace b` that declares + // one, `cfg.m()` reached `b::Config::m`, a confident wrong edge. + // + // A GENERIC qualified member is still reduced to its tail, here, because the + // template-argument match that picks a specialization (`Repo` over the + // primary `Repo`) keys on the bare base name; the qualified route is + // argument-blind and would refuse wherever a specialization exists. See the + // "Field type, QUALIFIED" block in query.ts. That keeps the tail hazard for + // generic members, and only for them. const effectiveType = - captures['@type-binding.field'] === undefined ? type : cppQualifiedTail(type); + captures['@type-binding.field'] !== undefined && type.includes('<') + ? cppQualifiedTail(type) + : type; // The reduced spelling is also the AS-WRITTEN one, and saying so is load // bearing. `collectTypeBindings` derives `TypeRef.declaredSpelling` from // `@type-binding.type` whenever that text differs from `rawTypeName`, and it diff --git a/gitnexus/src/core/ingestion/languages/cpp/qualified-name-claims.ts b/gitnexus/src/core/ingestion/languages/cpp/qualified-name-claims.ts new file mode 100644 index 000000000..77d2f3312 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/cpp/qualified-name-claims.ts @@ -0,0 +1,42 @@ +import type { Range, ScopeId } from 'gitnexus-shared'; +import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js'; +import { bindsTypeParameter, isClassLike } from '../../scope-resolution/scope/walkers.js'; + +/** Check lexical owners, excluding the finalize channel's hoisted declarations. */ +export function isCppNamespaceQualifierShadowed( + name: string, + inScope: ScopeId, + indexes: ScopeResolutionIndexes, + position?: Pick, +): boolean { + if (bindsTypeParameter(inScope, name, indexes)) return true; + const seen = new Set(); + let id: ScopeId | null = inScope; + while (id !== null && !seen.has(id)) { + seen.add(id); + const scope = indexes.scopeTree.getScope(id); + if (scope === undefined) break; + if ( + scope.bindings.get(name)?.some((binding) => { + if ( + !isClassLike(binding.def.type) && + binding.def.type !== 'Typedef' && + binding.def.type !== 'TypeAlias' + ) + return false; + const at = + binding.availableFrom ?? binding.declarationRange ?? binding.def.declarationRange; + return ( + position === undefined || + at === undefined || + scope.kind === 'Class' || + at.startLine < position.startLine || + (at.startLine === position.startLine && at.startCol <= position.startCol) + ); + }) + ) + return true; + id = scope.parent; + } + return false; +} diff --git a/gitnexus/src/core/ingestion/languages/cpp/query.ts b/gitnexus/src/core/ingestion/languages/cpp/query.ts index 7b708b60b..2f124a195 100644 --- a/gitnexus/src/core/ingestion/languages/cpp/query.ts +++ b/gitnexus/src/core/ingestion/languages/cpp/query.ts @@ -264,6 +264,10 @@ const CPP_SCOPE_QUERY = ` (type_definition declarator: (type_identifier) @declaration.name) @declaration.typedef +(namespace_alias_definition + name: (namespace_identifier) @import.name + . (_) @import.source) @import.namespace-alias @import.statement + ;; ─── Declarations — type alias (using Name = Type) ────────────────── (alias_declaration name: (type_identifier) @declaration.name) @declaration.typedef @@ -383,6 +387,17 @@ const CPP_SCOPE_QUERY = ` type: (template_type) @type-binding.type declarator: (identifier) @type-binding.name) @type-binding.annotation +;; Covers: rp::C x; std::vector users; +;; A QUALIFIED type is a qualified_identifier wrapping the type_identifier or +;; template_type, so the two rules above never matched it and rp::C x; bound +;; nothing — the receiver of x.m() was untyped. Matched on the OUTER node, one +;; pattern per declarator shape like the qualified field rules below (depth- and +;; genericity-agnostic, no alternation). The spelling is kept as written and +;; resolved by the qualifier-aware route (resolveQualifiedTypeSpellings). +(declaration + type: (qualified_identifier) @type-binding.type + declarator: (identifier) @type-binding.name) @type-binding.annotation + ;; ─── Type bindings — pointer variable declaration ─────────────────── ;; Covers: User* ptr = new User() (declaration @@ -391,6 +406,13 @@ const CPP_SCOPE_QUERY = ` declarator: (pointer_declarator declarator: (identifier) @type-binding.name))) @type-binding.annotation +;; Covers: rp::C* ptr = nullptr; (the qualified twin of the rule above) +(declaration + type: (qualified_identifier) @type-binding.type + declarator: (init_declarator + declarator: (pointer_declarator + declarator: (identifier) @type-binding.name))) @type-binding.annotation + ;; ─── Type bindings — auto + constructor call ──────────────────────── ;; Covers: auto user = User("alice") ;; AST: declaration > placeholder_type_specifier/auto > init_declarator > identifier + call_expression > identifier @@ -414,13 +436,18 @@ const CPP_SCOPE_QUERY = ` ;; ─── Type bindings — auto + scoped brace-init (qualified) ─────────── ;; Covers: auto client = ns::HttpClient{} ;; AST: compound_literal_expression > qualified_identifier > type_identifier +;; The WHOLE qualified spelling is captured, not its tail: \`ns::HttpClient\` is +;; resolved by the qualifier-aware route (\`resolveQualifiedTypeSpellings\`), +;; while the tail \`HttpClient\` bound whichever same-named class was nearer — a +;; global one through another include, or the enclosing namespace's own +;; (\`auto x = a::Config{}\` inside a \`namespace b\` declaring Config). Matching +;; the outer node is also depth-agnostic (\`a::b::T{}\`). (declaration type: (placeholder_type_specifier) declarator: (init_declarator declarator: (identifier) @type-binding.name value: (compound_literal_expression - type: (qualified_identifier - name: (type_identifier) @type-binding.type)))) @type-binding.constructor + type: (qualified_identifier) @type-binding.type))) @type-binding.constructor ;; ─── Type bindings — auto + new expression ────────────────────────── ;; Covers: auto user = new User(name) @@ -488,20 +515,43 @@ const CPP_SCOPE_QUERY = ` ;; Covers: User getUser() { ... } ;; AST: function_definition > type_identifier + function_declarator > identifier (function_definition - type: (type_identifier) @type-binding.type + type: [(type_identifier) (qualified_identifier)] @type-binding.type declarator: (function_declarator declarator: (identifier) @type-binding.name)) @type-binding.return ;; Return type — out-of-class method: User Class::getUser() { ... } (function_definition - type: (type_identifier) @type-binding.type + type: [(type_identifier) (qualified_identifier)] @type-binding.type declarator: (function_declarator declarator: (qualified_identifier name: (identifier) @type-binding.name))) @type-binding.return ;; Return type — pointer return: User* getUser() { ... } (function_definition - type: (type_identifier) @type-binding.type + type: [(type_identifier) (qualified_identifier)] @type-binding.type + declarator: (pointer_declarator + declarator: (function_declarator + declarator: (identifier) @type-binding.name))) @type-binding.return + +;; ─── Type bindings — free-function PROTOTYPE return type ───────────── +;; Covers: D makeD(); D* makeDp(); namespace rp { C make(); } +;; The usual layout is "header declares, .cpp defines", and a .cpp is never +;; included — so the header prototype is the only return-type fact an includer +;; can see. Only definitions carried one, which left \`auto x = makeD(); x.m()\` +;; and \`makeD().m()\` untyped in every file that merely includes the header. +;; Inside 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 +;; like any return type; captures.ts keeps only the matches at namespace scope +;; (isCppNamespaceScopeDeclaration). A reference return (\`D& f();\`) has no +;; rule here because such a function is not declared as a Function at all. +(declaration + type: [(type_identifier) (qualified_identifier)] @type-binding.type + declarator: (function_declarator + declarator: (identifier) @type-binding.name)) @type-binding.return + +;; Prototype pointer return: D* makeDp(); +(declaration + type: [(type_identifier) (qualified_identifier)] @type-binding.type declarator: (pointer_declarator declarator: (function_declarator declarator: (identifier) @type-binding.name))) @type-binding.return @@ -509,13 +559,13 @@ const CPP_SCOPE_QUERY = ` ;; ─── Type bindings — inline method return type ────────────────────── ;; Covers: class Foo { User getUser() { ... } }; (function_definition - type: (type_identifier) @type-binding.type + type: [(type_identifier) (qualified_identifier)] @type-binding.type declarator: (function_declarator declarator: (field_identifier) @type-binding.name)) @type-binding.return ;; Inline method pointer return type: class Foo { User* lookup(int) { ... } }; (function_definition - type: (type_identifier) @type-binding.type + type: [(type_identifier) (qualified_identifier)] @type-binding.type declarator: (pointer_declarator declarator: (function_declarator declarator: (field_identifier) @type-binding.name))) @type-binding.return @@ -524,13 +574,13 @@ const CPP_SCOPE_QUERY = ` ;; Covers: class User { User* lookup(int); std::string getName(); }; ;; AST: field_declaration > function_declarator > field_identifier (field_declaration - type: (type_identifier) @type-binding.type + type: [(type_identifier) (qualified_identifier)] @type-binding.type declarator: (function_declarator declarator: (field_identifier) @type-binding.name)) @type-binding.return ;; Method prototype pointer return type: User* lookup(int id); (field_declaration - type: (type_identifier) @type-binding.type + type: [(type_identifier) (qualified_identifier)] @type-binding.type declarator: (pointer_declarator declarator: (function_declarator declarator: (field_identifier) @type-binding.name))) @type-binding.return @@ -599,18 +649,16 @@ const CPP_SCOPE_QUERY = ` ;; a single node type in the field position, not an alternation — the ;; tree-sitter 0.21 hazard this repo has been bitten by before. ;; -;; The QUALIFIER IS THEN DROPPED, by cppQualifiedTail in interpret.ts, not -;; here — and dropping it was measured rather than assumed. Recording -;; ns::Repo resolves to NOTHING: findClassBindingInScope's dotted-tail -;; fallback splits on "." and C++ writes "::", and resolveClassBindingForName's -;; generic branch then looks up the base ns::Repo, which is not a key either -;; because C++ emits no @declaration.qualified_name and indexes ns::Repo under -;; Repo. Reducing to the tail lands on exactly the path the BARE spelling -;; already takes — one class-like match or decline — so a qualified member field -;; behaves like the bare one instead of like nothing. A tail that names no -;; workspace class (std::string with no "class string" in the repo) binds -;; nothing and emits nothing, which is why this is a miss-closing change rather -;; than an edge-fabricating one. +;; For a GENERIC member the QUALIFIER IS THEN DROPPED, by cppQualifiedTail in +;; interpret.ts, not here. resolveClassBindingForName's template-argument match +;; (the one that prefers a Repo specialization over the primary Repo) +;; looks up the BASE name, and C++ indexes ns::Repo under Repo, so only the tail +;; reaches it. A NON-GENERIC member keeps its qualifier and resolves through the +;; qualifier-aware route (ScopeResolver.resolveQualifiedTypeSpellings): its tail +;; bound the NEAREST same-named class, which inside a namespace declaring its +;; own Config made a::Config cfg; type cfg as that one. A qualified spelling +;; that names no workspace class (std::string with no namespace std in the +;; repo) binds nothing and emits nothing. ;; ;; Like the six above, each requires the declarator to reach the field_identifier ;; DIRECTLY, so a method whose return type is qualified (ns::Thing method();) diff --git a/gitnexus/src/core/ingestion/languages/cpp/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/cpp/scope-resolver.ts index ec6921112..5af298566 100644 --- a/gitnexus/src/core/ingestion/languages/cpp/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/cpp/scope-resolver.ts @@ -1,4 +1,4 @@ -import type { ParsedFile, SymbolDefinition } from 'gitnexus-shared'; +import type { ParsedFile, SymbolDefinition, TypeRef } from 'gitnexus-shared'; import { findClassBindingInScope, findEnclosingClassDef, @@ -44,6 +44,7 @@ import { clearCppInlineNamespaces, populateCppInlineNamespaceScopes, resolveCppQualifiedNamespaceMember, + splitCppQualifiedCallee, } from './inline-namespaces.js'; import { populateCppRangeBindings } from './range-bindings.js'; import { cppConstraintCompatibility } from './constraint-filter.js'; @@ -62,6 +63,7 @@ import { clearCppUsingDeclarations, populateCppIncludedUsingClaims, populateCppUsingBindings, + cppQualifiedTypeLookupSpelling, } from './using-bindings.js'; /** A pointee worth binding: a bare identifier, not `T**`, `T[]`, `A::B` or a @@ -231,6 +233,26 @@ export const cppScopeResolver: ScopeResolver = { }, populateNamespaceSiblings: (parsedFiles, indexes) => { + // Name-keyed return facts cannot choose between free overloads whose + // return types differ. Keep their declaration identities, drop the guess. + for (const parsed of parsedFiles) { + for (const scope of parsed.scopes) { + if (scope.kind !== 'Module' && scope.kind !== 'Namespace') continue; + const returns = new Map>(); + for (const definition of scope.ownedDefs) { + if (definition.type !== 'Function' || definition.qualifiedName === undefined) continue; + const name = definition.qualifiedName.slice( + definition.qualifiedName.lastIndexOf('.') + 1, + ); + const types = returns.get(name) ?? new Set(); + types.add(definition.returnType); + returns.set(name, types); + } + for (const [name, types] of returns) + if (types.size > 1 && scope.typeBindings.get(name)?.source === 'return-annotation') + (scope.typeBindings as Map).delete(name); + } + } populateCppUsingBindings(parsedFiles, indexes); populateCppIncludedOverloads(indexes); }, @@ -321,12 +343,18 @@ export const cppScopeResolver: ScopeResolver = { conversionOnlyArgTypePrefixes: CPP_CONVERSION_ONLY_ARG_TYPE_PREFIXES, // Range-for element type inference: for (auto& user : users) → bind user to User populateRangeBindings: populateCppRangeBindings, - // C++ method return-type bindings need to be visible from module scope - // for cross-file propagation and compound-receiver chain resolution. - // cppBindingScopeFor hoists @type-binding.return to Module scope. - hoistTypeBindingsToModule: true, + // Return bindings stay with their declaring class or namespace. An absent + // method fact must not borrow a same-name free function's return type. // Enable receiver-bound explicit-`this` fallback only for C++. resolveThisViaEnclosingClass: true, + // `rp::C x`, `void f(a::Config c)`: the capture keeps the qualifier, and + // neither `#include` (no namespace handle) nor the index (`rp::C` keyed as + // `C`) lets the shared receiver lookups read it. Resolve it the way a + // qualified base class already resolves — qualifier matched against the + // candidate's namespace, refuse on a tie — rather than by its tail, which + // binds the nearer same-named class the qualifier was written to exclude. + resolveQualifiedTypeSpellings: true, + qualifiedTypeLookupSpelling: cppQualifiedTypeLookupSpelling, resolveReceiverMember: resolveCppReceiverMember, // The `isFileLocalDef` hook on the global free-call fallback names // file-local linkage historically, but semantically gates "logically @@ -396,9 +424,38 @@ export const cppScopeResolver: ScopeResolver = { resolveQualifiedReceiverMember: ( receiverName, memberName, - _callerScope, + callerScope, scopes, parsedFiles, callsite, - ) => resolveCppQualifiedNamespaceMember(receiverName, memberName, parsedFiles, scopes, callsite), + ) => + resolveCppQualifiedNamespaceMember( + receiverName, + memberName, + parsedFiles, + scopes, + callsite, + callerScope, + ), + // `auto x = rp::make(); x.m()` / `rp::make().m()`: map the callee text back + // onto the receiver/member pair the call capture used, so the hook above + // answers the factory's definition the way it answered the call. + splitQualifiedCallee: (callee, callerScope, scopes, position) => { + // The namespace-member resolver does not bind aliases. Decline instead + // of treating an alias as an unrelated namespace with the same name. + if ( + callerScope !== undefined && + cppQualifiedTypeLookupSpelling( + { + rawName: callee, + declaredAtScope: callerScope, + source: 'assignment-inferred', + lookupPosition: position, + }, + scopes, + ) !== undefined + ) + return undefined; + return splitCppQualifiedCallee(callee, callerScope, scopes, position); + }, }; diff --git a/gitnexus/src/core/ingestion/languages/cpp/simple-hooks.ts b/gitnexus/src/core/ingestion/languages/cpp/simple-hooks.ts index 63500abd5..3dfb3e62d 100644 --- a/gitnexus/src/core/ingestion/languages/cpp/simple-hooks.ts +++ b/gitnexus/src/core/ingestion/languages/cpp/simple-hooks.ts @@ -21,17 +21,15 @@ export function cppBindingScopeFor( innermost: Scope, tree: ScopeTree, ): ScopeId | null { - // Hoist return-type bindings to Module scope so: - // 1. propagateImportedReturnTypes can mirror them across files - // 2. compound-receiver can find method return types via hoistTypeBindingsToModule + // Keep each callable's return fact with its declaring owner. In particular, + // a free prototype must not overwrite a same-name method's return type. if (decl['@type-binding.return'] !== undefined) { - let cur: Scope | undefined = innermost; - while (cur !== undefined && cur.kind !== 'Module') { - const parentId: ScopeId | null = cur.parent ?? null; - if (parentId === null) break; - cur = tree.getScope(parentId); + let current: Scope | undefined = innermost; + while (current !== undefined) { + if (current.kind === 'Class' || current.kind === 'Namespace' || current.kind === 'Module') + return current.id; + current = current.parent === null ? undefined : tree.getScope(current.parent); } - if (cur !== undefined && cur.kind === 'Module') return cur.id; } return null; // default auto-hoist for other bindings } diff --git a/gitnexus/src/core/ingestion/languages/cpp/using-bindings.ts b/gitnexus/src/core/ingestion/languages/cpp/using-bindings.ts index 5874c116f..c1414ed1d 100644 --- a/gitnexus/src/core/ingestion/languages/cpp/using-bindings.ts +++ b/gitnexus/src/core/ingestion/languages/cpp/using-bindings.ts @@ -8,14 +8,18 @@ import type { Range, Scope, ScopeId, + TypeRef, } from 'gitnexus-shared'; import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js'; +import { findClassBindingInScope, isClassLike } from '../../scope-resolution/scope/walkers.js'; import { isCppInlineNamespaceScope } from './inline-namespaces.js'; +import { isCppNamespaceQualifierShadowed } from './qualified-name-claims.js'; /** Namespace lookup facts, deliberately distinct from preprocessor file imports. */ export interface CppUsingDeclaration { readonly namespace: string; readonly name?: string; + readonly alias?: string; readonly range: Range; } @@ -35,7 +39,12 @@ export function recordCppUsingDeclaration(filePath: string, capture: CaptureMatc if (namespace === undefined || range === undefined) return; const name = capture['@import.name']?.text; const declarations = declarationsByFile.get(filePath) ?? []; - declarations.push({ namespace, range, ...(name === undefined ? {} : { name }) }); + const alias = capture['@import.namespace-alias'] === undefined ? undefined : name; + declarations.push({ + namespace, + range, + ...(alias === undefined ? (name === undefined ? {} : { name }) : { alias }), + }); declarationsByFile.set(filePath, declarations); } @@ -92,7 +101,7 @@ export function attachCppUsingClaims(captures: CaptureMatch[], filePath: string) byOwner.set(owner.match, claims); }; for (const declaration of collectCppUsingDeclarations(filePath)) { - if (declaration.name === undefined) continue; + if (declaration.alias !== undefined || declaration.name === undefined) continue; add({ name: declaration.name, range: declaration.range, @@ -276,6 +285,7 @@ export function populateCppIncludedUsingClaims( for (const parsed of parsedFiles) { const declarations = usingDeclarations(parsed).filter( (declaration) => + declaration.alias === undefined && declaration.name !== undefined && owningScope(parsed, declaration.range)?.id === parsed.moduleScope, ); @@ -371,16 +381,166 @@ export function populateCppIncludedUsingClaims( } } +interface NamespaceAliasTarget { + readonly target: string; + readonly availableFrom?: Pick; +} +const namespaceAliases = new WeakMap< + ScopeResolutionIndexes, + Map> +>(); +const classScopeIds = new WeakMap>(); + +function visibleNamespaceAlias( + name: string, + inScope: ScopeId, + indexes: ScopeResolutionIndexes, + position?: Pick, +): string | undefined { + let id: ScopeId | null = inScope; + const seen = new Set(); + while (id !== null && !seen.has(id)) { + seen.add(id); + const alias = namespaceAliases.get(indexes)?.get(id)?.get(name); + if ( + alias !== undefined && + (position === undefined || + alias.availableFrom === undefined || + compare(alias.availableFrom, position) <= 0) + ) + return alias.target; + const scope = indexes.scopeTree.getScope(id); + if ( + scope?.bindings + .get(name) + ?.some( + (binding) => + binding.def.type === 'Namespace' && + (position === undefined || + binding.declarationRange === undefined || + compare(binding.declarationRange, position) <= 0), + ) + ) + break; + id = scope?.parent ?? null; + } + return undefined; +} + +/** Expand only aliases visible from the binding's declaration scope. */ +export function cppQualifiedTypeLookupSpelling( + ref: TypeRef, + indexes?: ScopeResolutionIndexes, +): string | null | undefined { + if (indexes === undefined) return undefined; + const separator = ref.rawName.indexOf('::'); + if (separator <= 0) return undefined; + const head = ref.rawName.slice(0, separator).trim(); + if ( + isCppNamespaceQualifierShadowed( + head, + ref.declaredAtScope, + indexes, + ref.lookupPosition ?? ref.bindingRange, + ) + ) { + // A concrete class can still own the written nested type. A type alias or + // dependent qualifier with no class identity cannot borrow a namespace. + return findClassBindingInScope(ref.declaredAtScope, head, indexes, undefined, { + uniqueQualifiedNameFallback: false, + position: ref.lookupPosition ?? ref.bindingRange, + purpose: 'type', + }) === undefined + ? null + : undefined; + } + const alias = visibleNamespaceAlias( + head, + ref.declaredAtScope, + indexes, + ref.lookupPosition ?? ref.bindingRange, + ); + const expanded = + alias === undefined ? undefined : `${alias}::${ref.rawName.slice(separator + 2)}`; + const spelling = expanded ?? ref.rawName; + const last = spelling.lastIndexOf('::'); + const qualifier = spelling.slice(0, last).replace(/::/g, '.'); + const tail = spelling.slice(last + 2); + const qualifiers = new Set([qualifier]); + if (alias === undefined && !spelling.startsWith('::')) { + const enclosing: string[] = []; + let scope = indexes.scopeTree.getScope(ref.declaredAtScope); + const seen = new Set(); + while (scope !== undefined && !seen.has(scope.id)) { + seen.add(scope.id); + if (scope.kind === 'Namespace') { + const name = scope.ownedDefs.find((def) => def.type === 'Namespace')?.qualifiedName; + if (name !== undefined) enclosing.unshift(name.slice(name.lastIndexOf('.') + 1)); + } + scope = scope.parent === null ? undefined : indexes.scopeTree.getScope(scope.parent); + } + for (let length = enclosing.length; length > 0; length--) + qualifiers.add([...enclosing.slice(0, length), qualifier].join('.')); + } + const matches = new Map(); + for (const candidateId of indexes.qualifiedNames.get(tail)) { + const classScopeId = classScopeIds.get(indexes)?.get(candidateId); + if (classScopeId === undefined) continue; + let parent = indexes.scopeTree.getScope(classScopeId)?.parent ?? null; + const visited = new Set(); + const full: string[] = []; + const transparent: string[] = []; + let hasInline = false; + while (parent !== null && !visited.has(parent)) { + visited.add(parent); + const scope = indexes.scopeTree.getScope(parent); + if (scope === undefined) break; + if (scope.kind === 'Namespace') { + const name = scope.ownedDefs.find((def) => def.type === 'Namespace')?.qualifiedName; + if (name !== undefined) { + const segment = name.slice(name.lastIndexOf('.') + 1); + full.unshift(segment); + if (isCppInlineNamespaceScope(scope.id)) hasInline = true; + else transparent.unshift(segment); + } + } + parent = scope.parent; + } + if (hasInline && qualifiers.has(transparent.join('.'))) + matches.set(candidateId, [...full, tail].join('::')); + } + return matches.size === 1 ? matches.values().next().value : expanded; +} + /** Materialize namespace identities once, then augment only the using's owner. */ export function populateCppUsingBindings( parsedFiles: readonly ParsedFile[], indexes: ScopeResolutionIndexes, ): void { + const classes = new Map(); + for (const parsed of parsedFiles) { + for (const scope of parsed.scopes) { + if (scope.kind !== 'Class') continue; + const definition = scope.ownedDefs.find((def) => isClassLike(def.type)); + if (definition !== undefined) classes.set(definition.nodeId, scope.id); + } + } + classScopeIds.set(indexes, classes); if (!parsedFiles.some((parsed) => usingDeclarations(parsed).length > 0)) return; const namespaceLookup = createNamespaceLookup(parsedFiles, (id) => indexes.scopeTree.getScope(id), ); const { namespacePath } = namespaceLookup; + const aliases = new Map>(); + namespaceAliases.set(indexes, aliases); + const installAlias = (scope: ScopeId, name: string, value: NamespaceAliasTarget): void => { + let bucket = aliases.get(scope); + if (bucket === undefined) { + bucket = new Map(); + aliases.set(scope, bucket); + } + bucket.set(name, value); + }; const byFile = new Map(parsedFiles.map((parsed) => [parsed.filePath, parsed])); const visibleFiles = createIncludeClosure((filePath) => { const parsed = byFile.get(filePath); @@ -389,6 +549,38 @@ export function populateCppUsingBindings( edge.targetFile !== null && edge.linkStatus !== 'unresolved' ? [edge.targetFile] : [], ); }); + const consumersByHeader = new Map< + string, + Map | undefined> + >(); + const aliasFiles = new Set( + parsedFiles + .filter((parsed) => + usingDeclarations(parsed).some((declaration) => declaration.alias !== undefined), + ) + .map((parsed) => parsed.filePath), + ); + if (aliasFiles.size > 0) { + for (const consumer of parsedFiles) { + for (const edge of indexes.imports.get(consumer.moduleScope) ?? []) { + if (edge.targetFile === null || edge.linkStatus === 'unresolved') continue; + for (const header of visibleFiles(edge.targetFile)) { + if (header === consumer.filePath || !aliasFiles.has(header)) continue; + const consumers = consumersByHeader.get(header) ?? new Map(); + const previous = consumers.get(consumer.moduleScope); + const availableFrom = edge.atRange === undefined ? undefined : end(edge.atRange); + if ( + !consumers.has(consumer.moduleScope) || + availableFrom === undefined || + (previous !== undefined && compare(availableFrom, previous) < 0) + ) { + consumers.set(consumer.moduleScope, availableFrom); + } + consumersByHeader.set(header, consumers); + } + } + } + } const augmentations = indexes.bindingAugmentations as Map>; const append = ( scope: Scope, @@ -412,22 +604,72 @@ export function populateCppUsingBindings( } bindings.set(name, bucket); }; + const resolveDeclaration = (parsed: ParsedFile, declaration: CppUsingDeclaration) => { + const owner = owningScope(parsed, declaration.range); + if (owner === undefined) return undefined; + const parts = declaration.namespace.split('::').map((part) => part.trim()); + const head = parts[0]; + const alias = + head === undefined || head.length === 0 + ? undefined + : visibleNamespaceAlias(head, owner.id, indexes, declaration.range); + const resolved = resolveUsingMembers( + alias === undefined + ? declaration + : { ...declaration, namespace: `::${[alias, ...parts.slice(1)].join('::')}` }, + owner, + parsed.filePath, + visibleFiles(parsed.filePath), + namespaceLookup, + ); + return resolved === undefined ? undefined : { owner, ...resolved }; + }; + // Includes are leaves-first. Retry only unresolved aliases inside cycles; + // successful aliases have canonical targets and never need expanding again. + const ordered = new Map(); + for (const scc of indexes.sccs) + for (const file of scc.files) { + const parsed = byFile.get(file); + if (parsed !== undefined) ordered.set(file, parsed); + } + for (const parsed of parsedFiles) + if (!ordered.has(parsed.filePath)) ordered.set(parsed.filePath, parsed); + let pending = [...ordered.values()].flatMap((parsed) => + usingDeclarations(parsed) + .filter((declaration) => declaration.alias !== undefined) + .map((declaration) => ({ parsed, declaration })), + ); + while (pending.length > 0) { + const retry: typeof pending = []; + for (const item of pending) { + const resolved = resolveDeclaration(item.parsed, item.declaration); + const name = item.declaration.alias; + if (resolved === undefined || name === undefined) { + retry.push(item); + continue; + } + installAlias(resolved.owner.id, name, { + target: resolved.target, + availableFrom: item.declaration.range, + }); + if (resolved.owner.id === item.parsed.moduleScope) + for (const [consumer, availableFrom] of consumersByHeader.get(item.parsed.filePath) ?? []) + installAlias(consumer, name, { + target: resolved.target, + availableFrom, + }); + } + if (retry.length === pending.length) break; + pending = retry; + } for (const parsed of parsedFiles) { const facts = usingDeclarations(parsed); if (facts.length === 0) continue; - const visible = visibleFiles(parsed.filePath); for (const declaration of facts) { - const owner = owningScope(parsed, declaration.range); - if (owner === undefined) continue; - const resolved = resolveUsingMembers( - declaration, - owner, - parsed.filePath, - visible, - namespaceLookup, - ); + if (declaration.alias !== undefined) continue; + const resolved = resolveDeclaration(parsed, declaration); if (resolved === undefined) continue; - const { target, members } = resolved; + const { owner, target, members } = resolved; for (const [name, refs] of members) { if (declaration.name !== undefined) { append(owner, name, refs, declaration); diff --git a/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts b/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts index 9228f84ea..7006774ae 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts @@ -1146,6 +1146,30 @@ export interface ScopeResolver { */ readonly resolveNamespaceChains?: boolean; + /** + * When true, a receiver's declared type written as a QUALIFIED name + * (`rp::C x`, `void f(a::Config c)`, a factory returning `rp::C`) that binds + * nothing through the scope chain is resolved through the same precise route + * a qualified inheritance base and a qualified type reference take + * (`resolveQualifiedInheritanceBase`): the qualifier must match the + * candidate's namespace, and a tie resolves nothing. + * + * The shared receiver lookups read a qualifier only as a `.` namespace-import + * handle, so a language whose qualifier is a namespace PATH with no import + * handle behind it typed none of these receivers. File includes + * binds no handle and its index keys `rp::C` under `C`. The alternative — + * reducing the spelling to its tail at capture — binds the nearest + * same-named class, which is the class the qualifier was written to exclude. + * + * Per-language rather than universal because PHP also keeps qualified + * (`\`-separated) declared types and resolves them through its own import + * channel; it was not measured under this route. Off ⇒ every receiver + * lookup is unchanged. It runs only after every existing lookup missed, and + * after the template-argument match, so it can add an edge but never + * retarget one. + */ + readonly resolveQualifiedTypeSpellings?: boolean; + /** * How this language spells a construction expression, so the compound * receiver resolver can type an INLINE constructor receiver — the @@ -1471,6 +1495,36 @@ export interface ScopeResolver { callsite?: Callsite, ) => SymbolDefinition | 'ambiguous' | undefined; + /** + * Split a qualified CALLEE as written (`rp::make`, `a::b::make`) into the + * receiver/member pair this language's qualified call captures produce for + * the same spelling, or `undefined` when the callee is not qualified. + * + * A factory call types a receiver in two shapes — bound + * (`auto x = rp::make(); x.m()`, whose binding records the callee text) and + * inline (`rp::make().m()`) — and both reach the compound receiver resolver + * as callee TEXT, while the call itself was resolved from its captured + * receiver and member. This hook maps the text back onto that pair so the + * resolver can ask `resolveQualifiedReceiverMember` for the callee's + * definition and read the return type that definition declares (by + * definition identity, never by name). Splitting is the language's job: the + * separator and which segment counts as the receiver are grammar facts; a + * provider may capture `a::b::make` as `b` / `make`. The caller scope and + * indexes let it reject dependent or shadowed qualifiers before splitting. + * The optional position keeps later local declarations from hiding the + * factory expression; bound receivers pass their producer lookup position. + * + * Consulted only for a free-call head whose name binds no callable, and only + * when `resolveQualifiedReceiverMember` is implemented too. It is asked + * without call-site arity, so an `'ambiguous'` answer types nothing. + */ + readonly splitQualifiedCallee?: ( + callee: string, + callerScope?: ScopeId, + scopes?: ScopeResolutionIndexes, + position?: TypeRef['lookupPosition'], + ) => { readonly receiver: string; readonly member: string } | undefined; + /** * Every receiver spelling under which a namespace import's target is * reachable, and the file each spelling names (#2826). Returning @@ -1770,10 +1824,15 @@ export interface ScopeResolver { * `rawName` has many readers, and the compound resolver reads a dotted one as * a `value.member` alias path. Only receiver typing needs the distinction. * - * Return a lookup-ready class name (no decoration, no type arguments). Leave - * undefined for languages whose `rawName` keeps its qualifier. + * Return a module-visible class spelling (no decoration or type arguments). + * Providers may also expand a namespace alias using the binding's declaration + * scope and the supplied indexes. Leave undefined when no expansion is needed. + * Return null to refuse the written qualified lookup and its fallback. */ - readonly qualifiedTypeLookupSpelling?: (typeRef: TypeRef) => string | undefined; + readonly qualifiedTypeLookupSpelling?: ( + typeRef: TypeRef, + scopes?: ScopeResolutionIndexes, + ) => string | null | undefined; /** * Whether the compound-receiver resolver should strip C-style cast diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/compound-receiver.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/compound-receiver.ts index e945b73d4..ac54ba549 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/passes/compound-receiver.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/compound-receiver.ts @@ -31,13 +31,14 @@ import { } from '../../utils/template-arguments.js'; import type { DecodedReceiverChain } from '../../utils/receiver-chain-codec.js'; import { decodeReceiverChain } from '../../utils/receiver-chain-codec.js'; -import { namespaceCallReturnType } from './imported-return-types.js'; +import { namespaceCallReturnType, producerTypeRef } from './imported-return-types.js'; import type { ClassBindingLookup, DecorationStripper } from '../scope/walkers.js'; import { findClassBindingInScope, lookupNameClaim, hasExplicitNameClaim, resolveClassBindingForName, + resolveQualifiedClassSpelling, findEnclosingClassDef, findExportedDef, findExportedDefByName, @@ -186,12 +187,36 @@ interface ResolveCompoundReceiverOptions { * by the shared lookup's other callers — see the contract's own note on why * this is opt-in rather than global. */ readonly stripTypePreservingDecoration?: DecorationStripper; + /** `ScopeResolver.resolveQualifiedTypeSpellings`: a qualified declared or + * return type (`rp::C`) that binds nothing is resolved through the precise + * qualified route. Absent ⇒ both lookups are exactly as before. */ + readonly qualifiedTypeSpellings?: boolean; + /** The definition a QUALIFIED callee (`rp::make`) names, through + * `ScopeResolver.splitQualifiedCallee` + `resolveQualifiedReceiverMember` + * — the same answer the qualified call itself got. Consulted for a + * free-call head whose name binds no callable; absent ⇒ such a head types + * nothing, exactly as before. */ + readonly resolveQualifiedCallee?: ( + callee: string, + inScope: ScopeId, + position?: TypeRef['lookupPosition'], + ) => SymbolDefinition | undefined; /** `ScopeResolver.qualifiedTypeLookupSpelling`, tried before `rawName` by * every {@link classOfDeclaredType} caller (member, head and fold lookups). * Absent ⇒ `rawName` alone, exactly as before. */ readonly qualifiedTypeLookupSpelling?: ScopeResolver['qualifiedTypeLookupSpelling']; } +/** The options the two declared-type lookups below read. */ +type TypeLookupOptions = Pick< + ResolveCompoundReceiverOptions, + | 'recordReceiverType' + | 'onNameFallback' + | 'qualifiedTypeSpellings' + | 'qualifiedTypeLookupSpelling' + | 'namespaceMemberVisible' +>; + /** Is this hop the language's construction selector applied to the class * itself — `Factory.new` — rather than an ordinary member named `new` on a * value? Both call sites ask the identical question, so it is asked in one @@ -464,6 +489,7 @@ function classOfDeclaredType( position: typeRef.lookupPosition, purpose: typeRef.lookupPurpose ?? 'type', onNameFallback: options.onNameFallback, + qualifiedTypeSpellings: options.qualifiedTypeSpellings, }; // The report keeps the spelling it always had, whichever lookup answers: it // is read only for type ARGUMENTS (#2912), and the qualified spelling has none. @@ -519,11 +545,12 @@ export function classOfQualifiedTypeSpelling( ): SymbolDefinition | null | undefined { // Runs for every declared-type lookup in every language, so nothing is // allocated until the provider has actually returned a qualified spelling. - const qualified = qualifiedTypeLookupSpelling?.(typeRef); + const qualified = qualifiedTypeLookupSpelling?.(typeRef, scopes); + if (qualified === null) return null; if (qualified === undefined || qualified === typeRef.rawName) return undefined; const importScope = moduleScopeIdOf(typeRef.declaredAtScope, scopes) ?? typeRef.declaredAtScope; return ( - findClassBindingInScope(importScope, qualified, scopes, undefined, { + resolveClassBindingForName(importScope, qualified, scopes, undefined, { ...lookup, namespaceMemberVisible, uniqueQualifiedNameFallback: false, @@ -565,22 +592,72 @@ function noteReceiverType( * arguments intact. The spelling is built only once the lookup has actually * found a class, because it is discarded otherwise — and every fold hop * through a call reaches this, generic or not. + * + * A qualified return type (`rp::C make();`) binds nothing through the bare + * lookup for the same reason a qualified declared type does not, so it takes + * the same opt-in qualified route as a last resort (see + * `ScopeResolver.resolveQualifiedTypeSpellings`). */ function classOfReturnType( retType: TypeRef, scopes: ScopeResolutionIndexes, - record: ReceiverTypeRecorder | undefined, - onNameFallback?: () => void, + options: TypeLookupOptions, ): SymbolDefinition | undefined { - const def = findClassBindingInScope(retType.declaredAtScope, retType.rawName, scopes, undefined, { - position: retType.lookupPosition, - purpose: retType.lookupPurpose ?? 'type', - onNameFallback, - }); + const qualified = classOfQualifiedTypeSpelling( + retType, + scopes, + options.qualifiedTypeLookupSpelling, + { + position: retType.lookupPosition, + purpose: retType.lookupPurpose ?? 'type', + onNameFallback: options.onNameFallback, + qualifiedTypeSpellings: options.qualifiedTypeSpellings, + }, + options.namespaceMemberVisible, + ); + const def = + qualified === undefined + ? (findClassBindingInScope(retType.declaredAtScope, retType.rawName, scopes, undefined, { + position: retType.lookupPosition, + purpose: retType.lookupPurpose ?? 'type', + onNameFallback: options.onNameFallback, + }) ?? + (options.qualifiedTypeSpellings === true + ? resolveQualifiedClassSpelling(retType.declaredAtScope, retType.rawName, scopes) + : undefined)) + : (qualified ?? undefined); + const record = options.recordReceiverType; if (def === undefined || record === undefined) return def; return noteReceiverType(record, erasedTypeApplication(retType) ?? retType.rawName, def); } +/** + * The return type a free-call callee declares for ITSELF — read by definition + * identity (`producerTypeRef`), the way the chain follow types the bound form + * `auto x = make(); x.m()` — for when the name read finds no return binding + * under the callee's name. That happens whenever the callee arrived by a + * channel nothing mirrors a return binding through (a namespace directive), so + * `make().m()` dropped while the bound form beside it resolved. + * + * 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 typed a receiver from an unrelated module's + * same-named factory — a confident wrong edge where the name read declined. + */ +function boundCalleeReturnType( + fnDef: SymbolDefinition, + name: string, + inScope: ScopeId, + scopes: ScopeResolutionIndexes, + position: TypeRef['lookupPosition'], +): TypeRef | undefined { + const claim = lookupNameClaim(inScope, name, scopes, { position, purpose: 'value' }); + const identities = new Set(claim.bindings.map((binding) => binding.def.nodeId)); + return identities.size === 1 && identities.has(fnDef.nodeId) + ? producerTypeRef(fnDef.nodeId, scopes) + : undefined; +} + function typeOfMemberOnClass( owner: SymbolDefinition, memberName: string, @@ -1095,18 +1172,27 @@ export function resolveCompoundReceiverClass( purpose: 'value', }); if (fnDef !== undefined) { - const retType = findReceiverTypeBinding(inScope, fnExpr, scopes, { - position: options.lookupPosition, - }); + const retType = + findReceiverTypeBinding(inScope, fnExpr, scopes, { + position: options.lookupPosition, + }) ?? boundCalleeReturnType(fnDef, fnExpr, inScope, scopes, options.lookupPosition); const viaReturn = - retType === undefined - ? undefined - : classOfReturnType( - retType, - scopes, - options.recordReceiverType, - options.onNameFallback, - ); + retType === undefined ? undefined : classOfReturnType(retType, scopes, options); + if (viaReturn !== undefined) return viaReturn; + } else { + // A qualified callee (`rp::make`, from `rp::make().m()` or the binding + // `auto x = rp::make()`) binds no name; its definition comes from the + // language's qualified-member lookup — the qualifier is the evidence — + // and its return type from that definition's own declaration. + const qualifiedDef = options.resolveQualifiedCallee?.( + fnExpr, + inScope, + options.lookupPosition, + ); + const retType = + qualifiedDef === undefined ? undefined : producerTypeRef(qualifiedDef.nodeId, scopes); + const viaReturn = + retType === undefined ? undefined : classOfReturnType(retType, scopes, options); if (viaReturn !== undefined) return viaReturn; } // Inline construction — `Service(db).m()` / `new Service(db).m()`. @@ -1140,14 +1226,7 @@ export function resolveCompoundReceiverClass( // named import of that function (#3498). const returned = namespaceCallReturnType(fnExpr, inScope, scopes, options.lookupPosition); const viaReturn = - returned === undefined - ? undefined - : classOfReturnType( - returned, - scopes, - options.recordReceiverType, - options.onNameFallback, - ); + returned === undefined ? undefined : classOfReturnType(returned, scopes, options); if (viaReturn !== undefined) return viaReturn; return resolveConstructionExpressionClass(fnExpr, inScope, scopes, index, options); } @@ -1249,7 +1328,7 @@ export function resolveCompoundReceiverClass( } if (retType === undefined) return undefined; - return classOfReturnType(retType, scopes, options.recordReceiverType, options.onNameFallback); + return classOfReturnType(retType, scopes, options); } // Mixed dotted + call chain: `obj.field.method().field.method()…`. diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/imported-return-types.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/imported-return-types.ts index dba94193a..03ea9b441 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/passes/imported-return-types.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/imported-return-types.ts @@ -61,17 +61,30 @@ import { /** Definition identity → its own type fact; built once, facts read lazily after propagation. */ interface TypeFactOwner { - readonly scope: Scope; + readonly scope: ScopeId; readonly name: string; } const typeFactOwners = new WeakMap>(); -function producerTypeRef(defId: string, scopes: ScopeResolutionIndexes): TypeRef | undefined { +/** The type fact a definition declares for itself (a callable's return type), + * joined by definition identity rather than by name. */ +export function producerTypeRef( + defId: string, + scopes: ScopeResolutionIndexes, +): TypeRef | undefined { let owners = typeFactOwners.get(scopes); if (owners === undefined) { owners = new Map(); const returnsByDeclaration = new Map(); const definitions = new Map(); - for (const scope of scopes.scopeTree.byId?.values() ?? []) { + const pending = [...scopes.moduleScopes.byFilePath.values()]; + const visited = new Set(); + while (pending.length > 0) { + const id = pending.pop(); + if (id === undefined || visited.has(id)) continue; + visited.add(id); + const scope = scopes.scopeTree.getScope(id); + if (scope === undefined) continue; + for (const child of scopes.scopeTree.getChildren(id)) pending.push(child); for (const def of scope.ownedDefs ?? []) definitions.set(def.nodeId, def); for (const [name, ref] of scope.typeBindings) { // Some providers hoist method return annotations to the module. Join @@ -83,7 +96,10 @@ function producerTypeRef(defId: string, scopes: ScopeResolutionIndexes): TypeRef ) continue; const key = `${scope.filePath}\0${ref.bindingRange.startLine}:${ref.bindingRange.startCol}`; - returnsByDeclaration.set(key, returnsByDeclaration.has(key) ? null : { scope, name }); + returnsByDeclaration.set( + key, + returnsByDeclaration.has(key) ? null : { scope: scope.id, name }, + ); } for (const [name, bindings] of scope.bindings) { for (const binding of bindings) { @@ -94,7 +110,7 @@ function producerTypeRef(defId: string, scopes: ScopeResolutionIndexes): TypeRef ref !== undefined && !(ref.source === 'return-annotation' && ref.bindingRange !== undefined) ) - owners.set(binding.def.nodeId, { scope, name }); + owners.set(binding.def.nodeId, { scope: scope.id, name }); } } } @@ -109,7 +125,9 @@ function producerTypeRef(defId: string, scopes: ScopeResolutionIndexes): TypeRef typeFactOwners.set(scopes, owners); } const owner = owners.get(defId); - return owner?.scope.typeBindings.get(owner.name); + return owner === undefined + ? undefined + : scopes.scopeTree.getScope(owner.scope)?.typeBindings.get(owner.name); } /** @@ -266,6 +284,12 @@ export function followChainPostFinalize( if (next === undefined || claim.bindings.some((binding) => binding.origin !== 'local')) { const candidates = new Map(); for (const binding of claim.bindings) { + // A member exposed through an import bucket must not replace a + // return fact anchored on the module's own free function. + if (binding.def.type === 'Method' && next?.source === 'return-annotation') { + const owner = scopes.scopeTree.getScope(next.declaredAtScope); + if (owner?.kind === 'Module' && isModuleOwnReturn(next, owner, current.rawName)) continue; + } const producer = producerTypeRef(binding.def.nodeId, scopes); if (producer !== undefined) candidates.set(binding.def.nodeId, producer); } diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts index dc288a8b9..aa3d37899 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts @@ -215,6 +215,8 @@ type ReceiverBoundProviderSubset = Pick< | 'isBareClassReceiverCandidate' | 'importsBindAtLexicalScope' | 'resolveNamespaceChains' + | 'resolveQualifiedTypeSpellings' + | 'splitQualifiedCallee' >; /** A bare, undecorated identifier and nothing else — see {@link isBareTypeName}. */ @@ -279,6 +281,10 @@ export function classifyReceiverOrigin( * external. Same hook the three receiver-chain lookups in * `compound-receiver.ts` already receive. */ readonly stripTypePreservingDecoration?: DecorationStripper; + /** `ScopeResolver.resolveQualifiedTypeSpellings`, so a base declared with a + * qualified type (`rp::C x`) is classified by the same lookup that typed + * (or failed to type) it. */ + readonly qualifiedTypeSpellings?: boolean; /** `LanguageProvider.isBuiltInName`, threaded through the pass options the * same way `emitFreeCallFallback` receives it. THE only source of positive * external evidence available here; languages that declare no built-in set @@ -302,8 +308,9 @@ export function classifyReceiverOrigin( // arguments, so an in-program generic base (`Box b; b.open()`) // resolves instead of being mislabelled and dropped from the hedge. if ( - resolveClassBindingForName(binding.declaredAtScope, binding.rawName, scopes, strip) !== - undefined + resolveClassBindingForName(binding.declaredAtScope, binding.rawName, scopes, strip, { + qualifiedTypeSpellings: options.qualifiedTypeSpellings, + }) !== undefined ) { return 'in-program'; } @@ -639,6 +646,31 @@ export function emitReceiverBoundCalls( return undefined; return uniqueClassAcross(files, tail); }; + // A qualified callee as the free-call head of a compound receiver + // (`rp::make().m()`, or `x` bound to `rp::make`): the definition the + // qualified CALL resolves to, so its declared return type can type the + // receiver. Asked without call-site arity, so an ambiguous answer is none. + const splitQualifiedCallee = provider.splitQualifiedCallee; + const resolveQualifiedReceiverMember = provider.resolveQualifiedReceiverMember; + const resolveQualifiedCallee = + splitQualifiedCallee === undefined || resolveQualifiedReceiverMember === undefined + ? undefined + : ( + callee: string, + inScope: ScopeId, + position?: TypeRef['lookupPosition'], + ): SymbolDefinition | undefined => { + const parts = splitQualifiedCallee(callee, inScope, scopes, position); + if (parts === undefined) return undefined; + const def = resolveQualifiedReceiverMember( + parts.receiver, + parts.member, + inScope, + scopes, + parsedFiles, + ); + return def === 'ambiguous' ? undefined : def; + }; const compoundOpts = { fieldFallback, elementTypeOf: provider.elementTypeOf, @@ -652,12 +684,15 @@ export function emitReceiverBoundCalls( stripTypePreservingDecoration: provider.stripTypePreservingDecoration, qualifiedTypeLookupSpelling: provider.qualifiedTypeLookupSpelling, resolveThisViaEnclosingClass: provider.resolveThisViaEnclosingClass, + qualifiedTypeSpellings: provider.resolveQualifiedTypeSpellings === true, + resolveQualifiedCallee, }; // Loop-invariant: both hooks come off the pass arguments, so the options bag // for `classifyReceiverOrigin` is built once here rather than per dropped site. const receiverOriginOpts = { stripTypePreservingDecoration: provider.stripTypePreservingDecoration, isBuiltInName: options.isBuiltInName, + qualifiedTypeSpellings: provider.resolveQualifiedTypeSpellings === true, }; // Maps class-like graph ids back to ALL scope definitions that resolved to @@ -2366,6 +2401,7 @@ export function emitReceiverBoundCalls( position: typeRef.lookupPosition, purpose: typeRef.lookupPurpose ?? 'type', onNameFallback, + qualifiedTypeSpellings: provider.resolveQualifiedTypeSpellings === true, }; const qualifiedOwner = classOfQualifiedTypeSpelling( typeRef, @@ -3094,7 +3130,11 @@ export function emitReceiverBoundCalls( fieldDeclaredType.name, scopes, provider.stripTypePreservingDecoration, - { onNameFallback, purpose: fieldDeclaredType.purpose }, + { + onNameFallback, + purpose: fieldDeclaredType.purpose, + qualifiedTypeSpellings: provider.resolveQualifiedTypeSpellings === true, + }, ); if (receiverClass !== undefined) { const chain = [ diff --git a/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts b/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts index 863b8c48d..42756d884 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts @@ -946,6 +946,13 @@ export type ClassBindingLookup = IndexedNameLookupOptions & { * pass it is unchanged. */ readonly namespaceMemberVisible?: (def: SymbolDefinition) => boolean; + /** + * `ScopeResolver.resolveQualifiedTypeSpellings`. When true, + * {@link resolveClassBindingForName} answers a QUALIFIED spelling that bound + * nothing (`rp::C`, `a::Config`) through {@link resolveQualifiedClassSpelling}. + * Read by nothing else; absent (the default) leaves every lookup unchanged. + */ + readonly qualifiedTypeSpellings?: boolean; }; export function findClassBindingInScope( @@ -1384,7 +1391,8 @@ export function resolveClassBindingForName( // // If a future case genuinely needs it, it must be gated on // `bindsTypeParameter` and land with a test that fails without it. - if (!rawClassName.includes('<')) return undefined; + if (!rawClassName.includes('<')) + return qualifiedSpellingFallback(scopeId, rawClassName, scopes, lookup); const baseName = stripTemplateArguments(rawClassName).replace(/\s+/g, ''); if (baseName.length === 0) return undefined; @@ -1425,8 +1433,61 @@ export function resolveClassBindingForName( // ── Base-name route ──────────────────────────────────────────────────────── // Nothing matched the arguments as written, so what is left to find is the // declaration written against its PARAMETERS — the one instantiation-agnostic - // declaration the erasure is entitled to reach. - return resolveErasedBaseName(scopeId, baseName, scopes, lexical); + // declaration the erasure is entitled to reach. A qualified base + // (`ns::Repo`) is tried only after both, so a specialization the + // argument match can reach is never bypassed by the argument-blind + // qualified route. + return ( + resolveErasedBaseName(scopeId, baseName, scopes, lexical) ?? + qualifiedSpellingFallback(scopeId, rawClassName, scopes, lookup) + ); +} + +/** {@link resolveQualifiedClassSpelling}, for the providers that opted in. */ +function qualifiedSpellingFallback( + scopeId: string, + rawClassName: string, + scopes: ScopeResolutionIndexes, + lookup: ClassBindingLookup | undefined, +): SymbolDefinition | undefined { + return lookup?.qualifiedTypeSpellings === true + ? resolveQualifiedClassSpelling(scopeId, rawClassName, scopes) + : undefined; +} + +/** + * The class a QUALIFIED declared-type spelling names — `rp::C`, `a::Config`, + * `rp::Box` — through {@link resolveQualifiedInheritanceBase}, the route a + * qualified inheritance base and a qualified type reference already take. + * + * The receiver-typing lookups above understand a qualifier only as a `.` + * namespace-import handle, and a language whose qualifier is a namespace path + * (an include binds no handle, and the index keys `rp::C` under `C`) never + * has one, so a qualified local, parameter or factory result typed nothing. + * Reducing the spelling to its tail is NOT the fix: the tail binds the nearest + * same-named class, which is exactly the class the qualifier was written to + * rule out (`a::Config` inside a `namespace b` that declares its own Config). + * The qualified route matches the qualifier against each candidate's + * namespace and refuses on a tie, so it binds the named class or nothing. + * + * Declines for an unqualified spelling and for a qualifier that is a type + * parameter in scope (`T::type` is a dependent name, not a namespace path). + */ +export function resolveQualifiedClassSpelling( + scopeId: string, + rawClassName: string, + scopes: ScopeResolutionIndexes, +): SymbolDefinition | undefined { + const normalized = stripTrailingTypeArguments(normalizeQualifiedName(rawClassName)); + const dot = normalized.indexOf('.'); + if (dot <= 0) return undefined; + if (bindsTypeParameter(scopeId, normalized.slice(0, dot), scopes)) return undefined; + return resolveQualifiedInheritanceBase( + scopeId, + rawClassName, + scopes, + findEnclosingClassDef(scopeId, scopes), + ); } /** diff --git a/gitnexus/test/fixtures/cpp-qualified-receiver-boundaries.cpp b/gitnexus/test/fixtures/cpp-qualified-receiver-boundaries.cpp new file mode 100644 index 000000000..b0b103289 --- /dev/null +++ b/gitnexus/test/fixtures/cpp-qualified-receiver-boundaries.cpp @@ -0,0 +1,29 @@ +namespace impl { struct AliasC { int m(); }; } +namespace alias = impl; +struct Holder { alias::AliasC c; int alias_field() { return c.m(); } }; +int alias_brace() { auto c = alias::AliasC{}; return c.m(); } +struct A { int m(); }; +struct B { int m(); }; +struct Factory { A make(); }; +B make(); +int prototype_collision() { Factory factory; return factory.make().m(); } +namespace one { A overloaded(); } +namespace two { B overloaded(int); } +using namespace one; +using namespace two; +int using_overload() { return overloaded(1).m(); } +namespace rp { A produce(); } +template struct Client { + int dependent_bound() { auto x = rp::produce(); return x.m(); } + int dependent_inline() { return rp::produce().m(); } +}; +namespace outer { namespace nested { A produce(); } } +template struct DeepClient { + int dependent_outer() { return outer::nested::produce().m(); } +}; +int qualified_positive() { return rp::produce().m(); } + +struct AliasShadow { + struct alias { struct AliasC { int m(); }; }; + int alias_shadowed() { auto value = alias::AliasC{}; return value.m(); } +}; diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-qualified-receiver-typing/a.h b/gitnexus/test/fixtures/lang-resolution/cpp-qualified-receiver-typing/a.h new file mode 100644 index 000000000..b34d04249 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-qualified-receiver-typing/a.h @@ -0,0 +1,10 @@ +#ifndef QRT_A_H +#define QRT_A_H + +namespace a { +struct Config { + int m() { return 6; } +}; +} // namespace a + +#endif diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-qualified-receiver-typing/callers.cpp b/gitnexus/test/fixtures/lang-resolution/cpp-qualified-receiver-typing/callers.cpp new file mode 100644 index 000000000..a0ab2f852 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-qualified-receiver-typing/callers.cpp @@ -0,0 +1,58 @@ +#include "mod.h" +#include "other.h" +#include "a.h" + +// Qualified type spellings: `rp::C` must reach rp::C, never the global C that +// is visible here through other.h. +int declared_init() { rp::C x = rp::make(); return x.m(); } +int param_ns(rp::C x) { return x.m(); } +int param_ns_ptr(rp::C* x) { return x->m(); } +int param_template(rp::Box b) { return b.m(); } +int bare_global() { C x; return x.m(); } + +// Qualified declarations with no initializer. +int declared_ns() { rp::C x; return x.m(); } +int declared_ns_ptr() { rp::C* x = nullptr; return x->m(); } + +// Qualified brace-init and member field. +int brace_ns() { auto x = rp::C{}; return x.m(); } + +namespace b { +struct Config { + int m() { return 7; } +}; +int param_qualified(a::Config x) { return x.m(); } +int param_local(Config x) { return x.m(); } +int brace_qualified() { auto x = a::Config{}; return x.m(); } +struct Holder { + a::Config cfg; + int field_qualified() { return cfg.m(); } +}; +} // namespace b + +// Factories declared only by header prototypes (definitions live in mod.cpp, +// which nothing includes). +int auto_plain() { auto x = makeD(); return x.m(); } +int inline_plain() { return makeD().m(); } +int auto_ptr() { auto p = makeDp(); return p->m(); } +int global_factory() { auto y = make(); return y.m(); } + +// A block-scope `E y();` declares a local function; it must not leak a +// file-level `y => E` binding to other functions. +int vexing() { E y(); return 0; } +int uses_vexing_name() { return y.m(); } + +// Qualified factory callee. +int auto_ns() { auto x = rp::make(); return x.m(); } +int inline_ns() { return rp::make().m(); } +int ctor_ns() { auto x = rp::C(); return x.m(); } + +// An unknown qualified factory types nothing, and must not let the inner `x` +// fall through to the outer `D x`. +int shadowed_auto() { + D x; + { + auto x = rp::unknown(); + return x.m(); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-qualified-receiver-typing/mod.cpp b/gitnexus/test/fixtures/lang-resolution/cpp-qualified-receiver-typing/mod.cpp new file mode 100644 index 000000000..761d7ba99 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-qualified-receiver-typing/mod.cpp @@ -0,0 +1,8 @@ +#include "mod.h" + +namespace rp { +C make() { return C(); } +} // namespace rp + +D makeD() { return D(); } +D* makeDp() { return nullptr; } diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-qualified-receiver-typing/mod.h b/gitnexus/test/fixtures/lang-resolution/cpp-qualified-receiver-typing/mod.h new file mode 100644 index 000000000..ef0ca7965 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-qualified-receiver-typing/mod.h @@ -0,0 +1,34 @@ +#ifndef QRT_MOD_H +#define QRT_MOD_H + +namespace rp { +struct C { + int m() { return 1; } +}; +template +struct Box { + int m() { return 4; } +}; +C make(); +// Shares its tail with the global Widget below, in the same header. +struct Widget { + int m() { return 6; } +}; +Widget makeWidget(); +} // namespace rp + +struct Widget { + int n() { return 7; } +}; + +struct D { + int m() { return 3; } +}; +struct E { + int m() { return 5; } +}; + +D makeD(); +D* makeDp(); + +#endif diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-qualified-receiver-typing/other.h b/gitnexus/test/fixtures/lang-resolution/cpp-qualified-receiver-typing/other.h new file mode 100644 index 000000000..354a6aa96 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-qualified-receiver-typing/other.h @@ -0,0 +1,9 @@ +#ifndef QRT_OTHER_H +#define QRT_OTHER_H + +struct C { + int m() { return 2; } +}; +C make(); + +#endif diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-qualified-receiver-typing/using.cpp b/gitnexus/test/fixtures/lang-resolution/cpp-qualified-receiver-typing/using.cpp new file mode 100644 index 000000000..ec4019c4d --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-qualified-receiver-typing/using.cpp @@ -0,0 +1,14 @@ +#include "mod.h" + +// `make` is reached through the using-directive; its return type is the +// namespace prototype's `C`, which must mean rp::C even though a global C +// exists elsewhere in the workspace. +int using_auto() { using namespace rp; auto x = make(); return x.m(); } +// The same call as an INLINE receiver: typed from the callee's definition, as +// the bound form above is, not from a return binding found by name. +int using_inline() { using namespace rp; return make().m(); } + +// The prototype's `Widget` is rp::Widget, not the global Widget declared in the +// same header. +int using_same_file() { using namespace rp; auto x = makeWidget(); return x.m(); } +int using_inline_same_file() { using namespace rp; return makeWidget().m(); } diff --git a/gitnexus/test/integration/cpp-header-alias-order.test.ts b/gitnexus/test/integration/cpp-header-alias-order.test.ts new file mode 100644 index 000000000..6c94c9fad --- /dev/null +++ b/gitnexus/test/integration/cpp-header-alias-order.test.ts @@ -0,0 +1,71 @@ +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++ header alias activation and factory ownership', () => { + let root: string; + let result: Awaited>; + beforeAll(async () => { + root = await fs.mkdtemp(path.join(os.tmpdir(), 'cpp-header-alias-order-')); + await fs.writeFile( + path.join(root, 'provider.h'), + 'namespace pkg { struct Cross { int m(); }; Cross make(); } namespace imported = pkg;', + ); + await fs.writeFile( + path.join(root, 'main.cpp'), + ` +struct Decoy { int m(); }; +void before_include() { imported::Cross x; x.m(); } +#include "provider.h" +void after_include() { imported::Cross x; x.m(); } +void bound_factory() { auto x = pkg::make(); x.m(); } +void inline_factory() { pkg::make().m(); } +void typed_value(int pkg) { pkg::Cross x; x.m(); } +void auto_value(auto pkg) { pkg::Cross x; x.m(); } +void alias_type() { using pkg = Decoy; pkg::Cross x; x.m(); } +void later_alias() { pkg::Cross x; using pkg = Decoy; x.m(); } +void later_alias_bound() { auto x = pkg::make(); using pkg = Decoy; x.m(); } +void later_alias_inline() { pkg::make().m(); using pkg = Decoy; } +void later_class_bound() { auto x = pkg::make(); struct pkg {}; x.m(); } +void later_class_inline() { pkg::make().m(); struct pkg {}; } +void prior_alias_bound() { using pkg = Decoy; auto x = pkg::make(); x.m(); } +void prior_alias_inline() { using pkg = Decoy; pkg::make().m(); } +template void dependent_type() { typename pkg::Cross x; x.m(); } +`, + ); + result = await runPipelineFromRepo(root, () => {}, {}); + }, 120000); + afterAll(async () => { + if (root) await fs.rm(root, { recursive: true, force: true }); + }); + it.each([ + ['before_include', []], + ['after_include', ['Method:provider.h:Cross.m#0']], + ['bound_factory', ['Method:provider.h:Cross.m#0']], + ['inline_factory', ['Method:provider.h:Cross.m#0']], + ['typed_value', ['Method:provider.h:Cross.m#0']], + ['auto_value', ['Method:provider.h:Cross.m#0']], + ['alias_type', []], + ['later_alias', ['Method:provider.h:Cross.m#0']], + ['later_alias_bound', ['Method:provider.h:Cross.m#0']], + ['later_alias_inline', ['Method:provider.h:Cross.m#0']], + ['later_class_bound', ['Method:provider.h:Cross.m#0']], + ['later_class_inline', ['Method:provider.h:Cross.m#0']], + ['prior_alias_bound', []], + ['prior_alias_inline', []], + ['dependent_type', []], + ] as const)('%s uses only the visible producer', (caller, targets) => { + expect( + [...result.graph.iterRelationships()] + .filter( + (edge) => + edge.type === 'CALLS' && + result.graph.getNode(edge.sourceId)?.properties.name === caller && + result.graph.getNode(edge.targetId)?.properties.name === 'm', + ) + .map((edge) => edge.targetId), + ).toEqual(targets); + }); +}); diff --git a/gitnexus/test/integration/cpp-namespace-order-boundaries.test.ts b/gitnexus/test/integration/cpp-namespace-order-boundaries.test.ts new file mode 100644 index 000000000..18c6aa750 --- /dev/null +++ b/gitnexus/test/integration/cpp-namespace-order-boundaries.test.ts @@ -0,0 +1,55 @@ +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++ namespace spelling and header order', () => { + let root: string; + let result: Awaited>; + beforeAll(async () => { + root = await fs.mkdtemp(path.join(os.tmpdir(), 'cpp-namespace-order-')); + const sources: Record = { + 'z_provider.h': 'namespace one { struct FirstProduct { int m(); }; } namespace first = one;', + 'a_consumer.h': + '#include "z_provider.h"\nnamespace second = first; struct FirstHolder { second::FirstProduct c; int first_use() { return c.m(); } };', + 'a_provider.h': + 'namespace two { struct SecondProduct { int m(); }; } namespace before = two;', + 'z_consumer.h': + '#include "a_provider.h"\nnamespace after = before; struct SecondHolder { after::SecondProduct c; int second_use() { return c.m(); } };', + 'main.cpp': ` +#include "a_consumer.h" +#include "z_consumer.h" +namespace outer { +namespace lib { inline namespace v1 { struct RelativeProduct { int m(); }; } } +struct Decoy { int m(); }; +void relative_brace() { auto x = lib::RelativeProduct{}; x.m(); } +struct RelativeHolder { lib::RelativeProduct field; void relative_field() { field.m(); } }; +} +`, + }; + for (const [name, source] of Object.entries(sources)) + await fs.writeFile(path.join(root, name), source); + result = await runPipelineFromRepo(root, () => {}, {}); + }, 120000); + afterAll(async () => { + if (root) await fs.rm(root, { recursive: true, force: true }); + }); + it.each([ + ['relative_brace', 'Method:main.cpp:RelativeProduct.m#0'], + ['relative_field', 'Method:main.cpp:RelativeProduct.m#0'], + ['first_use', 'Method:z_provider.h:FirstProduct.m#0'], + ['second_use', 'Method:a_provider.h:SecondProduct.m#0'], + ])('%s resolves its own namespace', (caller, target) => { + expect( + [...result.graph.iterRelationships()] + .filter( + (edge) => + edge.type === 'CALLS' && + result.graph.getNode(edge.sourceId)?.properties.name === caller && + result.graph.getNode(edge.targetId)?.properties.name === 'm', + ) + .map((edge) => edge.targetId), + ).toEqual([target]); + }); +}); diff --git a/gitnexus/test/integration/cpp-qualified-alias-chain.test.ts b/gitnexus/test/integration/cpp-qualified-alias-chain.test.ts new file mode 100644 index 000000000..057b90eca --- /dev/null +++ b/gitnexus/test/integration/cpp-qualified-alias-chain.test.ts @@ -0,0 +1,55 @@ +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++ chained namespace alias receiver types', () => { + let root: string; + let result: Awaited>; + beforeAll(async () => { + root = await fs.mkdtemp(path.join(os.tmpdir(), 'cpp-alias-chain-')); + await fs.writeFile( + path.join(root, 'main.cpp'), + ` +namespace impl { struct C { int m(); }; } +namespace first = impl; +namespace second = first; +struct Decoy { int m(); }; +struct Holder { second::C c; int field_call() { return c.m(); } }; +int brace_call() { auto c = second::C{}; return c.m(); } +second::C make(); +namespace factory { second::C make(); } +int alias_return_inline() { return make().m(); } +int alias_return_bound() { auto c = make(); return c.m(); } +int qualified_alias_return_inline() { return factory::make().m(); } +int qualified_alias_return_bound() { auto c = factory::make(); return c.m(); } +int consumer_shadow() { using second = Decoy; return make().m(); } +`, + ); + result = await runPipelineFromRepo(root, () => {}, {}); + }, 120000); + afterAll(async () => { + if (root) await fs.rm(root, { recursive: true, force: true }); + }); + it.each([ + 'field_call', + 'brace_call', + 'alias_return_inline', + 'alias_return_bound', + 'qualified_alias_return_inline', + 'qualified_alias_return_bound', + 'consumer_shadow', + ])('%s resolves the canonical alias target', (caller) => { + expect( + [...result.graph.iterRelationships()] + .filter( + (edge) => + edge.type === 'CALLS' && + result.graph.getNode(edge.sourceId)?.properties.name === caller && + result.graph.getNode(edge.targetId)?.properties.name === 'm', + ) + .map((edge) => edge.targetId), + ).toEqual(['Method:main.cpp:C.m#0']); + }); +}); diff --git a/gitnexus/test/integration/cpp-qualified-callee-collisions.test.ts b/gitnexus/test/integration/cpp-qualified-callee-collisions.test.ts new file mode 100644 index 000000000..108c00423 --- /dev/null +++ b/gitnexus/test/integration/cpp-qualified-callee-collisions.test.ts @@ -0,0 +1,71 @@ +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 callee collisions', () => { + let root: string; + let result: Awaited>; + beforeAll(async () => { + root = await fs.mkdtemp(path.join(os.tmpdir(), 'cpp-callee-collisions-')); + await fs.writeFile( + path.join(root, 'lib.h'), + ` +struct FreeProduct { void m(); }; +struct MemberProduct { void m(); }; +struct Factory { MemberProduct get() { return MemberProduct(); } }; +inline FreeProduct get() { return FreeProduct(); } +namespace real { struct AliasProduct { void m(); }; AliasProduct make(); } +namespace handle { struct WrongProduct { void m(); }; WrongProduct make(); } +`, + ); + await fs.writeFile( + path.join(root, 'main.cpp'), + ` +#include "lib.h" +void bound() { auto x = get(); x.m(); } +void inlined() { get().m(); } +namespace client { +namespace handle = real; +void alias_inline() { handle::make().m(); } +void alias_bound() { auto x = handle::make(); x.m(); } +} +void actual_namespace() { handle::make().m(); } +`, + ); + result = await runPipelineFromRepo(root, () => {}, {}); + }, 120000); + afterAll(async () => { + if (root) await fs.rm(root, { recursive: true, force: true }); + }); + function owners(caller: string): string[] { + const edges = [...result.graph.iterRelationships()]; + return edges + .filter( + (edge) => + edge.type === 'CALLS' && + result.graph.getNode(edge.sourceId)?.properties.name === caller && + result.graph.getNode(edge.targetId)?.properties.name === 'm', + ) + .map((edge) => { + const owner = edges.find( + (candidate) => candidate.type === 'HAS_METHOD' && candidate.targetId === edge.targetId, + ); + return String(owner && result.graph.getNode(owner.sourceId)?.properties.name); + }) + .sort(); + } + it.each(['bound', 'inlined'])('%s keeps the free function return type', (caller) => { + expect(owners(caller)).toEqual(['FreeProduct']); + }); + it.each(['alias_inline', 'alias_bound'])( + '%s cannot borrow a shadowed namespace factory', + (caller) => { + expect(owners(caller)).toEqual([]); + }, + ); + it('keeps a real namespace factory with the same spelling', () => { + expect(owners('actual_namespace')).toEqual(['WrongProduct']); + }); +}); diff --git a/gitnexus/test/integration/cpp-qualified-factory-review.test.ts b/gitnexus/test/integration/cpp-qualified-factory-review.test.ts new file mode 100644 index 000000000..e59b03659 --- /dev/null +++ b/gitnexus/test/integration/cpp-qualified-factory-review.test.ts @@ -0,0 +1,99 @@ +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 factory review boundaries', () => { + let root: string; + let result: Awaited>; + beforeAll(async () => { + root = await fs.mkdtemp(path.join(os.tmpdir(), 'cpp-factory-review-')); + await fs.writeFile( + path.join(root, 'products.h'), + ` +namespace lib { inline namespace v1 { struct InlineProduct { int m(); }; } } +struct NoArg { int m(); }; +struct OneArg { int m(); }; +NoArg create(); +OneArg create(int); +namespace app { namespace json { struct ParsedDoc { void dump(); }; ParsedDoc parse(); } } +namespace outer { struct Factory { static NoArg make(); }; } +namespace Factory { OneArg make(); } +`, + ); + await fs.writeFile( + path.join(root, 'main.cpp'), + ` +#include "products.h" +void inline_brace() { auto x = lib::InlineProduct{}; x.m(); } +struct Holder { lib::InlineProduct field; void field_call() { field.m(); } }; +void overloaded_bound() { auto x = create(); x.m(); } +void overloaded_inline() { create().m(); } +void overloaded_argument_bound() { auto x = create(1); x.m(); } +void overloaded_argument_inline() { create(1).m(); } +void outer_mismatch_bound() { auto x = ext::json::parse(); x.dump(); } +void outer_mismatch_inline() { ext::json::parse().dump(); } +void outer_match_bound() { auto x = app::json::parse(); x.dump(); } +void outer_match_inline() { app::json::parse().dump(); } +namespace app { void relative_match() { json::parse().dump(); } } +void class_qualifier_collision() { outer::Factory::make().m(); } +void actual_factory_namespace() { Factory::make().m(); } +`, + ); + result = await runPipelineFromRepo(root, () => {}, {}); + }, 120000); + afterAll(async () => { + if (root) await fs.rm(root, { recursive: true, force: true }); + }); + function targets(caller: string): string[] { + const edges = [...result.graph.iterRelationships()]; + return edges + .filter( + (edge) => + edge.type === 'CALLS' && + result.graph.getNode(edge.sourceId)?.properties.name === caller && + ['m', 'dump'].includes(String(result.graph.getNode(edge.targetId)?.properties.name)), + ) + .map((edge) => { + const owner = edges.find( + (candidate) => candidate.type === 'HAS_METHOD' && candidate.targetId === edge.targetId, + ); + return String(owner && result.graph.getNode(owner.sourceId)?.properties.name); + }) + .sort(); + } + it.each(['inline_brace', 'field_call'])('%s sees through an inline namespace', (caller) => { + expect(targets(caller)).toEqual(['InlineProduct']); + }); + it.each([ + 'overloaded_bound', + 'overloaded_inline', + 'overloaded_argument_bound', + 'overloaded_argument_inline', + ])('%s leaves distinct prototype returns unresolved without overload selection', (caller) => { + // These calls are valid and distinguishable by arity in C++. Receiver + // typing currently lacks the selected overload's return identity. + expect(targets(caller)).toEqual([]); + }); + it.each(['outer_mismatch_bound', 'outer_mismatch_inline'])( + '%s cannot borrow a different outer namespace', + (caller) => { + expect(targets(caller)).toEqual([]); + }, + ); + it.each(['outer_match_bound', 'outer_match_inline', 'relative_match'])( + '%s keeps a factory in the written namespace', + (caller) => { + expect(targets(caller)).toEqual(['ParsedDoc']); + }, + ); + it('leaves an unsupported class-qualified factory unresolved without borrowing a namespace', () => { + // This is valid C++ and should eventually target NoArg::m. The current + // namespace factory hook cannot resolve a class owner. + expect(targets('class_qualifier_collision')).toEqual([]); + }); + it('keeps the real namespace that shares the class name', () => { + expect(targets('actual_factory_namespace')).toEqual(['OneArg']); + }); +}); diff --git a/gitnexus/test/integration/cpp-qualified-receiver-boundaries.test.ts b/gitnexus/test/integration/cpp-qualified-receiver-boundaries.test.ts new file mode 100644 index 000000000..4c7b5b9d2 --- /dev/null +++ b/gitnexus/test/integration/cpp-qualified-receiver-boundaries.test.ts @@ -0,0 +1,61 @@ +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>; + 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']); + }); +}); diff --git a/gitnexus/test/integration/cpp-qualified-template-review.test.ts b/gitnexus/test/integration/cpp-qualified-template-review.test.ts new file mode 100644 index 000000000..ac96bfbbd --- /dev/null +++ b/gitnexus/test/integration/cpp-qualified-template-review.test.ts @@ -0,0 +1,84 @@ +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++ lexical qualified factory boundaries', () => { + let root: string; + let result: Awaited>; + beforeAll(async () => { + root = await fs.mkdtemp(path.join(os.tmpdir(), 'cpp-template-review-')); + await fs.writeFile( + path.join(root, 'original.h'), + ` +namespace original { struct C { int m(); }; } +namespace alias = original; +`, + ); + await fs.writeFile( + path.join(root, 'inner.h'), + ` +#include "original.h" +namespace inner { +namespace alias { struct C { int m(); }; } +int nearer(alias::C c) { return c.m(); } +} +`, + ); + await fs.writeFile( + path.join(root, 'main.cpp'), + ` +#include "inner.h" +namespace actual { struct Product { int m(); }; } +struct Wrong { int m(); }; +struct Factory { actual::Product make(); }; +struct AutoFactory { auto make() { return actual::Product{}; } }; +Wrong make(); +int qualified_method() { Factory f; return f.make().m(); } +int auto_method() { AutoFactory f; return f.make().m(); } +struct A { int m(); }; +struct B { int m(); }; +namespace rp { A produce(); } +struct Actual { static B produce(); }; +template int function_template() { return rp::produce().m(); } +template A* pointer_template() { rp::produce().m(); return nullptr; } +struct Owner { template int member_template() { return rp::produce().m(); } }; +int concrete() { return rp::produce().m(); } +`, + ); + result = await runPipelineFromRepo(root, () => {}, {}); + }, 120000); + afterAll(async () => { + if (root) await fs.rm(root, { recursive: true, force: true }); + }); + function targets(caller: string): string[] { + return [...result.graph.iterRelationships()] + .filter( + (edge) => + edge.type === 'CALLS' && + result.graph.getNode(edge.sourceId)?.properties.name === caller && + result.graph.getNode(edge.targetId)?.properties.name === 'm', + ) + .map((edge) => edge.targetId) + .sort(); + } + it('lets a nearer namespace hide an outer alias', () => { + expect(targets('nearer')).toEqual(['Method:inner.h:C.m#0']); + }); + it('does not borrow a free prototype for a qualified method return', () => { + expect(targets('qualified_method')).toEqual(['Method:main.cpp:Product.m#0']); + }); + it.each(['function_template', 'member_template', 'pointer_template'])( + '%s retains dependent lookup', + (caller) => { + expect(targets(caller)).toEqual([]); + }, + ); + it('does not borrow a free return annotation for an undeduced auto method', () => { + expect(targets('auto_method')).toEqual([]); + }); + it('keeps the nondependent namespace factory', () => { + expect(targets('concrete')).toEqual(['Method:main.cpp:A.m#0']); + }); +}); diff --git a/gitnexus/test/integration/producer-return-disk-scope.test.ts b/gitnexus/test/integration/producer-return-disk-scope.test.ts new file mode 100644 index 000000000..eb4d77c37 --- /dev/null +++ b/gitnexus/test/integration/producer-return-disk-scope.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it, vi } 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'; +import { TransitionalScopeTree } from '../../src/storage/scope-index-store.js'; + +describe('producer return facts after the disk scope seal', () => { + it.each([ + ['cold unannotated factory', 'def make():\n pass\n\ndef caller():\n make().m()\n'], + [ + 'annotated factory with a competing method', + 'class Product:\n def save(self): pass\nclass OtherProduct:\n def save(self): pass\ndef make() -> Product:\n return Product()\ndef caller():\n make().save()\n', + ], + ])( + '%s matches resident resolution', + async (name, source) => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'producer-return-repo-')); + const store = await fs.mkdtemp(path.join(os.tmpdir(), 'producer-return-store-')); + const previous = process.env.GITNEXUS_DISK_SCOPE_INDEX; + const seal = vi.spyOn(TransitionalScopeTree.prototype, 'seal'); + const outcomes: string[][] = []; + try { + await fs.writeFile(path.join(root, 'factory.py'), source); + for (const disk of [false, true]) { + process.env.GITNEXUS_DISK_SCOPE_INDEX = disk ? '1' : '0'; + seal.mockClear(); + const result = await runPipelineFromRepo(root, () => {}, { + parsedFileStorePath: path.join(store, disk ? 'disk' : 'resident'), + }); + expect(seal).toHaveBeenCalledTimes(disk ? 1 : 0); + const edges = [...result.graph.iterRelationships()]; + outcomes.push( + edges + .filter((edge) => ['CALLS', 'ACCESSES', 'IMPORTS'].includes(edge.type)) + .map((edge) => `${edge.type}:${edge.sourceId}->${edge.targetId}:${edge.reason ?? ''}`) + .sort(), + ); + if (name.startsWith('annotated')) { + const product = [...result.graph.iterNodes()].find( + (node) => node.label === 'Class' && node.properties.name === 'Product', + ); + const methods = new Set( + edges + .filter((edge) => edge.type === 'HAS_METHOD' && edge.sourceId === product?.id) + .map((edge) => edge.targetId), + ); + const targets = edges + .filter( + (edge) => + edge.type === 'CALLS' && + result.graph.getNode(edge.sourceId)?.properties.name === 'caller' && + result.graph.getNode(edge.targetId)?.properties.name === 'save', + ) + .map((edge) => edge.targetId); + expect(targets).toHaveLength(1); + expect(methods.has(targets[0]!)).toBe(true); + } + } + expect(outcomes[1]).toEqual(outcomes[0]); + } finally { + seal.mockRestore(); + if (previous === undefined) delete process.env.GITNEXUS_DISK_SCOPE_INDEX; + else process.env.GITNEXUS_DISK_SCOPE_INDEX = previous; + await fs.rm(root, { recursive: true, force: true }); + await fs.rm(store, { recursive: true, force: true }); + } + }, + 120000, + ); +}); diff --git a/gitnexus/test/integration/resolvers/cpp-qualified-receiver-typing.test.ts b/gitnexus/test/integration/resolvers/cpp-qualified-receiver-typing.test.ts new file mode 100644 index 000000000..382541f58 --- /dev/null +++ b/gitnexus/test/integration/resolvers/cpp-qualified-receiver-typing.test.ts @@ -0,0 +1,99 @@ +/** + * C++ receiver typing through qualified type spellings, header prototypes and + * qualified factory callees. + * + * Every row names a caller in the `cpp-qualified-receiver-typing` fixture and + * the exact `m` targets it must reach, as `:.m`. The fixture puts + * a global `C` (other.h) beside `rp::C` (mod.h), and `a::Config` beside a + * `b::Config` declared in the caller's own namespace, so a lookup that drops + * the qualifier and lands on the nearer same-tail class fails a row instead of + * passing it. + */ +import { describe, it, expect, beforeAll } from 'vitest'; +import path from 'path'; +import { FIXTURES, getRelationships, runPipelineFromRepo, type PipelineResult } from './helpers.js'; + +const RP_C = 'mod.h:C.m'; +const GLOBAL_C = 'other.h:C.m'; +const D = 'mod.h:D.m'; + +describe('C++ qualified receiver typing', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'cpp-qualified-receiver-typing'), + () => {}, + ); + }, 60000); + + /** `:.m` for every CALLS edge from `caller` to a method `m`. */ + const memberTargets = (caller: string): string[] => + getRelationships(result, 'CALLS') + .filter((edge) => edge.source === caller && edge.target === 'm') + .map((edge) => `${edge.targetFilePath}:${edge.rel.targetId.split(':')[2]?.split('#')[0]}`) + .sort(); + + it.each<[string, string[]]>([ + // A qualified spelling reaches the namespace member it names, never the + // global `C` that other.h makes visible under the same tail. + ['declared_init', [RP_C]], + ['param_ns', [RP_C]], + ['param_ns_ptr', [RP_C]], + ['param_template', ['mod.h:Box.m']], + ['bare_global', [GLOBAL_C]], + // `a::Config` written inside `namespace b`, which declares its own Config. + ['param_qualified', ['a.h:Config.m']], + ['param_local', ['callers.cpp:Config.m']], + ])('qualified type spelling: %s -> %j', (caller, expected) => { + expect(memberTargets(caller)).toEqual(expected); + }); + + it.each<[string, string[]]>([ + ['declared_ns', [RP_C]], + ['declared_ns_ptr', [RP_C]], + ])('qualified declaration without initializer: %s -> %j', (caller, expected) => { + expect(memberTargets(caller)).toEqual(expected); + }); + + it.each<[string, string[]]>([ + ['brace_ns', [RP_C]], + ['brace_qualified', ['a.h:Config.m']], + ['field_qualified', ['a.h:Config.m']], + ])('qualified brace-init and member field: %s -> %j', (caller, expected) => { + expect(memberTargets(caller)).toEqual(expected); + }); + + it.each<[string, string[]]>([ + ['auto_plain', [D]], + ['inline_plain', [D]], + ['auto_ptr', [D]], + ['global_factory', [GLOBAL_C]], + // A namespace prototype's return type is looked up inside its namespace, + // never from the file's module scope where the global Widget sits. + ['using_auto', [RP_C]], + ['using_same_file', ['mod.h:Widget.m']], + // The block-scope `E y();` in `vexing` must not hoist a file-level `y`. + ['uses_vexing_name', []], + ])('header prototype return type: %s -> %j', (caller, expected) => { + expect(memberTargets(caller)).toEqual(expected); + }); + + it.each<[string, string[]]>([ + ['using_inline', [RP_C]], + ['using_inline_same_file', ['mod.h:Widget.m']], + ])('inline call receiver typed by definition identity: %s -> %j', (caller, expected) => { + expect(memberTargets(caller)).toEqual(expected); + }); + + it.each<[string, string[]]>([ + ['auto_ns', [RP_C]], + ['inline_ns', [RP_C]], + ['ctor_ns', [RP_C]], + // An unknown qualified factory types nothing, and the inner `x` must not + // fall through to the outer `D x`. + ['shadowed_auto', []], + ])('qualified factory callee: %s -> %j', (caller, expected) => { + expect(memberTargets(caller)).toEqual(expected); + }); +}); diff --git a/gitnexus/test/unit/scope-resolution/cpp/cpp-captures.test.ts b/gitnexus/test/unit/scope-resolution/cpp/cpp-captures.test.ts index 80cff1d60..8ace670cc 100644 --- a/gitnexus/test/unit/scope-resolution/cpp/cpp-captures.test.ts +++ b/gitnexus/test/unit/scope-resolution/cpp/cpp-captures.test.ts @@ -615,6 +615,42 @@ describe('C++ pointer parameter — declared spelling', () => { }); }); +/** + * Which name a declaration binds, and to what type — for the shapes where the + * written declaration names no type directly (`auto`) or names it in a way only + * one syntactic position may claim (a function prototype). + */ +describe('C++ deduced and prototype type bindings', () => { + function rawTypeOf(src: string, name: string): string | undefined { + const parsed = extractParsedFile(cppProvider, src, 'test.cpp'); + const refs = (parsed?.scopes ?? []).flatMap((scope) => { + const ref = scope.typeBindings.get(name); + return ref === undefined ? [] : [ref.rawName]; + }); + return refs[0]; + } + + it.each<[string, string, string | undefined]>([ + // `auto` names no type; a leftover placeholder binds nothing. + ['void f() { auto n = 5; }', 'n', undefined], + ['auto g = [](auto v) { return v; };', 'v', undefined], + // A qualified callee is bound as written, like a bare one. + ['void f() { auto x = make(); }', 'x', 'make'], + ['void f() { auto x = rp::make(); }', 'x', 'rp::make'], + ['void f() { auto x = a::b::make(); }', 'x', 'a::b::make'], + // A template factory keeps its dedicated rule (the template argument). + ['void f() { auto x = std::make_shared(); }', 'x', 'Dog'], + // A prototype records its return type at namespace scope, through an + // include guard, and never in a function body (the most vexing parse). + ['D makeD();', 'makeD', 'D'], + ['D* makeP();', 'makeP', 'D'], + ['#ifndef G\n#define G\nnamespace rp { C make(); }\n#endif\n', 'make', 'C'], + ['void f() { E y(); }', 'y', undefined], + ])('%s binds %s to %s', (src, name, expected) => { + expect(rawTypeOf(src, name)).toBe(expected); + }); +}); + describe('written operator positions', () => { it('keeps distinct synthetic operator references on their actual tokens', () => { const source = 'int combine(A a, A b, A c) { return a + b + c; }'; diff --git a/gitnexus/test/unit/scope-resolution/cpp/cpp-qualified-ns-index.test.ts b/gitnexus/test/unit/scope-resolution/cpp/cpp-qualified-ns-index.test.ts index 098c832a4..d5bc0a25d 100644 --- a/gitnexus/test/unit/scope-resolution/cpp/cpp-qualified-ns-index.test.ts +++ b/gitnexus/test/unit/scope-resolution/cpp/cpp-qualified-ns-index.test.ts @@ -23,6 +23,7 @@ import { markCppInlineNamespaceRange, populateCppInlineNamespaceScopes, resolveCppQualifiedNamespaceMember, + splitCppQualifiedCallee, } from '../../../../src/core/ingestion/languages/cpp/inline-namespaces.js'; const NO_SCOPES = {} as unknown as ScopeResolutionIndexes; @@ -331,3 +332,18 @@ describe('C++ qualified-namespace member index (#2788)', () => { 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::make', { receiver: 'ns::Box', member: 'make' }], + ['make', undefined], + ['::make', undefined], + ['Box', undefined], + ])('%s -> %j', (callee, expected) => { + expect(splitCppQualifiedCallee(callee)).toEqual(expected); + }); +}); diff --git a/gitnexus/test/unit/scope-resolution/lexical-name-claims.test.ts b/gitnexus/test/unit/scope-resolution/lexical-name-claims.test.ts index 3315d25bd..45ae37cfd 100644 --- a/gitnexus/test/unit/scope-resolution/lexical-name-claims.test.ts +++ b/gitnexus/test/unit/scope-resolution/lexical-name-claims.test.ts @@ -43,12 +43,14 @@ function indexes( return { scopeTree: { getScope: (id: string) => [inner, outer].find((s) => s.id === id), + getChildren: (id: string) => + [inner, outer].filter((entry) => entry.parent === id).map((entry) => entry.id), byId: new Map([ [inner.id, inner], [outer.id, outer], ]), }, - moduleScopes: new Map([['caller.ts', outer.id]]), + moduleScopes: { byFilePath: new Map([['caller.ts', outer.id]]) }, bindings: new Map(), bindingAugmentations: new Map(), imports: new Map(),