From e01f0912bcc0381c6ca3e7a78e90a4dc8bbfd16d Mon Sep 17 00:00:00 2001 From: WENJIE HUANG <82434538+SZU-WenjieHuang@users.noreply.github.com> Date: Thu, 14 May 2026 16:30:52 +0800 Subject: [PATCH 01/13] feat(cpp): migrate C++ to scope-based resolution model (#938) (#1520) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(cpp): complete scope-resolution parity * fix(ci): resolve formatting, lint errors for PR #1520 - prettier: format arity-metadata.ts, captures.ts, index.ts - eslint: rename unused HEADER_GLOB to _HEADER_GLOB - eslint: replace unsafe parser.parse() with parseSourceSafe() - eslint: suppress intentional console.warn/log in sync.ts - eslint: remove unused _it import alias in cpp.test.ts * fix(ci): complete formatting, lint, and typecheck fixes - prettier: format call-processor.ts, imported-return-types.ts, include-extractor.test.ts, cpp-captures.test.ts, cpp-imports.test.ts - eslint: suppress intentional console.warn in manifest-extractor.ts - typecheck: restore 'thrift' in ContractType union (was accidentally removed) and add thrift case to exhaustive switch in manifest-extractor * fix(ci): revert unintended group module changes that broke tests Restore types.ts, config-parser.ts, matching.ts, sync.ts, and manifest-extractor.ts to upstream/main versions. The original commit accidentally removed fields (thrift, workspace_deps, exclude_links_paths, exclude_links_param_only_paths) from DetectConfig/MatchingConfig/ContractType which are still referenced by matching.test.ts, config-parser.test.ts, sync.test.ts and other integration tests. This PR's scope is C++ scope-resolution parity only — group module type definitions and logic should remain unchanged. * fix(codeql): address security and quality alerts - arity-metadata.ts, interpret.ts: replace single-pass template strip regex (/<[^>]*>/g) with a while-loop to fully handle nested templates like Map> — resolves 'Incomplete multi-character sanitization' - cpp.test.ts: remove unused vitest 'it' import since the file defines its own 'it' via createResolverParityIt — resolves 'Assignment to constant' - include-extractor.test.ts: use fs.mkdtempSync() instead of predictable os.tmpdir()+Date.now() paths — resolves 'Insecure temporary file' - interpret.ts: remove redundant 'name !== undefined' check (already guaranteed by early return) — resolves 'Comparison between inconvertible types' * review: address Claude review findings on PR #1520 - Findings 1-3 (BLOCKERS): restore include-extractor.ts and its test to the main baseline. Block-comment fallback regression, suffix-resolve false-positive suppression, and the four deleted regression tests (#3-#6) are now back. These changes were unrelated to C++ scope parity and should not have been in this PR. - Finding 4 (MAJOR, partial): revert COMPOUND_RECEIVER_MAX_DEPTH 6 to 4. No C++ test exercises depth > 4 (cpp-chain-call uses a 2-hop chain), so the bump risked silent regressions on other migrated languages without justification. The wildcard-origin propagation in imported-return-types.ts is retained — C++ #include and using namespace both emit wildcard-origin bindings (cpp/import-decomposer .ts:40,90), so wildcard propagation is causal to C++ parity. - Finding 6: tighten write-access dedup test with exact per-field counts (nameWrites = 2, addrWrites = 1) instead of total-count + sub string containment, so a regression in one of the two name writes can no longer be masked. - Finding 8: skipped. Box-drawing characters in cpp/query.ts comments match the established convention used in csharp/java/php query files. Finding 5 (int/long normalization tie-breaker) left as documented follow-up — proper fix requires resolver-level tie-breaker logic and risks regressing other arity-matching tests. * fix(cpp): stop #include from leaking class methods and namespace members (U1) The C++ registry-primary resolver was emitting impossible CALLS edges for ordinary headers: an including file's unqualified save() resolved to User::save and unqualified foo() resolved to ns::foo. Two leak paths converged on localDefs: 1. expandCppWildcardNames (file-local-linkage.ts) iterated the flattened localDefs and exported every simple tail, including class-owned methods and namespace-contained symbols. Replaced with a scope-aware filter: build nodeId -> owning Scope from Scope.ownedDefs and skip defs whose owning scope is Namespace or Class. 2. The shared global free-call fallback's pickUniqueGlobalCallable walks the workspace registry by simple name and would still hit class methods / namespace members even with wildcard expansion fixed. Plugged the gap via the existing isFileLocalDef hook — semantically 'logically invisible cross-file' — by tracking per- file non-globally-visible nodeIds (populateCppNonGloballyVisible, called from populateOwners) and adding an ownerId !== undefined fast-path for class-owned defs. Side fix in shared finalize-algorithm.ts: when wildcard expansion resolves to a real target but produces zero propagating names, the edge was dropped, taking the file-level IMPORTS edge with it. Preserve the original wildcard edge so #include dependencies survive even when the header exposes no unqualified bindings. Tests: cpp-include-no-class-leak, cpp-include-no-namespace-leak, and cpp-anon-ns-same-file-visible fixtures. Negative tests mode-gated to REGISTRY_PRIMARY_CPP=1 via the expected-failures registry — legacy DAG has no scope-aware filtering on the global fallback; backporting is out of scope. All 2104 resolver integration tests pass under registry-primary mode. * fix(cpp): suppress receiver-bound CALLS when integer-width overloads collide (U2) C++ arity-metadata normalizes int, long, short, unsigned, size_t to 'int' so single-candidate flows like 'process(42L)' match a 'long'- typed parameter via loose matching. But when both 'process(int)' and 'process(long)' coexist as method overloads, they both end up with parameterTypes=['int'] in the registry, and pickOverload's narrowing returns 2 candidates with no way to disambiguate. The previous code picked candidates[0] arbitrarily, emitting a CALLS edge to the wrong overload roughly half the time. Fix: - Add isOverloadAmbiguousAfterNormalization in overload-narrowing.ts that detects >1 candidate sharing identical parameterTypes sequences. - Have pickOverload return a new OVERLOAD_AMBIGUOUS sentinel when this fires. - In the receiver-bound-calls loop, when pickOverload signals ambiguity, suppress the edge AND add the site to handledSites so the late-stage emitReferencesViaLookup pass does not re-emit the pre-resolved reference. Without the handled-mark, the reference index still carries a toDef and emits the same wrong edge. Graph schema has no ambiguous-target edge model, so emitting two edges (one per candidate) would require a separate schema change. Zero-edge is the only safe outcome. Other languages: the ambiguity check is a precondition gate, not a behavior change for normal narrowing. Languages whose normalizers do not collapse distinct types into a single token (verified by grep over *-arity-metadata.ts) will never produce >1 candidate with identical parameterTypes from genuinely distinct declarations, so the branch is effectively C++-only in practice. Test: cpp-overload-int-long fixture asserts exactly .toBe(0) CALLS edges. Count=1 = arbitrary pick (the bug); count>1 = unsupported ambiguous-edge model. Mode-gated to REGISTRY_PRIMARY_CPP=1 — legacy DAG has no OVERLOAD_AMBIGUOUS wiring; backporting is out of scope. All 2105 resolver integration tests pass under registry-primary; all 139 cpp tests pass under both modes (3 negative tests skipped in legacy as documented). * test(cpp): add integration coverage for anonymous-namespace, using-namespace conflict, and std-shim leakage (U3+U4+U5) Three new end-to-end fixtures exercise the resolver pipeline against scenarios that previously had only unit-level coverage or no coverage at all (Claude review Finding 7): U3 — cpp-anon-ns-cross-file: helper.cpp declares 'namespace { void worker(); }' and calls it internally. caller.cpp declares a separate 'void worker()' and calls it. Asserts (a) the cross-file CALLS edge from caller's run() does not target helper.cpp's anonymous-namespace worker, and (b) the same-file edge from helper_entry() to its own worker still resolves (positive guard against a 'no edges at all' regression making the negative check vacuously pass). Includes a state-isolation guard that re-runs the same fixture and asserts identical results, proving clearFileLocalNames() is called by the pipeline entry. U4 — cpp-using-namespace-conflict: Two headers each declaring 'namespace a { foo() }' and 'namespace b { foo() }' respectively, plus a caller doing 'using namespace a; using namespace b; foo()'. Asserts exactly zero CALLS edges. One edge = arbitrary pick (the bug); two edges would require an ambiguous-target edge model GitNexus does not have. Depends on U1 — without scope-aware filtering, both foo()s would already be in the importer's wildcard binding set as simple 'foo', so the test would pass for the wrong reason. U5 — cpp-using-namespace-std-smoke: Fixture-local 'namespace std { void cout_write(); void println(); }' shim rather than real — captures the wildcard-leak shape deterministically without depending on system-header modeling stability (out of scope per plan). Asserts (a) the project-local call resolves correctly, (b) no leak to shim STL symbols, and (c) no CALLS/ACCESSES edges from the caller into std-shim.h at all. Negative tests for U2/U4 mode-gated to REGISTRY_PRIMARY_CPP=1 via the expected-failures registry; legacy DAG lacks the OVERLOAD_AMBIGUOUS suppression and the namespace-aware filtering, so the leaks persist there. All 2112 resolver integration tests pass under registry-primary; all 146 cpp tests pass under both modes (4 negative tests skipped in legacy as documented). * chore(autofix): apply prettier + eslint fixes via /autofix command * fix(cpp): scope-aware isSuperReceiver classification (U1) The C++ isSuperReceiver hook used a regex `/^[A-Z]\w*::/` that misclassified any uppercase-qualified call as a super-receiver call. Singleton::getInstance(), std::Foo::bar(), and PascalCase namespace calls all entered the super branch, where the absence of an enclosing class (or wrong MRO context) dropped the resolution entirely. Fix: - New optional ScopeResolver hook isSuperReceiverInContext(text, callerScope, scopes). Languages where super classification depends on caller context define it; receiver-bound-calls.ts prefers it when defined and falls back to the simple isSuperReceiver(text) otherwise. Other migrated languages (Python, Java, C#, PHP, Go, TypeScript) are unchanged. - C++ implementation: parse the LHS of '::' from the receiver text, resolve via findClassBindingInScope, and return true only when the LHS is a class-like def in the caller's enclosing class's MRO. Returns false for namespace LHS, unresolved LHS, self-class LHS (qualified self-calls aren't super), and any non-'::' form. - Extended the C++ tree-sitter query to capture the LHS of qualified_identifier as @reference.receiver so qualified static member calls (Singleton::getInstance()) reach the receiver-bound Case 2 (class-name receiver) path. Without the receiver capture, qualified calls had no explicit receiver and could not resolve through any receiver-bound branch. Test: cpp-namespace-qualified-not-super fixture. Singleton::getInstance() from a free function asserts exactly 1 CALLS edge through the qualified-call path. Passes under both REGISTRY_PRIMARY_CPP=1 and =0. All 2113 resolver integration tests pass; all 147 cpp tests pass under both modes. * fix(cpp): suppress receiver-bound CALLS when default-arg overloads collide (U4) ISO C++ rejects 's.f(1)' as ambiguous when both 'void f(int)' and 'void f(int, int = 0)' are declared on S. The previous resolver returned the first viable candidate via pickOverload's fallback. Extended isOverloadAmbiguousAfterNormalization to take an optional argCount: when provided, the predicate compares only the first argCount slots of each candidate's parameterTypes. Candidates whose declared-prefix matches up to argCount are treated as ambiguous because default arguments make all of them equally viable for the call. Without argCount, behavior is unchanged (the original int/long normalization-collapse contract, full-length equality required). pickOverload now passes site.arity so default-arg ambiguity fires. Test: cpp-overload-default-arg-ambiguous fixture. s.f(1) where S has f(int) and f(int, int = 0) asserts exactly .toBe(0) CALLS edges. Passes under both REGISTRY_PRIMARY_CPP=1 and =0. All 2114 resolver integration tests pass; all 148 cpp tests pass under both modes. * fix(cpp): two-phase template lookup suppresses dependent-base members (U3) ISO C++ two-phase name lookup: inside a class template body, unqualified calls MUST NOT bind to members of a dependent base class. Only this->name or Base::name forms make the lookup dependent. GCC and Clang both reject the unqualified form with 'declaration of f must be available'. Before this fix, GitNexus's global free-call fallback walked the workspace registry by simple name and bound unqualified calls inside template bodies to dependent-base members, producing CALLS edges the compiler would reject. Implementation: - New languages/cpp/two-phase-lookup.ts module: per-pipeline state recording (className, dependentBaseName) pairs at capture time and resolving them to nodeId sets during populateOwners. - captures.ts detectCppDependentBases walks the AST once finding every template_declaration containing a class/struct definition. For each, it collects template-parameter names (typename T, class T, non-type int N, template-template parameters) and walks each base in the base_class_clause checking whether any inner type_identifier matches a template parameter. Conservative bias: typename T::U, decltype, and template-template-parameter shapes also classified as dependent. - Extended scope-resolution contract's isCallableVisibleFromCaller hook with optional callerScope and scopes fields. C++ implements the hook to consult isCppDependentBaseMember: when the candidate is a member of a dependent base of the caller's enclosing class, the hook returns false and pickUniqueGlobalCallable skips the candidate. - clearFileLocalNames also clears the dependent-base state per pipeline run. Fixtures: - cpp-two-phase-dependent-base: Derived deriving from Base, unqualified f() and i inside Derived's body. Asserts zero CALLS edges and zero ACCESSES edges respectively. - cpp-two-phase-this-qualified, cpp-two-phase-non-dependent-base, cpp-two-phase-namespace-free-call-inside-template: positive fixtures left as documented gaps (this-> and qualified-name resolution inside template bodies are pre-existing resolver weaknesses independent of U3). Tracked separately. Negative test mode-gated to REGISTRY_PRIMARY_CPP=1 via the expected- failures registry; legacy DAG has no two-phase lookup. All 2116 resolver integration tests pass under registry-primary; all 150 cpp tests pass under both modes (5 negative tests skipped in legacy as documented). * fix(cpp): implement V1 ADL (Koenig lookup) for free-function calls (U2) Plan 2026-05-13-001 U2. Adds argument-dependent lookup as a new candidate-generating tier in `emitFreeCallFallback`: when ordinary unqualified lookup is empty, ADL surfaces candidates from each value-class-typed argument's enclosing namespace. V1 boundary (locked by cpp-adl-pointer-arg-boundary fixture): - only direct enclosing-namespace closure - only directly-named class-type values (pointer / reference / template- spec args excluded; closure rules deferred to V2) - ADL fires ONLY when ordinary lookup is empty (no union-and-resolve) Parenthesized name `(f)(s)` suppresses ADL per ISO C++ [basic.lookup.argdep]/3.1. Multi-candidate ambiguity (e.g. `process(int)` vs `process(long)` after C++ int-width normalization) returns the ADL_AMBIGUOUS sentinel — caller suppresses entirely, mirroring the OVERLOAD_AMBIGUOUS contract from plan 2026-05-12-002 U2. Implementation: - `cpp/adl.ts` — new module: per-pipeline argInfoBySite + noAdlSites Maps populated at capture time, classToNamespaceQualifiedName Map populated during populateOwners; `pickCppAdlCandidates` returns SymbolDefinition | ADL_AMBIGUOUS | undefined - `scope-resolution/contract/scope-resolver.ts` — adds optional `resolveAdlCandidates` hook - `scope-resolution/passes/free-call-fallback.ts` — invokes ADL hook between `findCallableBindingInScope` and `pickUniqueGlobalCallable`; marks site handled on `'ambiguous'` so emit-references doesn't retry - `cpp/captures.ts` — detects `parenthesized_expression` function wrap; per-arg classification (pointer/reference/value class) preserving the shape info the existing arity-narrowing normalizer strips - `cpp/scope-resolver.ts` — registers hook, populates associated namespaces, clears state in loadResolutionConfig Negative tests (parens, pointer-boundary, ambiguous) gated under LEGACY_RESOLVER_PARITY_EXPECTED_FAILURES.cpp — legacy DAG has no V1/V2 ADL boundary or ADL_AMBIGUOUS suppression. 154/154 cpp integration tests pass under REGISTRY_PRIMARY_CPP=1; 147 pass + 7 skipped under =0 (legacy parity baseline). * fix(cpp): inline namespace transitive walking + qualified namespace resolution (U5) Plan 2026-05-13-001 U5. Two ISO C++ inline-namespace semantics: 1. Unqualified-lookup transitive visibility: inline-namespace members reach the enclosing namespace's scope as if declared there. The `populateCppNonGloballyVisible` exemption keeps them globally visible so cross-file unqualified lookup finds them. 2. Qualified-receiver transitive visibility: `outer::foo()` resolves to `outer::v1::foo()` when `v1` is inline (and through arbitrarily-deep nesting like `outer::v1::experimental::foo`, matching libc++ `__1` / libstdc++ `__cxx11`). The second behavior required a new resolver case in `receiver-bound-calls.ts` (Case 1.5: language-specific qualified-receiver member lookup) because C++ qualified-namespace member calls had no prior resolution path — receiver-bound Case 1 only handled `ParsedImport.kind === 'namespace'` (Python/JS-style) and Case 2 handles class receivers, neither of which fired for `outer::foo()`. The new hook `resolveQualifiedReceiverMember` is opt-in; languages without C++-style qualified-name semantics omit it. Implementation: - `cpp/inline-namespaces.ts` — new module: per-pipeline `inlineNamespaceRangesByFile` + `inlineNamespaceScopeIds` Sets; `markCppInlineNamespaceRange` at capture time; `populateCppInlineNamespaceScopes` resolves ranges → scope IDs; `resolveCppQualifiedNamespaceMember` walks namespace scopes by simple name and descends transitively through inline children only. - `scope-resolution/contract/scope-resolver.ts` — adds optional `resolveQualifiedReceiverMember` hook to the contract. - `scope-resolution/passes/receiver-bound-calls.ts` — Case 1.5 invokes the hook between Case 1 (namespace imports) and Case 2 (class-name receiver). Returns undefined for non-namespace receivers so Case 2 still resolves class-qualified calls. - `cpp/captures.ts` — detects `inline` keyword child on `namespace_definition`; records 1-based range to match Scope.range. - `cpp/file-local-linkage.ts` — `populateCppNonGloballyVisible` exempts inline-namespace scopes so cross-file unqualified lookup keeps their members visible. - `cpp/scope-resolver.ts` — wires `populateCppInlineNamespaceScopes` into populateOwners (BEFORE `populateCppNonGloballyVisible` so the exemption sees populated state); registers `resolveQualifiedReceiverMember` hook. 4 fixtures: `cpp-inline-namespace-unqualified`, `-versioned`, `-nested` (two transitive inline hops, STL `__1` shape), and `-adl-participation` (composes with U2 — ADL surfaces records declared inside inline child namespaces). All 4 assert exactly 1 CALLS edge with correct target file. Versioned fixture gated under LEGACY_RESOLVER_PARITY_EXPECTED_FAILURES.cpp — legacy DAG can't disambiguate two same-name foos without inline awareness. Other 3 coincidentally resolve in legacy. 158/158 cpp integration tests pass under REGISTRY_PRIMARY_CPP=1; 150 pass + 8 skipped under =0 (legacy parity baseline). * test(cpp): Phase 5 cross-unit composition tests for U1/U2/U3/U5 Plan 2026-05-13-001 Phase 5. Locks in correct behavior at the intersections between the previously-shipped scope-resolver units. Enhancement to U1: `isSuperReceiverInContext` strips template-argument lists (`Base` → `Base`) and namespace prefixes (`outer::v1::Base` → `Base`) before resolving the receiver in the caller's scope chain. This makes the super-receiver classification work for template-class heritage shapes like `Base::method()` and `outer::v1::Base::f()`. Three fixtures + four tests: - `cpp-phase5-u1-u3-qualified-base-call`: `template struct Derived : Base` with `Base::method()` inside a template body. Asserts NO mis-routing (count = 0) — documents the V1 gap that template-class inheritance isn't captured as EXTENDS by the legacy DAG, so MRO walks are empty and the super branch can't dispatch. The composition still works correctly: U1's template-arg-stripping classifies `Base` as a super candidate, but the empty-MRO terminates without false edges. - `cpp-phase5-u2-u3-adl-from-derived`: `Derived : Base` where `Base::record` shadows `audit::record`. Unqualified `record(e)` inside the template body should resolve via ADL to `audit::record` (because U3 + the `isFileLocalDef` class- owned filter suppress `Base::record`). Asserts 1 edge to audit.h and 0 edges to base.h. - `cpp-phase5-u3-u5-inline-base`: `template struct Derived : outer::v1::Base` where `v1` is inline. Unqualified `f()` inside `Derived::g()` should NOT bind to Base::f (dependent-base suppression even across inline namespace prefix). Asserts count = 0. Phase 5 tests asserting no-false-positives are gated under LEGACY_RESOLVER_PARITY_EXPECTED_FAILURES.cpp — legacy DAG over- resolves without the template-arg-stripping qualified-receiver path and without two-phase dependent-base suppression. 162/162 cpp integration tests pass under REGISTRY_PRIMARY_CPP=1; 152 pass + 10 skipped under =0 (legacy parity baseline). --------- Co-authored-by: HuangWenjie Co-authored-by: Gergo Magyar Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .../scope-resolution/finalize-algorithm.ts | 11 +- gitnexus/src/core/ingestion/call-processor.ts | 26 +- .../src/core/ingestion/languages/c-cpp.ts | 19 + .../src/core/ingestion/languages/cpp/adl.ts | 335 +++++++ .../ingestion/languages/cpp/arity-metadata.ts | 185 ++++ .../src/core/ingestion/languages/cpp/arity.ts | 35 + .../core/ingestion/languages/cpp/captures.ts | 832 ++++++++++++++++++ .../languages/cpp/file-local-linkage.ts | 214 +++++ .../ingestion/languages/cpp/header-scan.ts | 53 ++ .../languages/cpp/import-decomposer.ts | 120 +++ .../ingestion/languages/cpp/import-target.ts | 18 + .../src/core/ingestion/languages/cpp/index.ts | 16 + .../languages/cpp/inline-namespaces.ts | 170 ++++ .../core/ingestion/languages/cpp/interpret.ts | 112 +++ .../ingestion/languages/cpp/merge-bindings.ts | 38 + .../src/core/ingestion/languages/cpp/query.ts | 462 ++++++++++ .../ingestion/languages/cpp/range-bindings.ts | 255 ++++++ .../ingestion/languages/cpp/scope-resolver.ts | 230 +++++ .../ingestion/languages/cpp/simple-hooks.ts | 79 ++ .../languages/cpp/two-phase-lookup.ts | 133 +++ .../core/ingestion/registry-primary-flag.ts | 1 + .../contract/scope-resolver.ts | 100 +++ .../passes/free-call-fallback.ts | 49 +- .../passes/imported-return-types.ts | 3 +- .../passes/overload-narrowing.ts | 60 ++ .../passes/receiver-bound-calls.ts | 86 +- .../scope-resolution/pipeline/registry.ts | 2 + .../scope-resolution/pipeline/run.ts | 1 + .../core/ingestion/workers/parse-worker.ts | 3 + .../lang-resolution/cpp-adl-ambiguous/alpha.h | 7 + .../lang-resolution/cpp-adl-ambiguous/app.cpp | 8 + .../lang-resolution/cpp-adl-basic/app.cpp | 8 + .../lang-resolution/cpp-adl-basic/audit.h | 6 + .../cpp-adl-pointer-arg-boundary/app.cpp | 8 + .../cpp-adl-pointer-arg-boundary/audit.h | 6 + .../cpp-adl-suppressed-parens/app.cpp | 8 + .../cpp-adl-suppressed-parens/audit.h | 6 + .../cpp-anon-ns-cross-file/caller.cpp | 5 + .../cpp-anon-ns-cross-file/helper.cpp | 7 + .../cpp-anon-ns-same-file-visible/helper.cpp | 7 + .../cpp-include-no-class-leak/caller.cpp | 5 + .../cpp-include-no-class-leak/user.h | 6 + .../cpp-include-no-namespace-leak/caller.cpp | 5 + .../cpp-include-no-namespace-leak/lib.h | 5 + .../app.cpp | 8 + .../audit.h | 8 + .../cpp-inline-namespace-nested/caller.cpp | 5 + .../cpp-inline-namespace-nested/lib.h | 9 + .../caller.cpp | 5 + .../cpp-inline-namespace-unqualified/lib.h | 7 + .../cpp-inline-namespace-versioned/caller.cpp | 5 + .../cpp-inline-namespace-versioned/lib.h | 10 + .../caller.cpp | 5 + .../singleton.h | 6 + .../caller.cpp | 6 + .../service.cpp | 4 + .../service.h | 7 + .../cpp-overload-int-long/caller.cpp | 6 + .../cpp-overload-int-long/service.cpp | 4 + .../cpp-overload-int-long/service.h | 7 + .../classes.h | 13 + .../cpp-phase5-u2-u3-adl-from-derived/audit.h | 6 + .../cpp-phase5-u2-u3-adl-from-derived/base.h | 8 + .../derived.h | 11 + .../cpp-phase5-u3-u5-inline-base/base.h | 10 + .../cpp-phase5-u3-u5-inline-base/derived.h | 10 + .../cpp-two-phase-dependent-base/base.h | 7 + .../cpp-two-phase-dependent-base/derived.h | 13 + .../base.h | 6 + .../derived.h | 11 + .../helpers.h | 5 + .../concrete-base.h | 5 + .../derived.h | 10 + .../cpp-two-phase-this-qualified/base.h | 7 + .../cpp-two-phase-this-qualified/derived.h | 13 + .../cpp-using-namespace-conflict/a.h | 5 + .../cpp-using-namespace-conflict/b.h | 5 + .../cpp-using-namespace-conflict/caller.cpp | 9 + .../cpp-using-namespace-std-smoke/caller.cpp | 9 + .../cpp-using-namespace-std-smoke/helper.cpp | 1 + .../cpp-using-namespace-std-smoke/std-shim.h | 13 + .../test/integration/resolvers/cpp.test.ts | 611 ++++++++++++- .../test/integration/resolvers/helpers.ts | 68 ++ .../test/unit/registry-primary-flag.test.ts | 11 +- .../scope-resolution/cpp/cpp-arity.test.ts | 168 ++++ .../scope-resolution/cpp/cpp-captures.test.ts | 426 +++++++++ .../scope-resolution/cpp/cpp-imports.test.ts | 161 ++++ 87 files changed, 5445 insertions(+), 24 deletions(-) create mode 100644 gitnexus/src/core/ingestion/languages/cpp/adl.ts create mode 100644 gitnexus/src/core/ingestion/languages/cpp/arity-metadata.ts create mode 100644 gitnexus/src/core/ingestion/languages/cpp/arity.ts create mode 100644 gitnexus/src/core/ingestion/languages/cpp/captures.ts create mode 100644 gitnexus/src/core/ingestion/languages/cpp/file-local-linkage.ts create mode 100644 gitnexus/src/core/ingestion/languages/cpp/header-scan.ts create mode 100644 gitnexus/src/core/ingestion/languages/cpp/import-decomposer.ts create mode 100644 gitnexus/src/core/ingestion/languages/cpp/import-target.ts create mode 100644 gitnexus/src/core/ingestion/languages/cpp/index.ts create mode 100644 gitnexus/src/core/ingestion/languages/cpp/inline-namespaces.ts create mode 100644 gitnexus/src/core/ingestion/languages/cpp/interpret.ts create mode 100644 gitnexus/src/core/ingestion/languages/cpp/merge-bindings.ts create mode 100644 gitnexus/src/core/ingestion/languages/cpp/query.ts create mode 100644 gitnexus/src/core/ingestion/languages/cpp/range-bindings.ts create mode 100644 gitnexus/src/core/ingestion/languages/cpp/scope-resolver.ts create mode 100644 gitnexus/src/core/ingestion/languages/cpp/simple-hooks.ts create mode 100644 gitnexus/src/core/ingestion/languages/cpp/two-phase-lookup.ts create mode 100644 gitnexus/test/fixtures/lang-resolution/cpp-adl-ambiguous/alpha.h create mode 100644 gitnexus/test/fixtures/lang-resolution/cpp-adl-ambiguous/app.cpp create mode 100644 gitnexus/test/fixtures/lang-resolution/cpp-adl-basic/app.cpp create mode 100644 gitnexus/test/fixtures/lang-resolution/cpp-adl-basic/audit.h create mode 100644 gitnexus/test/fixtures/lang-resolution/cpp-adl-pointer-arg-boundary/app.cpp create mode 100644 gitnexus/test/fixtures/lang-resolution/cpp-adl-pointer-arg-boundary/audit.h create mode 100644 gitnexus/test/fixtures/lang-resolution/cpp-adl-suppressed-parens/app.cpp create mode 100644 gitnexus/test/fixtures/lang-resolution/cpp-adl-suppressed-parens/audit.h create mode 100644 gitnexus/test/fixtures/lang-resolution/cpp-anon-ns-cross-file/caller.cpp create mode 100644 gitnexus/test/fixtures/lang-resolution/cpp-anon-ns-cross-file/helper.cpp create mode 100644 gitnexus/test/fixtures/lang-resolution/cpp-anon-ns-same-file-visible/helper.cpp create mode 100644 gitnexus/test/fixtures/lang-resolution/cpp-include-no-class-leak/caller.cpp create mode 100644 gitnexus/test/fixtures/lang-resolution/cpp-include-no-class-leak/user.h create mode 100644 gitnexus/test/fixtures/lang-resolution/cpp-include-no-namespace-leak/caller.cpp create mode 100644 gitnexus/test/fixtures/lang-resolution/cpp-include-no-namespace-leak/lib.h create mode 100644 gitnexus/test/fixtures/lang-resolution/cpp-inline-namespace-adl-participation/app.cpp create mode 100644 gitnexus/test/fixtures/lang-resolution/cpp-inline-namespace-adl-participation/audit.h create mode 100644 gitnexus/test/fixtures/lang-resolution/cpp-inline-namespace-nested/caller.cpp create mode 100644 gitnexus/test/fixtures/lang-resolution/cpp-inline-namespace-nested/lib.h create mode 100644 gitnexus/test/fixtures/lang-resolution/cpp-inline-namespace-unqualified/caller.cpp create mode 100644 gitnexus/test/fixtures/lang-resolution/cpp-inline-namespace-unqualified/lib.h create mode 100644 gitnexus/test/fixtures/lang-resolution/cpp-inline-namespace-versioned/caller.cpp create mode 100644 gitnexus/test/fixtures/lang-resolution/cpp-inline-namespace-versioned/lib.h create mode 100644 gitnexus/test/fixtures/lang-resolution/cpp-namespace-qualified-not-super/caller.cpp create mode 100644 gitnexus/test/fixtures/lang-resolution/cpp-namespace-qualified-not-super/singleton.h create mode 100644 gitnexus/test/fixtures/lang-resolution/cpp-overload-default-arg-ambiguous/caller.cpp create mode 100644 gitnexus/test/fixtures/lang-resolution/cpp-overload-default-arg-ambiguous/service.cpp create mode 100644 gitnexus/test/fixtures/lang-resolution/cpp-overload-default-arg-ambiguous/service.h create mode 100644 gitnexus/test/fixtures/lang-resolution/cpp-overload-int-long/caller.cpp create mode 100644 gitnexus/test/fixtures/lang-resolution/cpp-overload-int-long/service.cpp create mode 100644 gitnexus/test/fixtures/lang-resolution/cpp-overload-int-long/service.h create mode 100644 gitnexus/test/fixtures/lang-resolution/cpp-phase5-u1-u3-qualified-base-call/classes.h create mode 100644 gitnexus/test/fixtures/lang-resolution/cpp-phase5-u2-u3-adl-from-derived/audit.h create mode 100644 gitnexus/test/fixtures/lang-resolution/cpp-phase5-u2-u3-adl-from-derived/base.h create mode 100644 gitnexus/test/fixtures/lang-resolution/cpp-phase5-u2-u3-adl-from-derived/derived.h create mode 100644 gitnexus/test/fixtures/lang-resolution/cpp-phase5-u3-u5-inline-base/base.h create mode 100644 gitnexus/test/fixtures/lang-resolution/cpp-phase5-u3-u5-inline-base/derived.h create mode 100644 gitnexus/test/fixtures/lang-resolution/cpp-two-phase-dependent-base/base.h create mode 100644 gitnexus/test/fixtures/lang-resolution/cpp-two-phase-dependent-base/derived.h create mode 100644 gitnexus/test/fixtures/lang-resolution/cpp-two-phase-namespace-free-call-inside-template/base.h create mode 100644 gitnexus/test/fixtures/lang-resolution/cpp-two-phase-namespace-free-call-inside-template/derived.h create mode 100644 gitnexus/test/fixtures/lang-resolution/cpp-two-phase-namespace-free-call-inside-template/helpers.h create mode 100644 gitnexus/test/fixtures/lang-resolution/cpp-two-phase-non-dependent-base/concrete-base.h create mode 100644 gitnexus/test/fixtures/lang-resolution/cpp-two-phase-non-dependent-base/derived.h create mode 100644 gitnexus/test/fixtures/lang-resolution/cpp-two-phase-this-qualified/base.h create mode 100644 gitnexus/test/fixtures/lang-resolution/cpp-two-phase-this-qualified/derived.h create mode 100644 gitnexus/test/fixtures/lang-resolution/cpp-using-namespace-conflict/a.h create mode 100644 gitnexus/test/fixtures/lang-resolution/cpp-using-namespace-conflict/b.h create mode 100644 gitnexus/test/fixtures/lang-resolution/cpp-using-namespace-conflict/caller.cpp create mode 100644 gitnexus/test/fixtures/lang-resolution/cpp-using-namespace-std-smoke/caller.cpp create mode 100644 gitnexus/test/fixtures/lang-resolution/cpp-using-namespace-std-smoke/helper.cpp create mode 100644 gitnexus/test/fixtures/lang-resolution/cpp-using-namespace-std-smoke/std-shim.h create mode 100644 gitnexus/test/unit/scope-resolution/cpp/cpp-arity.test.ts create mode 100644 gitnexus/test/unit/scope-resolution/cpp/cpp-captures.test.ts create mode 100644 gitnexus/test/unit/scope-resolution/cpp/cpp-imports.test.ts diff --git a/gitnexus-shared/src/scope-resolution/finalize-algorithm.ts b/gitnexus-shared/src/scope-resolution/finalize-algorithm.ts index a94012a0b..f5d3dd0bf 100644 --- a/gitnexus-shared/src/scope-resolution/finalize-algorithm.ts +++ b/gitnexus-shared/src/scope-resolution/finalize-algorithm.ts @@ -833,7 +833,16 @@ function expandWildcard( if (target === undefined) return [edge]; const names = hooks.expandsWildcardTo(edge.targetModuleScope, workspace); - if (names.length === 0) return []; + if (names.length === 0) { + // Resolved wildcard with zero propagating names is still a real file- + // level dependency (e.g. a C++ header that only declares classes — + // `#include` is a valid IMPORTS edge, but unqualified-binding names + // are correctly empty since class methods require `Class::method`). + // Preserve the original wildcard edge so the file→file IMPORTS edge + // survives; downstream binding materialization sees no propagated + // names because the edge has no `targetExportedName`/`localName`. + return [edge]; + } const expanded: ImportEdge[] = []; for (const name of names) { diff --git a/gitnexus/src/core/ingestion/call-processor.ts b/gitnexus/src/core/ingestion/call-processor.ts index b45478f7a..35a59dab4 100644 --- a/gitnexus/src/core/ingestion/call-processor.ts +++ b/gitnexus/src/core/ingestion/call-processor.ts @@ -774,6 +774,7 @@ export const processCalls = async ( propertyName: string; filePath: string; srcId: string; + line?: number; }[] = []; // Phase P cross-file: accumulate heritage across files for cross-file isSubclassOf. // Used as a secondary check when per-file parentMap lacks the relationship — helps @@ -1102,11 +1103,16 @@ export const processCalls = async ( provider, ); const srcId = enclosing || generateId('File', file.path); - // Defer resolution so write-access tracking sees the FINAL graph - // state — properties from the pre-pass are present, but receiver-type - // resolution can still depend on inference that completes during the - // main loop. Resolve after all files have been processed. - pendingWrites.push({ receiverTypeName, propertyName, filePath: file.path, srcId }); + // Defer resolution: Ruby attr_accessor properties are registered during + // this same loop, so cross-file lookups fail if the declaring file hasn't + // been processed yet. Collect now, resolve after all files are done. + pendingWrites.push({ + receiverTypeName, + propertyName, + filePath: file.path, + srcId, + line: captureMap['assignment'].startPosition.row + 1, + }); } // Assignment-only capture (no @call sibling): skip the rest of this // forEach iteration — this acts as a `continue` in the match loop. @@ -1516,7 +1522,10 @@ export const processCalls = async ( ); if (fieldOwner) { graph.addRelationship({ - id: generateId('ACCESSES', `${pw.srcId}:${fieldOwner.nodeId}:write`), + id: generateId( + 'ACCESSES', + `${pw.srcId}:${fieldOwner.nodeId}:write${pw.line !== undefined ? `:${pw.line}` : ''}`, + ), sourceId: pw.srcId, targetId: fieldOwner.nodeId, type: 'ACCESSES', @@ -3113,7 +3122,10 @@ export const processAssignmentsFromExtracted = ( const fieldOwner = resolveFieldOwnership(receiverTypeName, asn.propertyName, asn.filePath, ctx); if (!fieldOwner) continue; graph.addRelationship({ - id: generateId('ACCESSES', `${asn.sourceId}:${fieldOwner.nodeId}:write`), + id: generateId( + 'ACCESSES', + `${asn.sourceId}:${fieldOwner.nodeId}:write${asn.line !== undefined ? `:${asn.line}` : ''}`, + ), sourceId: asn.sourceId, targetId: fieldOwner.nodeId, type: 'ACCESSES', diff --git a/gitnexus/src/core/ingestion/languages/c-cpp.ts b/gitnexus/src/core/ingestion/languages/c-cpp.ts index 7fab4689b..58e59fe6f 100644 --- a/gitnexus/src/core/ingestion/languages/c-cpp.ts +++ b/gitnexus/src/core/ingestion/languages/c-cpp.ts @@ -55,6 +55,15 @@ import { cImportOwningScope, cReceiverBinding, } from './c/index.js'; +import { + emitCppScopeCaptures, + interpretCppImport, + interpretCppTypeBinding, + cppArityCompatibility, + cppBindingScopeFor, + cppImportOwningScope, + cppReceiverBinding, +} from './cpp/index.js'; const C_BUILT_INS: ReadonlySet = new Set([ 'printf', @@ -447,4 +456,14 @@ export const cppProvider = defineLanguage({ heritageExtractor: createHeritageExtractor(SupportedLanguages.CPlusPlus), labelOverride: cppLabelOverride, builtInNames: C_BUILT_INS, + + // ── RFC #909 Ring 3: scope-based resolution hooks (RFC §5) ────────── + emitScopeCaptures: emitCppScopeCaptures, + interpretImport: interpretCppImport, + interpretTypeBinding: interpretCppTypeBinding, + bindingScopeFor: cppBindingScopeFor, + importOwningScope: cppImportOwningScope, + receiverBinding: cppReceiverBinding, + arityCompatibility: cppArityCompatibility, + // mergeBindings + resolveImportTarget live on ScopeResolver (see cpp/scope-resolver.ts). }); diff --git a/gitnexus/src/core/ingestion/languages/cpp/adl.ts b/gitnexus/src/core/ingestion/languages/cpp/adl.ts new file mode 100644 index 000000000..112502a74 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/cpp/adl.ts @@ -0,0 +1,335 @@ +/** + * C++ argument-dependent lookup (ADL / Koenig lookup) — V1. + * + * When ordinary unqualified lookup fails for a free-call site, ADL also + * considers candidates declared in the **associated namespaces** of the + * call's argument types (ISO C++ `[basic.lookup.argdep]`). The canonical + * pattern V1 unlocks: + * + * namespace audit { struct Event; void record(Event); } + * namespace app { void run() { audit::Event e; record(e); } } + * + * Without ADL: `record(e)` is unresolved because `app::run` doesn't + * `using` anything. With V1 ADL: `audit::record` is discovered via + * `audit::Event`'s associated namespace. + * + * ## V1 boundary + * + * V1 covers ONE associated-entity rule: an argument that's a directly-named + * class type (`audit::Event e`) contributes its **direct enclosing + * namespace** to the candidate set. Anything else — pointer/reference + * arguments, function-pointer arguments, template specializations, + * base-class associated namespaces — is V2 closure work and is + * deliberately excluded. The `cpp-adl-pointer-arg-boundary` fixture + * locks the exclusion in CI. + * + * V1 also short-circuits to ADL only when ordinary lookup is empty + * (`findCallableBindingInScope` returned undefined). ISO C++ would + * normally merge ADL candidates with ordinary-lookup candidates and + * run overload resolution over the union; V1 defers that merge to V2. + * + * ## Parenthesized-name suppression + * + * `(f)(s)` MUST NOT trigger ADL — the parenthesized name forces ordinary + * lookup only. `captures.ts` records sites whose `function` child is a + * `parenthesized_expression` into `noAdlSites`; `pickCppAdlCandidates` + * short-circuits when the site key is present. + * + * ## State lifecycle + * + * Three module-level maps populated per pipeline invocation, cleared via + * `clearCppAdlState()` (called from `clearFileLocalNames`): + * + * - `argInfoBySite` — per-call-site argument shape (capture-time) + * - `noAdlSites` — call sites with parenthesized function (capture-time) + * - `classToNamespaceQualifiedName` — class def → its enclosing namespace + * qualified name (`populateCppAssociatedNamespaces` time) + * + * The class→namespace map uses qualified names (not scope IDs) because + * C++ namespaces are open: `namespace N { ... }` in file A and + * `namespace N { ... }` in file B produce two distinct Namespace scopes + * but logically share the same namespace. ADL must consider candidates + * declared in either file. + */ + +import type { ParsedFile, ScopeId, SymbolDefinition } from 'gitnexus-shared'; +import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js'; +import { + isOverloadAmbiguousAfterNormalization, + narrowOverloadCandidates, +} from '../../scope-resolution/passes/overload-narrowing.js'; + +/** + * Per-argument shape information collected at capture time. ADL only + * fires for arguments where `simpleClassName !== ''` AND `!isPointer` + * AND `!isReference` — i.e., directly-named class-type values. + */ +export interface CppAdlArgInfo { + /** Simple class-like type name (last segment of qualified name); empty + * for primitives, literals, function pointers, template specs, etc. */ + readonly simpleClassName: string; + /** True when the variable's declarator was a `pointer_declarator`. V1 + * excludes pointer-typed args (closure rules deferred to V2). */ + readonly isPointer: boolean; + /** True when the variable's declarator was a `reference_declarator`. */ + readonly isReference: boolean; +} + +const argInfoBySite = new Map(); +const noAdlSites = new Set(); +const classToNamespaceQualifiedName = new Map(); + +/** Sentinel returned by `pickCppAdlCandidates` when ADL surfaces multiple + * candidates that share normalized parameter types — the caller MUST + * suppress (zero edges) rather than pick arbitrarily. Mirrors the + * OVERLOAD_AMBIGUOUS contract from the receiver-bound path. */ +export const ADL_AMBIGUOUS = Symbol('ADL_AMBIGUOUS'); +export type AdlResult = SymbolDefinition | typeof ADL_AMBIGUOUS | undefined; + +function siteKey(filePath: string, line: number, col: number): string { + return `${filePath}:${line}:${col}`; +} + +/** Record per-call-site argument info. Called once per call site from + * `emitCppScopeCaptures`. */ +export function markCppAdlSiteArgs( + filePath: string, + line: number, + col: number, + args: readonly CppAdlArgInfo[], +): void { + argInfoBySite.set(siteKey(filePath, line, col), args); +} + +/** Mark a call site as ADL-suppressed (function child wrapped in + * `parenthesized_expression`, e.g. `(f)(s)`). */ +export function markCppAdlSiteNoAdl(filePath: string, line: number, col: number): void { + noAdlSites.add(siteKey(filePath, line, col)); +} + +/** Clear ADL state. Called from `clearFileLocalNames` so all C++ resolver + * per-pipeline state is reset together. */ +export function clearCppAdlState(): void { + argInfoBySite.clear(); + noAdlSites.clear(); + classToNamespaceQualifiedName.clear(); +} + +/** + * Walk `parsed.scopes` to record each Class def's enclosing namespace + * qualified name. Run from the cpp resolver's `populateOwners` hook so + * the index is available before any resolution pass consults it. + * + * Computes the namespace's qualified name by walking parent scope chain + * and looking up Namespace defs in each parent's `ownedDefs`. The + * resulting name is dot-joined (matching `populateClassOwnedMembers`'s + * dotted convention; conversion to `::` is consumer-internal). + */ +export function populateCppAssociatedNamespaces(parsed: ParsedFile): void { + const scopesById = new Map(); + for (const scope of parsed.scopes) scopesById.set(scope.id, scope); + + for (const scope of parsed.scopes) { + if (scope.kind !== 'Class') continue; + const nsQName = computeEnclosingNamespaceQName(scope, scopesById); + if (nsQName === '') continue; + for (const def of scope.ownedDefs) { + if (def.type !== 'Class' && def.type !== 'Struct' && def.type !== 'Interface') continue; + classToNamespaceQualifiedName.set(def.nodeId, nsQName); + } + } +} + +/** + * V1 ADL candidate picker. Returns: + * - `SymbolDefinition` — exactly one ADL candidate (or unique survivor + * after narrowing); caller emits the CALLS edge. + * - `ADL_AMBIGUOUS` — multiple candidates with no disambiguator; + * caller MUST suppress (zero edges). + * - `undefined` — no ADL candidates; caller falls through to ordinary + * `pickUniqueGlobalCallable` fallback. + * + * Fires only when: + * - the call site is not in `noAdlSites` (parenthesized form), AND + * - at least one argument is a directly-named class type (not pointer, + * not reference, not literal/primitive). + */ +export function pickCppAdlCandidates( + site: { + readonly name: string; + readonly arity?: number; + readonly argumentTypes?: readonly string[]; + readonly atRange: { startLine: number; startCol: number }; + }, + callerParsed: ParsedFile, + scopes: ScopeResolutionIndexes, + parsedFiles: readonly ParsedFile[], +): AdlResult { + const key = siteKey(callerParsed.filePath, site.atRange.startLine, site.atRange.startCol); + if (noAdlSites.has(key)) return undefined; + const args = argInfoBySite.get(key); + if (args === undefined || args.length === 0) return undefined; + + // Collect associated namespace QNames from every value-class-typed arg. + const associatedNamespaces = new Set(); + for (const arg of args) { + if (arg.simpleClassName === '') continue; + if (arg.isPointer || arg.isReference) continue; + const classDef = findCppClassDefBySimpleName(arg.simpleClassName, scopes); + if (classDef === undefined) continue; + const nsQName = classToNamespaceQualifiedName.get(classDef.nodeId); + if (nsQName !== undefined) associatedNamespaces.add(nsQName); + } + if (associatedNamespaces.size === 0) return undefined; + + // Walk every namespace scope in every parsed file; collect callable + // ownedDefs whose enclosing namespace matches one of the associated + // QNames AND whose simple name matches the call's name. + const candidates: SymbolDefinition[] = []; + const seenKey = new Set(); + for (const parsed of parsedFiles) { + const scopesById = new Map(); + for (const sc of parsed.scopes) scopesById.set(sc.id, sc); + for (const scope of parsed.scopes) { + if (scope.kind !== 'Namespace') continue; + const qName = computeNamespaceQName(scope, scopesById); + if (!associatedNamespaces.has(qName)) continue; + for (const def of scope.ownedDefs) { + if (def.type !== 'Function' && def.type !== 'Method' && def.type !== 'Constructor') { + continue; + } + const simple = def.qualifiedName?.split('.').pop() ?? def.qualifiedName ?? ''; + if (simple !== site.name) continue; + // Dedup by nodeId — using normalized parameter-types as the key + // would collapse `process(int)`/`process(long)`-style overloads + // (both normalize to `['int']`) before + // `isOverloadAmbiguousAfterNormalization` can detect them. + if (seenKey.has(def.nodeId)) continue; + seenKey.add(def.nodeId); + candidates.push(def); + } + } + } + if (candidates.length === 0) return undefined; + if (candidates.length === 1) return candidates[0]; + + // Multi-candidate: narrow then check ambiguity. Reuses the OVERLOAD_AMBIGUOUS + // sentinel contract from `overload-narrowing.ts` so int/long-collision-style + // ambiguity also suppresses on the ADL path. + const narrowed = narrowOverloadCandidates(candidates, site.arity, site.argumentTypes); + if (narrowed.length === 1) return narrowed[0]; + if (narrowed.length === 0) return undefined; + if (isOverloadAmbiguousAfterNormalization(narrowed, site.arity)) return ADL_AMBIGUOUS; + // Multiple surviving candidates that aren't normalization-ambiguous — + // ISO C++ would run overload resolution; V1 lacks conversion ranking so + // suppress rather than pick arbitrarily. Mirrors `pickImplicitThisOverload`'s + // unique-survivor requirement (see `pick-implicit-this-overload.test.ts`). + return ADL_AMBIGUOUS; +} + +/** Walk upward from a Class scope, finding the innermost enclosing + * Namespace scope, and return that namespace's qualified name (dot- + * joined, outermost-first). Returns '' when the class has no enclosing + * namespace (e.g., declared at translation-unit scope). */ +function computeEnclosingNamespaceQName( + classScope: { readonly parent: ScopeId | null }, + scopesById: ReadonlyMap< + ScopeId, + { + readonly parent: ScopeId | null; + readonly kind: string; + readonly ownedDefs: readonly SymbolDefinition[]; + } + >, +): string { + let parentId: ScopeId | null = classScope.parent; + while (parentId !== null) { + const parent = scopesById.get(parentId); + if (parent === undefined) return ''; + if (parent.kind === 'Namespace') { + return computeNamespaceQName(parent, scopesById); + } + parentId = parent.parent; + } + return ''; +} + +/** Walk upward from a Namespace scope collecting each enclosing + * Namespace's simple name (innermost last). Returns the dot-joined + * qualified name (e.g., `outer.inner`). The namespace's own def lives + * in its OWN scope's `ownedDefs` (the C++ extractor stamps the + * namespace-decl def into the namespace scope itself, not the parent + * module scope). */ +function computeNamespaceQName( + nsScope: { readonly parent: ScopeId | null; readonly ownedDefs: readonly SymbolDefinition[] }, + scopesById: ReadonlyMap< + ScopeId, + { + readonly parent: ScopeId | null; + readonly kind: string; + readonly ownedDefs: readonly SymbolDefinition[]; + } + >, +): string { + const segments: string[] = []; + let currentId: ScopeId | null = nsScope.parent; + let current: + | { readonly parent: ScopeId | null; readonly ownedDefs: readonly SymbolDefinition[] } + | undefined = nsScope; + // Outer guard against pathological cycles in malformed scope trees. + let safety = 64; + while (current !== undefined && safety-- > 0) { + const nsDef = findNamespaceDefInScope(current); + if (nsDef === undefined) { + // No name found — bail out. Returning a partial QName would risk + // false ADL associations. + return ''; + } + const simple = nsDef.qualifiedName?.split('.').pop() ?? nsDef.qualifiedName ?? ''; + segments.unshift(simple); + // Walk up to next enclosing namespace (skipping non-namespace parents). + let nextId: ScopeId | null = currentId; + let nextNs: typeof current | undefined; + while (nextId !== null) { + const nx = scopesById.get(nextId); + if (nx === undefined) break; + if (nx.kind === 'Namespace') { + nextNs = nx; + currentId = nx.parent; + break; + } + nextId = nx.parent; + } + current = nextNs; + } + return segments.join('.'); +} + +/** Find the Namespace def attached to this scope (the namespace's own + * decl, stamped into its own `ownedDefs` by the C++ extractor). Returns + * the first Namespace-type def encountered — for normal C++ the scope + * carries exactly one Namespace-typed self def. */ +function findNamespaceDefInScope(scope: { + readonly ownedDefs: readonly SymbolDefinition[]; +}): SymbolDefinition | undefined { + for (const def of scope.ownedDefs) { + if (def.type === 'Namespace') return def; + } + return undefined; +} + +/** Find a class-like def by simple name across the workspace. V1 + * arbitrary-pick on collisions (multiple classes share the simple name); + * C++ ADL strictness would require full type-driven lookup, but V1 + * trades that for simplicity. */ +function findCppClassDefBySimpleName( + simpleName: string, + scopes: ScopeResolutionIndexes, +): SymbolDefinition | undefined { + for (const def of scopes.defs.byId.values()) { + if (def.type !== 'Class' && def.type !== 'Struct' && def.type !== 'Interface') continue; + const simple = def.qualifiedName?.split('.').pop() ?? def.qualifiedName ?? ''; + if (simple === simpleName) return def; + } + return undefined; +} diff --git a/gitnexus/src/core/ingestion/languages/cpp/arity-metadata.ts b/gitnexus/src/core/ingestion/languages/cpp/arity-metadata.ts new file mode 100644 index 000000000..fb47d3122 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/cpp/arity-metadata.ts @@ -0,0 +1,185 @@ +import type { SyntaxNode } from '../../utils/ast-helpers.js'; + +export interface CppArityInfo { + parameterCount?: number; + requiredParameterCount?: number; + parameterTypes?: string[]; +} + +/** + * Compute declaration arity from a C++ function definition or declaration node. + * Extends the C arity computation with support for: + * - optional_parameter_declaration (default parameters) + * - variadic_parameter_declaration / parameter packs + * - (void) explicit zero-parameter form + */ +export function computeCppDeclarationArity(node: SyntaxNode): CppArityInfo { + const funcDecl = findFuncDeclarator(node); + if (funcDecl === null) return {}; + + const paramList = funcDecl.childForFieldName('parameters'); + if (paramList === null) return {}; + + const params: SyntaxNode[] = []; + // Track whether a C-style variadic `...` anonymous token appears. + // tree-sitter-cpp emits `...` as an anonymous (non-named) child of + // parameter_list, not as `variadic_parameter`. + let hasEllipsis = false; + for (let i = 0; i < paramList.childCount; i++) { + const child = paramList.child(i); + if (child === null) continue; + if ( + child.type === 'parameter_declaration' || + child.type === 'optional_parameter_declaration' || + child.type === 'variadic_parameter' || + child.type === 'variadic_parameter_declaration' + ) { + params.push(child); + } else if (child.type === '...' || (!child.isNamed && child.text === '...')) { + hasEllipsis = true; + } + } + + // Empty parameter list: C++ `void foo()` means zero params (unlike C) + if (params.length === 0 && !hasEllipsis) { + return { parameterCount: 0, requiredParameterCount: 0, parameterTypes: [] }; + } + + // (void) means zero parameters + if (params.length === 1 && params[0].type === 'parameter_declaration') { + const typeNode = params[0].childForFieldName('type'); + const hasDeclarator = params[0].childForFieldName('declarator') !== null; + if (typeNode !== null && typeNode.text === 'void' && !hasDeclarator) { + return { parameterCount: 0, requiredParameterCount: 0, parameterTypes: [] }; + } + } + + // C-style variadic: `void foo(int x, ...)` — the `...` is an anonymous + // token in tree-sitter-cpp, detected via `hasEllipsis` above. + // C++ parameter packs: `template void foo(Ts... args)` — + // detected as `variadic_parameter_declaration`. + const isVariadic = + hasEllipsis || + params.some( + (p) => p.type === 'variadic_parameter' || p.type === 'variadic_parameter_declaration', + ); + const optionalCount = params.filter((p) => p.type === 'optional_parameter_declaration').length; + const requiredCount = params.filter( + (p) => + p.type === 'parameter_declaration' || + // variadic_parameter_declaration with a name is a parameter pack — counts as one + p.type === 'variadic_parameter_declaration', + ).length; + const totalNonVariadic = requiredCount + optionalCount; + + const types: string[] = []; + for (const p of params) { + if (p.type === 'variadic_parameter') { + types.push('...'); + } else if (p.type === 'variadic_parameter_declaration') { + // Parameter pack: treated as variadic + types.push('...'); + } else { + const typeNode = p.childForFieldName('type'); + types.push(normalizeCppParamType(typeNode?.text ?? 'unknown')); + } + } + // Append '...' for C-style variadic if not already in types + if (hasEllipsis && !types.includes('...')) { + types.push('...'); + } + + return { + parameterCount: isVariadic ? undefined : totalNonVariadic, + requiredParameterCount: requiredCount, + parameterTypes: types, + }; +} + +/** + * Compute call-site arity from a call_expression node. + */ +export function computeCppCallArity(node: SyntaxNode): number { + const argList = node.childForFieldName('arguments'); + if (argList === null) return 0; + + let count = 0; + for (let i = 0; i < argList.childCount; i++) { + const child = argList.child(i); + if (child === null) continue; + if (child.type !== ',' && child.type !== '(' && child.type !== ')') { + count++; + } + } + return count; +} + +/** + * Normalize a C++ parameter type for overload disambiguation. + * Maps common qualified/aliased types to their canonical short forms + * so that `narrowOverloadCandidates` can match against literal-inferred + * argument types (e.g. `inferCppLiteralType` returns `'string'` for + * string literals, not `'std::string'`). + */ +function normalizeCppParamType(raw: string): string { + let t = raw.trim(); + // Strip const, volatile, etc. + t = t.replace(/\b(const|volatile|restrict|mutable|constexpr)\b/g, '').trim(); + // Strip reference/pointer markers + t = t.replace(/[&*]+\s*$/, '').trim(); + // Strip template parameters (loop handles nested: Map> → Map) + while (t.includes('<')) { + const stripped = t.replace(/<[^<>]*>/g, ''); + if (stripped === t) break; // avoid infinite loop on malformed input + t = stripped; + } + t = t.trim(); + // Map std:: types to canonical short forms + const STD_MAP: Record = { + 'std::string': 'string', + 'std::wstring': 'string', + 'std::string_view': 'string', + string: 'string', + char: 'char', + int: 'int', + long: 'int', + short: 'int', + unsigned: 'int', + 'unsigned int': 'int', + 'long long': 'int', + size_t: 'int', + 'std::size_t': 'int', + float: 'double', + double: 'double', + bool: 'bool', + nullptr_t: 'null', + 'std::nullptr_t': 'null', + }; + return STD_MAP[t] ?? t; +} + +function findFuncDeclarator(node: SyntaxNode): SyntaxNode | null { + let decl = node.childForFieldName('declarator'); + if (decl === null) { + for (let i = 0; i < node.childCount; i++) { + const c = node.child(i); + if (c?.type === 'function_declarator') return c; + } + return null; + } + // Unwrap pointer_declarator / reference_declarator + while (decl.type === 'pointer_declarator' || decl.type === 'reference_declarator') { + const next = decl.childForFieldName('declarator'); + if (next === null) { + // reference_declarator may not use field name + for (let i = 0; i < decl.childCount; i++) { + const c = decl.child(i); + if (c?.type === 'function_declarator') return c; + } + break; + } + decl = next; + } + if (decl.type === 'function_declarator') return decl; + return null; +} diff --git a/gitnexus/src/core/ingestion/languages/cpp/arity.ts b/gitnexus/src/core/ingestion/languages/cpp/arity.ts new file mode 100644 index 000000000..e13fa6a3a --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/cpp/arity.ts @@ -0,0 +1,35 @@ +import type { Callsite, SymbolDefinition } from 'gitnexus-shared'; + +/** + * C++ arity compatibility: supports overloading and default parameters. + * + * Unlike C (no overloading, exact match only), C++ has: + * - Overloaded functions (same name, different signatures) + * - Default parameters (requiredParameterCount < parameterCount) + * - Variadic functions (C-style `...`) + * - Parameter packs (V1: treated as variadic) + * - Templates (V1: generic-ignored, arity check on non-template params) + * + * Verdict: + * - 'compatible': callsite.arity fits within [required, total] range + * - 'incompatible': callsite.arity is outside the valid range + * - 'unknown': insufficient metadata to determine + */ +export function cppArityCompatibility( + def: SymbolDefinition, + callsite: Callsite, +): 'compatible' | 'unknown' | 'incompatible' { + const max = def.parameterCount; + const min = def.requiredParameterCount; + if (max === undefined && min === undefined) return 'unknown'; + if (!Number.isFinite(callsite.arity) || callsite.arity < 0) return 'unknown'; + + const variadic = def.parameterTypes?.some((t) => t === '...') ?? false; + + // Too few arguments: less than the minimum required + if (min !== undefined && callsite.arity < min) return 'incompatible'; + // Too many arguments: more than the maximum and not variadic + if (max !== undefined && callsite.arity > max && !variadic) return 'incompatible'; + + return 'compatible'; +} diff --git a/gitnexus/src/core/ingestion/languages/cpp/captures.ts b/gitnexus/src/core/ingestion/languages/cpp/captures.ts new file mode 100644 index 000000000..e6d63635a --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/cpp/captures.ts @@ -0,0 +1,832 @@ +import type { Capture, CaptureMatch } from 'gitnexus-shared'; +import { + findNodeAtRange, + nodeToCapture, + syntheticCapture, + type SyntaxNode, +} from '../../utils/ast-helpers.js'; +import { getCppParser, getCppScopeQuery } from './query.js'; +import { getTreeSitterBufferSize } from '../../constants.js'; +import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js'; +import { splitCppInclude, splitCppUsingDecl } from './import-decomposer.js'; +import { computeCppDeclarationArity, computeCppCallArity } from './arity-metadata.js'; +import { markFileLocal } from './file-local-linkage.js'; +import { markCppDependentBase } from './two-phase-lookup.js'; +import { markCppAdlSiteArgs, markCppAdlSiteNoAdl, type CppAdlArgInfo } from './adl.js'; +import { markCppInlineNamespaceRange } from './inline-namespaces.js'; + +export function emitCppScopeCaptures( + sourceText: string, + filePath: string, + cachedTree?: unknown, +): readonly CaptureMatch[] { + let tree = cachedTree as ReturnType['parse']> | undefined; + if (tree === undefined) { + tree = parseSourceSafe(getCppParser(), sourceText, undefined, { + bufferSize: getTreeSitterBufferSize(sourceText), + }); + } + + const rawMatches = getCppScopeQuery().matches(tree.rootNode); + const out: CaptureMatch[] = []; + + // Track ranges where typedef-struct was captured as @declaration.struct + // so we can suppress the duplicate @declaration.typedef match. + const structTypedefRanges = new Set(); + + for (const m of rawMatches) { + const grouped: Record = {}; + for (const c of m.captures) { + const tag = '@' + c.name; + if (tag.startsWith('@_')) continue; + grouped[tag] = nodeToCapture(tag, c.node); + } + if (Object.keys(grouped).length === 0) continue; + + // ── Handle #include statements ────────────────────────────────── + if (grouped['@import.statement'] !== undefined) { + const anchor = grouped['@import.statement']!; + const includeNode = findNodeAtRange(tree.rootNode, anchor.range, 'preproc_include'); + if (includeNode !== null) { + const split = splitCppInclude(includeNode); + if (split !== null) { + out.push(split); + continue; + } + } + } + + // ── Handle using declarations (using namespace / using name) ──── + if (grouped['@import.using-decl'] !== undefined) { + const anchor = grouped['@import.using-decl']!; + const usingNode = findNodeAtRange(tree.rootNode, anchor.range, 'using_declaration'); + if (usingNode !== null) { + const split = splitCppUsingDecl(usingNode); + if (split !== null) { + out.push(split); + continue; + } + } + } + + // ── Track typedef-struct ranges ───────────────────────────────── + const structAnchor = grouped['@declaration.struct'] ?? grouped['@declaration.class']; + if (structAnchor !== undefined) { + const r = structAnchor.range; + structTypedefRanges.add(`${r.startLine}:${r.startCol}:${r.endLine}:${r.endCol}`); + } + + // Suppress @declaration.typedef if the same range was already captured + const typedefAnchor = grouped['@declaration.typedef']; + if (typedefAnchor !== undefined) { + const r = typedefAnchor.range; + const key = `${r.startLine}:${r.startCol}:${r.endLine}:${r.endCol}`; + if (structTypedefRanges.has(key)) continue; + } + + // ── Enrich function/method declarations with arity metadata ───── + const declAnchor = grouped['@declaration.function'] ?? grouped['@declaration.method']; + if (declAnchor !== undefined) { + const fnNode = + findNodeAtRange(tree.rootNode, declAnchor.range, 'function_definition') ?? + findNodeAtRange(tree.rootNode, declAnchor.range, 'declaration') ?? + findNodeAtRange(tree.rootNode, declAnchor.range, 'field_declaration'); + if (fnNode !== null) { + const arity = computeCppDeclarationArity(fnNode); + if (arity.parameterCount !== undefined) { + grouped['@declaration.parameter-count'] = syntheticCapture( + '@declaration.parameter-count', + fnNode, + String(arity.parameterCount), + ); + } + if (arity.requiredParameterCount !== undefined) { + grouped['@declaration.required-parameter-count'] = syntheticCapture( + '@declaration.required-parameter-count', + fnNode, + String(arity.requiredParameterCount), + ); + } + if (arity.parameterTypes !== undefined) { + grouped['@declaration.parameter-types'] = syntheticCapture( + '@declaration.parameter-types', + fnNode, + JSON.stringify(arity.parameterTypes), + ); + } + + // Detect static storage class (file-local linkage) + if (hasStaticStorageClass(fnNode)) { + const nameText = grouped['@declaration.name']?.text; + if (nameText !== undefined) { + markFileLocal(filePath, nameText); + } + } + + // Detect anonymous namespace (file-local linkage) + if (isInsideAnonymousNamespace(fnNode)) { + const nameText = grouped['@declaration.name']?.text; + if (nameText !== undefined) { + markFileLocal(filePath, nameText); + } + } + } + } + + // ── Detect static variables (file-local linkage) ──────────────── + const varDeclAnchor = grouped['@declaration.variable']; + if (varDeclAnchor !== undefined) { + const varNode = findNodeAtRange(tree.rootNode, varDeclAnchor.range, 'declaration'); + if (varNode !== null) { + if (hasStaticStorageClass(varNode) || isInsideAnonymousNamespace(varNode)) { + const nameText = grouped['@declaration.name']?.text; + if (nameText !== undefined) { + markFileLocal(filePath, nameText); + } + } + } + } + + // ── Enrich call references with arity ─────────────────────────── + const callAnchor = + grouped['@reference.call.free'] ?? + grouped['@reference.call.member'] ?? + grouped['@reference.call.qualified']; + if (callAnchor !== undefined && grouped['@reference.arity'] === undefined) { + const callNode = findNodeAtRange(tree.rootNode, callAnchor.range, 'call_expression'); + if (callNode !== null) { + grouped['@reference.arity'] = syntheticCapture( + '@reference.arity', + callNode, + String(computeCppCallArity(callNode)), + ); + } + } + + // ── Enrich constructor calls (new Foo()) with arity ───────────── + const ctorCallAnchor = grouped['@reference.call.constructor']; + if (ctorCallAnchor !== undefined && grouped['@reference.arity'] === undefined) { + const newNode = findNodeAtRange(tree.rootNode, ctorCallAnchor.range, 'new_expression'); + if (newNode !== null) { + grouped['@reference.arity'] = syntheticCapture( + '@reference.arity', + newNode, + String(computeCppCallArity(newNode)), + ); + } + } + + // ── Synthesize argument types for overload narrowing ──────────── + const anyCallAnchor = callAnchor ?? ctorCallAnchor; + if (anyCallAnchor !== undefined && grouped['@reference.parameter-types'] === undefined) { + const cNode = + findNodeAtRange(tree.rootNode, anyCallAnchor.range, 'call_expression') ?? + findNodeAtRange(tree.rootNode, anyCallAnchor.range, 'new_expression'); + if (cNode !== null) { + const argTypes = inferCppCallArgTypes(cNode); + if (argTypes !== undefined && argTypes.length > 0) { + grouped['@reference.parameter-types'] = syntheticCapture( + '@reference.parameter-types', + cNode, + JSON.stringify(argTypes), + ); + } + } + } + + // ── Inline namespace detection ────────────────────────────────── + // `inline namespace v1 { ... }` — tree-sitter-cpp exposes the + // `inline` keyword as a child of `namespace_definition`. Record the + // namespace's source range so `populateCppInlineNamespaceScopes` + // (during populateOwners) can match it back to the corresponding + // Namespace scope. + if (grouped['@declaration.namespace'] !== undefined) { + const anchor = grouped['@declaration.namespace']!; + const nsNode = findNodeAtRange(tree.rootNode, anchor.range, 'namespace_definition'); + if (nsNode !== null && isInlineNamespace(nsNode)) { + // Range coords stored in the shared Range shape use 1-based + // line numbers (see `ast-helpers.ts` rangeForNode where + // `startPosition.row + 1` is applied). Match that convention so + // `populateCppInlineNamespaceScopes` can join against `Scope.range`. + markCppInlineNamespaceRange(filePath, { + startLine: nsNode.startPosition.row + 1, + startCol: nsNode.startPosition.column, + endLine: nsNode.endPosition.row + 1, + endCol: nsNode.endPosition.column, + }); + } + } + + // ── ADL (Koenig lookup) per-site recording ────────────────────── + // Only free-call sites (no explicit receiver) participate in ADL — + // qualified `Ns::f(s)` and member `obj.f(s)` calls bypass the + // free-call fallback entirely (handled by receiver-bound-calls). + if (grouped['@reference.call.free'] !== undefined) { + const freeCallNode = findNodeAtRange( + tree.rootNode, + grouped['@reference.call.free']!.range, + 'call_expression', + ); + if (freeCallNode !== null) { + const adlAnchorRange = grouped['@reference.call.free']!.range; + if (isParenthesizedFunctionCall(freeCallNode)) { + markCppAdlSiteNoAdl(filePath, adlAnchorRange.startLine, adlAnchorRange.startCol); + } + const adlArgs = inferCppCallAdlArgs(freeCallNode); + if (adlArgs.length > 0) { + markCppAdlSiteArgs(filePath, adlAnchorRange.startLine, adlAnchorRange.startCol, adlArgs); + } + } + } + + // ── Post-process @type-binding.assignment for auto declarations ── + // The wildcard `type: (_)` in the @type-binding.assignment query + // pattern matches before the more specific @type-binding.alias and + // @type-binding.member-access patterns. When the type is `auto` + // (placeholder_type_specifier), we re-inspect the AST to synthesize + // the correct capture tags so interpret.ts can produce the right + // rawTypeName for compound-receiver chain resolution. + if ( + grouped['@type-binding.assignment'] !== undefined && + grouped['@type-binding.type']?.text === 'auto' + ) { + const anchor = grouped['@type-binding.assignment']!; + const declNode = findNodeAtRange(tree.rootNode, anchor.range, 'declaration'); + if (declNode !== null) { + const declarator = declNode.childForFieldName('declarator'); + if (declarator?.type === 'init_declarator') { + const valueNode = declarator.childForFieldName('value'); + if (valueNode !== null) { + if (valueNode.type === 'identifier') { + // auto alias = existingVar → promote to @type-binding.alias + grouped['@type-binding.alias'] = anchor; + grouped['@type-binding.type'] = nodeToCapture('@type-binding.type', valueNode); + delete grouped['@type-binding.assignment']; + } else if (valueNode.type === 'field_expression') { + // auto addr = user.address → promote to @type-binding.member-access + const argNode = valueNode.childForFieldName('argument'); + const fieldNode = valueNode.childForFieldName('field'); + if (argNode !== null && fieldNode !== null) { + grouped['@type-binding.member-access'] = anchor; + grouped['@type-binding.member-access-receiver'] = nodeToCapture( + '@type-binding.member-access-receiver', + argNode, + ); + grouped['@type-binding.type'] = nodeToCapture('@type-binding.type', fieldNode); + delete grouped['@type-binding.assignment']; + } + } else if (valueNode.type === 'call_expression') { + const fnNode = valueNode.childForFieldName('function'); + if (fnNode?.type === 'field_expression') { + // auto city = addr.getCity() → promote to @type-binding.alias + // with dotted rawName "addr.getCity" for compound-receiver + const argNode = fnNode.childForFieldName('argument'); + const fieldNode = fnNode.childForFieldName('field'); + if (argNode !== null && fieldNode !== null) { + grouped['@type-binding.member-access'] = anchor; + grouped['@type-binding.member-access-receiver'] = nodeToCapture( + '@type-binding.member-access-receiver', + argNode, + ); + grouped['@type-binding.type'] = nodeToCapture('@type-binding.type', fieldNode); + delete grouped['@type-binding.assignment']; + } + } + } + } + } + } + } + + out.push(grouped); + } + + // ── Detect dependent-base relationships for two-phase template lookup ── + // Walk the tree once, finding every `template_declaration` whose + // child is a class/struct definition with a `base_class_clause` whose + // base names reference an in-scope template parameter. Record the + // (className, dependentBaseName) pair so `populateCppDependentBases` + // (called from the `populateOwners` hook) can resolve names to nodeIds + // and the resolver can suppress unqualified-call binding to those + // bases per ISO C++ two-phase lookup. + detectCppDependentBases(tree.rootNode, filePath); + + return out; +} + +/** + * Walk the AST finding every template_declaration containing a class or + * struct definition with a dependent base. Records (className, baseName) + * pairs into the module-level state via `markCppDependentBase`. + * + * A base is "dependent" when its name (typically a template_type like + * `Base`) uses a template parameter of the enclosing template_declaration. + * Conservative bias: `typename T::U`, `decltype(...)` and template-template + * parameter shapes are also treated as dependent. + */ +function detectCppDependentBases(root: SyntaxNode, filePath: string): void { + const stack: SyntaxNode[] = [root]; + while (stack.length > 0) { + const node = stack.pop()!; + if (node.type === 'template_declaration') { + // Collect template-parameter names declared by this declaration. + // Inner template_declarations shadow outer ones — handled by the + // recursive descent below (each template_declaration creates its + // own parameter scope). + const params = collectTemplateParameterNames(node); + + // Find the class/struct definition inside this template_declaration. + const classNode = findChildOfType(node, ['class_specifier', 'struct_specifier']); + if (classNode !== null) { + const className = getTypeIdentifierName(classNode); + if (className !== '') { + const baseClause = findChildOfType(classNode, ['base_class_clause']); + if (baseClause !== null) { + for (const base of iterBaseClasses(baseClause)) { + if (isBaseDependent(base, params)) { + const baseName = extractBaseSimpleName(base); + if (baseName !== '') { + markCppDependentBase(filePath, className, baseName); + } + } + } + } + } + } + } + for (let i = 0; i < node.childCount; i++) { + const child = node.child(i); + if (child !== null) stack.push(child); + } + } +} + +/** Collect simple template parameter names from a template_declaration. */ +function collectTemplateParameterNames(templateDecl: SyntaxNode): Set { + const names = new Set(); + const paramList = findChildOfType(templateDecl, ['template_parameter_list']); + if (paramList === null) return names; + for (let i = 0; i < paramList.childCount; i++) { + const param = paramList.child(i); + if (param === null) continue; + if ( + param.type === 'type_parameter_declaration' || + param.type === 'optional_type_parameter_declaration' || + param.type === 'variadic_type_parameter_declaration' + ) { + const idNode = findFirstDescendantOfType(param, 'type_identifier'); + if (idNode !== null) names.add(idNode.text); + } else if ( + param.type === 'parameter_declaration' || + param.type === 'optional_parameter_declaration' || + param.type === 'variadic_parameter_declaration' + ) { + // Non-type template parameter (e.g. `template`). + const idNode = findFirstDescendantOfType(param, 'identifier'); + if (idNode !== null) names.add(idNode.text); + } else if (param.type === 'template_template_parameter_declaration') { + // template-template parameter (e.g. `template class TT>`) + const idNode = findFirstDescendantOfType(param, 'type_identifier'); + if (idNode !== null) names.add(idNode.text); + } + } + return names; +} + +/** Yield each base-class entry from a `base_class_clause`. */ +function* iterBaseClasses(baseClause: SyntaxNode): IterableIterator { + for (let i = 0; i < baseClause.childCount; i++) { + const child = baseClause.child(i); + if (child === null) continue; + // Skip ':', ',', and access_specifier nodes — the base names are + // type_identifier, template_type, or qualified_identifier. + if ( + child.type === 'type_identifier' || + child.type === 'template_type' || + child.type === 'qualified_identifier' + ) { + yield child; + } + } +} + +/** + * A base is dependent when: + * - it's a `template_type` and its argument list contains a + * `type_identifier` matching one of the enclosing template's params + * (e.g., `Base` where `T` is a template parameter), OR + * - it contains a `typename`, `decltype`, or `template_template_parameter` + * shape (conservatively treated as dependent). + * + * Non-dependent: `Base`, `ConcreteBase`, `Base` where + * `MyConcrete` is not a template parameter. + */ +function isBaseDependent(baseNode: SyntaxNode, templateParams: Set): boolean { + if (baseNode.type !== 'template_type') { + // Bare `type_identifier` or `qualified_identifier` bases — not + // dependent (the base name itself doesn't reference a template + // parameter at this level). + return false; + } + // Walk all descendants of the template_argument_list looking for any + // type_identifier matching a template parameter, or any conservative- + // dependent shape. + const stack: SyntaxNode[] = [baseNode]; + while (stack.length > 0) { + const node = stack.pop()!; + if (node.type === 'type_identifier' && templateParams.has(node.text)) { + return true; + } + if ( + node.type === 'decltype' || + node.type === 'dependent_type' || + node.type === 'template_template_parameter_declaration' + ) { + return true; + } + if (node.type === 'qualified_identifier') { + // `typename T::U` or `T::nested` — if any inner identifier matches + // a template parameter, dependent. + for (let i = 0; i < node.childCount; i++) { + const c = node.child(i); + if (c !== null) stack.push(c); + } + continue; + } + for (let i = 0; i < node.childCount; i++) { + const c = node.child(i); + if (c !== null) stack.push(c); + } + } + return false; +} + +/** Extract the simple name of a base class node. */ +function extractBaseSimpleName(baseNode: SyntaxNode): string { + if (baseNode.type === 'type_identifier') return baseNode.text; + if (baseNode.type === 'template_type') { + const nameNode = baseNode.childForFieldName('name'); + if (nameNode !== null) return nameNode.text; + // Fallback: first type_identifier descendant. + const id = findFirstDescendantOfType(baseNode, 'type_identifier'); + if (id !== null) return id.text; + } + if (baseNode.type === 'qualified_identifier') { + const nameNode = baseNode.childForFieldName('name'); + if (nameNode !== null) return nameNode.text; + } + return ''; +} + +/** Find the first direct child matching one of the given types. */ +function findChildOfType(node: SyntaxNode, types: readonly string[]): SyntaxNode | null { + for (let i = 0; i < node.childCount; i++) { + const c = node.child(i); + if (c !== null && types.includes(c.type)) return c; + } + return null; +} + +/** Recursive search for the first descendant of a given type. */ +function findFirstDescendantOfType(node: SyntaxNode, type: string): SyntaxNode | null { + if (node.type === type) return node; + for (let i = 0; i < node.childCount; i++) { + const c = node.child(i); + if (c === null) continue; + const hit = findFirstDescendantOfType(c, type); + if (hit !== null) return hit; + } + return null; +} + +/** Get the name of a class/struct/template_type node via its `name` field. */ +function getTypeIdentifierName(node: SyntaxNode): string { + const nameNode = node.childForFieldName('name'); + if (nameNode !== null) return nameNode.text; + const id = findFirstDescendantOfType(node, 'type_identifier'); + return id !== null ? id.text : ''; +} + +/** + * Infer argument types from a call_expression or new_expression node. + * Used for overload disambiguation by parameter types. + * + * Only literal types are inferred — identifiers and complex expressions + * return empty string (unknown) so narrowOverloadCandidates treats them + * as any-match. + */ +function inferCppCallArgTypes(node: SyntaxNode): string[] | undefined { + const argList = node.childForFieldName('arguments'); + if (argList === null) return undefined; + + const types: string[] = []; + for (let i = 0; i < argList.childCount; i++) { + const child = argList.child(i); + if (child === null) continue; + if (child.type === ',' || child.type === '(' || child.type === ')') continue; + const litType = inferCppLiteralType(child); + if (litType !== '') { + types.push(litType); + } else if (child.type === 'identifier') { + // Variable reference — look up declared type in enclosing scope + types.push(lookupDeclaredTypeForIdentifier(child)); + } else { + types.push(''); + } + } + return types.length > 0 ? types : undefined; +} + +/** + * Infer the canonical type name of a C++ literal AST node. + * Returns empty string for non-literal / unknown nodes. + */ +function inferCppLiteralType(node: SyntaxNode): string { + switch (node.type) { + case 'number_literal': { + const text = node.text; + // Floating-point literals contain '.', 'e', 'E', or end with 'f'/'F' + if ( + text.includes('.') || + text.includes('e') || + text.includes('E') || + text.endsWith('f') || + text.endsWith('F') + ) { + return 'double'; + } + return 'int'; + } + case 'string_literal': + case 'raw_string_literal': + case 'concatenated_string': + return 'string'; + case 'char_literal': + return 'char'; + case 'true': + case 'false': + return 'bool'; + case 'null': + case 'nullptr': + return 'null'; + default: + return ''; + } +} + +/** + * Look up the declared type of a variable by scanning sibling declarations + * in the enclosing compound_statement (function body). Handles: + * - `std::string result = ...` → 'string' + * - `int n = ...` → 'int' + * - `const int n = ...` → 'int' + * Returns empty string if no declaration found or type is auto/placeholder. + */ +function lookupDeclaredTypeForIdentifier(identNode: SyntaxNode): string { + const varName = identNode.text; + // Walk up to the enclosing compound_statement (function body) + let scope: SyntaxNode | null = identNode.parent; + while ( + scope !== null && + scope.type !== 'compound_statement' && + scope.type !== 'translation_unit' + ) { + scope = scope.parent; + } + if (scope === null) return ''; + + // Scan declarations in the scope for a matching variable name + for (let i = 0; i < scope.childCount; i++) { + const stmt = scope.child(i); + if (stmt === null || stmt.type !== 'declaration') continue; + + const typeNode = stmt.childForFieldName('type'); + if (typeNode === null) continue; + // Skip auto/placeholder types — those need chain-follow, not literal + if (typeNode.type === 'placeholder_type_specifier') continue; + + // Check init_declarator children for the variable name + const declarator = stmt.childForFieldName('declarator'); + if (declarator === null) continue; + if (declarator.type === 'init_declarator') { + const nameChild = declarator.childForFieldName('declarator'); + if (nameChild !== null && nameChild.text === varName) { + return normalizeCppTypeText(typeNode.text); + } + } else if (declarator.text === varName) { + return normalizeCppTypeText(typeNode.text); + } + } + return ''; +} + +/** Normalize a type-specifier text for argument type matching. + * Strips qualifiers (const, volatile), namespace prefixes (std::), + * and pointer/reference markers. */ +function normalizeCppTypeText(text: string): string { + let t = text.trim(); + t = t.replace(/\b(const|volatile|static|extern|mutable)\b/g, '').trim(); + t = t.replace(/^.*::/, ''); // strip namespace prefix + t = t.replace(/[*&]/g, '').trim(); + return t; +} + +/** + * Detect whether a `namespace_definition` AST node is inline. + * Tree-sitter-cpp exposes the `inline` keyword as an anonymous child + * node — we scan direct children for that keyword. + */ +function isInlineNamespace(nsNode: SyntaxNode): boolean { + for (let i = 0; i < nsNode.childCount; i++) { + const c = nsNode.child(i); + if (c === null) continue; + if (c.type === 'inline') return true; + // Some grammar variants surface keywords by their text rather than + // by a dedicated node type; check both for resilience. + if (c.text === 'inline' && (c.type === 'storage_class_specifier' || c.type === 'inline')) { + return true; + } + } + return false; +} + +/** + * Detect `(f)(args)` shape — the call-expression's `function` field is a + * `parenthesized_expression`. ISO C++ specifies that this form suppresses + * ADL (`[basic.lookup.argdep]/3.1`): the parenthesized name is treated as + * an ordinary unqualified-lookup-only callee. + */ +function isParenthesizedFunctionCall(callNode: SyntaxNode): boolean { + const fn = callNode.childForFieldName('function'); + return fn !== null && fn.type === 'parenthesized_expression'; +} + +/** + * Per-argument ADL classification: walk each argument of a free call and + * decide whether it's a directly-named class type (V1 ADL fires) or + * something V1 excludes (pointer, reference, primitive, literal, function + * pointer, template specialization). + * + * V1 only fires for value class-typed args: `void f(N::S); N::S s; f(s);`. + * Pointer args (`N::S* p; f(p);`) intentionally return `simpleClassName=''` + * to lock the V1 boundary — the `cpp-adl-pointer-arg-boundary` fixture + * regression-tests this. + */ +function inferCppCallAdlArgs(callNode: SyntaxNode): CppAdlArgInfo[] { + const argList = callNode.childForFieldName('arguments'); + if (argList === null) return []; + const out: CppAdlArgInfo[] = []; + for (let i = 0; i < argList.childCount; i++) { + const child = argList.child(i); + if (child === null) continue; + if (child.type === ',' || child.type === '(' || child.type === ')') continue; + out.push(classifyAdlArg(child)); + } + return out; +} + +const EMPTY_ADL_ARG: CppAdlArgInfo = { simpleClassName: '', isPointer: false, isReference: false }; + +function classifyAdlArg(argNode: SyntaxNode): CppAdlArgInfo { + // Literals and primitive-shaped expressions never have associated namespaces. + if ( + argNode.type === 'number_literal' || + argNode.type === 'string_literal' || + argNode.type === 'raw_string_literal' || + argNode.type === 'char_literal' || + argNode.type === 'true' || + argNode.type === 'false' || + argNode.type === 'null' || + argNode.type === 'nullptr' + ) { + return EMPTY_ADL_ARG; + } + // Variable reference — look up its declared type (preserving pointer / + // reference / qualified-name shape; the existing arity-narrowing helper + // strips this info). + if (argNode.type === 'identifier') { + return lookupAdlIdentifierType(argNode); + } + // Other shapes (calls, member access, operators) — V1 unsupported. + return EMPTY_ADL_ARG; +} + +function lookupAdlIdentifierType(identNode: SyntaxNode): CppAdlArgInfo { + const varName = identNode.text; + let scope: SyntaxNode | null = identNode.parent; + while ( + scope !== null && + scope.type !== 'compound_statement' && + scope.type !== 'translation_unit' + ) { + scope = scope.parent; + } + if (scope === null) return EMPTY_ADL_ARG; + + for (let i = 0; i < scope.childCount; i++) { + const stmt = scope.child(i); + if (stmt === null || stmt.type !== 'declaration') continue; + const typeNode = stmt.childForFieldName('type'); + if (typeNode === null) continue; + if (typeNode.type === 'placeholder_type_specifier') continue; + + const declarator = stmt.childForFieldName('declarator'); + if (declarator === null) continue; + + // Unwrap declarator chain to find pointer/reference markers and the + // variable name. `init_declarator > pointer_declarator > identifier` + // means pointer-typed; `init_declarator > reference_declarator > ...` + // means reference-typed; bare `init_declarator > identifier` is value. + let isPointer = false; + let isReference = false; + let inner: SyntaxNode = declarator; + let nameText: string | null = null; + let safety = 16; // bound walk depth defensively + while (safety-- > 0) { + if (inner.type === 'pointer_declarator') { + isPointer = true; + const next = inner.childForFieldName('declarator'); + if (next === null) break; + inner = next; + continue; + } + if (inner.type === 'reference_declarator') { + isReference = true; + // reference_declarator has a single child (the inner declarator). + let next: SyntaxNode | null = null; + for (let j = 0; j < inner.namedChildCount; j++) { + const c = inner.namedChild(j); + if (c !== null) { + next = c; + break; + } + } + if (next === null) break; + inner = next; + continue; + } + if (inner.type === 'init_declarator') { + const next = inner.childForFieldName('declarator'); + if (next === null) break; + inner = next; + continue; + } + // Reached the leaf — usually `identifier`. Take its text. + nameText = inner.text; + break; + } + if (nameText !== varName) continue; + + const simpleClassName = extractAdlSimpleTypeName(typeNode); + return { simpleClassName, isPointer, isReference }; + } + return EMPTY_ADL_ARG; +} + +/** Extract the simple class-like type name from a `type:` field node. + * Returns '' for primitives, template specializations, function pointers, + * and any other shape V1 ADL doesn't support — those args are excluded + * from associated-namespace closure. */ +function extractAdlSimpleTypeName(typeNode: SyntaxNode): string { + if (typeNode.type === 'primitive_type') return ''; + if (typeNode.type === 'sized_type_specifier') return ''; + if (typeNode.type === 'type_identifier') return typeNode.text; + if (typeNode.type === 'qualified_identifier') { + const nameNode = typeNode.childForFieldName('name'); + if (nameNode !== null) return extractAdlSimpleTypeName(nameNode); + const id = findFirstDescendantOfType(typeNode, 'type_identifier'); + return id !== null ? id.text : ''; + } + // template_type (e.g. `vector`), function pointers, decltype — V1 excludes. + return ''; +} + +/** + * Check if a C++ function_definition or declaration has `static` storage class. + */ +function hasStaticStorageClass(node: SyntaxNode): boolean { + for (let i = 0; i < node.childCount; i++) { + const child = node.child(i); + if (child !== null && child.type === 'storage_class_specifier' && child.text === 'static') { + return true; + } + } + return false; +} + +/** + * Check if a node is inside an anonymous namespace (file-local linkage in C++). + * Anonymous namespaces have no `name` field in tree-sitter-cpp. + */ +function isInsideAnonymousNamespace(node: SyntaxNode): boolean { + let ancestor: SyntaxNode | null = node.parent ?? null; + while (ancestor !== null) { + if (ancestor.type === 'namespace_definition') { + // Anonymous namespace: has declaration_list but no name child + const nameChild = ancestor.childForFieldName?.('name') ?? null; + if (nameChild === null) return true; + } + ancestor = ancestor.parent; + } + return false; +} diff --git a/gitnexus/src/core/ingestion/languages/cpp/file-local-linkage.ts b/gitnexus/src/core/ingestion/languages/cpp/file-local-linkage.ts new file mode 100644 index 000000000..e46327558 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/cpp/file-local-linkage.ts @@ -0,0 +1,214 @@ +import type { ParsedFile, Scope, ScopeId, SymbolDefinition } from 'gitnexus-shared'; +import { isCppInlineNamespaceScope } from './inline-namespaces.js'; + +/** + * Per-file set of symbol names with file-local linkage. + * In C++ there are two sources of file-local linkage: + * 1. `static` storage class (same as C) + * 2. Anonymous namespace (`namespace { ... }`) + * + * Populated during `emitCppScopeCaptures` and consumed by + * `expandCppWildcardNames` to exclude file-local symbols from + * cross-file wildcard import visibility. + * + * NOTE: module-level state, single-process-single-repo use only. + * Call `clearFileLocalNames()` at the start of each resolution pass. + * + * Key: filePath, Value: Set of file-local symbol names. + */ +const fileLocalNames = new Map>(); + +/** + * Per-file set of `SymbolDefinition.nodeId`s that are NOT visible by + * unqualified lookup from outside the file — class-owned methods/fields + * and namespace-nested symbols. Populated by `populateCppNonGloballyVisible` + * during the per-file `populateOwners` hook; consumed by + * `isCppDefGloballyVisible` from both `expandCppWildcardNames` (wildcard + * propagation) and the global free-call fallback's `isFileLocalDef` hook. + * + * Tracked per filePath rather than as a single global set so cross-file + * lookup correctly compares the candidate's owning file's non-visible + * set without leaking across pipeline invocations (the global free-call + * fallback checks `def.filePath !== callerFilePath` and then asks "is + * this def visible from outside its own file?" — that's exactly what + * this set encodes). + */ +const nonGloballyVisibleNodeIds = new Map>(); + +/** Record a symbol name as file-local (static or anonymous namespace). */ +export function markFileLocal(filePath: string, name: string): void { + let names = fileLocalNames.get(filePath); + if (names === undefined) { + names = new Set(); + fileLocalNames.set(filePath, names); + } + names.add(name); +} + +/** Check whether a symbol name has file-local linkage in the given file. */ +export function isFileLocal(filePath: string, name: string): boolean { + return fileLocalNames.get(filePath)?.has(name) ?? false; +} + +/** Clear tracked file-local names (call at start of each resolution pass). */ +export function clearFileLocalNames(): void { + fileLocalNames.clear(); + nonGloballyVisibleNodeIds.clear(); +} + +/** + * Populate per-file "not globally visible" nodeIds by walking the parsed + * file's scopes. Run as part of the `populateOwners` hook so every C++ + * scope is reflected before any cross-file resolution pass consults the + * set. + * + * A def is "not globally visible" when its nearest structurally enclosing + * scope is a `Namespace` or `Class` — those require qualification + * (`ns::name`, `Class::method`) for cross-file unqualified lookup. + * Module-scoped defs remain globally visible. + */ +export function populateCppNonGloballyVisible(parsed: { + readonly filePath: string; + readonly scopes: readonly { + readonly id: ScopeId; + readonly kind: string; + readonly ownedDefs: readonly { readonly nodeId: string }[]; + }[]; +}): void { + let set = nonGloballyVisibleNodeIds.get(parsed.filePath); + if (set === undefined) { + set = new Set(); + nonGloballyVisibleNodeIds.set(parsed.filePath, set); + } + for (const scope of parsed.scopes) { + if (scope.kind !== 'Namespace' && scope.kind !== 'Class') continue; + // Inline namespaces (`inline namespace v1 { ... }`) propagate their + // members to the enclosing namespace's unqualified-lookup scope per + // ISO C++ `[namespace.def]/p4`. Skip them here so cross-file + // unqualified lookup can still see their callable defs. + if (scope.kind === 'Namespace' && isCppInlineNamespaceScope(scope.id)) continue; + for (const def of scope.ownedDefs) { + set.add(def.nodeId); + } + } +} + +/** + * Check whether a def is visible by unqualified lookup from outside its + * own file. Returns `false` for class-owned and namespace-nested defs. + * + * Used by the global free-call fallback's `isFileLocalDef` hook (which + * historically meant "static / anonymous-namespace" but semantically + * stands for "logically invisible cross-file"). Including class methods + * and namespace members under the same negative answer fixes the leak + * where unqualified `save()` resolved to `User::save` through a shared + * workspace registry walk. + */ +export function isCppDefGloballyVisible(filePath: string, nodeId: string): boolean { + return nonGloballyVisibleNodeIds.get(filePath)?.has(nodeId) !== true; +} + +/** + * Return the names visible through a C++ wildcard import (`#include` or + * `using namespace`). + * + * ## Contract + * + * C++ unqualified name lookup only sees names at the importer's enclosing + * scope. Class members and namespace-nested symbols are NOT visible by + * unqualified lookup from a free function in an including TU — they must + * be reached via `Class::method`, `ns::name`, or a working `using` + * declaration. The filter below enforces that contract for header + * propagation: only defs whose nearest enclosing scope is the header's + * `Module` scope are emitted as wildcard-binding names. + * + * ## Why scope-aware and not predicate-on-qualifiedName + * + * A naive `def.qualifiedName.indexOf('.') === -1` check is unreliable + * because `populateClassOwnedMembers` + * (`gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts`) + * only dot-qualifies `qualifiedName` for `Class` scopes. Namespace-nested + * defs (`namespace ns { void foo(); }`) arrive in `localDefs` with + * `qualifiedName === 'foo'` and `ownerId === undefined`, indistinguishable + * from a top-level free function. The structural truth lives in + * `Scope.ownedDefs`: each scope lists what it structurally owns; the + * Module scope owns only top-level symbols. We look the def up by + * `nodeId` against the scope tree to identify its owning kind. + * + * ## `localDefs` consumer survey (recorded for future maintainers) + * + * Other consumers of `ParsedFile.localDefs` were audited at the time + * this filter was introduced (see PR #1520 / plan + * `docs/plans/2026-05-12-002-fix-cpp-resolver-followups-plan.md`): + * + * - `finalize-orchestrator.ts:113,163` — flattens defs into a workspace + * registry keyed by `ownerId` + `qualifiedName`; class-owned and + * namespace-owned symbols are registered under their owner, not as + * unqualified names. Not a leak surface. + * - `csharp/namespace-siblings.ts:307`, `go/expand-wildcards.ts:86`, + * `php/scope-resolver.ts:141,151`, `c/static-linkage.ts:51` — other + * languages' own wildcard / sibling expansions. Each owns its own + * visibility contract. + * - `receiver-bound-calls.ts:99`, `reconcile-ownership.ts:66,119`, + * `mro.ts:61` — keyed by `ownerId` for member lookup, never used + * as unqualified bindings. + * - `go/interface-impls.ts:40,53`, `go/package-siblings.ts:41` — Go- + * specific, sibling-package scoped. + * + * No other consumer treats `localDefs` as a flat unqualified-binding + * set the way this function did before the fix. If a future consumer + * does, mirror this filter or harden registration so class/namespace + * members never enter `localDefs` unqualified. + */ +export function expandCppWildcardNames( + targetModuleScope: ScopeId, + parsedFiles: readonly ParsedFile[], +): readonly string[] { + const target = parsedFiles.find((p) => p.moduleScope === targetModuleScope); + if (target === undefined) return []; + + // Build nodeId → owning Scope map from the structural scope tree. + // `Scope.ownedDefs` is the canonical source of structural ownership; + // `localDefs` is its flattened union, which is why the original code + // leaked: walking only `localDefs` discards the owning-scope context. + const ownerScopeByNodeId = new Map(); + for (const scope of target.scopes) { + for (const ownedDef of scope.ownedDefs) { + ownerScopeByNodeId.set(ownedDef.nodeId, scope); + } + } + + const seen = new Set(); + const names: string[] = []; + for (const def of target.localDefs) { + // Defense-in-depth: class methods carry a non-undefined ownerId after + // `populateClassOwnedMembers` runs. Skip them outright. + if (def.ownerId !== undefined) continue; + + // Structural visibility check: exclude defs whose owning scope is a + // Namespace or Class — these require qualification (`ns::name`, + // `Class::method`) and are NOT reachable by unqualified lookup in an + // including TU. When the owning scope is unknown we default to + // include (preserves prior behavior for any def whose structural + // ownership wasn't recorded in `Scope.ownedDefs`). + const ownerScope = ownerScopeByNodeId.get(def.nodeId); + if ( + ownerScope !== undefined && + (ownerScope.kind === 'Namespace' || ownerScope.kind === 'Class') + ) { + continue; + } + + const name = simpleName(def); + if (name === '') continue; + if (isFileLocal(target.filePath, name)) continue; + if (seen.has(name)) continue; + seen.add(name); + names.push(name); + } + return names; +} + +function simpleName(def: SymbolDefinition): string { + return def.qualifiedName?.split('.').pop() ?? def.qualifiedName ?? ''; +} diff --git a/gitnexus/src/core/ingestion/languages/cpp/header-scan.ts b/gitnexus/src/core/ingestion/languages/cpp/header-scan.ts new file mode 100644 index 000000000..39ef608b3 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/cpp/header-scan.ts @@ -0,0 +1,53 @@ +import { readdirSync, type Dirent } from 'fs'; +import { join, relative } from 'path'; + +/** C++ header extensions to scan for in the workspace. */ +const HEADER_EXTENSIONS = new Set(['.h', '.hpp', '.hxx', '.hh']); + +/** + * Walk `repoPath` recursively and return relative paths of all C++ header files. + * Used by `loadResolutionConfig` so the C++ resolver can resolve `#include` + * targets that live in header files. + * + * Scans for: .h, .hpp, .hxx, .hh + */ +export function scanCppHeaderFiles(repoPath: string): ReadonlySet { + const headers = new Set(); + walk(repoPath, repoPath, headers); + return headers; +} + +function walk(dir: string, root: string, out: Set): void { + let entries: Dirent[]; + try { + entries = readdirSync(dir, { withFileTypes: true, encoding: 'utf8' }); + } catch { + return; // permission denied, etc. + } + for (const entry of entries) { + const name = entry.name; + const full = join(dir, name); + if (entry.isDirectory()) { + if ( + name === 'node_modules' || + name === '.git' || + name === 'vendor' || + name === 'dist' || + name === 'build' || + name === 'out' || + name === 'target' || + name === '_build' || + name === '.next' || + name.startsWith('cmake-build') + ) { + continue; + } + walk(full, root, out); + } else if (entry.isFile()) { + const ext = name.slice(name.lastIndexOf('.')); + if (HEADER_EXTENSIONS.has(ext)) { + out.add(relative(root, full).replace(/\\/g, '/')); + } + } + } +} diff --git a/gitnexus/src/core/ingestion/languages/cpp/import-decomposer.ts b/gitnexus/src/core/ingestion/languages/cpp/import-decomposer.ts new file mode 100644 index 000000000..eb6b252ce --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/cpp/import-decomposer.ts @@ -0,0 +1,120 @@ +import type { Capture, CaptureMatch } from 'gitnexus-shared'; +import { nodeToCapture, syntheticCapture, type SyntaxNode } from '../../utils/ast-helpers.js'; + +/** + * Decompose a `preproc_include` node into a CaptureMatch with structured + * import captures. C++ #include maps to a wildcard import (all symbols + * from the header are visible). Identical to C's splitCInclude. + */ +export function splitCppInclude(node: SyntaxNode): CaptureMatch | null { + const pathNode = node.childForFieldName?.('path') ?? null; + if (pathNode === null) { + for (let i = 0; i < node.childCount; i++) { + const child = node.child(i); + if (child === null) continue; + if (child.type === 'string_literal' || child.type === 'system_lib_string') { + return buildIncludeCapture(node, child); + } + } + return null; + } + return buildIncludeCapture(node, pathNode); +} + +function buildIncludeCapture(node: SyntaxNode, pathNode: SyntaxNode): CaptureMatch { + let raw: string; + if (pathNode.type === 'string_literal') { + const content = pathNode.namedChildren.find((c) => c.type === 'string_content'); + raw = content?.text ?? pathNode.text.replace(/^"|"$/g, ''); + } else { + raw = pathNode.text; + if (raw.startsWith('<') && raw.endsWith('>')) { + raw = raw.slice(1, -1); + } + } + + const isSystem = pathNode.type === 'system_lib_string'; + + const result: Record = { + '@import.statement': nodeToCapture('@import.statement', node), + '@import.kind': syntheticCapture('@import.kind', node, 'wildcard'), + '@import.source': syntheticCapture('@import.source', node, raw), + }; + + if (isSystem) { + result['@import.system'] = syntheticCapture('@import.system', node, 'true'); + } + + return result; +} + +/** + * Decompose a `using_declaration` node into a CaptureMatch. + * + * tree-sitter-cpp produces: + * using namespace std; → using_declaration { "using", "namespace", identifier("std"), ";" } + * using std::vector; → using_declaration { "using", qualified_identifier("std::vector"), ";" } + * + * The first form is a wildcard import (all names from namespace). + * The second form is a named import (single symbol). + */ +export function splitCppUsingDecl(node: SyntaxNode): CaptureMatch | null { + if (node.type !== 'using_declaration') return null; + + // Check for "namespace" keyword among anonymous children + let hasNamespaceKeyword = false; + for (let i = 0; i < node.childCount; i++) { + const child = node.child(i); + if (child !== null && !child.isNamed && child.text === 'namespace') { + hasNamespaceKeyword = true; + break; + } + } + + if (hasNamespaceKeyword) { + // using namespace ; + // The namespace name can be an identifier or qualified_identifier + let namespaceName: string | null = null; + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child === null) continue; + if (child.type === 'identifier' || child.type === 'qualified_identifier') { + namespaceName = child.text; + break; + } + } + if (namespaceName === null) return null; + + return { + '@import.statement': nodeToCapture('@import.statement', node), + '@import.kind': syntheticCapture('@import.kind', node, 'wildcard'), + '@import.source': syntheticCapture('@import.source', node, namespaceName), + '@import.using-namespace': syntheticCapture('@import.using-namespace', node, 'true'), + }; + } + + // using ; (e.g. using std::vector) + let qualId: SyntaxNode | null = null; + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child !== null && child.type === 'qualified_identifier') { + qualId = child; + break; + } + } + if (qualId === null) return null; + + // Extract the imported name (last identifier) and source (namespace part) + const nameNode = qualId.childForFieldName?.('name') ?? null; + const scopeNode = qualId.childForFieldName?.('scope') ?? null; + + const importedName = nameNode?.text ?? qualId.text.split('::').pop() ?? ''; + const source = scopeNode?.text ?? qualId.text.replace(new RegExp('::' + importedName + '$'), ''); + + return { + '@import.statement': nodeToCapture('@import.statement', node), + '@import.kind': syntheticCapture('@import.kind', node, 'named'), + '@import.source': syntheticCapture('@import.source', node, source), + '@import.name': syntheticCapture('@import.name', node, importedName), + }; +} diff --git a/gitnexus/src/core/ingestion/languages/cpp/import-target.ts b/gitnexus/src/core/ingestion/languages/cpp/import-target.ts new file mode 100644 index 000000000..26e317c6e --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/cpp/import-target.ts @@ -0,0 +1,18 @@ +import { resolveCImportTarget } from '../c/import-target.js'; + +/** + * Resolve a C++ #include path to a file in the workspace. + * C++ #include path resolution is identical to C: + * 1. Same-directory sibling (relative lookup) + * 2. Exact match + * 3. Suffix match with depth + lexicographic tiebreak + * + * Re-exports the C implementation since the #include semantics are shared. + */ +export function resolveCppImportTarget( + targetRaw: string, + fromFile: string, + allFilePaths: ReadonlySet, +): string | null { + return resolveCImportTarget(targetRaw, fromFile, allFilePaths); +} diff --git a/gitnexus/src/core/ingestion/languages/cpp/index.ts b/gitnexus/src/core/ingestion/languages/cpp/index.ts new file mode 100644 index 000000000..c4d208d76 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/cpp/index.ts @@ -0,0 +1,16 @@ +/** + * C++ scope-resolution hooks (RFC #909 Ring 3). + */ +export { emitCppScopeCaptures } from './captures.js'; +export { interpretCppImport, interpretCppTypeBinding, normalizeCppTypeName } from './interpret.js'; +export { splitCppInclude, splitCppUsingDecl } from './import-decomposer.js'; +export { cppArityCompatibility } from './arity.js'; +export { cppMergeBindings } from './merge-bindings.js'; +export { cppBindingScopeFor, cppImportOwningScope, cppReceiverBinding } from './simple-hooks.js'; +export { resolveCppImportTarget } from './import-target.js'; +export { + markFileLocal, + isFileLocal, + clearFileLocalNames, + expandCppWildcardNames, +} from './file-local-linkage.js'; diff --git a/gitnexus/src/core/ingestion/languages/cpp/inline-namespaces.ts b/gitnexus/src/core/ingestion/languages/cpp/inline-namespaces.ts new file mode 100644 index 000000000..c08402a85 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/cpp/inline-namespaces.ts @@ -0,0 +1,170 @@ +/** + * C++ inline namespace support (U5 of plan 2026-05-13-001). + * + * `inline namespace v1 { void foo(); }` has two ISO C++ semantics that + * GitNexus must model: + * + * 1. **Transitive unqualified visibility.** Names declared in an inline + * namespace are reachable by unqualified lookup from the enclosing + * namespace's scope, as if they were declared directly there. + * `populateCppNonGloballyVisible` (file-local-linkage.ts) treats + * inline-namespace members as globally visible for cross-file + * unqualified lookup. + * + * 2. **Transitive qualified visibility.** `outer::foo()` resolves to + * `outer::v1::foo()` when `v1` is inline. The qualified-namespace + * receiver resolver (`resolveCppQualifiedNamespaceMember`) walks + * inline-namespace children transitively when collecting candidates. + * + * State lifecycle: capture-time `markCppInlineNamespaceRange` records each + * inline namespace's source range; `populateCppInlineNamespaceScopes` + * resolves ranges to `ScopeId`s during `populateOwners`. Cleared via + * `clearCppInlineNamespaces`, called from `clearFileLocalNames`. + * + * STL idiom this enables: `std::__1::vector` (libc++) and `std::__cxx11` + * (libstdc++) are inline namespaces of `std`. With this support, + * `std::vector` qualified calls resolve to the inline-namespace + * declaration transparently. + */ + +import type { ParsedFile, ScopeId, SymbolDefinition } from 'gitnexus-shared'; +import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js'; + +interface RangeKey { + readonly startLine: number; + readonly startCol: number; + readonly endLine: number; + readonly endCol: number; +} + +const inlineNamespaceRangesByFile = new Map>(); +const inlineNamespaceScopeIds = new Set(); + +function rangeKey(r: RangeKey): string { + return `${r.startLine}:${r.startCol}:${r.endLine}:${r.endCol}`; +} + +/** Capture-time: record a namespace_definition's range as inline. + * Called from `emitCppScopeCaptures` when the tree-sitter AST shows an + * `inline` keyword child on `namespace_definition`. */ +export function markCppInlineNamespaceRange(filePath: string, range: RangeKey): void { + let set = inlineNamespaceRangesByFile.get(filePath); + if (set === undefined) { + set = new Set(); + inlineNamespaceRangesByFile.set(filePath, set); + } + set.add(rangeKey(range)); +} + +/** Clear all inline-namespace state. Called from `clearFileLocalNames`. */ +export function clearCppInlineNamespaces(): void { + inlineNamespaceRangesByFile.clear(); + inlineNamespaceScopeIds.clear(); +} + +/** Resolve captured ranges to actual ScopeIds by matching scope ranges + * against the inline-namespace ranges recorded for this file. Run from + * the cpp resolver's `populateOwners` hook so the per-pipeline Set is + * populated before any resolution pass consults it. */ +export function populateCppInlineNamespaceScopes(parsed: ParsedFile): void { + const ranges = inlineNamespaceRangesByFile.get(parsed.filePath); + if (ranges === undefined || ranges.size === 0) return; + for (const scope of parsed.scopes) { + if (scope.kind !== 'Namespace') continue; + if (ranges.has(rangeKey(scope.range))) { + inlineNamespaceScopeIds.add(scope.id); + } + } +} + +/** Predicate consumed by `populateCppNonGloballyVisible` to exempt + * inline-namespace members from cross-file unqualified-lookup + * exclusion (they remain reachable as if declared at the enclosing + * namespace's level). */ +export function isCppInlineNamespaceScope(scopeId: ScopeId): boolean { + return inlineNamespaceScopeIds.has(scopeId); +} + +/** + * Walk every parsed file looking for a Namespace scope whose qualified + * name matches `receiverName`, collect its callable ownedDefs matching + * `memberName`, transitively descending into any inline-namespace + * children (since they're members of the enclosing namespace under ISO + * C++). + * + * Returns the most specific (innermost) match — for `outer::foo()` + * where `inline namespace v1` declares `foo`, returns `v1::foo`. When + * multiple inline-namespace children declare the same name, ISO C++ + * leaves the call ambiguous; V1 returns the first match in source + * order (stable across runs). + */ +export function resolveCppQualifiedNamespaceMember( + receiverName: string, + memberName: string, + parsedFiles: readonly ParsedFile[], + _scopes: ScopeResolutionIndexes, +): SymbolDefinition | undefined { + for (const parsed of parsedFiles) { + const scopesById = new Map(); + for (const sc of parsed.scopes) scopesById.set(sc.id, sc); + for (const scope of parsed.scopes) { + if (scope.kind !== 'Namespace') continue; + const nsDef = findNamespaceDefInScope(scope); + if (nsDef === undefined) continue; + const nsName = nsDef.qualifiedName?.split('.').pop() ?? nsDef.qualifiedName ?? ''; + if (nsName !== receiverName) continue; + // Found a matching namespace scope in this file. Collect the + // member transitively through any inline-namespace children. + const hit = findMemberInNamespaceTransitive(scope, scopesById, memberName); + if (hit !== undefined) return hit; + } + } + return undefined; +} + +/** Recursively search a namespace scope and any inline-namespace + * descendants for a callable def with the given simple name. Non-inline + * nested namespaces are NOT traversed — they require explicit + * qualification (`outer::nested::foo`). */ +function findMemberInNamespaceTransitive( + scope: { + readonly id: ScopeId; + readonly ownedDefs: readonly SymbolDefinition[]; + readonly parent: ScopeId | null; + }, + scopesById: ReadonlyMap< + ScopeId, + { + readonly id: ScopeId; + readonly kind: string; + readonly parent: ScopeId | null; + readonly ownedDefs: readonly SymbolDefinition[]; + } + >, + memberName: string, +): SymbolDefinition | undefined { + // Check this scope's own ownedDefs first. + for (const def of scope.ownedDefs) { + if (def.type !== 'Function' && def.type !== 'Method' && def.type !== 'Constructor') continue; + const simple = def.qualifiedName?.split('.').pop() ?? def.qualifiedName ?? ''; + if (simple === memberName) return def; + } + // Descend into inline-namespace children. + for (const childScope of scopesById.values()) { + if (childScope.parent !== scope.id) continue; + if (childScope.kind !== 'Namespace') continue; + if (!inlineNamespaceScopeIds.has(childScope.id)) continue; + const hit = findMemberInNamespaceTransitive(childScope, scopesById, memberName); + if (hit !== undefined) return hit; + } + return undefined; +} + +function findNamespaceDefInScope(scope: { + readonly ownedDefs: readonly SymbolDefinition[]; +}): SymbolDefinition | undefined { + for (const def of scope.ownedDefs) { + if (def.type === 'Namespace') return def; + } + return undefined; +} diff --git a/gitnexus/src/core/ingestion/languages/cpp/interpret.ts b/gitnexus/src/core/ingestion/languages/cpp/interpret.ts new file mode 100644 index 000000000..a5c1692a8 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/cpp/interpret.ts @@ -0,0 +1,112 @@ +import type { CaptureMatch, ParsedImport, ParsedTypeBinding, TypeRef } from 'gitnexus-shared'; + +/** + * Interpret a C++ import capture into a ParsedImport. + * + * C++ has three import forms: + * 1. #include "file.h" → wildcard import (all symbols from header) + * 2. using namespace X; → wildcard import (all symbols from namespace X) + * 3. using X::name; → named import (single symbol from namespace X) + * + * System headers (#include <...>) are not resolved to local files. + */ +export function interpretCppImport(captures: CaptureMatch): ParsedImport | null { + const source = captures['@import.source']?.text; + if (source === undefined) return null; + + // System headers are not resolved to local files + if (captures['@import.system'] !== undefined) return null; + + const kind = captures['@import.kind']?.text; + + if (kind === 'named') { + // using X::name — named import + const importedName = captures['@import.name']?.text; + if (importedName === undefined) return null; + return { kind: 'named', targetRaw: source, localName: importedName, importedName }; + } + + // #include or using namespace — wildcard import + return { kind: 'wildcard', targetRaw: source }; +} + +/** + * Interpret a C++ type-binding capture into a ParsedTypeBinding. + * + * Source classification (strongest → weakest): + * - `'parameter-annotation'` — function parameter type + * - `'annotation'` — explicit type declaration (`User user;`) + * - `'assignment-inferred'` — typed init (`User user = ...`) + * - `'constructor'` — constructor call (`auto u = User(...)` / `User{}`) + * - `'return'` — function return type + * - `'field'` — class field type + * - `'alias'` — `auto x = existingVar` + */ +export function interpretCppTypeBinding(captures: CaptureMatch): ParsedTypeBinding | null { + const name = captures['@type-binding.name']?.text; + const type = captures['@type-binding.type']?.text; + if (name === undefined || type === undefined) return null; + + let source: TypeRef['source'] = 'annotation'; + + if (captures['@type-binding.parameter'] !== undefined) { + source = 'parameter-annotation'; + } else if (captures['@type-binding.constructor'] !== undefined) { + source = 'constructor-inferred'; + } else if (captures['@type-binding.return'] !== undefined) { + source = 'return-annotation'; + } else if (captures['@type-binding.field'] !== undefined) { + // Field types are structurally equivalent to annotations — the type + // is explicitly written, not inferred. + source = 'annotation'; + } else if (captures['@type-binding.member-access'] !== undefined) { + // auto addr = user.address — the type is inferred from the member access. + // Synthesize a dotted rawName ("receiver.field") so compound-receiver + // can resolve the chain: look up receiver's class, then field's type. + const receiver = captures['@type-binding.member-access-receiver']?.text; + if (receiver !== undefined) { + return { boundName: name, rawTypeName: `${receiver}.${type}`, source: 'assignment-inferred' }; + } + source = 'assignment-inferred'; + } else if (captures['@type-binding.alias'] !== undefined) { + // auto alias = existingVar — the type is inferred from the RHS variable. + source = 'assignment-inferred'; + } else if (captures['@type-binding.assignment'] !== undefined) { + source = 'assignment-inferred'; + } else if (captures['@type-binding.annotation'] !== undefined) { + source = 'annotation'; + } + + return { boundName: name, rawTypeName: normalizeCppTypeName(type), source }; +} + +/** + * Normalize a C++ type name: strip pointer/array/reference syntax, + * qualifiers, and template parameters (V1: generic-ignored). + */ +export function normalizeCppTypeName(text: string): string { + let t = text.trim(); + // Strip const, volatile, restrict, static, extern, inline, mutable, constexpr + t = t + .replace(/\b(const|volatile|restrict|static|extern|inline|mutable|constexpr|consteval)\b/g, '') + .trim(); + // Strip template parameters (loop handles nested: Map> → Map) + while (t.includes('<')) { + const stripped = t.replace(/<[^<>]*>/g, ''); + if (stripped === t) break; // avoid infinite loop on malformed input + t = stripped; + } + t = t.trim(); + // Strip pointer stars + while (t.endsWith('*')) t = t.slice(0, -1).trim(); + while (t.startsWith('*')) t = t.slice(1).trim(); + // Strip reference markers + while (t.endsWith('&')) t = t.slice(0, -1).trim(); + // Strip array brackets + t = t.replace(/\[.*?\]/g, '').trim(); + // Strip struct/union/enum/class prefixes + t = t.replace(/^(struct|union|enum|class)\s+/, ''); + // Strip leading :: (global namespace qualifier) + t = t.replace(/^::/, ''); + return t; +} diff --git a/gitnexus/src/core/ingestion/languages/cpp/merge-bindings.ts b/gitnexus/src/core/ingestion/languages/cpp/merge-bindings.ts new file mode 100644 index 000000000..6409cef46 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/cpp/merge-bindings.ts @@ -0,0 +1,38 @@ +import type { BindingRef } from 'gitnexus-shared'; + +const TIER: Record = { + local: 0, + namespace: 1, + import: 2, + reexport: 3, + wildcard: 4, +}; + +/** + * C++ merge bindings: first-wins by tier. + * + * C++ tier precedence: + * local(0) > namespace(1) > import(2) > reexport(3) > wildcard(4) + * + * Unlike C (no namespaces), C++ uses the `namespace` tier for symbols + * brought in via `using namespace X;` that are then locally referenced. + * The tier ordering ensures local definitions shadow namespace imports, + * which in turn shadow wildcard #include imports. + */ +export function cppMergeBindings( + existing: readonly BindingRef[], + incoming: readonly BindingRef[], + _scopeId: string, +): BindingRef[] { + const seen = new Set(); + return [...existing, ...incoming] + .sort( + (a, b) => + (TIER[a.origin] ?? 99) - (TIER[b.origin] ?? 99) || a.def.nodeId.localeCompare(b.def.nodeId), + ) + .filter((binding) => { + if (seen.has(binding.def.nodeId)) return false; + seen.add(binding.def.nodeId); + return true; + }); +} diff --git a/gitnexus/src/core/ingestion/languages/cpp/query.ts b/gitnexus/src/core/ingestion/languages/cpp/query.ts new file mode 100644 index 000000000..4e451617d --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/cpp/query.ts @@ -0,0 +1,462 @@ +import Parser from 'tree-sitter'; +import CPP from 'tree-sitter-cpp'; + +const CPP_SCOPE_QUERY = ` +;; ─── Scopes ────────────────────────────────────────────────────────── +(translation_unit) @scope.module +(namespace_definition) @scope.namespace +(class_specifier) @scope.class +(struct_specifier) @scope.class +(function_definition) @scope.function +(lambda_expression) @scope.function +(compound_statement) @scope.block +(if_statement) @scope.block +(for_statement) @scope.block +(for_range_loop) @scope.block +(while_statement) @scope.block +(do_statement) @scope.block +(switch_statement) @scope.block +(case_statement) @scope.block +(try_statement) @scope.block +(catch_clause) @scope.block + +;; ─── Declarations — namespace ──────────────────────────────────────── +(namespace_definition + name: (namespace_identifier) @declaration.name) @declaration.namespace + +;; Anonymous namespace (no name child) — captured as scope only, names +;; inside are marked file-local by captures.ts. + +;; ─── Declarations — class / struct (named) ─────────────────────────── +(class_specifier + name: (type_identifier) @declaration.name + body: (field_declaration_list)) @declaration.class + +(struct_specifier + name: (type_identifier) @declaration.name + body: (field_declaration_list)) @declaration.struct + +;; ─── Declarations — class / struct inside template_declaration ─────── +(template_declaration + (class_specifier + name: (type_identifier) @declaration.name + body: (field_declaration_list)) @declaration.class) + +(template_declaration + (struct_specifier + name: (type_identifier) @declaration.name + body: (field_declaration_list)) @declaration.struct) + +;; ─── Declarations — enum ───────────────────────────────────────────── +(enum_specifier + name: (type_identifier) @declaration.name) @declaration.enum + +;; ─── Declarations — enum constants ─────────────────────────────────── +(enumerator + name: (identifier) @declaration.name) @declaration.const + +;; ─── Declarations — function definition (plain identifier) ────────── +(function_definition + declarator: (function_declarator + declarator: (identifier) @declaration.name)) @declaration.function + +;; ─── Declarations — function definition with pointer return ───────── +(function_definition + declarator: (pointer_declarator + declarator: (function_declarator + declarator: (identifier) @declaration.name))) @declaration.function + +;; ─── Declarations — out-of-class method (qualified_identifier) ────── +(function_definition + declarator: (function_declarator + declarator: (qualified_identifier + name: (identifier) @declaration.name))) @declaration.method + +;; ─── Declarations — out-of-class method with pointer return ───────── +(function_definition + declarator: (pointer_declarator + declarator: (function_declarator + declarator: (qualified_identifier + name: (identifier) @declaration.name)))) @declaration.method + +;; ─── Declarations — out-of-class method (destructor_name) ─────────── +(function_definition + declarator: (function_declarator + declarator: (qualified_identifier + name: (destructor_name) @declaration.name))) @declaration.method + +;; ─── Declarations — template function definition ──────────────────── +(template_declaration + (function_definition + declarator: (function_declarator + declarator: (identifier) @declaration.name)) @declaration.function) + +;; ─── Declarations — template method (qualified) ───────────────────── +(template_declaration + (function_definition + declarator: (function_declarator + declarator: (qualified_identifier + name: (identifier) @declaration.name))) @declaration.method) + +;; ─── Declarations — inline method in class body (field_identifier) ── +;; tree-sitter-cpp uses field_identifier for names inside class bodies +(function_definition + declarator: (function_declarator + declarator: (field_identifier) @declaration.name)) @declaration.method + +;; ─── Declarations — inline method with pointer return (field_identifier) ── +;; Covers: User* lookup(int id) { ... } inside a class body +;; AST: function_definition > pointer_declarator > function_declarator > field_identifier +(function_definition + declarator: (pointer_declarator + declarator: (function_declarator + declarator: (field_identifier) @declaration.name))) @declaration.method + +;; ─── Declarations — inline method with reference return (field_identifier) ── +;; Covers: User& getRef() { ... } inside a class body +(function_definition + declarator: (reference_declarator + (function_declarator + declarator: (field_identifier) @declaration.name))) @declaration.method + +;; ─── Declarations — function prototype (forward declaration) ──────── +(declaration + declarator: (function_declarator + declarator: (identifier) @declaration.name)) @declaration.function + +;; ─── Declarations — function prototype with pointer return ────────── +(declaration + declarator: (pointer_declarator + declarator: (function_declarator + declarator: (identifier) @declaration.name))) @declaration.function + +;; ─── Declarations — typedef ───────────────────────────────────────── +(type_definition + declarator: (type_identifier) @declaration.name) @declaration.typedef + +;; ─── Declarations — type alias (using Name = Type) ────────────────── +(alias_declaration + name: (type_identifier) @declaration.name) @declaration.typedef + +;; ─── Declarations — method prototype in class body (forward decl) ──── +;; Covers: class User { void save(); std::string getName(); }; +;; AST: field_declaration > function_declarator > field_identifier +(field_declaration + declarator: (function_declarator + declarator: (field_identifier) @declaration.name)) @declaration.method + +;; Method prototype with pointer return: User* lookup(int id); +(field_declaration + declarator: (pointer_declarator + declarator: (function_declarator + declarator: (field_identifier) @declaration.name))) @declaration.method + +;; Method prototype with reference return: User& getRef(); +(field_declaration + declarator: (reference_declarator + (function_declarator + declarator: (field_identifier) @declaration.name))) @declaration.method + +;; ─── Declarations — fields ────────────────────────────────────────── +(field_declaration + declarator: (field_identifier) @declaration.name) @declaration.field + +;; Declarations — fields (pointer) +(field_declaration + declarator: (pointer_declarator + declarator: (field_identifier) @declaration.name)) @declaration.field + +;; Declarations — fields (reference) +(field_declaration + declarator: (reference_declarator + (field_identifier) @declaration.name)) @declaration.field + +;; ─── Declarations — variables (with initializer) ──────────────────── +(declaration + declarator: (init_declarator + declarator: (identifier) @declaration.name)) @declaration.variable + +;; ─── Declarations — macro definitions ─────────────────────────────── +(preproc_def + name: (identifier) @declaration.name) @declaration.macro + +(preproc_function_def + name: (identifier) @declaration.name) @declaration.macro + +;; ─── Imports — #include ───────────────────────────────────────────── +(preproc_include) @import.statement + +;; ─── Imports — using declaration ───────────────────────────────────── +;; Both "using namespace std;" and "using std::vector;" are +;; using_declaration nodes in tree-sitter-cpp. The captures.ts +;; differentiates between them by checking for a "namespace" anonymous +;; child token. +(using_declaration) @import.using-decl + +;; ─── Type bindings — parameter annotations ────────────────────────── +(parameter_declaration + type: (_) @type-binding.type + declarator: (identifier) @type-binding.name) @type-binding.parameter + +;; Type bindings — reference parameter (const std::string& name) +(parameter_declaration + type: (_) @type-binding.type + declarator: (reference_declarator + (identifier) @type-binding.name)) @type-binding.parameter + +;; Type bindings — pointer parameter (User* ptr) +(parameter_declaration + type: (_) @type-binding.type + declarator: (pointer_declarator + declarator: (identifier) @type-binding.name)) @type-binding.parameter + +;; ─── Type bindings — variable with type (init_declarator) ─────────── +;; Covers: User user("alice"), User user = ..., int x = 0 +(declaration + type: (_) @type-binding.type + declarator: (init_declarator + declarator: (identifier) @type-binding.name)) @type-binding.assignment + +;; ─── Type bindings — plain declaration (no initializer) ───────────── +;; Covers: User user; +(declaration + type: (type_identifier) @type-binding.type + declarator: (identifier) @type-binding.name) @type-binding.annotation + +;; ─── Type bindings — pointer variable declaration ─────────────────── +;; Covers: User* ptr = new User() +(declaration + type: (type_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 +(declaration + type: (placeholder_type_specifier) + declarator: (init_declarator + declarator: (identifier) @type-binding.name + value: (call_expression + function: (identifier) @type-binding.type))) @type-binding.constructor + +;; ─── Type bindings — auto + brace-init (compound_literal_expression) ─ +;; Covers: auto user = User{}, auto user = User{args} +;; AST: declaration > placeholder_type_specifier/auto > init_declarator > identifier + compound_literal_expression > type_identifier +(declaration + type: (placeholder_type_specifier) + declarator: (init_declarator + declarator: (identifier) @type-binding.name + value: (compound_literal_expression + type: (type_identifier) @type-binding.type))) @type-binding.constructor + +;; ─── Type bindings — auto + scoped brace-init (qualified) ─────────── +;; Covers: auto client = ns::HttpClient{} +;; AST: compound_literal_expression > qualified_identifier > type_identifier +(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 bindings — auto + new expression ────────────────────────── +;; Covers: auto user = new User(name) +;; AST: declaration > placeholder_type_specifier/auto > init_declarator > identifier + new_expression > type_identifier +(declaration + type: (placeholder_type_specifier) + declarator: (init_declarator + declarator: (identifier) @type-binding.name + value: (new_expression + type: (type_identifier) @type-binding.type))) @type-binding.constructor + +;; ─── Type bindings — auto + qualified template factory (std::make_shared()) ─ +;; AST: declaration(1 > placeholder_type_specifier(2)2 > init_declarator(3 > +;; identifier(4)4 > call_expression(5 > qualified_identifier(6 > +;; template_function(7 > template_argument_list(8 > type_descriptor(9 > +;; type_identifier(10)10 )9 )8 )7 )6 )5 )3 )1 +(declaration + type: (placeholder_type_specifier) + declarator: (init_declarator + declarator: (identifier) @type-binding.name + value: (call_expression + function: (qualified_identifier + name: (template_function + arguments: (template_argument_list + (type_descriptor + type: (type_identifier) @type-binding.type))))))) @type-binding.constructor + +;; ─── Type bindings — auto + bare template factory (make_shared()) ─────── +;; Same but without qualified_identifier wrapper — one fewer nesting level +(declaration + type: (placeholder_type_specifier) + declarator: (init_declarator + declarator: (identifier) @type-binding.name + value: (call_expression + function: (template_function + arguments: (template_argument_list + (type_descriptor + type: (type_identifier) @type-binding.type)))))) @type-binding.constructor + +;; ─── Type bindings — auto alias assignment ────────────────────────── +;; Covers: auto alias = existingVar (RHS is a plain identifier) +;; AST: declaration > placeholder_type_specifier/auto > init_declarator > identifier + identifier +(declaration + type: (placeholder_type_specifier) + declarator: (init_declarator + declarator: (identifier) @type-binding.name + value: (identifier) @type-binding.type)) @type-binding.alias + +;; ─── Type bindings — auto + member access (field_expression) ──────── +;; Covers: auto addr = user.address (RHS is obj.field) +;; AST: declaration > placeholder_type_specifier > init_declarator > identifier + field_expression +;; We capture the field name as @type-binding.type so the compound-receiver +;; chain resolver can look it up on the receiver class scope. +;; The full obj.field text is synthesized by interpret.ts into a dotted +;; rawName for chain-follow resolution. +(declaration + type: (placeholder_type_specifier) + declarator: (init_declarator + declarator: (identifier) @type-binding.name + value: (field_expression + argument: (_) @type-binding.member-access-receiver + field: (field_identifier) @type-binding.type))) @type-binding.member-access + +;; ─── Type bindings — function return type ─────────────────────────── +;; Covers: User getUser() { ... } +;; AST: function_definition > type_identifier + function_declarator > identifier +(function_definition + type: (type_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 + 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 + declarator: (pointer_declarator + declarator: (function_declarator + declarator: (identifier) @type-binding.name))) @type-binding.return + +;; ─── Type bindings — inline method return type ────────────────────── +;; Covers: class Foo { User getUser() { ... } }; +(function_definition + type: (type_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 + declarator: (pointer_declarator + declarator: (function_declarator + declarator: (field_identifier) @type-binding.name))) @type-binding.return + +;; ─── Type bindings — method prototype return type in class body ────── +;; Covers: class User { User* lookup(int); std::string getName(); }; +;; AST: field_declaration > function_declarator > field_identifier +(field_declaration + type: (type_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 + declarator: (pointer_declarator + declarator: (function_declarator + declarator: (field_identifier) @type-binding.name))) @type-binding.return + +;; ─── Type bindings — field type declarations (class members) ──────── +;; Covers: class User { Address address; }; +(field_declaration + type: (type_identifier) @type-binding.type + declarator: (field_identifier) @type-binding.name) @type-binding.field + +;; Field pointer type: Address* address; +(field_declaration + type: (type_identifier) @type-binding.type + declarator: (pointer_declarator + declarator: (field_identifier) @type-binding.name)) @type-binding.field + +;; Field reference type: Address& address; +(field_declaration + type: (type_identifier) @type-binding.type + declarator: (reference_declarator + (field_identifier) @type-binding.name)) @type-binding.field + +;; ─── References — constructor calls (new Foo()) ───────────────────── +(new_expression + type: (type_identifier) @reference.name) @reference.call.constructor + +;; Constructor call with qualified type: new ns::Foo() +(new_expression + type: (qualified_identifier + name: (type_identifier) @reference.name)) @reference.call.constructor + +;; ─── References — free calls ──────────────────────────────────────── +(call_expression + function: (identifier) @reference.name) @reference.call.free + +;; ─── References — qualified calls (Namespace func or Class method) ─── +;; Capture the LHS of scope-resolution as the explicit receiver so +;; qualified static member calls route through receiver-bound-calls +;; Case 2 (class-name receiver) path. Without the receiver capture, +;; qualified calls have no explicit receiver and class methods cannot +;; resolve through receiver-bound paths. +(call_expression + function: (qualified_identifier + scope: (_) @reference.receiver + name: (identifier) @reference.name)) @reference.call.qualified + +;; ─── References — member calls (obj.method() / ptr->method()) ─────── +(call_expression + function: (field_expression + argument: (_) @reference.receiver + field: (field_identifier) @reference.name)) @reference.call.member + +;; ─── References — template calls (func()) ──────────────────────── +(call_expression + function: (template_function + name: (identifier) @reference.name)) @reference.call.free + +;; Note: Ns::func() is parsed as qualified_identifier by tree-sitter-cpp, +;; already captured by the qualified calls pattern above. + +;; ─── References — field reads ─────────────────────────────────────── +(field_expression + argument: (_) @reference.receiver + field: (field_identifier) @reference.name) @reference.read + +;; ─── References — field writes (assignment) ───────────────────────── +(assignment_expression + left: (field_expression + argument: (_) @reference.receiver + field: (field_identifier) @reference.name)) @reference.write +`; + +let _parser: Parser | null = null; +let _query: Parser.Query | null = null; + +export function getCppParser(): Parser { + if (_parser === null) { + _parser = new Parser(); + _parser.setLanguage(CPP as Parameters[0]); + } + return _parser; +} + +export function getCppScopeQuery(): Parser.Query { + if (_query === null) { + _query = new Parser.Query(CPP as Parameters[0], CPP_SCOPE_QUERY); + } + return _query; +} diff --git a/gitnexus/src/core/ingestion/languages/cpp/range-bindings.ts b/gitnexus/src/core/ingestion/languages/cpp/range-bindings.ts new file mode 100644 index 000000000..204d1ab21 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/cpp/range-bindings.ts @@ -0,0 +1,255 @@ +import type { ParsedFile, Scope, TypeRef } from 'gitnexus-shared'; +import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js'; +import { getCppParser } from './query.js'; +import { getTreeSitterBufferSize } from '../../constants.js'; +import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js'; + +/** + * Populate range-for loop variable type bindings for C++. + * + * Handles three patterns: + * 1. `for (auto& user : users)` — simple range-for + * 2. `for (auto& [key, user] : userMap)` — structured binding + * 3. `for (auto& user : *usersPtr)` — dereference range-for + * + * Strategy: look up the range source variable's type in scope + * typeBindings, extract the last template argument as the element + * type, and inject a typeBinding for the loop variable. + */ +export function populateCppRangeBindings( + parsedFiles: readonly ParsedFile[], + _indexes: ScopeResolutionIndexes, + ctx: { + readonly fileContents: ReadonlyMap; + readonly treeCache?: { get(filePath: string): unknown }; + }, +): void { + const parser = getCppParser(); + + for (const parsed of parsedFiles) { + const sourceText = ctx.fileContents.get(parsed.filePath); + if (sourceText === undefined) continue; + + const cachedTree = ctx.treeCache?.get(parsed.filePath); + const tree = + (cachedTree as ReturnType | undefined) ?? + parseSourceSafe(parser, sourceText, undefined, { + bufferSize: getTreeSitterBufferSize(sourceText), + }); + + const moduleScope = parsed.scopes.find((s) => s.kind === 'Module'); + if (moduleScope === undefined) continue; + + const scopeMap = new Map(parsed.scopes.map((s) => [s.id, s])); + + // Build a map from parameter name → AST parameter_declaration node + // so we can extract the un-normalized template type from the AST. + const paramTypeMap = buildParamTemplateMap(tree.rootNode); + + for (const rangeNode of tree.rootNode.descendantsOfType('for_range_loop')) { + // Get the declarator (loop variable) + const declarator = rangeNode.childForFieldName('declarator'); + if (declarator === null) continue; + + // Get the range source expression (right side of ':') + const right = rangeNode.childForFieldName('right'); + if (right === null) continue; + + // Determine the loop variable name(s) and whether this is a structured binding + const varNames = extractLoopVarNames(declarator); + if (varNames.length === 0) continue; + + // Determine the range source variable name (handle dereference) + const sourceVarName = extractSourceVarName(right); + if (sourceVarName === null) continue; + + // Look up the source variable's full template type from the AST + // (scope typeBindings have been normalized and lost template params) + const fullType = paramTypeMap.get(sourceVarName); + if (fullType === undefined) continue; + + // Extract element type from the container type + const elementType = extractCppElementType(fullType); + if (elementType === null) continue; + + // Find the enclosing function scope + const functionScope = findEnclosingFunctionScope(rangeNode, scopeMap); + const targetScope = functionScope ?? moduleScope; + const mutable = targetScope.typeBindings as Map; + + // For structured binding [key, user], bind the last identifier to the element type + // For simple range-for, bind the single variable + const bindVar = varNames[varNames.length - 1]; + mutable.set(bindVar, { + rawName: elementType, + declaredAtScope: targetScope.id, + source: 'annotation', + }); + } + } +} + +/** Minimal tree-sitter node shape needed by range-binding helpers. */ +interface TsNode { + readonly type: string; + readonly text: string; + readonly childCount: number; + child(index: number): TsNode | null; + descendantsOfType(type: string): readonly TsNode[]; + childForFieldName(name: string): TsNode | null; +} + +/** + * Build a map from parameter name → full (un-normalized) type text + * by walking the AST for all `parameter_declaration` nodes. + * + * This bypasses `normalizeCppTypeName` which strips template params, + * giving us the raw `std::vector` text needed for element-type + * extraction. + */ +function buildParamTemplateMap(rootNode: TsNode): Map { + const map = new Map(); + for (const paramNode of rootNode.descendantsOfType('parameter_declaration')) { + const typeNode = paramNode.childForFieldName('type'); + if (typeNode === null) continue; + + // Extract the parameter name from the declarator subtree. + // The declarator may be: identifier, reference_declarator > identifier, + // or pointer_declarator > identifier. + const declNode = paramNode.childForFieldName('declarator'); + if (declNode === null) continue; + + const idents = declNode.descendantsOfType('identifier'); + if (idents.length === 0) continue; + const paramName = idents[idents.length - 1].text; + + // Use the full type node text (preserving template params) + map.set(paramName, typeNode.text); + } + return map; +} + +/** + * Extract loop variable name(s) from the declarator node. + * Handles both simple `identifier` and `structured_binding_declarator`. + */ +function extractLoopVarNames(declarator: TsNode): string[] { + // The declarator is typically reference_declarator or pointer_declarator wrapping + // either an identifier or a structured_binding_declarator. + const structBindings = declarator.descendantsOfType('structured_binding_declarator'); + if (structBindings.length > 0) { + // structured_binding_declarator contains identifiers like [key, user] + const idents = structBindings[0].descendantsOfType('identifier'); + return idents.map((id) => id.text).filter((t) => t !== '_'); + } + + // Simple case: reference_declarator > identifier or just identifier + const idents = declarator.descendantsOfType('identifier'); + if (idents.length > 0) { + return [idents[idents.length - 1].text]; + } + + return []; +} + +/** + * Extract the source variable name from the range expression. + * Handles plain identifiers and dereference expressions (*ptr). + */ +function extractSourceVarName(right: TsNode): string | null { + if (right.type === 'identifier') { + return right.text; + } + if (right.type === 'pointer_expression') { + // *usersPtr → get the argument (usersPtr) + const arg = right.childForFieldName('argument'); + if (arg !== null) return arg.text; + } + return null; +} + +/** + * Extract the element type from a C++ container type string. + * + * Examples: + * - `vector` → `User` + * - `std::vector` → `User` + * - `map` → `User` (last template arg) + * - `map` → `User` + * + * For structured bindings with maps, the last template arg is the value type. + * For vectors/sets, the first (and only) template arg is the element type. + */ +function extractCppElementType(rawType: string): string | null { + // Find the outermost template argument list + const ltIdx = rawType.indexOf('<'); + if (ltIdx === -1) return null; + + // Extract the template argument string (handle nested templates) + let depth = 0; + let lastCommaOrStart = ltIdx + 1; + let lastArg = ''; + + for (let i = ltIdx; i < rawType.length; i++) { + const ch = rawType[i]; + if (ch === '<') { + depth++; + } else if (ch === '>') { + depth--; + if (depth === 0) { + lastArg = rawType.slice(lastCommaOrStart, i).trim(); + break; + } + } else if (ch === ',' && depth === 1) { + lastCommaOrStart = i + 1; + } + } + + if (lastArg === '') return null; + + // Strip pointer/reference qualifiers and const + let elementType = lastArg + .replace(/^const\s+/, '') + .replace(/\s*[*&]+\s*$/, '') + .trim(); + + // Strip namespace prefix (std::string → string) + const lastColon = elementType.lastIndexOf('::'); + if (lastColon !== -1) { + elementType = elementType.slice(lastColon + 2); + } + + return elementType || null; +} + +/** + * Find the enclosing Function scope for a tree-sitter node by + * walking up the AST and matching source positions. + */ +function findEnclosingFunctionScope( + node: unknown, + scopeMap: ReadonlyMap, +): Scope | null { + const tsNode = node as { + readonly parent: unknown; + readonly type: string; + readonly startPosition: { readonly row: number; readonly column: number }; + }; + let current: typeof tsNode | null = tsNode; + while (current !== null) { + if (current.type === 'function_definition') { + for (const scope of scopeMap.values()) { + if ( + scope.kind === 'Function' && + scope.range.startLine === current.startPosition.row && + scope.range.startCol === current.startPosition.column + ) { + return scope; + } + } + break; + } + current = (current.parent as typeof tsNode) ?? null; + } + return null; +} diff --git a/gitnexus/src/core/ingestion/languages/cpp/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/cpp/scope-resolver.ts new file mode 100644 index 000000000..a85e5b113 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/cpp/scope-resolver.ts @@ -0,0 +1,230 @@ +import type { ParsedFile, SymbolDefinition } from 'gitnexus-shared'; +import { + findClassBindingInScope, + findEnclosingClassDef, +} from '../../scope-resolution/scope/walkers.js'; +import { SupportedLanguages } from 'gitnexus-shared'; +import { buildMro, defaultLinearize } from '../../scope-resolution/passes/mro.js'; +import { populateClassOwnedMembers } from '../../scope-resolution/scope/walkers.js'; +import type { ScopeResolver } from '../../scope-resolution/contract/scope-resolver.js'; +import { cppProvider } from '../c-cpp.js'; +import { cppArityCompatibility } from './arity.js'; +import { cppMergeBindings } from './merge-bindings.js'; +import { resolveCppImportTarget } from './import-target.js'; +import { scanCppHeaderFiles } from './header-scan.js'; +import { + expandCppWildcardNames, + isFileLocal, + clearFileLocalNames, + populateCppNonGloballyVisible, + isCppDefGloballyVisible, +} from './file-local-linkage.js'; +import { + populateCppDependentBases, + clearCppDependentBases, + isCppDependentBaseMember, +} from './two-phase-lookup.js'; +import { + populateCppAssociatedNamespaces, + clearCppAdlState, + pickCppAdlCandidates, + ADL_AMBIGUOUS, +} from './adl.js'; +import { + clearCppInlineNamespaces, + populateCppInlineNamespaceScopes, + resolveCppQualifiedNamespaceMember, +} from './inline-namespaces.js'; +import { populateCppRangeBindings } from './range-bindings.js'; + +/** + * C++ `ScopeResolver` registered in `SCOPE_RESOLVERS` and consumed by + * the generic `runScopeResolution` orchestrator (RFC #909 Ring 3). + * + * C++ extends C's scope resolution with: + * - Namespaces (`namespace foo { ... }`) + * - Classes with methods and multiple inheritance + * - `using namespace` (wildcard import from namespace) + * - `using X::name` (named import from namespace) + * - Anonymous namespace (file-local linkage, like C `static`) + * - Default parameters (requiredParameterCount < parameterCount) + * - Overloading (arity-based disambiguation) + * - Templates (V1: generic-ignored, `List` ≡ `List`) + * - Leftmost-base MRO for multiple inheritance + */ +export const cppScopeResolver: ScopeResolver = { + language: SupportedLanguages.CPlusPlus, + languageProvider: cppProvider, + importEdgeReason: 'cpp-scope: include', + + loadResolutionConfig: (repoPath: string) => { + // Clear stale per-pipeline state from any previous invocation. + clearFileLocalNames(); + clearCppDependentBases(); + clearCppAdlState(); + clearCppInlineNamespaces(); + return scanCppHeaderFiles(repoPath); + }, + + resolveImportTarget: (targetRaw, fromFile, allFilePaths, resolutionConfig) => { + // Augment allFilePaths with header files discovered via loadResolutionConfig. + // C++ .h/.hpp/.hxx/.hh files may be classified differently by language + // detection but are importable from .cpp files via #include. + const headerPaths = resolutionConfig as ReadonlySet | undefined; + if (headerPaths !== undefined && headerPaths.size > 0) { + const augmented = new Set(allFilePaths); + for (const h of headerPaths) augmented.add(h); + return resolveCppImportTarget(targetRaw, fromFile, augmented); + } + return resolveCppImportTarget(targetRaw, fromFile, allFilePaths); + }, + + expandsWildcardTo: (targetModuleScope, parsedFiles) => + expandCppWildcardNames(targetModuleScope, parsedFiles), + + mergeBindings: (existing, incoming, scopeId) => cppMergeBindings(existing, incoming, scopeId), + + // Adapter: cppArityCompatibility predates ScopeResolver and uses + // (def, callsite). ScopeResolver contract is (callsite, def). + arityCompatibility: (callsite, def) => cppArityCompatibility(def, callsite), + + buildMro: (graph, parsedFiles, nodeLookup) => + buildMro(graph, parsedFiles, nodeLookup, defaultLinearize), + + populateOwners: (parsed: ParsedFile) => { + populateClassOwnedMembers(parsed); + // Resolve inline-namespace ranges (recorded at capture time) to + // ScopeIds BEFORE `populateCppNonGloballyVisible` runs, so the + // inline-namespace exemption sees the populated Set. + populateCppInlineNamespaceScopes(parsed); + // Track namespace-nested and class-nested defs so the global free-call + // fallback and wildcard expansion can suppress them as unqualified + // cross-file callables. + populateCppNonGloballyVisible(parsed); + // Resolve recorded template-class → dependent-base simple names to + // class nodeIds for two-phase template lookup (U3 of plan + // 2026-05-13-001). + populateCppDependentBases(parsed); + // Build the class-def → enclosing-namespace-qualified-name map used + // by ADL (U2 of plan 2026-05-13-001) to identify each argument type's + // associated namespace for Koenig lookup. + populateCppAssociatedNamespaces(parsed); + }, + + // Simple `isSuperReceiver` returns false for C++. Real super + // classification is caller-context-dependent and lives in + // `isSuperReceiverInContext` below — without scope context the + // previous regex `/^[A-Z]\w*::/` misclassified namespace-qualified + // calls (e.g., `Singleton::getInstance()`) as super calls and routed + // them through the wrong resolution branch. + isSuperReceiver: () => false, + + isSuperReceiverInContext: (text, callerScope, scopes) => { + // The receiver text comes from the LHS of `::` in `qualified_identifier` + // (e.g., for `Base::method()`, text is `Base`). Strip template + // arguments (V1: name-only matching, generics ignored) and any leading + // namespace qualifier so the lookup matches the bare class def's + // simple name. `Base::method()` → `Base`; `outer::v1::Base` → + // `Base`. This handles the Phase 5 cross-unit composition where + // qualified base-method calls appear inside template bodies. + let lhs = text; + const sepIdx = lhs.indexOf('::'); + if (sepIdx > 0) lhs = lhs.slice(0, sepIdx).trim(); + // Strip trailing template-argument list (greedy: drop everything from + // the first `<` onward — V1 ignores generics). + const lt = lhs.indexOf('<'); + if (lt > 0) lhs = lhs.slice(0, lt).trim(); + // Strip nested namespace prefix from the receiver text itself (the + // `outer::v1::Base` shape that appears in derived-list `base_class_clause`). + const lastDoubleColon = lhs.lastIndexOf('::'); + if (lastDoubleColon >= 0) lhs = lhs.slice(lastDoubleColon + 2).trim(); + if (lhs.length === 0) return false; + + // Resolve the LHS in the caller's scope chain. Only class-like + // resolutions can be super receivers; Namespace and unresolved + // names are not super calls. + const lhsDef = findClassBindingInScope(callerScope, lhs, scopes); + if (lhsDef === undefined) return false; + + // The caller must have an enclosing class — super calls only make + // sense inside a class body. Free functions can use `ClassName::` + // for namespace-qualified calls but those are not super. + const enclosing = findEnclosingClassDef(callerScope, scopes); + if (enclosing === undefined) return false; + + // `lhsDef` must be in the caller's MRO (i.e., the caller's enclosing + // class derives from it). The class itself counts as its own MRO + // root — `Self::method()` is a qualified self-call, not a super + // call, so exclude the caller's own class. + if (lhsDef.nodeId === enclosing.nodeId) return false; + const mro = scopes.methodDispatch.mroFor(enclosing.nodeId); + return mro.includes(lhsDef.nodeId); + }, + + // C++ is statically typed — disable field fallback heuristic + fieldFallbackOnMethodLookup: false, + // C++ needs return type propagation across #include boundaries + propagatesReturnTypesAcrossImports: true, + // C++ #include brings in all symbols — enable global free call fallback + allowGlobalFreeCallFallback: true, + // 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, + // The `isFileLocalDef` hook on the global free-call fallback names + // file-local linkage historically, but semantically gates "logically + // invisible cross-file" defs. C++ extends this to also reject class- + // owned methods/fields and namespace-nested symbols — an unqualified + // call from a free function MUST NOT resolve to `User::save` or + // `ns::foo` (Cppreference, "Unqualified name lookup"). Without this + // gate, the global fallback walks every callable in the workspace + // registry and matches any class method or namespace function by + // simple name. + isFileLocalDef: (def: SymbolDefinition) => { + const simple = def.qualifiedName?.split('.').pop() ?? def.qualifiedName ?? ''; + if (isFileLocal(def.filePath, simple)) return true; + // Class-owned (Method/Field) — `populateClassOwnedMembers` already + // stamps `ownerId`; cheap fast-path before consulting the scope map. + if (def.ownerId !== undefined) return true; + // Namespace-nested defs — require qualification cross-file. Scope- + // walked at `populateOwners` time into a per-file nodeId set. + if (!isCppDefGloballyVisible(def.filePath, def.nodeId)) return true; + return false; + }, + + // C++ two-phase template lookup: inside a class template body, + // unqualified calls MUST NOT bind to members of a dependent base + // class. The standard requires `this->name()` or `Base::name()` + // forms to make the lookup dependent. Without this gate the global + // free-call fallback walks the workspace registry and silently binds + // unqualified calls to dependent-base members, producing CALLS edges + // the compiler would reject. See plan 2026-05-13-001 U3. + isCallableVisibleFromCaller: ({ candidate, callerScope, scopes }) => { + if (callerScope === undefined || scopes === undefined) return true; + // Reject when the candidate is a member of a dependent base of the + // caller's enclosing template class. Otherwise allow. + return !isCppDependentBaseMember(callerScope, candidate, scopes); + }, + + // C++ argument-dependent / Koenig lookup (U2 of plan 2026-05-13-001). + // Fires after `findCallableBindingInScope` returns undefined; surfaces + // candidates from the associated namespaces of class-typed arguments. + // V1 limitation: only direct enclosing-namespace closure for value + // class-typed args; pointer/reference/template-spec args excluded. + resolveAdlCandidates: (site, callerParsed, scopes, parsedFiles) => { + const result = pickCppAdlCandidates(site, callerParsed, scopes, parsedFiles); + if (result === ADL_AMBIGUOUS) return 'ambiguous'; + return result; + }, + + // C++ qualified namespace-member resolution (U5 of plan 2026-05-13-001). + // Handles `outer::foo()` where `outer` is a namespace (not a class). + // Walks each parsed file's namespace scopes by simple name, then + // descends transitively through inline-namespace children when + // searching for the called member. Returns undefined for non-namespace + // receivers so receiver-bound-calls Case 2 still gets a chance. + resolveQualifiedReceiverMember: (receiverName, memberName, _callerScope, scopes, parsedFiles) => + resolveCppQualifiedNamespaceMember(receiverName, memberName, parsedFiles, scopes), +}; diff --git a/gitnexus/src/core/ingestion/languages/cpp/simple-hooks.ts b/gitnexus/src/core/ingestion/languages/cpp/simple-hooks.ts new file mode 100644 index 000000000..63500abd5 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/cpp/simple-hooks.ts @@ -0,0 +1,79 @@ +import type { + CaptureMatch, + ParsedImport, + Scope, + ScopeId, + ScopeTree, + TypeRef, +} from 'gitnexus-shared'; + +/** + * C++ binding scope: default auto-hoist (null) for most declarations. + * + * For `for` statement init-scope variables (e.g. `for (int i = 0; ...)`), + * the variable is scoped to the for-block, not the enclosing function. + * The tree-sitter scope query already captures for_statement as @scope.block, + * so tree-sitter's scope nesting handles this automatically — we return null + * to let the default auto-hoist apply. + */ +export function cppBindingScopeFor( + decl: CaptureMatch, + 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 + 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); + } + if (cur !== undefined && cur.kind === 'Module') return cur.id; + } + return null; // default auto-hoist for other bindings +} + +/** + * C++ import owning scope: default (null). + * #include and using declarations are file-scoped in C++. + */ +export function cppImportOwningScope( + _imp: ParsedImport, + _innermost: Scope, + _tree: ScopeTree, +): ScopeId | null { + return null; +} + +/** + * C++ receiver binding: return `this` TypeRef for methods inside a class. + * + * When a function scope is inside a class scope, the implicit `this` pointer + * refers to the enclosing class. This enables `this->method()` and implicit + * `this` member access resolution. + */ +export function cppReceiverBinding(functionScope: Scope): TypeRef | null { + // Walk up the scope tree to find an enclosing class scope + if (functionScope.parent === null) return null; + + // The scope tree structure nests function scopes inside class scopes. + // The orchestrator provides the function scope; we need to check if + // its parent chain contains a class scope. + // + // However, the ScopeResolver.receiverBinding contract receives only + // the function Scope (not the full ScopeTree), and the Scope type + // includes `parent` (a ScopeId) but not a reference to the parent + // Scope object. + // + // The orchestrator already handles this by looking up the class owner + // via populateOwners. We return null here and let the shared infra + // handle receiver resolution through the class-ownership mechanism. + // + // This is consistent with how C# and Go handle it — the receiver + // binding is established through populateOwners + the MRO chain, + // not through this hook. + return null; +} diff --git a/gitnexus/src/core/ingestion/languages/cpp/two-phase-lookup.ts b/gitnexus/src/core/ingestion/languages/cpp/two-phase-lookup.ts new file mode 100644 index 000000000..7840ed81a --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/cpp/two-phase-lookup.ts @@ -0,0 +1,133 @@ +/** + * C++ two-phase template lookup support. + * + * Inside a class template body, names from a dependent base class are NOT + * found by ordinary unqualified lookup. The standard requires the + * `this->name` or `Base::name` forms to make the lookup dependent. + * GitNexus's global free-call fallback otherwise binds such names to the + * dependent base's members, producing CALLS edges the compiler would + * reject. + * + * This module records — during `emitCppScopeCaptures` — which template + * class declarations have which dependent base class names (per file). + * `populateCppDependentBases` then resolves those names to class nodeIds + * using the workspace registry, building the per-class set the + * `isDependentBaseMember` predicate consumes. + * + * NOTE: module-level state, single-process-single-repo use only. + * `clearFileLocalNames()` clears this state alongside file-local linkage + * (see `file-local-linkage.ts`). + */ + +import type { ParsedFile, ScopeId, SymbolDefinition } from 'gitnexus-shared'; +import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js'; +import { findEnclosingClassDef } from '../../scope-resolution/scope/walkers.js'; + +/** + * Capture-time record: for each template class declaration in a file, + * the simple names of its dependent base classes. + * + * Key: filePath + * Value: Map> + */ +const dependentBasesByFile = new Map>>(); + +/** + * Post-`populateOwners` resolution: per-class-nodeId, the set of + * dependent-base-class nodeIds. Built by `populateCppDependentBases` + * from `dependentBasesByFile` + the workspace registry. + */ +const dependentBaseNodeIds = new Map>(); + +/** + * Record a dependent-base relationship discovered during scope-capture + * emission. `className` is the simple name of the template class; + * `baseName` is the simple name of the dependent base class. + * + * The capture-time recorder uses simple names because the registry + * resolution that maps names → nodeIds runs later (in + * `populateCppDependentBases`). + */ +export function markCppDependentBase(filePath: string, className: string, baseName: string): void { + let perFile = dependentBasesByFile.get(filePath); + if (perFile === undefined) { + perFile = new Map(); + dependentBasesByFile.set(filePath, perFile); + } + let bases = perFile.get(className); + if (bases === undefined) { + bases = new Set(); + perFile.set(className, bases); + } + bases.add(baseName); +} + +/** Clear two-phase-lookup state. Called from `clearFileLocalNames`. */ +export function clearCppDependentBases(): void { + dependentBasesByFile.clear(); + dependentBaseNodeIds.clear(); +} + +/** + * Resolve recorded dependent-base simple names to class nodeIds using + * the parsed file's localDefs. Run as part of `populateOwners` so the + * resolved set is available before any resolution pass consults it. + * + * Matches by simple name within the same file (the template class and + * its base are typically declared in the same TU; cross-file template + * bases are an edge case deferred to V2). + */ +export function populateCppDependentBases(parsed: ParsedFile): void { + const perFile = dependentBasesByFile.get(parsed.filePath); + if (perFile === undefined) return; + + // Build simple-name → nodeId index for this file's class-like defs. + const classByName = new Map(); + for (const def of parsed.localDefs) { + if (def.type !== 'Class' && def.type !== 'Struct' && def.type !== 'Interface') continue; + const simple = def.qualifiedName?.split('.').pop() ?? def.qualifiedName ?? ''; + if (simple !== '') classByName.set(simple, def.nodeId); + } + + for (const [className, baseNames] of perFile) { + const classNodeId = classByName.get(className); + if (classNodeId === undefined) continue; + let bases = dependentBaseNodeIds.get(classNodeId); + if (bases === undefined) { + bases = new Set(); + dependentBaseNodeIds.set(classNodeId, bases); + } + for (const baseName of baseNames) { + const baseNodeId = classByName.get(baseName); + if (baseNodeId !== undefined) bases.add(baseNodeId); + } + } +} + +/** + * Two-phase lookup predicate: is the candidate def a member of a + * dependent base of the caller's enclosing template class? + * + * Used as an additional reject-filter in `pickUniqueGlobalCallable` and + * the receiver-bound member chain walk. ONLY apply for unqualified + * call forms — `this->name` and `Base::name` are dependent lookup + * forms that the standard allows. + * + * Conservative bias: when the caller's enclosing class can't be + * identified, return `false` (let normal resolution proceed). Over- + * rejection is acceptable for the template case because the standard + * itself requires `this->` or qualified forms for dependent base + * access; missing edges here match the compiler's diagnostic shape. + */ +export function isCppDependentBaseMember( + callerScopeId: ScopeId, + candidateDef: SymbolDefinition, + scopes: ScopeResolutionIndexes, +): boolean { + if (candidateDef.ownerId === undefined) return false; + const enclosing = findEnclosingClassDef(callerScopeId, scopes); + if (enclosing === undefined) return false; + const bases = dependentBaseNodeIds.get(enclosing.nodeId); + if (bases === undefined) return false; + return bases.has(candidateDef.ownerId); +} diff --git a/gitnexus/src/core/ingestion/registry-primary-flag.ts b/gitnexus/src/core/ingestion/registry-primary-flag.ts index 6818007a3..869157adb 100644 --- a/gitnexus/src/core/ingestion/registry-primary-flag.ts +++ b/gitnexus/src/core/ingestion/registry-primary-flag.ts @@ -72,6 +72,7 @@ export const MIGRATED_LANGUAGES: ReadonlySet = new Set::<...>` (or another super- + * form the language recognizes), AND + * - `` resolves (via scope chain) to a class-like def, AND + * - that class is in the MRO of the caller's enclosing class. + * + * Returns `false` for namespace-qualified calls, unresolved names, + * class-qualified calls where the class is NOT in the caller's MRO, + * and any text the simple `isSuperReceiver` hook also rejects. + */ + readonly isSuperReceiverInContext?: ( + receiverText: string, + callerScope: ScopeId, + scopes: ScopeResolutionIndexes, + ) => boolean; + // ─── Optional toggles ────────────────────────────────────────────────────── /** @@ -522,8 +560,70 @@ export interface ScopeResolver { readonly isCallableVisibleFromCaller?: (ctx: { readonly callerParsed: ParsedFile; readonly candidate: SymbolDefinition; + /** Caller's enclosing scope id. Languages that gate visibility on + * caller scope (e.g. C++ two-phase template lookup) consult it; + * others ignore. Optional so existing implementations stay valid. */ + readonly callerScope?: ScopeId; + /** ScopeResolutionIndexes for scope-tree walks. Optional for the + * same reason as `callerScope`. */ + readonly scopes?: ScopeResolutionIndexes; }) => boolean; + /** + * Optional argument-dependent-lookup (ADL / Koenig lookup) hook for + * languages with C++-style associated-namespace candidate addition. + * + * Runs in the free-call fallback AFTER `findCallableBindingInScope` + * returns `undefined` and BEFORE `pickUniqueGlobalCallable`. The hook + * inspects the call site's argument types, computes the associated + * namespace set, and returns either: + * - a unique `SymbolDefinition` — emit the CALLS edge to it. + * - `'ambiguous'` — multiple candidates share normalized parameter + * types; the caller MUST suppress (zero edges). Mirrors the + * OVERLOAD_AMBIGUOUS sentinel from `overload-narrowing.ts`. + * - `undefined` — no ADL candidates; caller falls through to the + * global free-call fallback (`pickUniqueGlobalCallable`). + * + * Languages without C++-style ADL leave this undefined. The + * cross-language contract is "additive tier" — defining the hook never + * removes candidates the prior tier would have produced. + */ + readonly resolveAdlCandidates?: ( + site: { + readonly name: string; + readonly arity?: number; + readonly argumentTypes?: readonly string[]; + readonly atRange: { readonly startLine: number; readonly startCol: number }; + }, + callerParsed: ParsedFile, + scopes: ScopeResolutionIndexes, + parsedFiles: readonly ParsedFile[], + ) => SymbolDefinition | 'ambiguous' | undefined; + + /** + * Optional resolver for qualified-receiver member calls where the + * receiver is a namespace (not a class) and ordinary scope-chain / + * import resolution doesn't find the member. C++ uses this for + * `outer::foo()` style calls and to walk through inline-namespace + * children transitively (`outer::v1::foo` reachable as `outer::foo`). + * + * Languages whose qualified-name semantics are already covered by the + * receiver-bound-calls Case-1 namespace-targets path (e.g., Python's + * `import X; X.foo()`) leave this undefined. + * + * Receiver-bound-calls invokes this hook AFTER Case 1 (namespace + * imports) and AFTER Case 2 (class-name receiver) fail to resolve. + * Returns the target def, or `undefined` to fall through to the + * remaining cases. + */ + readonly resolveQualifiedReceiverMember?: ( + receiverName: string, + memberName: string, + callerScope: ScopeId, + scopes: ScopeResolutionIndexes, + parsedFiles: readonly ParsedFile[], + ) => SymbolDefinition | undefined; + /** * Optional post-finalize hook to inject cross-file bindings that * aren't modeled via explicit imports. Runs after diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/free-call-fallback.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/free-call-fallback.ts index eb6dd71fb..2dc20d2ef 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/passes/free-call-fallback.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/free-call-fallback.ts @@ -42,7 +42,20 @@ export function emitFreeCallFallback( readonly isCallableVisibleFromCaller?: (ctx: { readonly callerParsed: ParsedFile; readonly candidate: SymbolDefinition; + readonly callerScope?: ScopeId; + readonly scopes?: ScopeResolutionIndexes; }) => boolean; + readonly resolveAdlCandidates?: ( + site: { + readonly name: string; + readonly arity?: number; + readonly argumentTypes?: readonly string[]; + readonly atRange: { readonly startLine: number; readonly startCol: number }; + }, + callerParsed: ParsedFile, + scopes: ScopeResolutionIndexes, + parsedFiles: readonly ParsedFile[], + ) => SymbolDefinition | 'ambiguous' | undefined; } = {}, ): number { let emitted = 0; @@ -75,6 +88,35 @@ export function emitFreeCallFallback( if (fnDef === undefined) { fnDef = findCallableBindingInScope(site.inScope, site.name, scopes); } + // V1 ADL tier (C++ Koenig lookup, opt-in via provider.resolveAdlCandidates). + // Fires only when ordinary lookup is empty — V1 limitation per + // plan 2026-05-13-001 U2; ISO C++ would merge ADL with ordinary lookup + // and run overload resolution over the union. + // + // Sentinel 'ambiguous': ADL surfaced multiple candidates with + // identical normalized parameter types (mirrors OVERLOAD_AMBIGUOUS). + // We mark the site handled so `emit-references` does not retry, and + // continue to the next site without emitting an edge. + if (fnDef === undefined && options.resolveAdlCandidates !== undefined) { + const adlResult = options.resolveAdlCandidates( + { + name: site.name, + arity: site.arity, + argumentTypes: site.argumentTypes, + atRange: { startLine: site.atRange.startLine, startCol: site.atRange.startCol }, + }, + parsed, + scopes, + parsedFiles, + ); + if (adlResult === 'ambiguous') { + handledSites.add(`${parsed.filePath}:${site.atRange.startLine}:${site.atRange.startCol}`); + continue; + } + if (adlResult !== undefined) { + fnDef = adlResult; + } + } // V1: pickUniqueGlobalCallable ignores import context — resolves to any // globally-unique callable. False cross-package edges are possible when // the caller does not import the target package. Same-package calls are @@ -89,7 +131,12 @@ export function emitFreeCallFallback( site.arity, options.isCallableVisibleFromCaller !== undefined ? (candidate) => - options.isCallableVisibleFromCaller!({ callerParsed: parsed, candidate }) + options.isCallableVisibleFromCaller!({ + callerParsed: parsed, + candidate, + callerScope: site.inScope, + scopes, + }) : undefined, ); } 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 cbdfc62aa..33716778d 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 @@ -151,7 +151,8 @@ export function propagateImportedReturnTypes( const refs = lookupBindingsAt(importerModule.id, localName, indexes); for (const ref of refs) { - if (ref.origin !== 'import' && ref.origin !== 'reexport') continue; + if (ref.origin !== 'import' && ref.origin !== 'reexport' && ref.origin !== 'wildcard') + continue; const sourceModule = moduleScopeByFile.get(ref.def.filePath); if (sourceModule === undefined) continue; diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/overload-narrowing.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/overload-narrowing.ts index f36287052..bff16d27e 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/passes/overload-narrowing.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/overload-narrowing.ts @@ -88,3 +88,63 @@ export function narrowOverloadCandidates( return candidates; } + +/** + * Detect when >1 candidate share identical `parameterTypes` after the + * per-language normalizer has collapsed distinct underlying types. This + * signals "the resolver cannot pick the right overload — the + * normalization that helps single-candidate flows now hides a real + * ambiguity" and lets callers suppress the edge rather than pick + * arbitrarily. + * + * Concrete trigger (PR #1520 review follow-up plan U2, Claude review + * Finding 5): the C++ `arity-metadata.ts` normalizer collapses `int`, + * `long`, `short`, `unsigned`, and `size_t` to `'int'`. Without this + * check, `process(int)` and `process(long)` both end up with + * `parameterTypes === ['int']`, and `pickOverload` arbitrarily picks + * the first — emitting a false CALLS edge to the wrong overload. + * + * Returns false when: + * - 0 or 1 candidates (no ambiguity to detect) + * - any candidate has undefined `parameterTypes` (can't compare) + * - candidates differ in arity or in any parameter-type slot + * + * Other languages: this check is a precondition gate, not a behavior + * change for normal narrowing. Languages whose normalizers do not + * collapse distinct types (verified by grep over `*-arity-metadata.ts` + * — no `int → int` collapse outside C++) will never produce >1 + * candidate with identical `parameterTypes` from genuinely distinct + * declarations, so this returns false for them. The branch is + * effectively C++-only in practice. + */ +export function isOverloadAmbiguousAfterNormalization( + candidates: readonly SymbolDefinition[], + argCount?: number, +): boolean { + if (candidates.length < 2) return false; + const first = candidates[0].parameterTypes; + if (first === undefined) return false; + // When argCount is provided, compare only the first `argCount` slots — + // this catches default-argument ambiguity: `void f(int); void f(int, int = 0);` + // called with `f(1)` (argCount=1) leaves both candidates viable because + // default args make them arity-compatible, and their first slot is + // identical even though full parameterTypes lengths differ. + // Without argCount, fall back to full-sequence comparison (the original + // int/long normalization-collapse case). + const compareUpTo = argCount !== undefined ? argCount : first.length; + if (compareUpTo === 0) return false; + if (first.length < compareUpTo) return false; + for (let i = 1; i < candidates.length; i++) { + const p = candidates[i].parameterTypes; + if (p === undefined) return false; + if (p.length < compareUpTo) return false; + for (let j = 0; j < compareUpTo; j++) { + if (p[j] !== first[j]) return false; + } + // When argCount is NOT provided, also require length equality so + // distinct-arity candidates that happen to share a prefix don't + // collapse to ambiguous (preserves the original int/long contract). + if (argCount === undefined && p.length !== first.length) return false; + } + return true; +} 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 0cb544db9..9b57a7555 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 @@ -51,7 +51,10 @@ import { import { tryEmitEdge } from '../graph-bridge/edges.js'; import { resolveCompoundReceiverClass } from '../passes/compound-receiver.js'; import { resolveDefGraphId } from '../graph-bridge/ids.js'; -import { narrowOverloadCandidates } from './overload-narrowing.js'; +import { + narrowOverloadCandidates, + isOverloadAmbiguousAfterNormalization, +} from './overload-narrowing.js'; /** Subset of `ScopeResolver` consumed by this pass. Accepting the * subset rather than the full provider keeps tests and partial @@ -59,10 +62,12 @@ import { narrowOverloadCandidates } from './overload-narrowing.js'; type ReceiverBoundProviderSubset = Pick< ScopeResolver, | 'isSuperReceiver' + | 'isSuperReceiverInContext' | 'fieldFallbackOnMethodLookup' | 'collapseMemberCallsByCallerTarget' | 'unwrapCollectionAccessor' | 'hoistTypeBindingsToModule' + | 'resolveQualifiedReceiverMember' >; export function emitReceiverBoundCalls( @@ -162,7 +167,14 @@ export function emitReceiverBoundCalls( const siteKey = `${parsed.filePath}:${site.atRange.startLine}:${site.atRange.startCol}`; // ── super branch ───────────────────────────────────────────── - if (provider.isSuperReceiver(receiverName)) { + // Languages with caller-context-dependent super classification + // (C++) define `isSuperReceiverInContext`; we prefer it. Simple + // text-only languages (Python, Java, PHP) use the plain hook. + const isSuper = + provider.isSuperReceiverInContext !== undefined + ? provider.isSuperReceiverInContext(receiverName, site.inScope, scopes) + : provider.isSuperReceiver(receiverName); + if (isSuper) { const enclosingClass = findEnclosingClassDef(site.inScope, scopes); if (enclosingClass !== undefined) { // For super-receiver dispatch (`parent::`, `base.`, `super()`), @@ -285,6 +297,38 @@ export function emitReceiverBoundCalls( if (found) continue; } + // ── Case 1.5: qualified namespace-receiver (language-specific) ─── + // Languages whose qualified-name semantics need workspace-wide + // namespace-scope walking (C++ `outer::foo()`, including inline- + // namespace transitive traversal) implement `resolveQualifiedReceiverMember`. + // Runs before Case 2 so namespace receivers don't accidentally match a + // class with the same simple name. + if (provider.resolveQualifiedReceiverMember !== undefined) { + const memberDef = provider.resolveQualifiedReceiverMember( + receiverName, + memberName, + site.inScope, + scopes, + parsedFiles, + ); + if (memberDef !== undefined) { + const ok = tryEmitEdge( + graph, + scopes, + nodeLookup, + site, + memberDef, + memberDef.filePath !== parsed.filePath ? 'import-resolved' : 'global', + seen, + 0.85, + collapse, + ); + if (ok) emitted++; + handledSites.add(siteKey); + continue; + } + } + // ── Case 2: class-name receiver ────────────────────────────── const classDef = findClassBindingInScope(site.inScope, receiverName, scopes); if (classDef !== undefined) { @@ -454,9 +498,24 @@ export function emitReceiverBoundCalls( if (ownerDef !== undefined) { const chain = [ownerDef.nodeId, ...scopes.methodDispatch.mroFor(ownerDef.nodeId)]; let memberDef: SymbolDefinition | undefined; + let ambiguous = false; for (const ownerId of chain) { - memberDef = pickOverload(ownerId, memberName, site, model); - if (memberDef !== undefined) break; + const picked = pickOverload(ownerId, memberName, site, model); + if (picked === OVERLOAD_AMBIGUOUS) { + ambiguous = true; + break; + } + if (picked !== undefined) { + memberDef = picked; + break; + } + } + if (ambiguous) { + // Suppress and mark handled so `emitReferencesViaLookup` + // doesn't re-emit the pre-resolved reference. See + // OVERLOAD_AMBIGUOUS docstring for the upstream cause. + handledSites.add(siteKey); + continue; } if (memberDef !== undefined) { // For read/write ACCESSES, mirror the legacy DAG's reason @@ -509,7 +568,7 @@ function pickOverload( memberName: string, site: ParsedFile['referenceSites'][number], model: SemanticModel, -): SymbolDefinition | undefined { +): SymbolDefinition | typeof OVERLOAD_AMBIGUOUS | undefined { const overloads = model.methods.lookupAllByOwner(ownerId, memberName); if (overloads.length === 0) { // Non-callable member (field / property / variable) — ACCESSES @@ -520,5 +579,22 @@ function pickOverload( if (overloads.length === 1) return overloads[0]; const candidates = narrowOverloadCandidates(overloads, site.arity, site.argumentTypes); + // When narrowing leaves >1 candidate that share identical normalized + // parameter-types (e.g., C++ `f(int)` vs `f(long)` both collapsed to + // `['int']` by `normalizeCppParamType`), suppress the edge entirely. + // The graph schema has no ambiguous-target edge model, so emitting one + // would arbitrarily pick a candidate and lie about the call's target. + // PR #1520 review follow-up plan U2 / Claude review Finding 5. + if (isOverloadAmbiguousAfterNormalization(candidates, site.arity)) return OVERLOAD_AMBIGUOUS; return candidates[0] ?? overloads[0]; } + +/** + * Sentinel returned by `pickOverload` when narrowing leaves >1 candidate + * sharing identical normalized parameter-types. Callers should suppress + * the CALLS edge AND mark the site as handled so `emitReferencesViaLookup` + * does not re-emit from the pre-resolved reference index. See + * `pickOverload` JSDoc for the upstream cause (per-language normalizer + * collapses distinct types in arity-metadata). + */ +export const OVERLOAD_AMBIGUOUS = Symbol('overload-ambiguous'); diff --git a/gitnexus/src/core/ingestion/scope-resolution/pipeline/registry.ts b/gitnexus/src/core/ingestion/scope-resolution/pipeline/registry.ts index c606661c8..713497da4 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/pipeline/registry.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/pipeline/registry.ts @@ -17,6 +17,7 @@ import { typescriptScopeResolver } from '../../languages/typescript/scope-resolv import { goScopeResolver } from '../../languages/go/scope-resolver.js'; import { javaScopeResolver } from '../../languages/java/scope-resolver.js'; import { cScopeResolver } from '../../languages/c/scope-resolver.js'; +import { cppScopeResolver } from '../../languages/cpp/scope-resolver.js'; import { phpScopeResolver } from '../../languages/php/scope-resolver.js'; /** Map of `SupportedLanguages` → `ScopeResolver`. The phase iterates @@ -33,5 +34,6 @@ export const SCOPE_RESOLVERS: ReadonlyMap = n [SupportedLanguages.Go, goScopeResolver], [SupportedLanguages.Java, javaScopeResolver], [SupportedLanguages.C, cScopeResolver], + [SupportedLanguages.CPlusPlus, cppScopeResolver], [SupportedLanguages.PHP, phpScopeResolver], ]); diff --git a/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts b/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts index 47f8a3551..4f74cfce2 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts @@ -308,6 +308,7 @@ export function runScopeResolution( allowGlobalFallback: provider.allowGlobalFreeCallFallback === true, isFileLocalDef: provider.isFileLocalDef, isCallableVisibleFromCaller: provider.isCallableVisibleFromCaller, + resolveAdlCandidates: provider.resolveAdlCandidates, }, ); const { emitted, skipped } = emitReferencesViaLookup( diff --git a/gitnexus/src/core/ingestion/workers/parse-worker.ts b/gitnexus/src/core/ingestion/workers/parse-worker.ts index def4e1299..9a71fc16c 100644 --- a/gitnexus/src/core/ingestion/workers/parse-worker.ts +++ b/gitnexus/src/core/ingestion/workers/parse-worker.ts @@ -181,6 +181,8 @@ export interface ExtractedAssignment { propertyName: string; /** Resolved type name of the receiver if available from TypeEnv */ receiverTypeName?: string; + /** 1-indexed line number of the assignment site (used for per-site dedup) */ + line?: number; } // `ExtractedHeritage` now lives in `../model/heritage-map.ts` and is @@ -1580,6 +1582,7 @@ const processFileGroup = ( sourceId: srcId, receiverText, propertyName, + line: captureMap['assignment'].startPosition.row + 1, ...(receiverTypeName ? { receiverTypeName } : {}), }); } diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-adl-ambiguous/alpha.h b/gitnexus/test/fixtures/lang-resolution/cpp-adl-ambiguous/alpha.h new file mode 100644 index 000000000..c873a3a67 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-adl-ambiguous/alpha.h @@ -0,0 +1,7 @@ +#pragma once + +namespace alpha { + struct Token {}; + void process(Token t, int n); + void process(Token t, long n); +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-adl-ambiguous/app.cpp b/gitnexus/test/fixtures/lang-resolution/cpp-adl-ambiguous/app.cpp new file mode 100644 index 000000000..94c283ac5 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-adl-ambiguous/app.cpp @@ -0,0 +1,8 @@ +#include "alpha.h" + +namespace app { + void run() { + alpha::Token t; + process(t, 42); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-adl-basic/app.cpp b/gitnexus/test/fixtures/lang-resolution/cpp-adl-basic/app.cpp new file mode 100644 index 000000000..7fc21a0be --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-adl-basic/app.cpp @@ -0,0 +1,8 @@ +#include "audit.h" + +namespace app { + void run() { + audit::Event e; + record(e); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-adl-basic/audit.h b/gitnexus/test/fixtures/lang-resolution/cpp-adl-basic/audit.h new file mode 100644 index 000000000..2c9803a3d --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-adl-basic/audit.h @@ -0,0 +1,6 @@ +#pragma once + +namespace audit { + struct Event {}; + void record(Event e); +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-adl-pointer-arg-boundary/app.cpp b/gitnexus/test/fixtures/lang-resolution/cpp-adl-pointer-arg-boundary/app.cpp new file mode 100644 index 000000000..3eff3b66c --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-adl-pointer-arg-boundary/app.cpp @@ -0,0 +1,8 @@ +#include "audit.h" + +namespace app { + void run() { + audit::Event* p; + record(p); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-adl-pointer-arg-boundary/audit.h b/gitnexus/test/fixtures/lang-resolution/cpp-adl-pointer-arg-boundary/audit.h new file mode 100644 index 000000000..ca2c149d2 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-adl-pointer-arg-boundary/audit.h @@ -0,0 +1,6 @@ +#pragma once + +namespace audit { + struct Event {}; + void record(Event* e); +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-adl-suppressed-parens/app.cpp b/gitnexus/test/fixtures/lang-resolution/cpp-adl-suppressed-parens/app.cpp new file mode 100644 index 000000000..64b2c6451 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-adl-suppressed-parens/app.cpp @@ -0,0 +1,8 @@ +#include "audit.h" + +namespace app { + void run() { + audit::Event e; + (record)(e); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-adl-suppressed-parens/audit.h b/gitnexus/test/fixtures/lang-resolution/cpp-adl-suppressed-parens/audit.h new file mode 100644 index 000000000..2c9803a3d --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-adl-suppressed-parens/audit.h @@ -0,0 +1,6 @@ +#pragma once + +namespace audit { + struct Event {}; + void record(Event e); +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-anon-ns-cross-file/caller.cpp b/gitnexus/test/fixtures/lang-resolution/cpp-anon-ns-cross-file/caller.cpp new file mode 100644 index 000000000..d60127bfe --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-anon-ns-cross-file/caller.cpp @@ -0,0 +1,5 @@ +void worker(); + +void run() { + worker(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-anon-ns-cross-file/helper.cpp b/gitnexus/test/fixtures/lang-resolution/cpp-anon-ns-cross-file/helper.cpp new file mode 100644 index 000000000..feeef4747 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-anon-ns-cross-file/helper.cpp @@ -0,0 +1,7 @@ +namespace { + void worker() {} +} + +void helper_entry() { + worker(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-anon-ns-same-file-visible/helper.cpp b/gitnexus/test/fixtures/lang-resolution/cpp-anon-ns-same-file-visible/helper.cpp new file mode 100644 index 000000000..3d992bfe5 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-anon-ns-same-file-visible/helper.cpp @@ -0,0 +1,7 @@ +namespace { + void w() {} +} + +void run() { + w(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-include-no-class-leak/caller.cpp b/gitnexus/test/fixtures/lang-resolution/cpp-include-no-class-leak/caller.cpp new file mode 100644 index 000000000..8de016602 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-include-no-class-leak/caller.cpp @@ -0,0 +1,5 @@ +#include "user.h" + +void run() { + save(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-include-no-class-leak/user.h b/gitnexus/test/fixtures/lang-resolution/cpp-include-no-class-leak/user.h new file mode 100644 index 000000000..089fa6c59 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-include-no-class-leak/user.h @@ -0,0 +1,6 @@ +#pragma once + +class User { +public: + void save(); +}; diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-include-no-namespace-leak/caller.cpp b/gitnexus/test/fixtures/lang-resolution/cpp-include-no-namespace-leak/caller.cpp new file mode 100644 index 000000000..90b1f2aff --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-include-no-namespace-leak/caller.cpp @@ -0,0 +1,5 @@ +#include "lib.h" + +void run() { + foo(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-include-no-namespace-leak/lib.h b/gitnexus/test/fixtures/lang-resolution/cpp-include-no-namespace-leak/lib.h new file mode 100644 index 000000000..11f71286f --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-include-no-namespace-leak/lib.h @@ -0,0 +1,5 @@ +#pragma once + +namespace ns { + void foo(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-inline-namespace-adl-participation/app.cpp b/gitnexus/test/fixtures/lang-resolution/cpp-inline-namespace-adl-participation/app.cpp new file mode 100644 index 000000000..7fc21a0be --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-inline-namespace-adl-participation/app.cpp @@ -0,0 +1,8 @@ +#include "audit.h" + +namespace app { + void run() { + audit::Event e; + record(e); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-inline-namespace-adl-participation/audit.h b/gitnexus/test/fixtures/lang-resolution/cpp-inline-namespace-adl-participation/audit.h new file mode 100644 index 000000000..9d657461a --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-inline-namespace-adl-participation/audit.h @@ -0,0 +1,8 @@ +#pragma once + +namespace audit { + inline namespace v1 { + struct Event {}; + void record(Event e); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-inline-namespace-nested/caller.cpp b/gitnexus/test/fixtures/lang-resolution/cpp-inline-namespace-nested/caller.cpp new file mode 100644 index 000000000..f2aa44454 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-inline-namespace-nested/caller.cpp @@ -0,0 +1,5 @@ +#include "lib.h" + +void run() { + outer::foo(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-inline-namespace-nested/lib.h b/gitnexus/test/fixtures/lang-resolution/cpp-inline-namespace-nested/lib.h new file mode 100644 index 000000000..ba85f2aed --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-inline-namespace-nested/lib.h @@ -0,0 +1,9 @@ +#pragma once + +namespace outer { + inline namespace v1 { + inline namespace experimental { + void foo(); + } + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-inline-namespace-unqualified/caller.cpp b/gitnexus/test/fixtures/lang-resolution/cpp-inline-namespace-unqualified/caller.cpp new file mode 100644 index 000000000..f2aa44454 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-inline-namespace-unqualified/caller.cpp @@ -0,0 +1,5 @@ +#include "lib.h" + +void run() { + outer::foo(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-inline-namespace-unqualified/lib.h b/gitnexus/test/fixtures/lang-resolution/cpp-inline-namespace-unqualified/lib.h new file mode 100644 index 000000000..e0ffb1eca --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-inline-namespace-unqualified/lib.h @@ -0,0 +1,7 @@ +#pragma once + +namespace outer { + inline namespace v1 { + void foo(); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-inline-namespace-versioned/caller.cpp b/gitnexus/test/fixtures/lang-resolution/cpp-inline-namespace-versioned/caller.cpp new file mode 100644 index 000000000..f2aa44454 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-inline-namespace-versioned/caller.cpp @@ -0,0 +1,5 @@ +#include "lib.h" + +void run() { + outer::foo(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-inline-namespace-versioned/lib.h b/gitnexus/test/fixtures/lang-resolution/cpp-inline-namespace-versioned/lib.h new file mode 100644 index 000000000..0ff3e61b3 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-inline-namespace-versioned/lib.h @@ -0,0 +1,10 @@ +#pragma once + +namespace outer { + inline namespace v1 { + void foo(); + } + namespace v0 { + void foo(); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-namespace-qualified-not-super/caller.cpp b/gitnexus/test/fixtures/lang-resolution/cpp-namespace-qualified-not-super/caller.cpp new file mode 100644 index 000000000..d1edc41c4 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-namespace-qualified-not-super/caller.cpp @@ -0,0 +1,5 @@ +#include "singleton.h" + +void run() { + Singleton::getInstance(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-namespace-qualified-not-super/singleton.h b/gitnexus/test/fixtures/lang-resolution/cpp-namespace-qualified-not-super/singleton.h new file mode 100644 index 000000000..aa2e255e6 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-namespace-qualified-not-super/singleton.h @@ -0,0 +1,6 @@ +#pragma once + +class Singleton { +public: + static Singleton* getInstance(); +}; diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-overload-default-arg-ambiguous/caller.cpp b/gitnexus/test/fixtures/lang-resolution/cpp-overload-default-arg-ambiguous/caller.cpp new file mode 100644 index 000000000..2c4ee7f29 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-overload-default-arg-ambiguous/caller.cpp @@ -0,0 +1,6 @@ +#include "service.h" + +void run() { + S s; + s.f(1); +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-overload-default-arg-ambiguous/service.cpp b/gitnexus/test/fixtures/lang-resolution/cpp-overload-default-arg-ambiguous/service.cpp new file mode 100644 index 000000000..cd6f080e5 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-overload-default-arg-ambiguous/service.cpp @@ -0,0 +1,4 @@ +#include "service.h" + +void S::f(int) {} +void S::f(int, int) {} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-overload-default-arg-ambiguous/service.h b/gitnexus/test/fixtures/lang-resolution/cpp-overload-default-arg-ambiguous/service.h new file mode 100644 index 000000000..66ad00371 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-overload-default-arg-ambiguous/service.h @@ -0,0 +1,7 @@ +#pragma once + +class S { +public: + void f(int); + void f(int, int = 0); +}; diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-overload-int-long/caller.cpp b/gitnexus/test/fixtures/lang-resolution/cpp-overload-int-long/caller.cpp new file mode 100644 index 000000000..89e62ead1 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-overload-int-long/caller.cpp @@ -0,0 +1,6 @@ +#include "service.h" + +void run() { + Service s; + s.process(42); +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-overload-int-long/service.cpp b/gitnexus/test/fixtures/lang-resolution/cpp-overload-int-long/service.cpp new file mode 100644 index 000000000..9bde80f6f --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-overload-int-long/service.cpp @@ -0,0 +1,4 @@ +#include "service.h" + +void Service::process(int x) {} +void Service::process(long x) {} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-overload-int-long/service.h b/gitnexus/test/fixtures/lang-resolution/cpp-overload-int-long/service.h new file mode 100644 index 000000000..1e4c5de07 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-overload-int-long/service.h @@ -0,0 +1,7 @@ +#pragma once + +class Service { +public: + void process(int x); + void process(long x); +}; diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-phase5-u1-u3-qualified-base-call/classes.h b/gitnexus/test/fixtures/lang-resolution/cpp-phase5-u1-u3-qualified-base-call/classes.h new file mode 100644 index 000000000..1d53b7dfa --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-phase5-u1-u3-qualified-base-call/classes.h @@ -0,0 +1,13 @@ +#pragma once + +template +struct Base { + void method(); +}; + +template +struct Derived : Base { + void g() { + Base::method(); + } +}; diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-phase5-u2-u3-adl-from-derived/audit.h b/gitnexus/test/fixtures/lang-resolution/cpp-phase5-u2-u3-adl-from-derived/audit.h new file mode 100644 index 000000000..2c9803a3d --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-phase5-u2-u3-adl-from-derived/audit.h @@ -0,0 +1,6 @@ +#pragma once + +namespace audit { + struct Event {}; + void record(Event e); +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-phase5-u2-u3-adl-from-derived/base.h b/gitnexus/test/fixtures/lang-resolution/cpp-phase5-u2-u3-adl-from-derived/base.h new file mode 100644 index 000000000..ccbe5b670 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-phase5-u2-u3-adl-from-derived/base.h @@ -0,0 +1,8 @@ +#pragma once + +#include "audit.h" + +template +struct Base { + void record(audit::Event e); +}; diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-phase5-u2-u3-adl-from-derived/derived.h b/gitnexus/test/fixtures/lang-resolution/cpp-phase5-u2-u3-adl-from-derived/derived.h new file mode 100644 index 000000000..ca37c8109 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-phase5-u2-u3-adl-from-derived/derived.h @@ -0,0 +1,11 @@ +#pragma once + +#include "base.h" + +template +struct Derived : Base { + void g() { + audit::Event e; + record(e); + } +}; diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-phase5-u3-u5-inline-base/base.h b/gitnexus/test/fixtures/lang-resolution/cpp-phase5-u3-u5-inline-base/base.h new file mode 100644 index 000000000..e9711ff08 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-phase5-u3-u5-inline-base/base.h @@ -0,0 +1,10 @@ +#pragma once + +namespace outer { + inline namespace v1 { + template + struct Base { + void f(); + }; + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-phase5-u3-u5-inline-base/derived.h b/gitnexus/test/fixtures/lang-resolution/cpp-phase5-u3-u5-inline-base/derived.h new file mode 100644 index 000000000..b18febac8 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-phase5-u3-u5-inline-base/derived.h @@ -0,0 +1,10 @@ +#pragma once + +#include "base.h" + +template +struct Derived : outer::v1::Base { + void g() { + f(); + } +}; diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-two-phase-dependent-base/base.h b/gitnexus/test/fixtures/lang-resolution/cpp-two-phase-dependent-base/base.h new file mode 100644 index 000000000..1c7084ee6 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-two-phase-dependent-base/base.h @@ -0,0 +1,7 @@ +#pragma once + +template +struct Base { + void f(); + int i; +}; diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-two-phase-dependent-base/derived.h b/gitnexus/test/fixtures/lang-resolution/cpp-two-phase-dependent-base/derived.h new file mode 100644 index 000000000..fc66c6725 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-two-phase-dependent-base/derived.h @@ -0,0 +1,13 @@ +#pragma once + +#include "base.h" + +template +struct Derived : Base { + void g() { + f(); + } + int h() { + return i; + } +}; diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-two-phase-namespace-free-call-inside-template/base.h b/gitnexus/test/fixtures/lang-resolution/cpp-two-phase-namespace-free-call-inside-template/base.h new file mode 100644 index 000000000..2b7804ba4 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-two-phase-namespace-free-call-inside-template/base.h @@ -0,0 +1,6 @@ +#pragma once + +template +struct Base { + void unused(); +}; diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-two-phase-namespace-free-call-inside-template/derived.h b/gitnexus/test/fixtures/lang-resolution/cpp-two-phase-namespace-free-call-inside-template/derived.h new file mode 100644 index 000000000..ef57810fc --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-two-phase-namespace-free-call-inside-template/derived.h @@ -0,0 +1,11 @@ +#pragma once + +#include "base.h" +#include "helpers.h" + +template +struct D : Base { + void g() { + utils::ns_helper(); + } +}; diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-two-phase-namespace-free-call-inside-template/helpers.h b/gitnexus/test/fixtures/lang-resolution/cpp-two-phase-namespace-free-call-inside-template/helpers.h new file mode 100644 index 000000000..5e291aba6 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-two-phase-namespace-free-call-inside-template/helpers.h @@ -0,0 +1,5 @@ +#pragma once + +namespace utils { + void ns_helper(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-two-phase-non-dependent-base/concrete-base.h b/gitnexus/test/fixtures/lang-resolution/cpp-two-phase-non-dependent-base/concrete-base.h new file mode 100644 index 000000000..7b5d1a167 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-two-phase-non-dependent-base/concrete-base.h @@ -0,0 +1,5 @@ +#pragma once + +struct ConcreteBase { + void f(); +}; diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-two-phase-non-dependent-base/derived.h b/gitnexus/test/fixtures/lang-resolution/cpp-two-phase-non-dependent-base/derived.h new file mode 100644 index 000000000..e0db6269c --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-two-phase-non-dependent-base/derived.h @@ -0,0 +1,10 @@ +#pragma once + +#include "concrete-base.h" + +template +struct Derived : ConcreteBase { + void g() { + f(); + } +}; diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-two-phase-this-qualified/base.h b/gitnexus/test/fixtures/lang-resolution/cpp-two-phase-this-qualified/base.h new file mode 100644 index 000000000..1c7084ee6 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-two-phase-this-qualified/base.h @@ -0,0 +1,7 @@ +#pragma once + +template +struct Base { + void f(); + int i; +}; diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-two-phase-this-qualified/derived.h b/gitnexus/test/fixtures/lang-resolution/cpp-two-phase-this-qualified/derived.h new file mode 100644 index 000000000..5c13c1737 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-two-phase-this-qualified/derived.h @@ -0,0 +1,13 @@ +#pragma once + +#include "base.h" + +template +struct Derived : Base { + void g() { + this->f(); + } + int h() { + return this->i; + } +}; diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-using-namespace-conflict/a.h b/gitnexus/test/fixtures/lang-resolution/cpp-using-namespace-conflict/a.h new file mode 100644 index 000000000..c02e1e19c --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-using-namespace-conflict/a.h @@ -0,0 +1,5 @@ +#pragma once + +namespace a { + void foo(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-using-namespace-conflict/b.h b/gitnexus/test/fixtures/lang-resolution/cpp-using-namespace-conflict/b.h new file mode 100644 index 000000000..67b75dd79 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-using-namespace-conflict/b.h @@ -0,0 +1,5 @@ +#pragma once + +namespace b { + void foo(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-using-namespace-conflict/caller.cpp b/gitnexus/test/fixtures/lang-resolution/cpp-using-namespace-conflict/caller.cpp new file mode 100644 index 000000000..37270861c --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-using-namespace-conflict/caller.cpp @@ -0,0 +1,9 @@ +#include "a.h" +#include "b.h" + +using namespace a; +using namespace b; + +void run() { + foo(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-using-namespace-std-smoke/caller.cpp b/gitnexus/test/fixtures/lang-resolution/cpp-using-namespace-std-smoke/caller.cpp new file mode 100644 index 000000000..56737c424 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-using-namespace-std-smoke/caller.cpp @@ -0,0 +1,9 @@ +#include "std-shim.h" + +using namespace std; + +void project_helper(); + +void run() { + project_helper(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-using-namespace-std-smoke/helper.cpp b/gitnexus/test/fixtures/lang-resolution/cpp-using-namespace-std-smoke/helper.cpp new file mode 100644 index 000000000..010ffb083 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-using-namespace-std-smoke/helper.cpp @@ -0,0 +1 @@ +void project_helper() {} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-using-namespace-std-smoke/std-shim.h b/gitnexus/test/fixtures/lang-resolution/cpp-using-namespace-std-smoke/std-shim.h new file mode 100644 index 000000000..6055204c6 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-using-namespace-std-smoke/std-shim.h @@ -0,0 +1,13 @@ +#pragma once + +// Fixture-local std-shaped namespace. Captures the wildcard-leak shape +// without depending on real system-header modeling. The names mirror +// common STL identifiers (cout_write, println) so a regression that +// re-introduces unqualified std:: binding shows up in the assertions +// below — without us having to control whether GitNexus parses real +// system headers. + +namespace std { + void cout_write(); + void println(); +} diff --git a/gitnexus/test/integration/resolvers/cpp.test.ts b/gitnexus/test/integration/resolvers/cpp.test.ts index 0839d59f0..1f6135408 100644 --- a/gitnexus/test/integration/resolvers/cpp.test.ts +++ b/gitnexus/test/integration/resolvers/cpp.test.ts @@ -1,7 +1,7 @@ /** * C++: diamond inheritance + include-based imports + ambiguous #include disambiguation */ -import { describe, it, expect, beforeAll } from 'vitest'; +import { describe, expect, beforeAll } from 'vitest'; import path from 'path'; import { FIXTURES, @@ -11,9 +11,12 @@ import { getNodesByLabelFull, edgeSet, runPipelineFromRepo, + createResolverParityIt, type PipelineResult, } from './helpers.js'; +const it = createResolverParityIt('cpp'); + // --------------------------------------------------------------------------- // Heritage: diamond inheritance + include-based imports // --------------------------------------------------------------------------- @@ -937,10 +940,13 @@ describe('Write access tracking (C++)', () => { it('emits ACCESSES write edges for field assignments', () => { const accesses = getRelationships(result, 'ACCESSES'); const writes = accesses.filter((e) => e.rel.reason === 'write'); - expect(writes.length).toBe(2); - const fieldNames = writes.map((e) => e.target); - expect(fieldNames).toContain('name'); - expect(fieldNames).toContain('address'); + expect(writes.length).toBe(3); + // Per-field exact counts: both `user.name = ...` and `user.name += ...` + // must produce distinct edges (no dedup); single write to `address`. + const nameWrites = writes.filter((e) => e.target === 'name'); + expect(nameWrites.length).toBe(2); + const addrWrites = writes.filter((e) => e.target === 'address'); + expect(addrWrites.length).toBe(1); const sources = writes.map((e) => e.source); expect(sources).toContain('updateUser'); }); @@ -1582,3 +1588,598 @@ describe('C++ Derived : A, B — diamond inheritance via leftmost-base MRO (SM-1 expect(methodCall!.source).toBe('run'); }); }); + +// --------------------------------------------------------------------------- +// U1: `#include` must not leak class-owned methods as unqualified bindings +// --------------------------------------------------------------------------- + +describe('C++ include does not leak class methods', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'cpp-include-no-class-leak'), () => {}); + }, 60000); + + it('does NOT resolve unqualified save() to User::save via #include', () => { + const calls = getRelationships(result, 'CALLS'); + const leak = calls.filter((c) => c.source === 'run' && c.target === 'save'); + expect(leak.length).toBe(0); + }); + + it('preserves the file-level #include IMPORTS edge', () => { + const imports = getRelationships(result, 'IMPORTS'); + expect(imports.length).toBe(1); + expect(imports[0].targetFilePath).toBe('user.h'); + }); +}); + +// --------------------------------------------------------------------------- +// U1: `#include` must not leak namespace-nested symbols as unqualified bindings +// --------------------------------------------------------------------------- + +describe('C++ include does not leak namespace members', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'cpp-include-no-namespace-leak'), + () => {}, + ); + }, 60000); + + it('does NOT resolve unqualified foo() to ns::foo via #include', () => { + const calls = getRelationships(result, 'CALLS'); + const leak = calls.filter((c) => c.source === 'run' && c.target === 'foo'); + expect(leak.length).toBe(0); + }); + + it('preserves the file-level #include IMPORTS edge', () => { + const imports = getRelationships(result, 'IMPORTS'); + expect(imports.length).toBe(1); + expect(imports[0].targetFilePath).toBe('lib.h'); + }); +}); + +// --------------------------------------------------------------------------- +// U1: anonymous-namespace symbols remain visible within their declaring TU +// (positive companion to the cross-file exclusion test below) +// --------------------------------------------------------------------------- + +describe('C++ anonymous namespace symbols visible in same TU', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'cpp-anon-ns-same-file-visible'), + () => {}, + ); + }, 60000); + + it('resolves run() -> w() within the same TU', () => { + const calls = getRelationships(result, 'CALLS'); + const wCalls = calls.filter((c) => c.source === 'run' && c.target === 'w'); + expect(wCalls.length).toBe(1); + }); +}); + +// --------------------------------------------------------------------------- +// U2: integer-width overload ambiguity suppresses CALLS edge entirely +// (PR #1520 review follow-up plan U2; Claude review Finding 5) +// --------------------------------------------------------------------------- + +describe('C++ ambiguous integer-width overloads', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'cpp-overload-int-long'), () => {}); + }, 60000); + + it('emits zero CALLS edges when process(int)/process(long) collide after normalization', () => { + const calls = getRelationships(result, 'CALLS'); + const processCalls = calls.filter((c) => c.source === 'run' && c.target === 'process'); + // Exact .toBe(0): any non-zero count is a regression. count=1 = arbitrary + // pick (the bug U2 fixes); count=2+ would require an ambiguous-edge model + // GitNexus does not have. The resolver must suppress entirely. + expect(processCalls.length).toBe(0); + }); +}); + +// --------------------------------------------------------------------------- +// U3: anonymous-namespace symbols MUST NOT leak across translation units +// (full-pipeline integration test; unit-level coverage exists separately) +// PR #1520 review follow-up plan U3 / Claude review Finding 7 +// --------------------------------------------------------------------------- + +describe('C++ anonymous namespace cross-file exclusion (integration)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'cpp-anon-ns-cross-file'), () => {}); + }, 60000); + + it('caller.cpp::run -> worker does NOT target helper.cpp anonymous-namespace worker', () => { + const calls = getRelationships(result, 'CALLS'); + const crossFileLeak = calls.filter( + (c) => + c.source === 'run' && c.target === 'worker' && c.targetFilePath?.includes('helper.cpp'), + ); + expect(crossFileLeak.length).toBe(0); + }); + + it('helper.cpp::helper_entry still resolves its OWN anonymous-namespace worker (positive guard)', () => { + const calls = getRelationships(result, 'CALLS'); + const sameFileResolve = calls.filter( + (c) => + c.source === 'helper_entry' && + c.target === 'worker' && + c.targetFilePath?.includes('helper.cpp'), + ); + // Pairs with the negative test above so a "no edges at all" regression + // doesn't make the cross-file leak check pass vacuously. + expect(sameFileResolve.length).toBe(1); + }); +}); + +// State-isolation guard: re-run the same fixture and assert identical +// results. Proves `clearFileLocalNames()` (called from the cpp resolver's +// `loadResolutionConfig`) is exercised by `runPipelineFromRepo` and +// that module-level `fileLocalNames` state doesn't bleed across runs. +describe('C++ anonymous namespace state-isolation guard', () => { + it('second run of the same fixture produces identical worker-cross-file edge count', async () => { + const fixture = path.join(FIXTURES, 'cpp-anon-ns-cross-file'); + const r1 = await runPipelineFromRepo(fixture, () => {}); + const r2 = await runPipelineFromRepo(fixture, () => {}); + const countLeak = (r: PipelineResult): number => + getRelationships(r, 'CALLS').filter( + (c) => + c.source === 'run' && c.target === 'worker' && c.targetFilePath?.includes('helper.cpp'), + ).length; + expect(countLeak(r1)).toBe(0); + expect(countLeak(r2)).toBe(0); + }, 120000); +}); + +// --------------------------------------------------------------------------- +// U4: `using namespace` with conflicting names from two namespaces +// The resolver MUST emit zero CALLS edges — emitting one is arbitrary +// pick; emitting two requires an ambiguous-target edge model GitNexus +// does not have. +// Depends on U1 (without scope-aware filtering, `a::foo` and `b::foo` +// would already be in the importer's wildcard binding set as simple +// `foo` and this test would pass for the wrong reason). +// PR #1520 review follow-up plan U4 / Claude review Finding 7 +// --------------------------------------------------------------------------- + +describe('C++ using-namespace with conflicting names', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'cpp-using-namespace-conflict'), + () => {}, + ); + }, 60000); + + it('emits zero CALLS edges for ambiguous foo() bound via two using-namespace declarations', () => { + const calls = getRelationships(result, 'CALLS'); + const fooCalls = calls.filter((c) => c.source === 'run' && c.target === 'foo'); + expect(fooCalls.length).toBe(0); + }); +}); + +// --------------------------------------------------------------------------- +// U5: `using namespace std` MUST NOT leak shim STL symbols into unqualified +// bindings. Uses a fixture-local `namespace std { ... }` shim rather than +// real — captures the wildcard-leak shape deterministically +// without depending on system-header modeling stability. +// PR #1520 review follow-up plan U5 / Claude review Finding 7 +// --------------------------------------------------------------------------- + +describe('C++ using-namespace std smoke test', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'cpp-using-namespace-std-smoke'), + () => {}, + ); + }, 60000); + + it('resolves the project call (positive guard against vacuous pass)', () => { + const calls = getRelationships(result, 'CALLS'); + const projectCalls = calls.filter((c) => c.source === 'run' && c.target === 'project_helper'); + expect(projectCalls.length).toBe(1); + }); + + it('does NOT leak unqualified bindings for shim STL symbols', () => { + const calls = getRelationships(result, 'CALLS'); + const stlLeaks = calls.filter( + (c) => c.source === 'run' && (c.target === 'cout_write' || c.target === 'println'), + ); + expect(stlLeaks.length).toBe(0); + }); + + it('emits no CALLS or ACCESSES edges from run() into std-shim.h', () => { + const calls = getRelationships(result, 'CALLS'); + const accesses = getRelationships(result, 'ACCESSES'); + const intoShim = [...calls, ...accesses].filter( + (e) => e.source === 'run' && e.targetFilePath?.includes('std-shim.h'), + ); + expect(intoShim.length).toBe(0); + }); +}); + +// --------------------------------------------------------------------------- +// U1 (follow-up plan 2026-05-13-001): namespace-qualified or class-qualified +// calls from outside that class MUST NOT be classified as super-receiver calls. +// The `isSuperReceiverInContext` hook consults the caller's MRO. +// --------------------------------------------------------------------------- + +describe('C++ namespace-qualified call is not a super receiver', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'cpp-namespace-qualified-not-super'), + () => {}, + ); + }, 60000); + + it('resolves Singleton::getInstance() from a free function (not as super call)', () => { + const calls = getRelationships(result, 'CALLS'); + const getInstanceCalls = calls.filter((c) => c.source === 'run' && c.target === 'getInstance'); + // Exactly 1: routed through the normal qualified-call path, NOT the super + // branch. Before the U1 fix the regex `/^[A-Z]\w*::/` matched Singleton::, + // entered the super branch with no enclosing class, and dropped the edge. + expect(getInstanceCalls.length).toBe(1); + expect(getInstanceCalls[0].targetFilePath).toContain('singleton.h'); + }); +}); + +// --------------------------------------------------------------------------- +// U4 (follow-up plan 2026-05-13-001): default-argument overload ambiguity. +// `void f(int); void f(int, int = 0); f(1);` is ambiguous per ISO C++. The +// OVERLOAD_AMBIGUOUS sentinel from plan 2026-05-12-002 U2 should detect +// this case via isOverloadAmbiguousAfterNormalization. +// --------------------------------------------------------------------------- + +describe('C++ default-argument overload ambiguity', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'cpp-overload-default-arg-ambiguous'), + () => {}, + ); + }, 60000); + + it('s.f(1) emits zero CALLS edges when f(int) and f(int, int=0) both match', () => { + const calls = getRelationships(result, 'CALLS'); + const fCalls = calls.filter((c) => c.source === 'run' && c.target === 'f'); + // Exact .toBe(0): count=1 means arbitrary pick (the bug); count=2+ would + // require an ambiguous-target edge model GitNexus does not have. The + // resolver must suppress entirely. Standard C++ rejects the call as + // ambiguous (GCC/Clang both diagnose). + expect(fCalls.length).toBe(0); + }); +}); + +// --------------------------------------------------------------------------- +// U3 (follow-up plan 2026-05-13-001): two-phase template lookup. +// Inside a class template body, unqualified calls MUST NOT bind to members +// of a dependent base class. Only `this->name()` or `Base::name()` forms +// should resolve. +// --------------------------------------------------------------------------- + +describe('C++ two-phase template lookup — dependent base suppression', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'cpp-two-phase-dependent-base'), + () => {}, + ); + }, 60000); + + it('Derived::g() -> f() does NOT bind to Base::f (dependent base)', () => { + const calls = getRelationships(result, 'CALLS'); + const leaks = calls.filter((c) => c.source === 'g' && c.target === 'f'); + expect(leaks.length).toBe(0); + }); + + it('Derived::h() -> i does NOT bind to Base::i (dependent base)', () => { + const accesses = getRelationships(result, 'ACCESSES'); + const leaks = accesses.filter((c) => c.source === 'h' && c.target === 'i'); + expect(leaks.length).toBe(0); + }); +}); + +// NOTE: positive guards (this->f() resolves, non-dependent-base unqualified +// f() resolves, namespace-qualified utils::ns_helper() resolves) inside +// template bodies are documented gaps in C++ template-context resolution +// independent of U3's dependent-base suppression. The U3 core asserts only +// the negative behavior (dependent-base members are NOT bound by unqualified +// calls); the positive cases would require additional `this` type-binding +// and template-body member-lookup work tracked separately. See plan +// 2026-05-13-001 follow-ups. + +// --------------------------------------------------------------------------- +// U2 (follow-up plan 2026-05-13-001): argument-dependent (Koenig) lookup. +// Free-function calls with class-typed arguments must consider candidates +// declared in the argument's enclosing namespace (associated namespace). +// V1 boundary: only direct enclosing-namespace closure for value class- +// typed args; pointer / reference / template-spec args excluded. +// --------------------------------------------------------------------------- + +describe('C++ ADL — basic associated-namespace closure', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'cpp-adl-basic'), () => {}); + }, 60000); + + it('record(e) where e is audit::Event resolves to audit::record via ADL', () => { + const calls = getRelationships(result, 'CALLS'); + const recordCalls = calls.filter((c) => c.source === 'run' && c.target === 'record'); + // Exactly 1: ordinary lookup is empty (no `using` statement, no local + // declaration), ADL surfaces audit::record because audit::Event's + // associated namespace is `audit`. The CALLS edge should target the + // declaration in audit.h. + expect(recordCalls.length).toBe(1); + expect(recordCalls[0].targetFilePath).toContain('audit.h'); + }); +}); + +describe('C++ ADL — parenthesized name suppresses ADL', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'cpp-adl-suppressed-parens'), () => {}); + }, 60000); + + it('(record)(e) emits zero CALLS edges — ADL is suppressed by parentheses', () => { + const calls = getRelationships(result, 'CALLS'); + const recordCalls = calls.filter((c) => c.source === 'run' && c.target === 'record'); + // Exact .toBe(0): ISO C++ [basic.lookup.argdep]/3.1 specifies that the + // parenthesized form `(f)(x)` forces ordinary lookup only — ADL must + // NOT fire. Without ordinary-lookup candidates (no `using`, no local + // declaration), the call goes unresolved. + expect(recordCalls.length).toBe(0); + }); +}); + +describe('C++ ADL — pointer-arg V1 boundary', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'cpp-adl-pointer-arg-boundary'), + () => {}, + ); + }, 60000); + + it('record(p) where p is audit::Event* emits zero CALLS — V1 ADL excludes pointer args', () => { + const calls = getRelationships(result, 'CALLS'); + const recordCalls = calls.filter((c) => c.source === 'run' && c.target === 'record'); + // Exact .toBe(0): V1 ADL covers only directly-named class-type values + // (per plan 2026-05-13-001 R4). Pointer-typed args fall under + // associated-entity closure rules deferred to V2. This fixture locks + // the boundary in CI so the implementer cannot accidentally extend + // V1 to include pointer types. Real ISO C++ would resolve via V2 + // closure; matching that requires the V2 follow-up plan. + expect(recordCalls.length).toBe(0); + }); +}); + +describe('C++ ADL — int/long-collision overloads suppress via OVERLOAD_AMBIGUOUS', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'cpp-adl-ambiguous'), () => {}); + }, 60000); + + it('process(t, 42) emits zero CALLS edges when ADL surfaces process(Token,int)/process(Token,long) (collide after C++ int normalization)', () => { + const calls = getRelationships(result, 'CALLS'); + const processCalls = calls.filter((c) => c.source === 'run' && c.target === 'process'); + // Exact .toBe(0): both alpha::process(Token, int) and + // alpha::process(Token, long) are surfaced via ADL (alpha::Token's + // associated namespace). C++ arity-metadata normalizes int/long to + // 'int', so both candidates have parameterTypes ['Token', 'int']. + // narrowOverloadCandidates can't disambiguate (arg-types are + // ['', 'int']), and isOverloadAmbiguousAfterNormalization detects + // the collision → ADL_AMBIGUOUS sentinel → caller suppresses. + // count=1 is the bug (arbitrary first-pick); count=2 would require + // an ambiguous-target edge model GitNexus does not have. + expect(processCalls.length).toBe(0); + }); +}); + +// --------------------------------------------------------------------------- +// U5 (follow-up plan 2026-05-13-001): inline namespace transitive walking. +// `inline namespace v1 { ... }` makes its members reachable through the +// enclosing namespace's qualified lookup as if declared directly there +// (ISO C++ `[namespace.def]/p4`). Adds a C++-specific +// `resolveQualifiedReceiverMember` hook on the ScopeResolver contract. +// --------------------------------------------------------------------------- + +describe('C++ inline namespace — outer::foo resolves to inline child', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'cpp-inline-namespace-unqualified'), + () => {}, + ); + }, 60000); + + it('outer::foo() resolves to outer::v1::foo via inline-namespace transitive walking', () => { + const calls = getRelationships(result, 'CALLS'); + const fooCalls = calls.filter((c) => c.source === 'run' && c.target === 'foo'); + // Exactly 1: the inline-namespace exemption lets `outer::foo()` reach + // the declaration in `outer::v1::foo()`. Without U5 the call would be + // unresolved (count = 0). + expect(fooCalls.length).toBe(1); + expect(fooCalls[0].targetFilePath).toContain('lib.h'); + }); +}); + +describe('C++ inline namespace — versioned (v1 inline, v0 not)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'cpp-inline-namespace-versioned'), + () => {}, + ); + }, 60000); + + it('outer::foo() resolves to outer::v1::foo (inline child), NOT outer::v0::foo', () => { + const calls = getRelationships(result, 'CALLS'); + const fooCalls = calls.filter((c) => c.source === 'run' && c.target === 'foo'); + // Exactly 1: only inline-namespace children are reachable through the + // enclosing namespace's qualified lookup. `v0` is NOT inline so its + // `foo` is NOT visible as `outer::foo`. + expect(fooCalls.length).toBe(1); + expect(fooCalls[0].targetFilePath).toContain('lib.h'); + }); +}); + +describe('C++ inline namespace — nested (STL __1-style)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'cpp-inline-namespace-nested'), + () => {}, + ); + }, 60000); + + it('outer::foo() resolves through two transitive inline namespaces (v1 then experimental)', () => { + const calls = getRelationships(result, 'CALLS'); + const fooCalls = calls.filter((c) => c.source === 'run' && c.target === 'foo'); + // Exactly 1: the resolver descends inline namespaces depth-first, so + // `outer::foo` reaches `outer::v1::experimental::foo` through two + // transitive inline-namespace hops. Mirrors libc++ `std::__1::vector` + // / libstdc++ `std::__cxx11` qualified-call shape. + expect(fooCalls.length).toBe(1); + expect(fooCalls[0].targetFilePath).toContain('lib.h'); + }); +}); + +describe('C++ inline namespace — ADL participation', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'cpp-inline-namespace-adl-participation'), + () => {}, + ); + }, 60000); + + it('ADL surfaces audit::v1::record through inline-namespace transitive walking', () => { + const calls = getRelationships(result, 'CALLS'); + const recordCalls = calls.filter((c) => c.source === 'run' && c.target === 'record'); + // Exactly 1: `audit::Event e;` resolves Event's enclosing namespace + // to `audit` (the inline child `v1` is transparent — see U2's + // computeNamespaceQName walking through the inline scope). ADL then + // surfaces every callable named `record` in any namespace scope + // matching qname 'audit' across files. Since inline namespaces are + // exempted from the non-globally-visible filter, the `record` + // declared inside `inline namespace v1` is reachable. count=0 + // would be the bug — ADL failing to walk inline children. + expect(recordCalls.length).toBe(1); + expect(recordCalls[0].targetFilePath).toContain('audit.h'); + }); +}); + +// --------------------------------------------------------------------------- +// Phase 5 (follow-up plan 2026-05-13-001): cross-unit composition tests. +// Lock in correct interaction between U1 (super-receiver context), U2 (ADL), +// U3 (two-phase lookup), and U5 (inline namespaces). +// --------------------------------------------------------------------------- + +describe('C++ Phase 5 U1×U3 — qualified Base::method() inside template body (no false positives)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'cpp-phase5-u1-u3-qualified-base-call'), + () => {}, + ); + }, 60000); + + it('Base::method() does NOT mis-route to a class method outside the MRO', () => { + const calls = getRelationships(result, 'CALLS'); + const methodCalls = calls.filter((c) => c.source === 'g' && c.target === 'method'); + // V1 documented gap: cross-file (and same-file) template-class + // inheritance is not captured as an EXTENDS edge by the legacy DAG + // (the cpp captures.ts has no `base_class_clause` heritage emitter + // for template_type bases). Without an EXTENDS edge, MRO is empty + // and the U1 super branch can't dispatch. Result: 0 CALLS edges. + // + // This Phase 5 cross-unit composition test locks in that the + // template-arg-stripping U1 logic produces NO false positives — + // `Base` correctly classifies as a super-receiver candidate but + // (due to empty MRO) doesn't accidentally route to an unrelated + // method named `method` via any other case. count > 0 here would + // indicate the U1 stripped lookup mis-resolved across cases. + expect(methodCalls.length).toBe(0); + }); +}); + +describe('C++ Phase 5 U2×U3 — ADL routes around dependent-base shadow', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'cpp-phase5-u2-u3-adl-from-derived'), + () => {}, + ); + }, 60000); + + it('record(e) inside Derived::g() resolves via ADL to audit::record (not Base::record)', () => { + const calls = getRelationships(result, 'CALLS'); + const recordCalls = calls.filter((c) => c.source === 'g' && c.target === 'record'); + // Exactly 1: Base::record is class-owned so the global free-call + // fallback's `isFileLocalDef` blocks it (and U3's two-phase + // suppression also fires for unqualified calls inside template + // body when the candidate is a dependent-base member). ADL then + // surfaces audit::record via `audit::Event`'s associated namespace. + // The two-phase + ADL composition leaves exactly one CALLS edge — + // to audit::record in audit.h. + expect(recordCalls.length).toBe(1); + expect(recordCalls[0].targetFilePath).toContain('audit.h'); + }); + + it('record(e) does NOT bind to Base::record (class-owned dependent-base member)', () => { + const calls = getRelationships(result, 'CALLS'); + const baseRecordLeaks = calls.filter( + (c) => c.source === 'g' && c.target === 'record' && c.targetFilePath?.includes('base.h'), + ); + expect(baseRecordLeaks.length).toBe(0); + }); +}); + +describe('C++ Phase 5 U3×U5 — template Derived : outer::v1::Base (inline)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'cpp-phase5-u3-u5-inline-base'), + () => {}, + ); + }, 60000); + + it('unqualified f() inside Derived::g() does NOT bind to outer::v1::Base::f (dependent base across inline namespace)', () => { + const calls = getRelationships(result, 'CALLS'); + const fLeaks = calls.filter((c) => c.source === 'g' && c.target === 'f'); + // Exact .toBe(0): same suppression rationale as the plain U3 fixture + // (`cpp-two-phase-dependent-base`) — `f()` is unqualified, Base is a + // dependent base, and Base::f is class-owned so the global free-call + // fallback's `isFileLocalDef` blocks it. The inline-namespace wrapper + // doesn't change the suppression behavior: dependent-base detection + // walks the heritage's simple name (`Base`) regardless of the + // qualifying namespace path. + expect(fLeaks.length).toBe(0); + }); +}); diff --git a/gitnexus/test/integration/resolvers/helpers.ts b/gitnexus/test/integration/resolvers/helpers.ts index 571f2121f..7cbfaaf6c 100644 --- a/gitnexus/test/integration/resolvers/helpers.ts +++ b/gitnexus/test/integration/resolvers/helpers.ts @@ -90,6 +90,74 @@ const LEGACY_RESOLVER_PARITY_EXPECTED_FAILURES: Readonly([ + // The legacy DAG path has no scope-aware filtering on the global + // free-call fallback, so `#include`d headers still leak class + // methods (`User::save`) and namespace members (`ns::foo`) as + // resolution targets for unqualified calls. The scope-resolver + // path filters via `populateCppNonGloballyVisible` + + // `isFileLocalDef`. Scope-resolver-only correctness win + // (PR #1520 review follow-up plan U1); backporting to legacy is + // out of scope. + 'does NOT resolve unqualified save() to User::save via #include', + 'does NOT resolve unqualified foo() to ns::foo via #include', + // The legacy DAG path lacks the OVERLOAD_AMBIGUOUS suppression + // wired through `pickOverload` + `isOverloadAmbiguousAfterNormalization`, + // so it arbitrarily picks the first overload when `f(int)` and + // `f(long)` collide after C++ integer-width normalization. Scope- + // resolver-only correctness win (PR #1520 review follow-up plan U2 / + // Claude review Finding 5); backporting to legacy is out of scope. + 'emits zero CALLS edges when process(int)/process(long) collide after normalization', + // The legacy DAG path resolves `using namespace a; using namespace b; foo()` + // by walking the workspace registry by simple name and binding to + // the first match — same shape as the integer-width collision, just + // with namespace-resolution as the ambiguity source. Scope-resolver- + // only correctness win (PR #1520 review follow-up plan U4 / Claude + // review Finding 7); backporting to legacy is out of scope. + 'emits zero CALLS edges for ambiguous foo() bound via two using-namespace declarations', + // The legacy DAG path lacks two-phase template lookup. Unqualified + // calls inside a class template body bind to dependent-base members + // there, producing CALLS edges the compiler would reject (ISO C++ + // two-phase name lookup). Scope-resolver-only correctness win + // (PR #1520 review follow-up plan 2026-05-13-001 U3); backporting + // is out of scope. + 'Derived::g() -> f() does NOT bind to Base::f (dependent base)', + // The legacy DAG path has no V1/V2 ADL boundary — pointer-typed + // arguments resolve via the workspace-wide simple-name walk. The + // scope-resolver V1 ADL pass excludes pointer args (closure rules + // deferred to V2) per plan 2026-05-13-001 U2 / R4. Scope-resolver- + // only correctness win; backporting is out of scope. + 'record(p) where p is audit::Event* emits zero CALLS — V1 ADL excludes pointer args', + // The legacy DAG path has no ADL_AMBIGUOUS suppression sentinel. + // When ADL surfaces multiple overloads that collide after C++ + // int/long normalization, legacy picks the first match arbitrarily. + // The scope-resolver path suppresses via the ADL_AMBIGUOUS sentinel + // (mirroring OVERLOAD_AMBIGUOUS for receiver-bound paths). Scope- + // resolver-only correctness win (PR #1520 review follow-up plan + // 2026-05-13-001 U2); backporting is out of scope. + 'process(t, 42) emits zero CALLS edges when ADL surfaces process(Token,int)/process(Token,long) (collide after C++ int normalization)', + // The legacy DAG path has no qualified namespace-member resolver + // and no inline-namespace awareness. For the versioned fixture + // (`outer::v1::foo` inline, `outer::v0::foo` not), the registry- + // primary path resolves `outer::foo()` to v1 via the inline + // exemption; legacy can't see EITHER and emits zero edges. The + // unqualified / nested fixtures coincidentally resolve in legacy + // because their global free-call fallback picks the unique simple- + // name match; the versioned fixture has two `foo`s and legacy can't + // disambiguate. Scope-resolver-only correctness win (PR #1520 + // review follow-up plan 2026-05-13-001 U5); backporting is out of + // scope. + 'outer::foo() resolves to outer::v1::foo (inline child), NOT outer::v0::foo', + // Phase 5 cross-unit composition tests assert no false positives + // for compositions where the legacy DAG over-resolves. The legacy + // path has no template-arg-stripping qualified-receiver logic and + // no two-phase dependent-base suppression, so it produces CALLS + // edges where the registry-primary path correctly suppresses. + // Scope-resolver-only correctness wins (PR #1520 review follow-up + // plan 2026-05-13-001 Phase 5); backporting is out of scope. + 'Base::method() does NOT mis-route to a class method outside the MRO', + 'unqualified f() inside Derived::g() does NOT bind to outer::v1::Base::f (dependent base across inline namespace)', + ]), }; type ResolverParityEnv = Readonly>; diff --git a/gitnexus/test/unit/registry-primary-flag.test.ts b/gitnexus/test/unit/registry-primary-flag.test.ts index 864754b7f..e800bd14a 100644 --- a/gitnexus/test/unit/registry-primary-flag.test.ts +++ b/gitnexus/test/unit/registry-primary-flag.test.ts @@ -127,8 +127,10 @@ describe('isRegistryPrimary', () => { it('handles the CPlusPlus → REGISTRY_PRIMARY_CPP mapping correctly', () => { process.env['REGISTRY_PRIMARY_CPP'] = 'true'; expect(isRegistryPrimary(SupportedLanguages.CPlusPlus)).toBe(true); - // Negative: the TS-key-style name is NOT read. - delete process.env['REGISTRY_PRIMARY_CPP']; + // Negative: the TS-key-style name is NOT read. CPlusPlus is now in + // MIGRATED_LANGUAGES, so we must explicitly opt it out via the + // canonical env var to verify the wrong-name var has no effect. + process.env['REGISTRY_PRIMARY_CPP'] = 'false'; process.env['REGISTRY_PRIMARY_CPLUSPLUS'] = 'true'; expect(isRegistryPrimary(SupportedLanguages.CPlusPlus)).toBe(false); }); @@ -151,8 +153,8 @@ describe('primaryLanguages', () => { // testing explicit env overrides. Java (unmigrated) opts in. // Opt out every member of MIGRATED_LANGUAGES dynamically so this test // does not have to be updated each time a new language ships its - // Ring 3 migration (PHP joined the set in commit 69786b16; future - // Ring 3 additions land here without test churn). + // Ring 3 migration (C++ and PHP joined the set in their respective + // Ring 3 migrations; future Ring 3 additions land here without test churn). for (const lang of MIGRATED_LANGUAGES) { process.env[envVarNameFor(lang)] = 'false'; } @@ -161,6 +163,7 @@ describe('primaryLanguages', () => { expect(enabled.has(SupportedLanguages.Python)).toBe(false); expect(enabled.has(SupportedLanguages.CSharp)).toBe(false); expect(enabled.has(SupportedLanguages.Go)).toBe(false); + expect(enabled.has(SupportedLanguages.CPlusPlus)).toBe(false); expect(enabled.has(SupportedLanguages.PHP)).toBe(false); expect(enabled.has(SupportedLanguages.Java)).toBe(true); // Only Java is on: migrated defaults overridden off, Java explicitly on. diff --git a/gitnexus/test/unit/scope-resolution/cpp/cpp-arity.test.ts b/gitnexus/test/unit/scope-resolution/cpp/cpp-arity.test.ts new file mode 100644 index 000000000..a89a3167d --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/cpp/cpp-arity.test.ts @@ -0,0 +1,168 @@ +/** + * Unit tests for C++ arity compatibility and metadata. + */ + +import { describe, it, expect } from 'vitest'; +import { cppArityCompatibility } from '../../../../src/core/ingestion/languages/cpp/arity.js'; +import { + computeCppDeclarationArity, + computeCppCallArity, +} from '../../../../src/core/ingestion/languages/cpp/arity-metadata.js'; +import { getCppParser } from '../../../../src/core/ingestion/languages/cpp/query.js'; +import type { SyntaxNode } from '../../../../src/core/ingestion/utils/ast-helpers.js'; +import type { SymbolDefinition, Callsite } from 'gitnexus-shared'; + +function parseFuncDef(src: string): SyntaxNode | null { + const tree = getCppParser().parse(src); + for (let i = 0; i < tree.rootNode.namedChildCount; i++) { + const child = tree.rootNode.namedChild(i); + if (child?.type === 'function_definition') return child as SyntaxNode; + } + return null; +} + +function parseCallExpr(src: string): SyntaxNode | null { + const tree = getCppParser().parse(src); + const walk = (node: SyntaxNode): SyntaxNode | null => { + if (node.type === 'call_expression') return node; + for (let i = 0; i < node.namedChildCount; i++) { + const found = walk(node.namedChild(i) as SyntaxNode); + if (found) return found; + } + return null; + }; + return walk(tree.rootNode as SyntaxNode); +} + +function mkDef(overrides: Partial = {}): SymbolDefinition { + return { + nodeId: 'test-def', + qualifiedName: 'test', + filePath: 'test.cpp', + type: 'Function', + ...overrides, + } as SymbolDefinition; +} + +function mkCallsite(arity: number): Callsite { + return { arity } as Callsite; +} + +// ── Declaration arity ─────────────────────────────────────────────────────── + +describe('computeCppDeclarationArity', () => { + it('computes arity for zero-parameter function', () => { + const node = parseFuncDef('void foo() {}'); + expect(node).not.toBeNull(); + const arity = computeCppDeclarationArity(node!); + expect(arity.parameterCount).toBe(0); + expect(arity.requiredParameterCount).toBe(0); + }); + + it('computes arity for (void) parameter', () => { + const node = parseFuncDef('void foo(void) {}'); + expect(node).not.toBeNull(); + const arity = computeCppDeclarationArity(node!); + expect(arity.parameterCount).toBe(0); + expect(arity.requiredParameterCount).toBe(0); + }); + + it('computes arity for multiple parameters', () => { + const node = parseFuncDef('void foo(int x, int y, int z) {}'); + expect(node).not.toBeNull(); + const arity = computeCppDeclarationArity(node!); + expect(arity.parameterCount).toBe(3); + expect(arity.requiredParameterCount).toBe(3); + }); + + it('computes arity with default parameters', () => { + const node = parseFuncDef('void foo(int x, int y = 5, int z = 10) {}'); + expect(node).not.toBeNull(); + const arity = computeCppDeclarationArity(node!); + expect(arity.parameterCount).toBe(3); + expect(arity.requiredParameterCount).toBe(1); + }); + + it('detects variadic function', () => { + const node = parseFuncDef('void foo(int x, ...) {}'); + expect(node).not.toBeNull(); + const arity = computeCppDeclarationArity(node!); + expect(arity.parameterCount).toBeUndefined(); // variadic → undefined max + expect(arity.requiredParameterCount).toBe(1); + expect(arity.parameterTypes).toContain('...'); + }); + + it('handles pointer return type', () => { + const node = parseFuncDef('int* create(int size) {}'); + expect(node).not.toBeNull(); + const arity = computeCppDeclarationArity(node!); + expect(arity.parameterCount).toBe(1); + }); +}); + +// ── Call-site arity ───────────────────────────────────────────────────────── + +describe('computeCppCallArity', () => { + it('computes arity for no-argument call', () => { + const node = parseCallExpr('void f() { foo(); }'); + expect(node).not.toBeNull(); + expect(computeCppCallArity(node!)).toBe(0); + }); + + it('computes arity for multi-argument call', () => { + const node = parseCallExpr('void f() { foo(1, 2, 3); }'); + expect(node).not.toBeNull(); + expect(computeCppCallArity(node!)).toBe(3); + }); + + it('computes arity for single-argument call', () => { + const node = parseCallExpr('void f() { foo(42); }'); + expect(node).not.toBeNull(); + expect(computeCppCallArity(node!)).toBe(1); + }); +}); + +// ── Arity compatibility ───────────────────────────────────────────────────── + +describe('cppArityCompatibility', () => { + it('returns compatible for exact match', () => { + const def = mkDef({ parameterCount: 2, requiredParameterCount: 2 }); + expect(cppArityCompatibility(def, mkCallsite(2))).toBe('compatible'); + }); + + it('returns compatible when call uses default params', () => { + const def = mkDef({ parameterCount: 3, requiredParameterCount: 1 }); + expect(cppArityCompatibility(def, mkCallsite(1))).toBe('compatible'); + expect(cppArityCompatibility(def, mkCallsite(2))).toBe('compatible'); + expect(cppArityCompatibility(def, mkCallsite(3))).toBe('compatible'); + }); + + it('returns incompatible for too few args', () => { + const def = mkDef({ parameterCount: 3, requiredParameterCount: 2 }); + expect(cppArityCompatibility(def, mkCallsite(1))).toBe('incompatible'); + }); + + it('returns incompatible for too many args (non-variadic)', () => { + const def = mkDef({ parameterCount: 2, requiredParameterCount: 2 }); + expect(cppArityCompatibility(def, mkCallsite(5))).toBe('incompatible'); + }); + + it('returns compatible for variadic with extra args', () => { + const def = mkDef({ + parameterCount: undefined, + requiredParameterCount: 1, + parameterTypes: ['int', '...'], + }); + expect(cppArityCompatibility(def, mkCallsite(5))).toBe('compatible'); + }); + + it('returns unknown when no metadata', () => { + const def = mkDef({}); + expect(cppArityCompatibility(def, mkCallsite(2))).toBe('unknown'); + }); + + it('returns unknown for negative arity', () => { + const def = mkDef({ parameterCount: 2, requiredParameterCount: 2 }); + expect(cppArityCompatibility(def, mkCallsite(-1))).toBe('unknown'); + }); +}); diff --git a/gitnexus/test/unit/scope-resolution/cpp/cpp-captures.test.ts b/gitnexus/test/unit/scope-resolution/cpp/cpp-captures.test.ts new file mode 100644 index 000000000..8e000261c --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/cpp/cpp-captures.test.ts @@ -0,0 +1,426 @@ +/** + * Unit tests for C++ scope query + captures orchestrator. + * + * Pins the capture-tag vocabulary + range shape for every construct + * the scope-resolution pipeline reads. Runs against tree-sitter-cpp. + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { emitCppScopeCaptures } from '../../../../src/core/ingestion/languages/cpp/captures.js'; +import { + clearFileLocalNames, + isFileLocal, +} from '../../../../src/core/ingestion/languages/cpp/file-local-linkage.js'; + +function tagsFor(src: string, filePath = 'test.cpp'): string[][] { + const matches = emitCppScopeCaptures(src, filePath); + return matches.map((m) => Object.keys(m).sort()); +} + +function findMatch(src: string, predicate: (tags: string[]) => boolean, filePath = 'test.cpp') { + const matches = emitCppScopeCaptures(src, filePath); + return matches.find((m) => predicate(Object.keys(m))); +} + +function allMatches(src: string, predicate: (tags: string[]) => boolean, filePath = 'test.cpp') { + const matches = emitCppScopeCaptures(src, filePath); + return matches.filter((m) => predicate(Object.keys(m))); +} + +// ── Scopes ────────────────────────────────────────────────────────────────── + +describe('emitCppScopeCaptures — scopes', () => { + it('captures translation_unit as @scope.module', () => { + const all = tagsFor('int x = 1;'); + expect(all.some((t) => t.includes('@scope.module'))).toBe(true); + }); + + it('captures class_specifier as @scope.class', () => { + const all = tagsFor('class Foo { int x; };'); + expect(all.some((t) => t.includes('@scope.class'))).toBe(true); + }); + + it('captures struct_specifier as @scope.class', () => { + const all = tagsFor('struct Point { int x; int y; };'); + expect(all.some((t) => t.includes('@scope.class'))).toBe(true); + }); + + it('captures namespace_definition as @scope.namespace', () => { + const all = tagsFor('namespace foo { int x; }'); + expect(all.some((t) => t.includes('@scope.namespace'))).toBe(true); + }); + + it('captures function_definition as @scope.function', () => { + const all = tagsFor('void foo() { }'); + expect(all.some((t) => t.includes('@scope.function'))).toBe(true); + }); + + it('captures lambda_expression as @scope.function', () => { + const all = tagsFor('auto f = [](int x) { return x; };'); + expect(all.some((t) => t.includes('@scope.function'))).toBe(true); + }); + + it('captures block-level scopes (if, for, while, do, switch, case, try, catch)', () => { + const src = ` + void f() { + if (true) { } + for (int i = 0; i < 10; i++) { } + while (true) { } + do { } while (false); + switch (0) { case 0: break; } + try { } catch (...) { } + } + `; + const all = tagsFor(src); + const blocks = all.filter((t) => t.includes('@scope.block')); + expect(blocks.length).toBeGreaterThanOrEqual(6); + }); + + it('captures for_range_loop as @scope.block', () => { + const src = ` + #include + void f() { + std::vector v; + for (auto& x : v) { } + } + `; + const all = tagsFor(src); + const blocks = all.filter((t) => t.includes('@scope.block')); + expect(blocks.length).toBeGreaterThanOrEqual(1); + }); +}); + +// ── Declarations — classes / structs ──────────────────────────────────────── + +describe('emitCppScopeCaptures — class declarations', () => { + it('captures named class with @declaration.class', () => { + const m = findMatch('class Foo { int x; };', (t) => t.includes('@declaration.class')); + expect(m).toBeDefined(); + expect(m!['@declaration.name'].text).toBe('Foo'); + }); + + it('captures named struct with @declaration.struct', () => { + const m = findMatch('struct Point { int x; int y; };', (t) => + t.includes('@declaration.struct'), + ); + expect(m).toBeDefined(); + expect(m!['@declaration.name'].text).toBe('Point'); + }); + + it('captures template class with @declaration.class', () => { + const m = findMatch('template class Container { T val; };', (t) => + t.includes('@declaration.class'), + ); + expect(m).toBeDefined(); + expect(m!['@declaration.name'].text).toBe('Container'); + }); +}); + +// ── Declarations — namespaces ─────────────────────────────────────────────── + +describe('emitCppScopeCaptures — namespace declarations', () => { + it('captures named namespace with @declaration.namespace', () => { + const m = findMatch('namespace foo { int x; }', (t) => t.includes('@declaration.namespace')); + expect(m).toBeDefined(); + expect(m!['@declaration.name'].text).toBe('foo'); + }); + + it('anonymous namespace has no @declaration.namespace (only @scope.namespace)', () => { + const matches = allMatches('namespace { int x; }', (t) => t.includes('@declaration.namespace')); + // Anonymous namespace should NOT produce a @declaration.namespace + expect(matches.length).toBe(0); + }); +}); + +// ── Declarations — functions / methods ────────────────────────────────────── + +describe('emitCppScopeCaptures — function declarations', () => { + it('captures function definition with @declaration.function', () => { + const m = findMatch('void foo() {}', (t) => t.includes('@declaration.function')); + expect(m).toBeDefined(); + expect(m!['@declaration.name'].text).toBe('foo'); + }); + + it('captures function with pointer return as @declaration.function', () => { + const m = findMatch('int* create() {}', (t) => t.includes('@declaration.function')); + expect(m).toBeDefined(); + expect(m!['@declaration.name'].text).toBe('create'); + }); + + it('captures out-of-class method (qualified_identifier) as @declaration.method', () => { + const m = findMatch('void Foo::bar() {}', (t) => t.includes('@declaration.method')); + expect(m).toBeDefined(); + expect(m!['@declaration.name'].text).toBe('bar'); + }); + + it('captures destructor as @declaration.method', () => { + const m = findMatch('void Foo::~Foo() {}', (t) => t.includes('@declaration.method')); + expect(m).toBeDefined(); + // destructor_name includes the ~ + expect(m!['@declaration.name'].text).toContain('~'); + }); + + it('captures inline method (field_identifier) as @declaration.method', () => { + const src = 'class Foo { void bar() {} };'; + const m = findMatch(src, (t) => t.includes('@declaration.method')); + expect(m).toBeDefined(); + expect(m!['@declaration.name'].text).toBe('bar'); + }); + + it('captures function prototype as @declaration.function', () => { + const m = findMatch('void foo();', (t) => t.includes('@declaration.function')); + expect(m).toBeDefined(); + expect(m!['@declaration.name'].text).toBe('foo'); + }); + + it('captures template function as @declaration.function', () => { + const m = findMatch('template void foo(T x) {}', (t) => + t.includes('@declaration.function'), + ); + expect(m).toBeDefined(); + expect(m!['@declaration.name'].text).toBe('foo'); + }); +}); + +// ── Declarations — fields ─────────────────────────────────────────────────── + +describe('emitCppScopeCaptures — field declarations', () => { + it('captures plain field', () => { + const m = findMatch('class Foo { int val; };', (t) => t.includes('@declaration.field')); + expect(m).toBeDefined(); + expect(m!['@declaration.name'].text).toBe('val'); + }); + + it('captures pointer field', () => { + const m = findMatch('class Foo { int* ptr; };', (t) => t.includes('@declaration.field')); + expect(m).toBeDefined(); + expect(m!['@declaration.name'].text).toBe('ptr'); + }); + + it('captures reference field', () => { + const m = findMatch('class Foo { int& ref; };', (t) => t.includes('@declaration.field')); + expect(m).toBeDefined(); + expect(m!['@declaration.name'].text).toBe('ref'); + }); +}); + +// ── Declarations — variables ──────────────────────────────────────────────── + +describe('emitCppScopeCaptures — variable declarations', () => { + it('captures variable with initializer', () => { + const m = findMatch('int x = 42;', (t) => t.includes('@declaration.variable')); + expect(m).toBeDefined(); + expect(m!['@declaration.name'].text).toBe('x'); + }); +}); + +// ── Declarations — enums ──────────────────────────────────────────────────── + +describe('emitCppScopeCaptures — enum declarations', () => { + it('captures enum with @declaration.enum', () => { + const m = findMatch('enum Color { Red, Green, Blue };', (t) => t.includes('@declaration.enum')); + expect(m).toBeDefined(); + expect(m!['@declaration.name'].text).toBe('Color'); + }); + + it('captures enum constants with @declaration.const', () => { + const matches = allMatches('enum Color { Red, Green, Blue };', (t) => + t.includes('@declaration.const'), + ); + expect(matches.length).toBe(3); + const names = matches.map((m) => m['@declaration.name'].text).sort(); + expect(names).toEqual(['Blue', 'Green', 'Red']); + }); +}); + +// ── Declarations — typedef / alias ────────────────────────────────────────── + +describe('emitCppScopeCaptures — typedef/alias declarations', () => { + it('captures typedef as @declaration.typedef', () => { + const m = findMatch('typedef int MyInt;', (t) => t.includes('@declaration.typedef')); + expect(m).toBeDefined(); + expect(m!['@declaration.name'].text).toBe('MyInt'); + }); + + it('captures using alias as @declaration.typedef', () => { + const m = findMatch('using MyInt = int;', (t) => t.includes('@declaration.typedef')); + expect(m).toBeDefined(); + expect(m!['@declaration.name'].text).toBe('MyInt'); + }); +}); + +// ── Declarations — macros ─────────────────────────────────────────────────── + +describe('emitCppScopeCaptures — macro declarations', () => { + it('captures #define as @declaration.macro', () => { + const m = findMatch('#define MAX 100', (t) => t.includes('@declaration.macro')); + expect(m).toBeDefined(); + expect(m!['@declaration.name'].text).toBe('MAX'); + }); + + it('captures #define function as @declaration.macro', () => { + const m = findMatch('#define ADD(a,b) ((a)+(b))', (t) => t.includes('@declaration.macro')); + expect(m).toBeDefined(); + expect(m!['@declaration.name'].text).toBe('ADD'); + }); +}); + +// ── Imports ───────────────────────────────────────────────────────────────── + +describe('emitCppScopeCaptures — imports', () => { + it('captures #include local as wildcard import', () => { + const m = findMatch('#include "foo.h"', (t) => t.includes('@import.source')); + expect(m).toBeDefined(); + expect(m!['@import.source'].text).toBe('foo.h'); + expect(m!['@import.kind'].text).toBe('wildcard'); + expect(m!['@import.system']).toBeUndefined(); + }); + + it('captures #include system with system marker', () => { + const m = findMatch('#include ', (t) => t.includes('@import.source')); + expect(m).toBeDefined(); + expect(m!['@import.source'].text).toBe('iostream'); + expect(m!['@import.system']).toBeDefined(); + }); + + it('captures using namespace as wildcard import', () => { + const m = findMatch('using namespace std;', (t) => t.includes('@import.using-namespace')); + expect(m).toBeDefined(); + expect(m!['@import.source'].text).toBe('std'); + expect(m!['@import.kind'].text).toBe('wildcard'); + }); + + it('captures using declaration as named import', () => { + const m = findMatch('using std::vector;', (t) => t.includes('@import.name')); + expect(m).toBeDefined(); + expect(m!['@import.source'].text).toBe('std'); + expect(m!['@import.name'].text).toBe('vector'); + expect(m!['@import.kind'].text).toBe('named'); + }); +}); + +// ── References ────────────────────────────────────────────────────────────── + +describe('emitCppScopeCaptures — references', () => { + it('captures free call', () => { + const src = 'void f() { foo(); }'; + const m = findMatch(src, (t) => t.includes('@reference.call.free')); + expect(m).toBeDefined(); + expect(m!['@reference.name'].text).toBe('foo'); + }); + + it('captures member call (obj.method())', () => { + const src = 'void f() { obj.method(); }'; + const m = findMatch(src, (t) => t.includes('@reference.call.member')); + expect(m).toBeDefined(); + expect(m!['@reference.name'].text).toBe('method'); + }); + + it('captures member call (ptr->method())', () => { + const src = 'void f() { ptr->method(); }'; + const m = findMatch(src, (t) => t.includes('@reference.call.member')); + expect(m).toBeDefined(); + expect(m!['@reference.name'].text).toBe('method'); + }); + + it('captures qualified call (Namespace::func())', () => { + const src = 'void f() { Foo::bar(); }'; + const m = findMatch(src, (t) => t.includes('@reference.call.qualified')); + expect(m).toBeDefined(); + expect(m!['@reference.name'].text).toBe('bar'); + }); + + it('captures field read', () => { + const src = 'void f() { int x = obj.val; }'; + const m = findMatch(src, (t) => t.includes('@reference.read')); + expect(m).toBeDefined(); + expect(m!['@reference.name'].text).toBe('val'); + }); + + it('captures field write', () => { + const src = 'void f() { obj.val = 42; }'; + const m = findMatch(src, (t) => t.includes('@reference.write')); + expect(m).toBeDefined(); + expect(m!['@reference.name'].text).toBe('val'); + }); +}); + +// ── Type bindings ─────────────────────────────────────────────────────────── + +describe('emitCppScopeCaptures — type bindings', () => { + it('captures parameter type binding', () => { + const src = 'void foo(int x) {}'; + const m = findMatch(src, (t) => t.includes('@type-binding.parameter')); + expect(m).toBeDefined(); + expect(m!['@type-binding.name'].text).toBe('x'); + }); + + it('captures variable type binding', () => { + const src = 'int x = 42;'; + const m = findMatch(src, (t) => t.includes('@type-binding.assignment')); + expect(m).toBeDefined(); + expect(m!['@type-binding.name'].text).toBe('x'); + }); +}); + +// ── Arity enrichment ──────────────────────────────────────────────────────── + +describe('emitCppScopeCaptures — arity enrichment', () => { + it('enriches function declaration with parameter count', () => { + const m = findMatch('void foo(int x, int y) {}', (t) => + t.includes('@declaration.parameter-count'), + ); + expect(m).toBeDefined(); + expect(m!['@declaration.parameter-count'].text).toBe('2'); + }); + + it('enriches zero-parameter function', () => { + const m = findMatch('void foo() {}', (t) => t.includes('@declaration.parameter-count')); + expect(m).toBeDefined(); + expect(m!['@declaration.parameter-count'].text).toBe('0'); + }); + + it('detects default parameters (required < total)', () => { + const m = findMatch('void foo(int x, int y = 5) {}', (t) => + t.includes('@declaration.required-parameter-count'), + ); + expect(m).toBeDefined(); + expect(m!['@declaration.required-parameter-count'].text).toBe('1'); + expect(m!['@declaration.parameter-count'].text).toBe('2'); + }); + + it('enriches call reference with arity', () => { + const src = 'void f() { foo(1, 2, 3); }'; + const m = findMatch(src, (t) => t.includes('@reference.arity')); + expect(m).toBeDefined(); + expect(m!['@reference.arity'].text).toBe('3'); + }); +}); + +// ── Static / anonymous namespace detection ────────────────────────────────── + +describe('emitCppScopeCaptures — file-local linkage', () => { + beforeEach(() => { + clearFileLocalNames(); + }); + + it('detects static function as file-local', () => { + emitCppScopeCaptures('static void helper() {}', 'test.cpp'); + expect(isFileLocal('test.cpp', 'helper')).toBe(true); + }); + + it('does not mark non-static function as file-local', () => { + emitCppScopeCaptures('void helper() {}', 'test.cpp'); + expect(isFileLocal('test.cpp', 'helper')).toBe(false); + }); + + it('detects function in anonymous namespace as file-local', () => { + emitCppScopeCaptures('namespace { void helper() {} }', 'test.cpp'); + expect(isFileLocal('test.cpp', 'helper')).toBe(true); + }); + + it('does not mark function in named namespace as file-local', () => { + emitCppScopeCaptures('namespace foo { void helper() {} }', 'test.cpp'); + expect(isFileLocal('test.cpp', 'helper')).toBe(false); + }); +}); diff --git a/gitnexus/test/unit/scope-resolution/cpp/cpp-imports.test.ts b/gitnexus/test/unit/scope-resolution/cpp/cpp-imports.test.ts new file mode 100644 index 000000000..6bc6e1b86 --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/cpp/cpp-imports.test.ts @@ -0,0 +1,161 @@ +/** + * Unit tests for C++ import decomposition, interpretation, and target resolution. + */ + +import { describe, it, expect } from 'vitest'; +import { getCppParser } from '../../../../src/core/ingestion/languages/cpp/query.js'; +import { + splitCppInclude, + splitCppUsingDecl, +} from '../../../../src/core/ingestion/languages/cpp/import-decomposer.js'; +import { interpretCppImport } from '../../../../src/core/ingestion/languages/cpp/interpret.js'; +import { resolveCppImportTarget } from '../../../../src/core/ingestion/languages/cpp/import-target.js'; +import type { SyntaxNode } from '../../../../src/core/ingestion/utils/ast-helpers.js'; + +function parseNode(src: string, type: string): SyntaxNode | null { + const tree = getCppParser().parse(src); + for (let i = 0; i < tree.rootNode.namedChildCount; i++) { + const child = tree.rootNode.namedChild(i); + if (child?.type === type) return child as SyntaxNode; + } + return null; +} + +function capt(name: string, text: string) { + return { name, text, range: { startLine: 1, startCol: 1, endLine: 1, endCol: 1 } }; +} + +// ── #include decomposition ────────────────────────────────────────────────── + +describe('C++ include decomposition (splitCppInclude)', () => { + it('decomposes local include "#include \\"foo.h\\""', () => { + const node = parseNode('#include "foo.h"', 'preproc_include'); + expect(node).not.toBeNull(); + const match = splitCppInclude(node!); + expect(match).not.toBeNull(); + expect(match!['@import.source'].text).toBe('foo.h'); + expect(match!['@import.kind'].text).toBe('wildcard'); + expect(match!['@import.system']).toBeUndefined(); + }); + + it('decomposes system include "#include "', () => { + const node = parseNode('#include ', 'preproc_include'); + expect(node).not.toBeNull(); + const match = splitCppInclude(node!); + expect(match).not.toBeNull(); + expect(match!['@import.source'].text).toBe('iostream'); + expect(match!['@import.system']).toBeDefined(); + }); + + it('decomposes C++ header include "#include \\"utils/helpers.hpp\\""', () => { + const node = parseNode('#include "utils/helpers.hpp"', 'preproc_include'); + expect(node).not.toBeNull(); + const match = splitCppInclude(node!); + expect(match).not.toBeNull(); + expect(match!['@import.source'].text).toBe('utils/helpers.hpp'); + }); +}); + +// ── using declaration decomposition ───────────────────────────────────────── + +describe('C++ using declaration decomposition (splitCppUsingDecl)', () => { + it('decomposes "using namespace std;" as wildcard import', () => { + const node = parseNode('using namespace std;', 'using_declaration'); + expect(node).not.toBeNull(); + const match = splitCppUsingDecl(node!); + expect(match).not.toBeNull(); + expect(match!['@import.kind'].text).toBe('wildcard'); + expect(match!['@import.source'].text).toBe('std'); + expect(match!['@import.using-namespace']).toBeDefined(); + }); + + it('decomposes "using std::vector;" as named import', () => { + const node = parseNode('using std::vector;', 'using_declaration'); + expect(node).not.toBeNull(); + const match = splitCppUsingDecl(node!); + expect(match).not.toBeNull(); + expect(match!['@import.kind'].text).toBe('named'); + expect(match!['@import.source'].text).toBe('std'); + expect(match!['@import.name'].text).toBe('vector'); + }); + + it('decomposes nested namespace "using namespace foo::bar;"', () => { + const node = parseNode('using namespace foo::bar;', 'using_declaration'); + expect(node).not.toBeNull(); + const match = splitCppUsingDecl(node!); + expect(match).not.toBeNull(); + expect(match!['@import.kind'].text).toBe('wildcard'); + expect(match!['@import.source'].text).toBe('foo::bar'); + }); +}); + +// ── Import interpretation ─────────────────────────────────────────────────── + +describe('C++ import interpretation (interpretCppImport)', () => { + it('interprets local include as wildcard import', () => { + const result = interpretCppImport({ + '@import.kind': capt('@import.kind', 'wildcard'), + '@import.source': capt('@import.source', 'header.hpp'), + }); + expect(result).toEqual({ kind: 'wildcard', targetRaw: 'header.hpp' }); + }); + + it('returns null for system headers', () => { + const result = interpretCppImport({ + '@import.kind': capt('@import.kind', 'wildcard'), + '@import.source': capt('@import.source', 'iostream'), + '@import.system': capt('@import.system', 'true'), + }); + expect(result).toBeNull(); + }); + + it('interprets named import (using std::vector)', () => { + const result = interpretCppImport({ + '@import.kind': capt('@import.kind', 'named'), + '@import.source': capt('@import.source', 'std'), + '@import.name': capt('@import.name', 'vector'), + }); + expect(result).not.toBeNull(); + expect(result!.kind).toBe('named'); + expect(result!.targetRaw).toBe('std'); + }); + + it('returns null when @import.source is missing', () => { + const result = interpretCppImport({ + '@import.kind': capt('@import.kind', 'wildcard'), + }); + expect(result).toBeNull(); + }); +}); + +// ── Import target resolution ──────────────────────────────────────────────── + +describe('C++ import target resolution (resolveCppImportTarget)', () => { + it('resolves .hpp header', () => { + const result = resolveCppImportTarget('foo.hpp', 'main.cpp', new Set(['foo.hpp', 'bar.cpp'])); + expect(result).toBe('foo.hpp'); + }); + + it('resolves .hxx header', () => { + const result = resolveCppImportTarget('foo.hxx', 'main.cpp', new Set(['foo.hxx'])); + expect(result).toBe('foo.hxx'); + }); + + it('prefers same-directory sibling', () => { + const result = resolveCppImportTarget( + 'bar.hpp', + 'src/foo.cpp', + new Set(['include/bar.hpp', 'src/bar.hpp']), + ); + expect(result).toBe('src/bar.hpp'); + }); + + it('resolves suffix match with depth tiebreak', () => { + const result = resolveCppImportTarget('foo.h', 'main.cpp', new Set(['a/b/c/foo.h', 'z/foo.h'])); + expect(result).toBe('z/foo.h'); + }); + + it('returns null for no match', () => { + expect(resolveCppImportTarget('missing.hpp', 'main.cpp', new Set(['foo.h']))).toBeNull(); + }); +}); From 75cb49477ea313f0258391251ea07dc37fcf4b7a Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Thu, 14 May 2026 12:25:05 +0100 Subject: [PATCH 02/13] feat(cpp): emit EXTENDS edges for template and qualified template bases (#1581) * Initial plan * fix: emit cpp extends edges for template bases Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/eaddb1ac-7b57-4f44-94ba-a07a578d078d Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * chore: address final review notes Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/eaddb1ac-7b57-4f44-94ba-a07a578d078d Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * fix: keep cpp extends edges class-owned Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/b10bbb4d-6746-46fa-9b82-5c0962cd8b3f Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * test: address cpp follow-up review findings Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/be67e437-055f-4a71-a24e-d3bfb87ad0cd Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> --- .../core/ingestion/languages/cpp/captures.ts | 73 +++++++++++++++-- .../src/core/ingestion/languages/cpp/query.ts | 19 +++++ .../contract/scope-resolver.ts | 3 + .../scope-resolution/pipeline/run.ts | 79 ++++++++++++++++++- .../base.h | 7 ++ .../derived.h | 6 ++ .../base.h | 12 +++ .../derived.h | 11 +++ .../test/integration/resolvers/cpp.test.ts | 72 +++++++++++++---- .../test/integration/resolvers/helpers.ts | 7 +- 10 files changed, 262 insertions(+), 27 deletions(-) create mode 100644 gitnexus/test/fixtures/lang-resolution/cpp-phase5-u1-u3-template-multi-base-list/base.h create mode 100644 gitnexus/test/fixtures/lang-resolution/cpp-phase5-u1-u3-template-multi-base-list/derived.h create mode 100644 gitnexus/test/fixtures/lang-resolution/cpp-phase5-u1-u3-u5-qualified-inline-base-call/base.h create mode 100644 gitnexus/test/fixtures/lang-resolution/cpp-phase5-u1-u3-u5-qualified-inline-base-call/derived.h diff --git a/gitnexus/src/core/ingestion/languages/cpp/captures.ts b/gitnexus/src/core/ingestion/languages/cpp/captures.ts index e6d63635a..325742bfb 100644 --- a/gitnexus/src/core/ingestion/languages/cpp/captures.ts +++ b/gitnexus/src/core/ingestion/languages/cpp/captures.ts @@ -301,6 +301,13 @@ export function emitCppScopeCaptures( out.push(grouped); } + // ── Emit inheritance references for scope-resolution MRO / EXTENDS ── + // Walk every class/struct base list and synthesize `@reference.inherits` + // captures consumed by the registry-primary graph bridge. The lookup name + // is normalized to the bare class name so `Base` / `outer::v1::Base` + // resolve through V1's simple-name `findClassBindingInScope('Base')`. + emitCppInheritanceCaptures(tree.rootNode, out); + // ── Detect dependent-base relationships for two-phase template lookup ── // Walk the tree once, finding every `template_declaration` whose // child is a class/struct definition with a `base_class_clause` whose @@ -314,6 +321,40 @@ export function emitCppScopeCaptures( return out; } +/** + * Walk every C++ class/struct base clause and emit `@reference.inherits` + * captures for each base so scope resolution can resolve them into EXTENDS + * edges. Lookup names are normalized to bare class names (`Base` → `Base`, + * `outer::v1::Base` → `Base`) to match the V1 simple-name + * `findClassBindingInScope` contract. This intentionally preserves the + * existing scope-chain tradeoff: qualified namespace context is discarded + * here instead of introducing a C++-only name-resolution lane in shared + * ingestion infrastructure. + */ +function emitCppInheritanceCaptures(root: SyntaxNode, out: CaptureMatch[]): void { + const stack: SyntaxNode[] = [root]; + while (stack.length > 0) { + const node = stack.pop()!; + if (node.type === 'class_specifier' || node.type === 'struct_specifier') { + const baseClause = findChildOfType(node, ['base_class_clause']); + if (baseClause !== null) { + for (const base of iterBaseClasses(baseClause)) { + const baseName = extractBaseLookupName(base); + if (baseName.length === 0) continue; + out.push({ + '@reference.inherits': nodeToCapture('@reference.inherits', base), + '@reference.name': syntheticCapture('@reference.name', base, baseName), + }); + } + } + } + for (let i = 0; i < node.childCount; i++) { + const child = node.child(i); + if (child !== null) stack.push(child); + } + } +} + /** * Walk the AST finding every template_declaration containing a class or * struct definition with a dependent base. Records (className, baseName) @@ -344,7 +385,7 @@ function detectCppDependentBases(root: SyntaxNode, filePath: string): void { if (baseClause !== null) { for (const base of iterBaseClasses(baseClause)) { if (isBaseDependent(base, params)) { - const baseName = extractBaseSimpleName(base); + const baseName = extractBaseLookupName(base); if (baseName !== '') { markCppDependentBase(filePath, className, baseName); } @@ -461,19 +502,35 @@ function isBaseDependent(baseNode: SyntaxNode, templateParams: Set): boo return false; } -/** Extract the simple name of a base class node. */ -function extractBaseSimpleName(baseNode: SyntaxNode): string { - if (baseNode.type === 'type_identifier') return baseNode.text; +/** + * Recursively extract the bare lookup name of a base class node. + * Examples: `Base` → `Base`, `Base` → `Base`, + * `outer::v1::Base` → `Base`. Namespace qualifiers are intentionally + * dropped to align with V1 scope-chain lookup everywhere else in the + * registry-primary pipeline. + */ +function extractBaseLookupName(baseNode: SyntaxNode): string { + if (baseNode.type === 'type_identifier' || baseNode.type === 'identifier') return baseNode.text; if (baseNode.type === 'template_type') { const nameNode = baseNode.childForFieldName('name'); - if (nameNode !== null) return nameNode.text; - // Fallback: first type_identifier descendant. - const id = findFirstDescendantOfType(baseNode, 'type_identifier'); + if (nameNode !== null) return extractBaseLookupName(nameNode); + const id = + findFirstDescendantOfType(baseNode, 'type_identifier') ?? + findFirstDescendantOfType(baseNode, 'identifier'); if (id !== null) return id.text; } if (baseNode.type === 'qualified_identifier') { const nameNode = baseNode.childForFieldName('name'); - if (nameNode !== null) return nameNode.text; + if (nameNode !== null) { + const nested = extractBaseLookupName(nameNode); + if (nested.length > 0) return nested; + } + for (let i = baseNode.childCount - 1; i >= 0; i--) { + const child = baseNode.child(i); + if (child === null) continue; + const nested = extractBaseLookupName(child); + if (nested.length > 0) return nested; + } } return ''; } diff --git a/gitnexus/src/core/ingestion/languages/cpp/query.ts b/gitnexus/src/core/ingestion/languages/cpp/query.ts index 4e451617d..0e6a0a7ae 100644 --- a/gitnexus/src/core/ingestion/languages/cpp/query.ts +++ b/gitnexus/src/core/ingestion/languages/cpp/query.ts @@ -417,6 +417,25 @@ const CPP_SCOPE_QUERY = ` scope: (_) @reference.receiver name: (identifier) @reference.name)) @reference.call.qualified +;; Nested qualified receiver: outer::v1::Base::f() +;; tree-sitter-cpp nests this as qualified_identifier(name: +;; qualified_identifier(scope: qualified_identifier(...), name: identifier)). +;; Capturing the innermost receiver still gives isSuperReceiverInContext +;; enough text to strip qualifiers/template args down to Base. +(call_expression + function: (qualified_identifier + name: (qualified_identifier + scope: (_) @reference.receiver + name: (identifier) @reference.name))) @reference.call.qualified + +;; Double-nested qualified receiver: outer::v1::Base::f() +(call_expression + function: (qualified_identifier + name: (qualified_identifier + name: (qualified_identifier + scope: (_) @reference.receiver + name: (identifier) @reference.name)))) @reference.call.qualified + ;; ─── References — member calls (obj.method() / ptr->method()) ─────── (call_expression function: (field_expression 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 d7b3618a2..d29b6efa8 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts @@ -87,6 +87,9 @@ * attempting emission (even on dedup-collapse), because the * per-(caller, target) collapse semantics require multiple call * sites in the same caller body not produce multiple edges. + * `preEmitInheritanceEdges` also pre-marks every `inherits` site so + * the generic bridge cannot remap class heritage into method-owned + * EXTENDS edges via `resolveCallerGraphId`. * * - **I3 — `propagateImportedReturnTypes` mutation timing + ordering.** * The pass mutates `Scope.typeBindings` (a plain `new Map(...)` from diff --git a/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts b/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts index 4f74cfce2..0809d59ca 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts @@ -32,16 +32,88 @@ import { extractParsedFile } from '../../scope-extractor-bridge.js'; import { finalizeScopeModel } from '../../finalize-orchestrator.js'; import { resolveReferenceSites, type ResolveStats } from '../../resolve-references.js'; import { buildGraphNodeLookup } from '../graph-bridge/node-lookup.js'; +import { resolveDefGraphId } from '../graph-bridge/ids.js'; import { buildPopulatedMethodDispatch } from '../graph-bridge/method-dispatch.js'; +import { tryEmitEdge } from '../graph-bridge/edges.js'; import { propagateImportedReturnTypes } from '../passes/imported-return-types.js'; import { emitReceiverBoundCalls } from '../passes/receiver-bound-calls.js'; import { emitFreeCallFallback } from '../passes/free-call-fallback.js'; import { emitReferencesViaLookup } from '../graph-bridge/references-to-edges.js'; import { emitImportEdges } from '../graph-bridge/imports-to-edges.js'; import type { ScopeResolver } from '../contract/scope-resolver.js'; +import { findClassBindingInScope, findEnclosingClassDef } from '../scope/walkers.js'; import { buildWorkspaceResolutionIndex } from '../workspace-index.js'; import { logger } from '../../../logger.js'; + +/** + * Resolve inheritance reference sites early and pre-emit their EXTENDS edges + * before MRO construction. This lets template-base captures contribute to the + * graph in time for `buildMro`, while `handledSites` prevents the generic + * reference-edge bridge from re-emitting the same sites later. + * + * @returns Site keys to seed the downstream handled-site skip set. + */ +function preEmitInheritanceEdges( + graph: KnowledgeGraph, + scopes: ReturnType, + nodeLookup: ReturnType, +): Set { + const handledSites = new Set(); + const seen = new Set(); + const existing = new Set(); + for (const rel of graph.iterRelationshipsByType('EXTENDS')) { + existing.add(`${rel.sourceId}->${rel.targetId}`); + } + + for (const site of scopes.referenceSites) { + if (site.kind !== 'inherits') continue; + const scope = scopes.scopeTree.getScope(site.inScope); + const siteKey = + scope?.filePath !== undefined + ? `${scope.filePath}:${site.atRange.startLine}:${site.atRange.startCol}` + : undefined; + if (siteKey !== undefined) { + // Intentionally suppress every `inherits` site from the generic + // reference bridge, even when this pre-pass can't emit an EXTENDS + // edge. The shared bridge resolves the source via + // `resolveCallerGraphId`, which can degrade class-heritage sites into + // method-owned EXTENDS edges once methods exist on the class. This + // pre-pass is the authoritative inheritance emitter, so broad + // suppression keeps `buildMro` and the final graph class-owned. + handledSites.add(siteKey); + } + + const targetDef = findClassBindingInScope(site.inScope, site.name, scopes); + if (targetDef === undefined) continue; + + const callerClass = findEnclosingClassDef(site.inScope, scopes); + if (callerClass === undefined) continue; + const callerGraphId = resolveDefGraphId(callerClass.filePath, callerClass, nodeLookup); + const targetGraphId = resolveDefGraphId(targetDef.filePath, targetDef, nodeLookup); + if (callerGraphId === undefined || targetGraphId === undefined) continue; + const edgeKey = `${callerGraphId}->${targetGraphId}`; + if (existing.has(edgeKey)) continue; + + if ( + tryEmitEdge( + graph, + scopes, + nodeLookup, + site, + targetDef, + 'scope-resolution: inherits', + seen, + 0.85, + ) + ) { + existing.add(edgeKey); + } + } + + return handledSites; +} + interface RunScopeResolutionInput { readonly graph: KnowledgeGraph; /** @@ -183,8 +255,6 @@ export function runScopeResolution( // ── Phase 2: finalize → ScopeResolutionIndexes ───────────────────────── const allFilePaths = new Set(parsedFiles.map((f) => f.filePath)); const nodeLookup = buildGraphNodeLookup(graph); - const mroByClassDefId = provider.buildMro(graph, parsedFiles, nodeLookup); - const extendsOnlyMroByClassDefId = provider.buildExtendsOnlyMro?.(graph, parsedFiles, nodeLookup); const resolutionConfig = input.resolutionConfig; const finalized = finalizeScopeModel(parsedFiles, { @@ -197,6 +267,9 @@ export function runScopeResolution( provider.mergeBindings(existing, incoming, scopeId), }, }); + const preEmittedInheritanceSites = preEmitInheritanceEdges(graph, finalized, nodeLookup); + const mroByClassDefId = provider.buildMro(graph, parsedFiles, nodeLookup); + const extendsOnlyMroByClassDefId = provider.buildExtendsOnlyMro?.(graph, parsedFiles, nodeLookup); // Replace the empty MethodDispatchIndex that finalizeScopeModel // builds by design with the populated one derived from the @@ -273,7 +346,7 @@ export function runScopeResolution( const tResolve = PROF ? process.hrtime.bigint() : 0n; // ── Phase 4: emit graph edges (LOAD-BEARING ORDER — see I1) ──────────── - const handledSites = new Set(); + const handledSites = new Set(preEmittedInheritanceSites); const receiverExtras = emitReceiverBoundCalls( graph, indexes, diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-phase5-u1-u3-template-multi-base-list/base.h b/gitnexus/test/fixtures/lang-resolution/cpp-phase5-u1-u3-template-multi-base-list/base.h new file mode 100644 index 000000000..30b2f5bd6 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-phase5-u1-u3-template-multi-base-list/base.h @@ -0,0 +1,7 @@ +#pragma once + +template +struct A {}; + +template +struct B {}; diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-phase5-u1-u3-template-multi-base-list/derived.h b/gitnexus/test/fixtures/lang-resolution/cpp-phase5-u1-u3-template-multi-base-list/derived.h new file mode 100644 index 000000000..7fe417b6a --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-phase5-u1-u3-template-multi-base-list/derived.h @@ -0,0 +1,6 @@ +#pragma once + +#include "base.h" + +template +struct Derived : A, B {}; diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-phase5-u1-u3-u5-qualified-inline-base-call/base.h b/gitnexus/test/fixtures/lang-resolution/cpp-phase5-u1-u3-u5-qualified-inline-base-call/base.h new file mode 100644 index 000000000..de3e84def --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-phase5-u1-u3-u5-qualified-inline-base-call/base.h @@ -0,0 +1,12 @@ +#pragma once + +namespace outer { + inline namespace v1 { + template + struct Base { + void f(); + }; + + void free_fn(); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-phase5-u1-u3-u5-qualified-inline-base-call/derived.h b/gitnexus/test/fixtures/lang-resolution/cpp-phase5-u1-u3-u5-qualified-inline-base-call/derived.h new file mode 100644 index 000000000..2ff5523a1 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-phase5-u1-u3-u5-qualified-inline-base-call/derived.h @@ -0,0 +1,11 @@ +#pragma once + +#include "base.h" + +template +struct Derived : outer::v1::Base { + void g() { + outer::v1::Base::f(); + outer::v1::free_fn(); + } +}; diff --git a/gitnexus/test/integration/resolvers/cpp.test.ts b/gitnexus/test/integration/resolvers/cpp.test.ts index 1f6135408..bcc90350a 100644 --- a/gitnexus/test/integration/resolvers/cpp.test.ts +++ b/gitnexus/test/integration/resolvers/cpp.test.ts @@ -2098,7 +2098,7 @@ describe('C++ inline namespace — ADL participation', () => { // U3 (two-phase lookup), and U5 (inline namespaces). // --------------------------------------------------------------------------- -describe('C++ Phase 5 U1×U3 — qualified Base::method() inside template body (no false positives)', () => { +describe('C++ Phase 5 U1×U3 — qualified Base::method() inside template body', () => { let result: PipelineResult; beforeAll(async () => { @@ -2108,22 +2108,33 @@ describe('C++ Phase 5 U1×U3 — qualified Base::method() inside template bod ); }, 60000); - it('Base::method() does NOT mis-route to a class method outside the MRO', () => { + it('emits EXTENDS edge: Derived → Base for template base Base', () => { + const extends_ = getRelationships(result, 'EXTENDS'); + expect(edgeSet(extends_)).toContain('Derived → Base'); + }); + + it('Base::method() resolves to Base::method inside template body', () => { const calls = getRelationships(result, 'CALLS'); const methodCalls = calls.filter((c) => c.source === 'g' && c.target === 'method'); - // V1 documented gap: cross-file (and same-file) template-class - // inheritance is not captured as an EXTENDS edge by the legacy DAG - // (the cpp captures.ts has no `base_class_clause` heritage emitter - // for template_type bases). Without an EXTENDS edge, MRO is empty - // and the U1 super branch can't dispatch. Result: 0 CALLS edges. - // - // This Phase 5 cross-unit composition test locks in that the - // template-arg-stripping U1 logic produces NO false positives — - // `Base` correctly classifies as a super-receiver candidate but - // (due to empty MRO) doesn't accidentally route to an unrelated - // method named `method` via any other case. count > 0 here would - // indicate the U1 stripped lookup mis-resolved across cases. - expect(methodCalls.length).toBe(0); + expect(methodCalls.length).toBe(1); + expect(methodCalls[0].targetFilePath).toContain('classes.h'); + }); +}); + +describe('C++ Phase 5 U1×U3 — template multi-base list', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'cpp-phase5-u1-u3-template-multi-base-list'), + () => {}, + ); + }, 60000); + + it('emits EXTENDS edges: Derived → A, Derived → B for template multi-base list', () => { + const extends_ = getRelationships(result, 'EXTENDS'); + expect(extends_.length).toBe(2); + expect(edgeSet(extends_)).toEqual(['Derived → A', 'Derived → B']); }); }); @@ -2183,3 +2194,34 @@ describe('C++ Phase 5 U3×U5 — template Derived : outer::v1::Base (inline)' expect(fLeaks.length).toBe(0); }); }); + +describe('C++ Phase 5 U1×U3×U5 — qualified outer::v1::Base::f() inside template body', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'cpp-phase5-u1-u3-u5-qualified-inline-base-call'), + () => {}, + ); + }, 60000); + + it('emits EXTENDS edge: Derived → Base for qualified template base outer::v1::Base', () => { + const extends_ = getRelationships(result, 'EXTENDS'); + expect(edgeSet(extends_)).toContain('Derived → Base'); + }); + + it('outer::v1::Base::f() resolves to Base::f inside template body', () => { + const calls = getRelationships(result, 'CALLS'); + const fCalls = calls.filter((c) => c.source === 'g' && c.target === 'f'); + expect(fCalls.length).toBe(1); + expect(fCalls[0].targetFilePath).toContain('base.h'); + }); + + it('outer::v1::free_fn() resolves as a namespace free function, not a super-receiver method', () => { + const calls = getRelationships(result, 'CALLS'); + const freeCalls = calls.filter((c) => c.source === 'g' && c.target === 'free_fn'); + expect(freeCalls.length).toBe(1); + expect(freeCalls[0].targetLabel).toBe('Function'); + expect(freeCalls[0].rel.reason).toBe('import-resolved'); + }); +}); diff --git a/gitnexus/test/integration/resolvers/helpers.ts b/gitnexus/test/integration/resolvers/helpers.ts index 7cbfaaf6c..418bbc0fb 100644 --- a/gitnexus/test/integration/resolvers/helpers.ts +++ b/gitnexus/test/integration/resolvers/helpers.ts @@ -155,8 +155,13 @@ const LEGACY_RESOLVER_PARITY_EXPECTED_FAILURES: Readonly::method() does NOT mis-route to a class method outside the MRO', + 'emits EXTENDS edge: Derived → Base for template base Base', + 'emits EXTENDS edges: Derived → A, Derived → B for template multi-base list', + 'Base::method() resolves to Base::method inside template body', 'unqualified f() inside Derived::g() does NOT bind to outer::v1::Base::f (dependent base across inline namespace)', + 'emits EXTENDS edge: Derived → Base for qualified template base outer::v1::Base', + 'outer::v1::Base::f() resolves to Base::f inside template body', + 'outer::v1::free_fn() resolves as a namespace free function, not a super-receiver method', ]), }; From c901ee4666c47c09fcf21887613c9084c582a3ec Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Thu, 14 May 2026 13:06:39 +0100 Subject: [PATCH 03/13] fix(cpp): workspace-wide dependent-base name resolution for cross-file templates (#1586) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Initial plan * fix(cpp): workspace-wide dependent-base name resolution (cross-file support) - Replace per-file `populateCppDependentBases(parsed)` with a workspace-wide `populateCppDependentBases(parsedFiles)` that builds a cross-file class index - Use qualified-name prefix for namespace disambiguation when multiple classes share a simple name (e.g. `Box` in two namespaces) - Move the call from `populateOwners` (per-file) to the new `populateWorkspaceOwners` hook so all files are processed before resolution runs - Add `cpp-two-phase-dependent-base-ns` fixture: Base in a namespace in a separate file from Derived, plus a namespace-free function with the same name — exercises the path where the class-owned filter does not apply - Add two integration tests for the new fixture" Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/d78fae8b-cd32-45d8-a815-2b27d7d89e62 Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * fix(cpp): clarify V1 conservative exact-prefix namespace match in two-phase-lookup Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/d78fae8b-cd32-45d8-a815-2b27d7d89e62 Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Gergő Magyar Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> --- .../ingestion/languages/cpp/scope-resolver.ts | 13 +- .../languages/cpp/two-phase-lookup.ts | 126 ++++++++++++++---- .../cpp-two-phase-dependent-base-ns/base.h | 19 +++ .../cpp-two-phase-dependent-base-ns/derived.h | 21 +++ .../test/integration/resolvers/cpp.test.ts | 31 +++++ 5 files changed, 179 insertions(+), 31 deletions(-) create mode 100644 gitnexus/test/fixtures/lang-resolution/cpp-two-phase-dependent-base-ns/base.h create mode 100644 gitnexus/test/fixtures/lang-resolution/cpp-two-phase-dependent-base-ns/derived.h diff --git a/gitnexus/src/core/ingestion/languages/cpp/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/cpp/scope-resolver.ts index a85e5b113..02aaff3bf 100644 --- a/gitnexus/src/core/ingestion/languages/cpp/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/cpp/scope-resolver.ts @@ -101,16 +101,21 @@ export const cppScopeResolver: ScopeResolver = { // fallback and wildcard expansion can suppress them as unqualified // cross-file callables. populateCppNonGloballyVisible(parsed); - // Resolve recorded template-class → dependent-base simple names to - // class nodeIds for two-phase template lookup (U3 of plan - // 2026-05-13-001). - populateCppDependentBases(parsed); // Build the class-def → enclosing-namespace-qualified-name map used // by ADL (U2 of plan 2026-05-13-001) to identify each argument type's // associated namespace for Koenig lookup. populateCppAssociatedNamespaces(parsed); }, + // Resolve recorded template-class → dependent-base simple names to + // class nodeIds for two-phase template lookup (U3 of plan + // 2026-05-13-001). Runs AFTER all files have had `populateOwners` + // applied so that cross-file base classes (e.g. Base in base.h, + // Derived in derived.h) are reachable in the workspace index. + populateWorkspaceOwners: (parsedFiles: readonly ParsedFile[]) => { + populateCppDependentBases(parsedFiles); + }, + // Simple `isSuperReceiver` returns false for C++. Real super // classification is caller-context-dependent and lives in // `isSuperReceiverInContext` below — without scope context the diff --git a/gitnexus/src/core/ingestion/languages/cpp/two-phase-lookup.ts b/gitnexus/src/core/ingestion/languages/cpp/two-phase-lookup.ts index 7840ed81a..8d0050eb1 100644 --- a/gitnexus/src/core/ingestion/languages/cpp/two-phase-lookup.ts +++ b/gitnexus/src/core/ingestion/languages/cpp/two-phase-lookup.ts @@ -11,8 +11,20 @@ * This module records — during `emitCppScopeCaptures` — which template * class declarations have which dependent base class names (per file). * `populateCppDependentBases` then resolves those names to class nodeIds - * using the workspace registry, building the per-class set the - * `isDependentBaseMember` predicate consumes. + * using a workspace-wide registry, building the per-class set the + * `isCppDependentBaseMember` predicate consumes. + * + * Cross-file resolution: `Base` may be declared in a different header + * than `Derived`. `populateCppDependentBases` therefore runs as a + * workspace-wide pass (`populateWorkspaceOwners` hook) after every file + * has had `populateOwners` applied, so all class defs are reachable. + * + * Namespace disambiguation: when multiple classes share a simple name + * (e.g., `Box` in two namespaces), the resolver prefers the candidate + * whose qualified-name prefix (namespace path) matches the deriving + * class's prefix. If no namespace match is found, a unique simple-name + * match is accepted; ambiguous matches (multiple candidates, no + * namespace winner) are skipped conservatively. * * NOTE: module-level state, single-process-single-repo use only. * `clearFileLocalNames()` clears this state alongside file-local linkage @@ -69,37 +81,97 @@ export function clearCppDependentBases(): void { } /** - * Resolve recorded dependent-base simple names to class nodeIds using - * the parsed file's localDefs. Run as part of `populateOwners` so the - * resolved set is available before any resolution pass consults it. + * Resolve recorded dependent-base simple names to class nodeIds using a + * workspace-wide index. Run as `populateWorkspaceOwners` after every + * file has had `populateOwners` applied, so class defs from ALL files + * are reachable. * - * Matches by simple name within the same file (the template class and - * its base are typically declared in the same TU; cross-file template - * bases are an edge case deferred to V2). + * Disambiguation strategy (multiple classes sharing a simple name): + * 1. Prefer the candidate whose qualified-name namespace prefix matches + * the deriving class's namespace prefix (same-namespace bias). + * 2. Fall back to accepting a unique simple-name match. + * 3. Skip when multiple candidates exist and no namespace match is + * found (conservative: avoids false associations). */ -export function populateCppDependentBases(parsed: ParsedFile): void { - const perFile = dependentBasesByFile.get(parsed.filePath); - if (perFile === undefined) return; +export function populateCppDependentBases(parsedFiles: readonly ParsedFile[]): void { + if (dependentBasesByFile.size === 0) return; - // Build simple-name → nodeId index for this file's class-like defs. - const classByName = new Map(); - for (const def of parsed.localDefs) { - if (def.type !== 'Class' && def.type !== 'Struct' && def.type !== 'Interface') continue; - const simple = def.qualifiedName?.split('.').pop() ?? def.qualifiedName ?? ''; - if (simple !== '') classByName.set(simple, def.nodeId); + // Build workspace-wide index: simpleName → {nodeId, nsPrefix}[] + // nsPrefix is the dot-joined namespace path (qualifiedName without the + // last segment). Classes at global scope have nsPrefix = ''. + const classesBySimpleName = new Map(); + for (const parsed of parsedFiles) { + for (const def of parsed.localDefs) { + if (def.type !== 'Class' && def.type !== 'Struct' && def.type !== 'Interface') continue; + const qn = def.qualifiedName ?? ''; + const lastDot = qn.lastIndexOf('.'); + const simple = lastDot >= 0 ? qn.slice(lastDot + 1) : qn; + if (simple === '') continue; + const nsPrefix = lastDot >= 0 ? qn.slice(0, lastDot) : ''; + let entries = classesBySimpleName.get(simple); + if (entries === undefined) { + entries = []; + classesBySimpleName.set(simple, entries); + } + entries.push({ nodeId: def.nodeId, nsPrefix }); + } } - for (const [className, baseNames] of perFile) { - const classNodeId = classByName.get(className); - if (classNodeId === undefined) continue; - let bases = dependentBaseNodeIds.get(classNodeId); - if (bases === undefined) { - bases = new Set(); - dependentBaseNodeIds.set(classNodeId, bases); + // Build a filePath → ParsedFile lookup for fast per-file access. + const parsedByFile = new Map(); + for (const parsed of parsedFiles) parsedByFile.set(parsed.filePath, parsed); + + for (const [filePath, perFile] of dependentBasesByFile) { + const parsed = parsedByFile.get(filePath); + if (parsed === undefined) continue; + + // Build a simple-name → {nodeId, nsPrefix} map for THIS file's + // class-like defs so we can identify each template class precisely + // (avoids cross-file name collisions for the deriving class itself). + const localClassByName = new Map(); + for (const def of parsed.localDefs) { + if (def.type !== 'Class' && def.type !== 'Struct' && def.type !== 'Interface') continue; + const qn = def.qualifiedName ?? ''; + const lastDot = qn.lastIndexOf('.'); + const simple = lastDot >= 0 ? qn.slice(lastDot + 1) : qn; + if (simple === '') continue; + const nsPrefix = lastDot >= 0 ? qn.slice(0, lastDot) : ''; + localClassByName.set(simple, { nodeId: def.nodeId, nsPrefix }); } - for (const baseName of baseNames) { - const baseNodeId = classByName.get(baseName); - if (baseNodeId !== undefined) bases.add(baseNodeId); + + for (const [className, baseNames] of perFile) { + const classEntry = localClassByName.get(className); + if (classEntry === undefined) continue; + + let bases = dependentBaseNodeIds.get(classEntry.nodeId); + if (bases === undefined) { + bases = new Set(); + dependentBaseNodeIds.set(classEntry.nodeId, bases); + } + + for (const baseName of baseNames) { + const candidates = classesBySimpleName.get(baseName); + if (candidates === undefined || candidates.length === 0) continue; + + if (candidates.length === 1) { + // Unique simple-name match — accept regardless of namespace. + bases.add(candidates[0].nodeId); + continue; + } + + // Multiple classes share the same simple name — prefer the one + // whose namespace matches the deriving class's namespace. + // V1: exact dot-prefix match only. Cross-namespace inheritance + // (e.g., `ns::outer::Derived` extending bare `Inner` defined in + // `ns::outer::inner`) and inline-namespace cases are deferred to + // V2; the conservative skip-on-ambiguity below avoids false + // associations in those edge cases. + const nsMatch = candidates.find((c) => c.nsPrefix === classEntry.nsPrefix); + if (nsMatch !== undefined) { + bases.add(nsMatch.nodeId); + } + // else: ambiguous (multiple candidates, no namespace match) → skip. + } } } } diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-two-phase-dependent-base-ns/base.h b/gitnexus/test/fixtures/lang-resolution/cpp-two-phase-dependent-base-ns/base.h new file mode 100644 index 000000000..c9ecf5c22 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-two-phase-dependent-base-ns/base.h @@ -0,0 +1,19 @@ +#pragma once + +namespace geom { + +template +struct Base { + void compute(); + int area; +}; + +// Free function inside the same namespace — no ownerId, so the +// class-owned filter does NOT apply to this candidate. It is instead +// suppressed by the namespace-nesting filter (isCppDefGloballyVisible). +// The test therefore exercises a candidate path that is orthogonal to +// the class-owned filter, proving the overall suppression stack is +// robust even when ownerId-based blocking is absent. +void compute(); + +} // namespace geom diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-two-phase-dependent-base-ns/derived.h b/gitnexus/test/fixtures/lang-resolution/cpp-two-phase-dependent-base-ns/derived.h new file mode 100644 index 000000000..9596bcc81 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-two-phase-dependent-base-ns/derived.h @@ -0,0 +1,21 @@ +#pragma once + +#include "base.h" + +namespace geom { + +template +struct Derived : Base { + // Unqualified call to compute() inside a template body whose base is + // dependent. Two-phase lookup: the compiler does NOT look into + // Base for this name — so GitNexus must also suppress the edge. + void g() { + compute(); + } + // Unqualified field access — same reasoning applies. + int h() { + return area; + } +}; + +} // namespace geom diff --git a/gitnexus/test/integration/resolvers/cpp.test.ts b/gitnexus/test/integration/resolvers/cpp.test.ts index bcc90350a..26fb02d36 100644 --- a/gitnexus/test/integration/resolvers/cpp.test.ts +++ b/gitnexus/test/integration/resolvers/cpp.test.ts @@ -1903,6 +1903,37 @@ describe('C++ two-phase template lookup — dependent base suppression', () => { // and template-body member-lookup work tracked separately. See plan // 2026-05-13-001 follow-ups. +// --------------------------------------------------------------------------- +// U3 cross-file namespace variant: Base lives in a different file AND +// inside a namespace. The fixture also contains a free function with the +// same name inside the namespace — that candidate has no ownerId, so the +// class-owned filter does NOT apply to it; it is instead suppressed by the +// namespace-nesting filter. Both candidates must still yield zero edges. +// --------------------------------------------------------------------------- + +describe('C++ two-phase template lookup — cross-file namespace variant', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'cpp-two-phase-dependent-base-ns'), + () => {}, + ); + }, 60000); + + it('geom::Derived::g() -> compute() does NOT bind to geom::Base::compute (cross-file dependent base, class-owned)', () => { + const calls = getRelationships(result, 'CALLS'); + const leaks = calls.filter((c) => c.source === 'g' && c.target === 'compute'); + expect(leaks.length).toBe(0); + }); + + it('geom::Derived::h() -> area does NOT bind to geom::Base::area (cross-file dependent base, class-owned)', () => { + const accesses = getRelationships(result, 'ACCESSES'); + const leaks = accesses.filter((c) => c.source === 'h' && c.target === 'area'); + expect(leaks.length).toBe(0); + }); +}); + // --------------------------------------------------------------------------- // U2 (follow-up plan 2026-05-13-001): argument-dependent (Koenig) lookup. // Free-function calls with class-typed arguments must consider candidates From 586dbf7aa1a2b4ba9b03b1b374ebd641df61f032 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Thu, 14 May 2026 15:00:29 +0100 Subject: [PATCH 04/13] feat(cpp): disambiguate template specializations in class graph IDs and receiver routing (#1587) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Initial plan * fix(cpp): disambiguate template specializations in class graph IDs Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/c929edb2-f2e9-41c6-a2e9-2092b967f603 * fix(cpp): guard template-specialization class lookup fallback Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/c929edb2-f2e9-41c6-a2e9-2092b967f603 * fix(cpp): address github-actions inline review findings Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/68d8fbac-4ff4-47f7-b732-eaf2c2f94043 * fix(cpp): cover template-type receiver binding for specialization routing Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/9505dfcd-3fb6-4bc2-a134-f60fe0dc8cd9 * chore(cpp): clarify specialization-binding fallback assumptions Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/9505dfcd-3fb6-4bc2-a134-f60fe0dc8cd9 --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Gergő Magyar --- .../src/scope-resolution/symbol-definition.ts | 2 + .../class-extractors/configs/c-cpp.ts | 57 ++++++++++++++ .../ingestion/class-extractors/generic.ts | 10 +++ gitnexus/src/core/ingestion/class-types.ts | 16 ++++ .../core/ingestion/languages/cpp/interpret.ts | 14 ++-- .../src/core/ingestion/languages/cpp/query.ts | 31 ++++++++ .../src/core/ingestion/model/symbol-table.ts | 4 + .../src/core/ingestion/parsing-processor.ts | 49 +++++++++++- .../src/core/ingestion/scope-extractor.ts | 5 ++ .../scope-resolution/graph-bridge/ids.ts | 20 ++++- .../graph-bridge/node-lookup.ts | 17 ++++ .../passes/receiver-bound-calls.ts | 53 ++++++++++++- .../src/core/ingestion/tree-sitter-queries.ts | 13 ++++ .../src/core/ingestion/utils/ast-helpers.ts | 12 ++- .../ingestion/utils/template-arguments.ts | 57 ++++++++++++++ .../core/ingestion/workers/parse-worker.ts | 51 +++++++++++- .../app.cpp | 7 ++ .../list_order.h | 14 ++++ .../list_user.h | 14 ++++ .../test/integration/resolvers/cpp.test.ts | 78 +++++++++++++++++++ .../test/integration/resolvers/helpers.ts | 6 ++ 21 files changed, 517 insertions(+), 13 deletions(-) create mode 100644 gitnexus/src/core/ingestion/utils/template-arguments.ts create mode 100644 gitnexus/test/fixtures/lang-resolution/cpp-template-specialization-disambiguation/app.cpp create mode 100644 gitnexus/test/fixtures/lang-resolution/cpp-template-specialization-disambiguation/list_order.h create mode 100644 gitnexus/test/fixtures/lang-resolution/cpp-template-specialization-disambiguation/list_user.h diff --git a/gitnexus-shared/src/scope-resolution/symbol-definition.ts b/gitnexus-shared/src/scope-resolution/symbol-definition.ts index d07dbf38b..7f9840f5c 100644 --- a/gitnexus-shared/src/scope-resolution/symbol-definition.ts +++ b/gitnexus-shared/src/scope-resolution/symbol-definition.ts @@ -30,6 +30,8 @@ export interface SymbolDefinition { returnType?: string; /** Declared type for non-callable symbols — fields/properties (e.g. 'Address', 'List') */ declaredType?: string; + /** Generic/template specialization arguments for class-like symbols (e.g. ['User'], ['T*']). */ + templateArguments?: string[]; /** Links Method/Constructor/Property to owning Class/Struct/Trait nodeId */ ownerId?: string; } diff --git a/gitnexus/src/core/ingestion/class-extractors/configs/c-cpp.ts b/gitnexus/src/core/ingestion/class-extractors/configs/c-cpp.ts index fcc1a22bf..fb5df99c3 100644 --- a/gitnexus/src/core/ingestion/class-extractors/configs/c-cpp.ts +++ b/gitnexus/src/core/ingestion/class-extractors/configs/c-cpp.ts @@ -2,6 +2,40 @@ import { SupportedLanguages } from 'gitnexus-shared'; import type { ClassExtractionConfig } from '../../class-types.js'; +import { + extractTemplateArguments, + stripTemplateArguments, +} from '../../utils/template-arguments.js'; + +function shouldSkipCppTemplateDuplicateCapture( + captureMap: Record, + definitionName: string | undefined, + capturedName: string | undefined, +): boolean { + if (captureMap['template-arguments'] !== undefined) return false; + if (!definitionName) return false; + const argsFromDefinitionName = extractTemplateArguments(definitionName); + if (argsFromDefinitionName === undefined) return false; + const argsFromCaptureName = capturedName ? extractTemplateArguments(capturedName) : undefined; + // Generic class capture emits only `List`, while the specialization-aware + // capture emits `List` + `@declaration.template-arguments`. Skip the former + // when the declaration name itself is templated to avoid duplicate class defs. + return argsFromCaptureName === undefined; +} + +function extractCppTemplateArgumentsWithFallback( + captureMap: Record, + definitionName: string | undefined, + capturedName: string | undefined, +): string[] | undefined { + return ( + (captureMap['template-arguments'] + ? extractTemplateArguments(captureMap['template-arguments'].text) + : undefined) ?? + (definitionName ? extractTemplateArguments(definitionName) : undefined) ?? + (capturedName ? extractTemplateArguments(capturedName) : undefined) + ); +} export const cClassConfig: ClassExtractionConfig = { language: SupportedLanguages.C, @@ -12,4 +46,27 @@ export const cppClassConfig: ClassExtractionConfig = { language: SupportedLanguages.CPlusPlus, typeDeclarationNodes: ['class_specifier', 'struct_specifier', 'enum_specifier'], ancestorScopeNodeTypes: ['namespace_definition', 'class_specifier', 'struct_specifier'], + extractName: (node) => { + const nameNode = node.childForFieldName?.('name'); + if (!nameNode) return undefined; + if (nameNode.type !== 'template_type') return undefined; + return stripTemplateArguments(nameNode.text); + }, + extractTemplateArguments: (node) => { + const nameNode = node.childForFieldName?.('name'); + if (!nameNode || nameNode.type !== 'template_type') return undefined; + return extractTemplateArguments(nameNode.text); + }, + shouldSkipClassCapture: ({ captureMap, definitionNode, nameNode }) => + shouldSkipCppTemplateDuplicateCapture( + captureMap, + definitionNode?.childForFieldName?.('name')?.text, + nameNode?.text, + ), + extractTemplateArgumentsFromCapture: ({ captureMap, definitionNode, nameNode }) => + extractCppTemplateArgumentsWithFallback( + captureMap, + definitionNode?.childForFieldName?.('name')?.text, + nameNode?.text, + ), }; diff --git a/gitnexus/src/core/ingestion/class-extractors/generic.ts b/gitnexus/src/core/ingestion/class-extractors/generic.ts index 303eb80c0..5f20d1dc2 100644 --- a/gitnexus/src/core/ingestion/class-extractors/generic.ts +++ b/gitnexus/src/core/ingestion/class-extractors/generic.ts @@ -154,10 +154,12 @@ export function createClassExtractor(config: ClassExtractionConfig): ClassExtrac if (!name || !type) return null; + const templateArguments = config.extractTemplateArguments?.(node); return { name, type, qualifiedName: buildQualifiedName(node, name) || name, + ...(templateArguments !== undefined ? { templateArguments } : {}), }; }; @@ -173,5 +175,13 @@ export function createClassExtractor(config: ClassExtractionConfig): ClassExtrac extractQualifiedName(node: SyntaxNode, simpleName: string): string | null { return extract(node, { name: simpleName })?.qualifiedName ?? null; }, + + shouldSkipClassCapture(context): boolean { + return config.shouldSkipClassCapture?.(context) ?? false; + }, + + extractTemplateArgumentsFromCapture(context): string[] | undefined { + return config.extractTemplateArgumentsFromCapture?.(context); + }, }; } diff --git a/gitnexus/src/core/ingestion/class-types.ts b/gitnexus/src/core/ingestion/class-types.ts index 858d4c2eb..9407d41fa 100644 --- a/gitnexus/src/core/ingestion/class-types.ts +++ b/gitnexus/src/core/ingestion/class-types.ts @@ -10,6 +10,13 @@ export interface ExtractedClassSymbol { name: string; type: ClassLikeNodeLabel; qualifiedName: string; + templateArguments?: string[]; +} + +export interface ClassCaptureContext { + captureMap: Record; + definitionNode: SyntaxNode | null; + nameNode: SyntaxNode | undefined; } /** @@ -30,6 +37,10 @@ export interface ClassExtractor { }, ): ExtractedClassSymbol | null; extractQualifiedName(node: SyntaxNode, simpleName: string): string | null; + shouldSkipClassCapture?( + context: ClassCaptureContext & { nodeLabel: ClassLikeNodeLabel }, + ): boolean; + extractTemplateArgumentsFromCapture?(context: ClassCaptureContext): string[] | undefined; } export interface ClassExtractionConfig { @@ -41,4 +52,9 @@ export interface ClassExtractionConfig { extractName?: (node: SyntaxNode) => string | undefined; extractType?: (node: SyntaxNode) => ClassLikeNodeLabel | undefined; extractScopeSegments?: (node: SyntaxNode) => string[] | null | undefined; + extractTemplateArguments?: (node: SyntaxNode) => string[] | undefined; + shouldSkipClassCapture?( + context: ClassCaptureContext & { nodeLabel: ClassLikeNodeLabel }, + ): boolean; + extractTemplateArgumentsFromCapture?(context: ClassCaptureContext): string[] | undefined; } diff --git a/gitnexus/src/core/ingestion/languages/cpp/interpret.ts b/gitnexus/src/core/ingestion/languages/cpp/interpret.ts index a5c1692a8..b330a3fc0 100644 --- a/gitnexus/src/core/ingestion/languages/cpp/interpret.ts +++ b/gitnexus/src/core/ingestion/languages/cpp/interpret.ts @@ -82,7 +82,12 @@ export function interpretCppTypeBinding(captures: CaptureMatch): ParsedTypeBindi /** * Normalize a C++ type name: strip pointer/array/reference syntax, - * qualifiers, and template parameters (V1: generic-ignored). + * qualifiers, while preserving template arguments for specialization-aware + * receiver binding (`List` vs `List`). + * + * Keeping template arguments here allows receiver-bound fallback to match + * specialization-specific class defs first; non-template behavior is preserved + * by base-name fallback in resolveClassBindingForName. */ export function normalizeCppTypeName(text: string): string { let t = text.trim(); @@ -90,13 +95,6 @@ export function normalizeCppTypeName(text: string): string { t = t .replace(/\b(const|volatile|restrict|static|extern|inline|mutable|constexpr|consteval)\b/g, '') .trim(); - // Strip template parameters (loop handles nested: Map> → Map) - while (t.includes('<')) { - const stripped = t.replace(/<[^<>]*>/g, ''); - if (stripped === t) break; // avoid infinite loop on malformed input - t = stripped; - } - t = t.trim(); // Strip pointer stars while (t.endsWith('*')) t = t.slice(0, -1).trim(); while (t.startsWith('*')) t = t.slice(1).trim(); diff --git a/gitnexus/src/core/ingestion/languages/cpp/query.ts b/gitnexus/src/core/ingestion/languages/cpp/query.ts index 0e6a0a7ae..70d544e3d 100644 --- a/gitnexus/src/core/ingestion/languages/cpp/query.ts +++ b/gitnexus/src/core/ingestion/languages/cpp/query.ts @@ -32,21 +32,47 @@ const CPP_SCOPE_QUERY = ` name: (type_identifier) @declaration.name body: (field_declaration_list)) @declaration.class +(class_specifier + name: (template_type + (type_identifier) @declaration.name + (template_argument_list) @declaration.template-arguments) + body: (field_declaration_list)) @declaration.class + (struct_specifier name: (type_identifier) @declaration.name body: (field_declaration_list)) @declaration.struct +(struct_specifier + name: (template_type + (type_identifier) @declaration.name + (template_argument_list) @declaration.template-arguments) + body: (field_declaration_list)) @declaration.struct + ;; ─── Declarations — class / struct inside template_declaration ─────── (template_declaration (class_specifier name: (type_identifier) @declaration.name body: (field_declaration_list)) @declaration.class) +(template_declaration + (class_specifier + name: (template_type + (type_identifier) @declaration.name + (template_argument_list) @declaration.template-arguments) + body: (field_declaration_list)) @declaration.class) + (template_declaration (struct_specifier name: (type_identifier) @declaration.name body: (field_declaration_list)) @declaration.struct) +(template_declaration + (struct_specifier + name: (template_type + (type_identifier) @declaration.name + (template_argument_list) @declaration.template-arguments) + body: (field_declaration_list)) @declaration.struct) + ;; ─── Declarations — enum ───────────────────────────────────────────── (enum_specifier name: (type_identifier) @declaration.name) @declaration.enum @@ -223,6 +249,11 @@ const CPP_SCOPE_QUERY = ` type: (type_identifier) @type-binding.type declarator: (identifier) @type-binding.name) @type-binding.annotation +;; Covers: List users; +(declaration + type: (template_type) @type-binding.type + declarator: (identifier) @type-binding.name) @type-binding.annotation + ;; ─── Type bindings — pointer variable declaration ─────────────────── ;; Covers: User* ptr = new User() (declaration diff --git a/gitnexus/src/core/ingestion/model/symbol-table.ts b/gitnexus/src/core/ingestion/model/symbol-table.ts index c22f63acb..a730c66eb 100644 --- a/gitnexus/src/core/ingestion/model/symbol-table.ts +++ b/gitnexus/src/core/ingestion/model/symbol-table.ts @@ -128,6 +128,7 @@ export interface AddMetadata { parameterTypes?: string[]; returnType?: string; declaredType?: string; + templateArguments?: string[]; ownerId?: string; qualifiedName?: string; } @@ -277,6 +278,9 @@ export const createSymbolTable = (): InternalSymbolTable => { : {}), ...(metadata?.returnType !== undefined ? { returnType: metadata.returnType } : {}), ...(metadata?.declaredType !== undefined ? { declaredType: metadata.declaredType } : {}), + ...(metadata?.templateArguments !== undefined + ? { templateArguments: metadata.templateArguments } + : {}), ...(metadata?.ownerId !== undefined ? { ownerId: metadata.ownerId } : {}), }; diff --git a/gitnexus/src/core/ingestion/parsing-processor.ts b/gitnexus/src/core/ingestion/parsing-processor.ts index 04a17db4f..f88e78ed9 100644 --- a/gitnexus/src/core/ingestion/parsing-processor.ts +++ b/gitnexus/src/core/ingestion/parsing-processor.ts @@ -30,6 +30,7 @@ import { constTagForId, buildCollisionGroups, } from './utils/method-props.js'; +import { extractTemplateArguments, templateArgumentsIdTag } from './utils/template-arguments.js'; import type { LanguageProvider } from './language-provider.js'; import type { ParsedFile } from 'gitnexus-shared'; import { WorkerPool } from './workers/worker-pool.js'; @@ -129,6 +130,7 @@ export const mergeChunkResults = ( parameterTypes: sym.parameterTypes, returnType: sym.returnType, declaredType: sym.declaredType, + templateArguments: sym.templateArguments, ownerId: sym.ownerId, qualifiedName: sym.qualifiedName, }); @@ -483,6 +485,23 @@ const processParsingSequential = async ( }) : null; const nodeLabel = extractedClassSymbol?.type ?? defaultNodeLabel; + const isClassLikeLabel = + nodeLabel === 'Class' || + nodeLabel === 'Struct' || + nodeLabel === 'Interface' || + nodeLabel === 'Enum' || + nodeLabel === 'Record'; + if ( + isClassLikeLabel && + provider.classExtractor?.shouldSkipClassCapture?.({ + captureMap, + definitionNode, + nameNode, + nodeLabel, + }) === true + ) { + return; + } // Synthesize name for constructors without explicit @name capture (e.g. Swift init) if (!nameNode && nodeLabel !== 'Constructor' && !extractedClassSymbol) return; const nodeName = extractedClassSymbol?.name ?? (nameNode ? nameNode.text : 'init'); @@ -610,7 +629,31 @@ const processParsingSequential = async ( cached.groups, ); } - const nodeId = generateId(nodeLabel, `${file.path}:${qualifiedName}${arityTag}`); + const classTemplateArguments = + extractedClassSymbol?.templateArguments ?? + provider.classExtractor?.extractTemplateArgumentsFromCapture?.({ + captureMap, + definitionNode, + nameNode, + }) ?? + (captureMap['template-arguments'] + ? extractTemplateArguments(captureMap['template-arguments'].text) + : undefined) ?? + (nameNode && nameNode.text ? extractTemplateArguments(nameNode.text) : undefined); + const classTemplateTag = + (nodeLabel === 'Class' || + nodeLabel === 'Struct' || + nodeLabel === 'Interface' || + nodeLabel === 'Enum' || + nodeLabel === 'Record') && + classTemplateArguments !== undefined && + classTemplateArguments.length > 0 + ? templateArgumentsIdTag(classTemplateArguments) + : ''; + const nodeId = generateId( + nodeLabel, + `${file.path}:${qualifiedName}${classTemplateTag}${arityTag}`, + ); const classNodeForSymbol = definitionNodeForRange || definitionNode || nameNode; const qualifiedTypeName = extractedClassSymbol?.qualifiedName ?? @@ -643,6 +686,9 @@ const processParsingSequential = async ( nodeName, ), ...(qualifiedTypeName !== undefined ? { qualifiedName: qualifiedTypeName } : {}), + ...(classTemplateArguments !== undefined && classTemplateArguments.length > 0 + ? { templateArguments: classTemplateArguments } + : {}), ...(frameworkHint ? { astFrameworkMultiplier: frameworkHint.entryPointMultiplier, @@ -700,6 +746,7 @@ const processParsingSequential = async ( parameterTypes: methodProps.parameterTypes as string[] | undefined, returnType: methodProps.returnType as string | undefined, declaredType, + templateArguments: classTemplateArguments, ownerId: enclosingClassId ?? undefined, qualifiedName: qualifiedTypeName, }); diff --git a/gitnexus/src/core/ingestion/scope-extractor.ts b/gitnexus/src/core/ingestion/scope-extractor.ts index f13cb2c73..44088b49f 100644 --- a/gitnexus/src/core/ingestion/scope-extractor.ts +++ b/gitnexus/src/core/ingestion/scope-extractor.ts @@ -76,6 +76,7 @@ import type { } from 'gitnexus-shared'; import { buildPositionIndex, buildScopeTree, canParentScope, makeScopeId } from 'gitnexus-shared'; import type { LanguageProvider } from './language-provider.js'; +import { extractTemplateArguments } from './utils/template-arguments.js'; // ─── Narrow hook surface the extractor actually uses ─────────────────────── @@ -533,6 +534,9 @@ function buildDefFromDeclarationMatch( const qualifiedCap = match['@declaration.qualified_name']; const qualifiedName = qualifiedCap?.text; + const templateArguments = + extractTemplateArguments(match['@declaration.template-arguments']?.text ?? '') ?? + extractTemplateArguments(qualifiedName ?? nameCap.text); // Optional arity metadata — producers (e.g. Python emit-captures) // synthesize these on function/method declarations. Their absence is @@ -554,6 +558,7 @@ function buildDefFromDeclarationMatch( ...(parameterTypes !== undefined ? { parameterTypes } : {}), ...(declaredType !== undefined ? { declaredType } : {}), ...(returnType !== undefined ? { returnType } : {}), + ...(templateArguments !== undefined ? { templateArguments } : {}), }; } diff --git a/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/ids.ts b/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/ids.ts index ad59f4f10..adc32bbd3 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/ids.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/ids.ts @@ -71,7 +71,12 @@ function isCallerAnchorLabel(label: NodeLabel): boolean { */ export function resolveDefGraphId( filePath: string, - def: { qualifiedName?: string; type?: NodeLabel; parameterTypes?: readonly string[] }, + def: { + qualifiedName?: string; + type?: NodeLabel; + parameterTypes?: readonly string[]; + templateArguments?: readonly string[]; + }, nodeLookup: GraphNodeLookup, ): string | undefined { const qn = def.qualifiedName; @@ -89,6 +94,19 @@ export function resolveDefGraphId( const pHit = nodeLookup.get(pKey); if (pHit !== undefined) return pHit; } + if ( + (def.type === 'Class' || + def.type === 'Struct' || + def.type === 'Interface' || + def.type === 'Enum' || + def.type === 'Record') && + def.templateArguments !== undefined && + def.templateArguments.length > 0 + ) { + const tKey = qualifiedKey(filePath, def.type, `${qn}~${def.templateArguments.join(',')}`); + const tHit = nodeLookup.get(tKey); + if (tHit !== undefined) return tHit; + } const qualifiedHit = nodeLookup.get(qualifiedKey(filePath, def.type, qn)); if (qualifiedHit !== undefined) return qualifiedHit; } diff --git a/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/node-lookup.ts b/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/node-lookup.ts index c3b53c6f7..d712c29e3 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/node-lookup.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/node-lookup.ts @@ -67,6 +67,7 @@ export function buildGraphNodeLookup(graph: KnowledgeGraph): GraphNodeLookup { filePath?: string; name?: string; qualifiedName?: string; + templateArguments?: readonly string[]; }; if (props.filePath === undefined || props.name === undefined) continue; if (!isLinkableLabel(node.label)) continue; @@ -96,6 +97,22 @@ export function buildGraphNodeLookup(graph: KnowledgeGraph): GraphNodeLookup { // Each overload is unique — set unconditionally. lookup.set(pKey, node.id); } + if ( + (node.label === 'Class' || + node.label === 'Struct' || + node.label === 'Interface' || + node.label === 'Enum' || + node.label === 'Record') && + props.templateArguments !== undefined && + props.templateArguments.length > 0 + ) { + const tKey = qualifiedKey( + props.filePath, + node.label, + `${qualified}~${props.templateArguments.join(',')}`, + ); + if (!lookup.has(tKey)) lookup.set(tKey, node.id); + } } // Fallback key: simple name. First-wins within a file — used when 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 9b57a7555..80ff3a200 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 @@ -55,6 +55,10 @@ import { narrowOverloadCandidates, isOverloadAmbiguousAfterNormalization, } from './overload-narrowing.js'; +import { + extractTemplateArguments, + stripTemplateArguments, +} from '../../utils/template-arguments.js'; /** Subset of `ScopeResolver` consumed by this pass. Accepting the * subset rather than the full provider keeps tests and partial @@ -70,6 +74,53 @@ type ReceiverBoundProviderSubset = Pick< | 'resolveQualifiedReceiverMember' >; +function normalizeTemplateArgToken(value: string): string { + return value.replace(/\s+/g, ''); +} + +function resolveClassBindingForName( + scopeId: string, + rawClassName: string, + scopes: ScopeResolutionIndexes, +): SymbolDefinition | undefined { + const direct = findClassBindingInScope(scopeId, rawClassName, scopes); + if (direct !== undefined) return direct; + + if (!rawClassName.includes('<')) return undefined; + const baseName = stripTemplateArguments(rawClassName).replace(/\s+/g, ''); + if (baseName.length === 0) return undefined; + + const wantedArgs = extractTemplateArguments(rawClassName)?.map(normalizeTemplateArgToken); + if (wantedArgs !== undefined && wantedArgs.length > 0) { + // qualifiedNames is a Map and may not contain the stripped base name at all + // (e.g., unresolved type binding or only template-qualified entries), so + // default to [] before checking `.length`. + const qnameIds = scopes.qualifiedNames.get(baseName) ?? []; + if (qnameIds.length === 0) { + return findClassBindingInScope(scopeId, baseName, scopes); + } + const matches: SymbolDefinition[] = []; + for (const id of qnameIds) { + const def = scopes.defs.get(id); + if (def === undefined || !isClassLike(def.type)) continue; + const defArgs = def.templateArguments?.map(normalizeTemplateArgToken); + if ( + defArgs !== undefined && + defArgs.length === wantedArgs.length && + defArgs.every((value, i) => value === wantedArgs[i]) + ) { + matches.push(def); + } + } + if (matches.length === 1) return matches[0]; + // Scope extractor only records class definitions with bodies in C++, so + // forward declarations are not expected here. Keep fallback behavior for + // safety in non-ODR or mixed-language edge cases. + } + + return findClassBindingInScope(scopeId, baseName, scopes); +} + export function emitReceiverBoundCalls( graph: KnowledgeGraph, scopes: ScopeResolutionIndexes, @@ -470,7 +521,7 @@ export function emitReceiverBoundCalls( // ── Case 4: simple typeBinding (`u: U`) ────────────────────── if (typeRef !== undefined && !typeRef.rawName.includes('.')) { - let ownerDef = findClassBindingInScope(site.inScope, typeRef.rawName, scopes); + let ownerDef = resolveClassBindingForName(site.inScope, typeRef.rawName, scopes); // `findClassBindingInScope(..., typeRef.rawName)` only works when // rawName is itself a class symbol reachable through scope bindings. // For languages with namespace-style imports (Go), imported types diff --git a/gitnexus/src/core/ingestion/tree-sitter-queries.ts b/gitnexus/src/core/ingestion/tree-sitter-queries.ts index d65229808..f02ae2cb2 100644 --- a/gitnexus/src/core/ingestion/tree-sitter-queries.ts +++ b/gitnexus/src/core/ingestion/tree-sitter-queries.ts @@ -680,7 +680,15 @@ export const GO_QUERIES = ` export const CPP_QUERIES = ` ; Classes, Structs, Namespaces (class_specifier name: (type_identifier) @name) @definition.class +(class_specifier + name: (template_type + (type_identifier) @name + (template_argument_list) @template-arguments)) @definition.class (struct_specifier name: (type_identifier) @name) @definition.struct +(struct_specifier + name: (template_type + (type_identifier) @name + (template_argument_list) @template-arguments)) @definition.struct (namespace_definition name: (namespace_identifier) @name) @definition.namespace (enum_specifier name: (type_identifier) @name) @definition.enum @@ -762,6 +770,11 @@ export const CPP_QUERIES = ` ; Templates (template_declaration (class_specifier name: (type_identifier) @name)) @definition.template +(template_declaration + (class_specifier + name: (template_type + (type_identifier) @name + (template_argument_list) @template-arguments))) @definition.template (template_declaration (function_definition declarator: (function_declarator declarator: (identifier) @name))) @definition.template ; Includes diff --git a/gitnexus/src/core/ingestion/utils/ast-helpers.ts b/gitnexus/src/core/ingestion/utils/ast-helpers.ts index ec76cd1db..351cfdedb 100644 --- a/gitnexus/src/core/ingestion/utils/ast-helpers.ts +++ b/gitnexus/src/core/ingestion/utils/ast-helpers.ts @@ -2,6 +2,11 @@ import type Parser from 'tree-sitter'; import type { Capture, NodeLabel, Range } from 'gitnexus-shared'; import type { LanguageProvider } from '../language-provider.js'; import { generateId } from '../../../lib/utils.js'; +import { + extractTemplateArguments, + stripTemplateArguments, + templateArgumentsIdTag, +} from './template-arguments.js'; /** Tree-sitter AST node. Re-exported for use across ingestion modules. */ export type SyntaxNode = Parser.SyntaxNode; @@ -390,8 +395,13 @@ export const findEnclosingClassInfo = ( ) { label = 'Interface'; } + const templateArguments = extractTemplateArguments(nameNode.text); + const classIdName = + templateArguments !== undefined + ? `${stripTemplateArguments(nameNode.text)}${templateArgumentsIdTag(templateArguments)}` + : nameNode.text; return { - classId: generateId(label, `${filePath}:${nameNode.text}`), + classId: generateId(label, `${filePath}:${classIdName}`), className: nameNode.text, }; } diff --git a/gitnexus/src/core/ingestion/utils/template-arguments.ts b/gitnexus/src/core/ingestion/utils/template-arguments.ts new file mode 100644 index 000000000..e1c6e3463 --- /dev/null +++ b/gitnexus/src/core/ingestion/utils/template-arguments.ts @@ -0,0 +1,57 @@ +/** + * Parse top-level generic/template arguments from a type-like string. + * + * Examples: + * - `List` -> ['int'] + * - `Map>` -> ['string', 'vector'] + * - `List` -> ['T*'] + */ +export function extractTemplateArguments(text: string): string[] | undefined { + const start = text.indexOf('<'); + if (start === -1) return undefined; + let depth = 0; + let end = -1; + for (let i = start; i < text.length; i += 1) { + const ch = text[i]; + if (ch === '<') depth += 1; + else if (ch === '>') { + depth -= 1; + if (depth === 0) { + end = i; + break; + } + if (depth < 0) return undefined; + } + } + if (end === -1) return undefined; + const inner = text.slice(start + 1, end); + if (inner.trim().length === 0) return undefined; + + const args: string[] = []; + let tokenStart = 0; + let nested = 0; + for (let i = 0; i < inner.length; i += 1) { + const ch = inner[i]; + if (ch === '<') nested += 1; + else if (ch === '>') nested -= 1; + else if (ch === ',' && nested === 0) { + const token = inner.slice(tokenStart, i).replace(/\s+/g, ''); + if (token.length > 0) args.push(token); + tokenStart = i + 1; + } + } + const last = inner.slice(tokenStart).replace(/\s+/g, ''); + if (last.length > 0) args.push(last); + return args.length > 0 ? args : undefined; +} + +export function stripTemplateArguments(text: string): string { + const start = text.indexOf('<'); + if (start === -1) return text; + return text.slice(0, start); +} + +export function templateArgumentsIdTag(templateArguments?: readonly string[]): string { + if (templateArguments === undefined || templateArguments.length === 0) return ''; + return `~${templateArguments.join(',')}`; +} diff --git a/gitnexus/src/core/ingestion/workers/parse-worker.ts b/gitnexus/src/core/ingestion/workers/parse-worker.ts index 9a71fc16c..e22b927ed 100644 --- a/gitnexus/src/core/ingestion/workers/parse-worker.ts +++ b/gitnexus/src/core/ingestion/workers/parse-worker.ts @@ -82,6 +82,7 @@ import { constTagForId, buildCollisionGroups, } from '../utils/method-props.js'; +import { extractTemplateArguments, templateArgumentsIdTag } from '../utils/template-arguments.js'; import type { LanguageProvider } from '../language-provider.js'; import type { ParsedFile } from 'gitnexus-shared'; import { extractParsedFile } from '../scope-extractor-bridge.js'; @@ -129,6 +130,7 @@ interface ParsedSymbol { parameterTypes?: string[]; returnType?: string; declaredType?: string; + templateArguments?: string[]; ownerId?: string; visibility?: string; isStatic?: boolean; @@ -2001,6 +2003,23 @@ const processFileGroup = ( }) : null; const nodeLabel = extractedClassSymbol?.type ?? defaultNodeLabel; + const isClassLikeLabel = + nodeLabel === 'Class' || + nodeLabel === 'Struct' || + nodeLabel === 'Interface' || + nodeLabel === 'Enum' || + nodeLabel === 'Record'; + if ( + isClassLikeLabel && + provider.classExtractor?.shouldSkipClassCapture?.({ + captureMap, + definitionNode, + nameNode, + nodeLabel, + }) === true + ) { + continue; + } // Dedup: variable captures (Const/Static/Variable) may overlap with higher-priority // captures (e.g. `const fn = () => {}` matches both @definition.function and @definition.const). @@ -2114,7 +2133,31 @@ const processFileGroup = ( ); arityTag += constTagForId(defMethodMap, nodeName, arityForId, defMethodInfo, groups); } - const nodeId = generateId(nodeLabel, `${file.path}:${qualifiedName}${arityTag}`); + const classTemplateArguments = + extractedClassSymbol?.templateArguments ?? + provider.classExtractor?.extractTemplateArgumentsFromCapture?.({ + captureMap, + definitionNode, + nameNode, + }) ?? + (captureMap['template-arguments'] + ? extractTemplateArguments(captureMap['template-arguments'].text) + : undefined) ?? + (nameNode && nameNode.text ? extractTemplateArguments(nameNode.text) : undefined); + const classTemplateTag = + (nodeLabel === 'Class' || + nodeLabel === 'Struct' || + nodeLabel === 'Interface' || + nodeLabel === 'Enum' || + nodeLabel === 'Record') && + classTemplateArguments !== undefined && + classTemplateArguments.length > 0 + ? templateArgumentsIdTag(classTemplateArguments) + : ''; + const nodeId = generateId( + nodeLabel, + `${file.path}:${qualifiedName}${classTemplateTag}${arityTag}`, + ); const classNodeForSymbol = definitionNode || nameNode; const qualifiedTypeName = extractedClassSymbol?.qualifiedName ?? @@ -2237,6 +2280,9 @@ const processFileGroup = ( ? isVueSetupTopLevel(nameNode || definitionNode) : cachedExportCheck(provider.exportChecker, nameNode || definitionNode, nodeName), ...(qualifiedTypeName !== undefined ? { qualifiedName: qualifiedTypeName } : {}), + ...(classTemplateArguments !== undefined && classTemplateArguments.length > 0 + ? { templateArguments: classTemplateArguments } + : {}), ...(frameworkHint ? { astFrameworkMultiplier: frameworkHint.entryPointMultiplier, @@ -2262,6 +2308,9 @@ const processFileGroup = ( parameterTypes: methodProps.parameterTypes as string[] | undefined, returnType: methodProps.returnType as string | undefined, ...(declaredType !== undefined ? { declaredType } : {}), + ...(classTemplateArguments !== undefined && classTemplateArguments.length > 0 + ? { templateArguments: classTemplateArguments } + : {}), ...(enclosingClassId ? { ownerId: enclosingClassId } : {}), visibility: methodProps.visibility as string | undefined, isStatic: methodProps.isStatic as boolean | undefined, diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-template-specialization-disambiguation/app.cpp b/gitnexus/test/fixtures/lang-resolution/cpp-template-specialization-disambiguation/app.cpp new file mode 100644 index 000000000..237ce1cae --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-template-specialization-disambiguation/app.cpp @@ -0,0 +1,7 @@ +#include "list_user.h" +#include "list_order.h" + +void callUserSave() { + List list; + list.save(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-template-specialization-disambiguation/list_order.h b/gitnexus/test/fixtures/lang-resolution/cpp-template-specialization-disambiguation/list_order.h new file mode 100644 index 000000000..015a16b5b --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-template-specialization-disambiguation/list_order.h @@ -0,0 +1,14 @@ +#pragma once + +struct Order {}; + +template +class List; + +template <> +class List { +public: + void callSave() { save(); } + void save() { persistOrder(); } + void persistOrder() {} +}; diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-template-specialization-disambiguation/list_user.h b/gitnexus/test/fixtures/lang-resolution/cpp-template-specialization-disambiguation/list_user.h new file mode 100644 index 000000000..d9ba7fb24 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-template-specialization-disambiguation/list_user.h @@ -0,0 +1,14 @@ +#pragma once + +struct User {}; + +template +class List; + +template <> +class List { +public: + void callSave() { save(); } + void save() { persistUser(); } + void persistUser() {} +}; diff --git a/gitnexus/test/integration/resolvers/cpp.test.ts b/gitnexus/test/integration/resolvers/cpp.test.ts index 26fb02d36..25a7f9de7 100644 --- a/gitnexus/test/integration/resolvers/cpp.test.ts +++ b/gitnexus/test/integration/resolvers/cpp.test.ts @@ -1464,6 +1464,84 @@ describe('C++ template overload cross-file and chain resolution', () => { }); }); +describe('C++ template specialization disambiguation across files', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'cpp-template-specialization-disambiguation'), + () => {}, + ); + }, 60000); + + it('emits distinct Class nodes for List and List', () => { + const classes = getNodesByLabelFull(result, 'Class').filter( + (c) => c.name === 'List' && Array.isArray(c.properties.templateArguments), + ); + expect(classes.length).toBe(2); + const fingerprints = new Set(classes.map((c) => c.properties.templateArguments.join(','))); + expect(fingerprints).toEqual(new Set(['User', 'Order'])); + }); + + it('callSave() in each specialization resolves to its own save()', () => { + const calls = getRelationships(result, 'CALLS'); + const saveEdges = calls.filter((c) => c.source === 'callSave' && c.target === 'save'); + expect(saveEdges.length).toBe(2); + + const hasMethod = getRelationships(result, 'HAS_METHOD'); + const ownerFingerprints = new Set(); + for (const edge of saveEdges) { + const sourceOwnerEdge = hasMethod.find((e) => e.rel.targetId === edge.rel.sourceId); + const targetOwnerEdge = hasMethod.find((e) => e.rel.targetId === edge.rel.targetId); + expect(sourceOwnerEdge).toBeDefined(); + expect(targetOwnerEdge).toBeDefined(); + expect(sourceOwnerEdge!.rel.sourceId).toBe(targetOwnerEdge!.rel.sourceId); + const ownerNode = result.graph.getNode(sourceOwnerEdge!.rel.sourceId); + const fp = ownerNode?.properties.templateArguments?.join(','); + if (fp) ownerFingerprints.add(fp); + } + expect(ownerFingerprints).toEqual(new Set(['User', 'Order'])); + }); + + it('save specialization bodies route to their own sibling method', () => { + const calls = getRelationships(result, 'CALLS'); + + const persistUserCalls = calls.filter((c) => c.target === 'persistUser'); + expect(persistUserCalls.length).toBe(1); + const userSaveOwner = getRelationships(result, 'HAS_METHOD').find( + (e) => e.rel.targetId === persistUserCalls[0].rel.sourceId, + ); + expect(userSaveOwner).toBeDefined(); + const userOwnerNode = result.graph.getNode(userSaveOwner!.rel.sourceId); + expect(userOwnerNode?.properties.templateArguments).toEqual(['User']); + + const persistOrderCalls = calls.filter((c) => c.target === 'persistOrder'); + expect(persistOrderCalls.length).toBe(1); + const orderSaveOwner = getRelationships(result, 'HAS_METHOD').find( + (e) => e.rel.targetId === persistOrderCalls[0].rel.sourceId, + ); + expect(orderSaveOwner).toBeDefined(); + const orderOwnerNode = result.graph.getNode(orderSaveOwner!.rel.sourceId); + expect(orderOwnerNode?.properties.templateArguments).toEqual(['Order']); + }); + + it('resolves external List receiver call to List::save', () => { + const calls = getRelationships(result, 'CALLS'); + const edge = calls.find( + (c) => + c.source === 'callUserSave' && c.target === 'save' && c.targetFilePath === 'list_user.h', + ); + expect(edge).toBeDefined(); + + const ownerEdge = getRelationships(result, 'HAS_METHOD').find( + (e) => e.rel.targetId === edge!.rel.targetId, + ); + expect(ownerEdge).toBeDefined(); + const ownerNode = result.graph.getNode(ownerEdge!.rel.sourceId); + expect(ownerNode?.properties.templateArguments).toEqual(['User']); + }); +}); + // ── Phase P: C++ out-of-class method definition + overload disambiguation ─ describe('C++ out-of-class method definition with overloaded declarations', () => { diff --git a/gitnexus/test/integration/resolvers/helpers.ts b/gitnexus/test/integration/resolvers/helpers.ts index 418bbc0fb..5149e3e69 100644 --- a/gitnexus/test/integration/resolvers/helpers.ts +++ b/gitnexus/test/integration/resolvers/helpers.ts @@ -162,6 +162,12 @@ const LEGACY_RESOLVER_PARITY_EXPECTED_FAILURES: Readonly', 'outer::v1::Base::f() resolves to Base::f inside template body', 'outer::v1::free_fn() resolves as a namespace free function, not a super-receiver method', + // Template specialization owner identity currently relies on + // class-template fingerprints in the registry-primary graph bridge. + // Legacy DAG collapses specializations to the simple class name. + 'emits distinct Class nodes for List and List', + 'callSave() in each specialization resolves to its own save()', + 'save specialization bodies route to their own sibling method', ]), }; From 911a2ee1e6e4662d9b732e707e65ae45901c60b6 Mon Sep 17 00:00:00 2001 From: Harlan Zhou Date: Thu, 14 May 2026 23:15:17 +0800 Subject: [PATCH 05/13] fix: apply ESM .js extension fallback to tsconfig path alias resolution (#1530) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: apply ESM .js extension fallback to tsconfig path alias resolution Path alias imports (e.g. `@/utils.js` via tsconfig paths) now correctly strip JS-family extensions and retry with TS equivalents when the literal .js file does not exist. This applies the same stripJsExtension fallback already used for relative imports to the alias resolution branch. Fixes #1528 * chore(autofix): apply prettier + eslint fixes via /autofix command * test(esm): cover .mjs/.cjs path-alias extension resolution Co-authored-by: Cursor * test(esm): use Map for path aliases in resolveWithAlias helper Matches TsconfigPaths.aliases from language-config. CI cannot run tsc -p tsconfig.test.json yet: the project has hundreds of pre-existing errors under test/ (fixtures + unit/integration); enable that step after backlog cleanup. Co-authored-by: Cursor --------- Co-authored-by: Gergő Magyar Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Cursor --- .../ingestion/import-resolvers/standard.ts | 10 ++- .../unit/esm-extension-resolution.test.ts | 73 +++++++++++++++++++ 2 files changed, 80 insertions(+), 3 deletions(-) diff --git a/gitnexus/src/core/ingestion/import-resolvers/standard.ts b/gitnexus/src/core/ingestion/import-resolvers/standard.ts index 47e2dabb2..4ee6e1440 100644 --- a/gitnexus/src/core/ingestion/import-resolvers/standard.ts +++ b/gitnexus/src/core/ingestion/import-resolvers/standard.ts @@ -72,6 +72,13 @@ export const resolveImportPath = ( const resolved = tryResolveWithExtensions(rewritten, allFiles); if (resolved) return cache(resolved); + // ESM fallback: strip .js/.jsx/.mjs/.cjs and retry with TS equivalents + const strippedAlias = stripJsExtension(rewritten); + if (strippedAlias !== null) { + const esmResolved = tryResolveWithExtensions(strippedAlias, allFiles); + if (esmResolved) return cache(esmResolved); + } + // Try suffix matching as fallback const parts = rewritten.split('/').filter(Boolean); const suffixResult = suffixResolve(parts, normalizedFileList, allFileList, index); @@ -132,9 +139,6 @@ export const resolveImportPath = ( // TypeScript ESM: imports use .js/.jsx/.mjs/.cjs but source files are // .ts/.tsx/.mts/.cts. Strip the JS-family extension and re-resolve. - // NOTE: This fallback only applies to relative imports. Path alias imports - // (e.g. @/utils.js via tsconfig paths) do not yet strip .js extensions — - // that is a known limitation tracked for follow-up. if (language === SupportedLanguages.TypeScript || language === SupportedLanguages.JavaScript) { const stripped = stripJsExtension(basePath); if (stripped !== null) { diff --git a/gitnexus/test/unit/esm-extension-resolution.test.ts b/gitnexus/test/unit/esm-extension-resolution.test.ts index 69dc652cf..794888049 100644 --- a/gitnexus/test/unit/esm-extension-resolution.test.ts +++ b/gitnexus/test/unit/esm-extension-resolution.test.ts @@ -151,3 +151,76 @@ describe('stripJsExtension', () => { it('returns null for .ts', () => expect(stripJsExtension('foo/bar.ts')).toBeNull()); it('returns null for no extension', () => expect(stripJsExtension('foo/bar')).toBeNull()); }); + +describe('ESM extension resolution — path aliases with .js extensions', () => { + const aliasAtToSrc = new Map([['@/', 'src/']]); + const aliasTildeToSrc = new Map([['~/', 'src/']]); + + function resolveWithAlias( + currentFile: string, + importPath: string, + ctx: ReturnType, + aliases: Map, + baseUrl = '.', + ): string | null { + return resolveImportPath( + currentFile, + importPath, + ctx.allFilesSet, + ctx.files, + ctx.normalized, + ctx.cache, + SupportedLanguages.TypeScript, + { aliases, baseUrl }, + ctx.index, + ); + } + + it('resolves @/utils.js to src/utils.ts via alias', () => { + const ctx = makeCtx(['src/index.ts', 'src/utils.ts']); + const result = resolveWithAlias('src/index.ts', '@/utils.js', ctx, aliasAtToSrc, '.'); + expect(result).toBe('src/utils.ts'); + }); + + it('resolves @/component.jsx to src/component.tsx via alias', () => { + const ctx = makeCtx(['src/index.ts', 'src/component.tsx']); + const result = resolveWithAlias('src/index.ts', '@/component.jsx', ctx, aliasAtToSrc, '.'); + expect(result).toBe('src/component.tsx'); + }); + + it('resolves @/config.mjs to src/config.mts via alias', () => { + const ctx = makeCtx(['src/index.ts', 'src/config.mts']); + const result = resolveWithAlias('src/index.ts', '@/config.mjs', ctx, aliasAtToSrc, '.'); + expect(result).toBe('src/config.mts'); + }); + + it('resolves @/legacy.cjs to src/legacy.cts via alias', () => { + const ctx = makeCtx(['src/index.ts', 'src/legacy.cts']); + const result = resolveWithAlias('src/index.ts', '@/legacy.cjs', ctx, aliasAtToSrc, '.'); + expect(result).toBe('src/legacy.cts'); + }); + + it('prefers actual .js file over TS fallback in alias resolution', () => { + const ctx = makeCtx(['src/index.ts', 'src/utils.js', 'src/utils.ts']); + const result = resolveWithAlias('src/index.ts', '@/utils.js', ctx, aliasAtToSrc, '.'); + expect(result).toBe('src/utils.js'); + }); + + it('resolves alias with baseUrl prefix', () => { + const ctx = makeCtx(['app/src/index.ts', 'app/src/helpers/token.ts']); + const result = resolveWithAlias( + 'app/src/index.ts', + '~/helpers/token.js', + ctx, + aliasTildeToSrc, + 'app', + ); + expect(result).toBe('app/src/helpers/token.ts'); + }); + + it('returns null when alias .js import has no matching source', () => { + const ctx = makeCtx(['src/index.ts']); + const result = resolveWithAlias('src/index.ts', '@/missing.js', ctx, aliasAtToSrc, '.'); + expect(result).toBeNull(); + }); +}); From 89c03b2ebb8a21b09ae927ce4358141888976b8c Mon Sep 17 00:00:00 2001 From: Derek Pearson <32114370+dpearson2699@users.noreply.github.com> Date: Thu, 14 May 2026 11:39:30 -0400 Subject: [PATCH 06/13] fix: skip Claude augment hook when GitNexus server owns DB (#1493) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(claude): skip augment hook when server owns db * chore(autofix): apply prettier + eslint fixes via /autofix command * fix(hooks): cross-platform DB lock probe for MCP owner guard Extract hook-db-lock-probe.cjs with a single hasGitNexusDbLockedByGitNexusServer entry point used by both Claude hooks: - Linux: scan /proc//fd via dev+inode (no lsof required), optional lsof fallback; GITNEXUS_HOOK_LINUX_PROC_BUDGET_MS caps scan time - macOS and other Unix: trusted lsof + ps (absolute paths / env overrides) - Windows: Restart Manager + Win32_Process via win-rm-list-json.ps1 and GITNEXUS_HOOK_POWERSHELL_PATH Update hooks.test.ts source coverage for the probe module. Co-authored-by: Cursor * Update gitnexus/hooks/claude/win-rm-list-json.ps1 Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> * Apply suggestion from @github-actions[bot] Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> * fix(gitnexus): repair package.json JSON after malformed engines edit Co-authored-by: Cursor * Update Node.js engine version requirement to 22.0.0 * Update Node.js engine version to >=22.0.0 * fix(hooks): address ce-code-review findings on PR #1493 P0: - Replace malformed `RM_UNIQUE_PROCESS` block in `gitnexus/hooks/claude/win-rm-list-json.ps1` (duplicate struct decl + duplicate `ProcessStartTime` + unbalanced braces) with a single well-formed `[StructLayout(LayoutKind.Sequential, Pack = 4)]` struct, so PowerShell `Add-Type` actually compiles and the Windows DB-lock probe stops fail-open on every machine. - `gitnexus/src/cli/setup.ts` now copies `hook-db-lock-probe.cjs` and `win-rm-list-json.ps1` into the user's `~/.claude/hooks/gitnexus/` alongside `hook-lock.cjs`, preventing the `MODULE_NOT_FOUND` thrown by `gitnexus-hook.cjs:18`'s top-level require on every fresh install. `gitnexus/test/unit/setup.test.ts` extended to assert both new copy destinations. - Four fail-open hook tests (`ENOENT lsof`, `npx parent line`, `non-GitNexus ps line`, `ps ENOENT`) now seed `createHookToolDir` with a valid `[GitNexus]` stderr line so `expect(parseHookOutput).not.toBeNull()` actually holds on CI. P1: - Plugin copy of `win-rm-list-json.ps1` gains `Pack = 4` so its CLR struct matches the 12-byte native `RM_UNIQUE_PROCESS` layout (multi-blocker `RmGetList` no longer reads mangled `dwProcessId`). - `GITNEXUS_HOOK_CLI_PATH = ''` now falls through to the resolution chain in `gitnexus-hook.cjs`, matching the plugin copy and removing the twin-file divergence on empty-string envs. - Lock-warning suppression test seeds `gitnexusMarkerPath` and asserts the augment subprocess actually ran, plus `GITNEXUS_DEBUG=1` preserves the full discarded prefix. - MCP-owner skip branch in both hook copies now emits `[GitNexus] augment skipped: MCP server owns DB` on stderr, so agents can distinguish intentional skip from silent failure. P2: - `ps` loop in `hook-db-lock-probe.cjs` fails-closed on `ETIMEDOUT` to mirror the `lsof` handling (symmetric subprocess-probe contract). - `RmStartSession` return value captured in both `.ps1` copies; exits early with `[]` on non-zero so subsequent RM API calls don't operate on an invalid handle. - Windows RM-list `.ps1` encoded cache distinguishes uninitialized (`undefined`) from load-failed (`null`) with a one-shot `GITNEXUS_DEBUG` warning instead of silently caching empty string. - `createHookToolDir` helper accepts `lsofOutputLines` and `psOutputByPid`; the multi-PID test uses them instead of duplicating the fake-binary construction inline. - All five skip-path tests now assert `result.status === 0` and the new skip-signal stderr line. - `AGENTS.md` documents the seven hook configuration env vars (`GITNEXUS_HOOK_CLI_PATH`, `_LSOF_PATH`, `_PS_PATH`, `_POWERSHELL_PATH`, `_LINUX_PROC_BUDGET_MS`, `_RM_TARGET`, `GITNEXUS_DEBUG`). - `GITNEXUS_DEBUG` path in `gitnexus-hook.cjs`/`.js` writes the full discarded stderr prefix instead of a 180-char preview. - Inline comment in `hook-db-lock-probe.cjs` explains the intentional Windows ETIMEDOUT fail-closed semantics. - Removed the unnecessary `as WriteFileOptions` cast and orphaned `import type { WriteFileOptions }` in `hooks.test.ts`. P3: - `isGitNexusServerCommand` unexported from `hook-db-lock-probe.cjs` (kept as private helper). - Env-path overrides (`GITNEXUS_HOOK_CLI_PATH`, `_POWERSHELL_PATH`, `_LSOF_PATH`, `_PS_PATH`) require `fs.existsSync` before being returned, so typos / stale config fall through to the standard resolution chain. Misc: - `gitnexus/package.json` engines.node back to `>=22.0.0` (matches origin/main and the original PR reviewer's earlier request). Twin-tree parity / CI sync mechanism tracked separately at abhigyanpatwari/GitNexus#1591. Test plan: vitest run test/unit/hooks.test.ts → 113 passed, 18 Unix-only skipped; setup.test.ts → 14 passed. * chore(autofix): apply prettier + eslint fixes via /autofix command * trigger --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Gergő Magyar Co-authored-by: Cursor --- AGENTS.md | 14 + gitnexus-claude-plugin/hooks/gitnexus-hook.js | 42 +- .../hooks/hook-db-lock-probe.cjs | 238 +++++++++ .../hooks/win-rm-list-json.ps1 | 76 +++ gitnexus/hooks/claude/gitnexus-hook.cjs | 37 +- gitnexus/hooks/claude/hook-db-lock-probe.cjs | 238 +++++++++ gitnexus/hooks/claude/win-rm-list-json.ps1 | 76 +++ gitnexus/src/cli/setup.ts | 18 + gitnexus/test/unit/hooks.test.ts | 498 ++++++++++++++++++ gitnexus/test/unit/setup.test.ts | 15 + gitnexus/test/utils/hook-test-helpers.ts | 2 + 11 files changed, 1248 insertions(+), 6 deletions(-) create mode 100644 gitnexus-claude-plugin/hooks/hook-db-lock-probe.cjs create mode 100644 gitnexus-claude-plugin/hooks/win-rm-list-json.ps1 create mode 100644 gitnexus/hooks/claude/hook-db-lock-probe.cjs create mode 100644 gitnexus/hooks/claude/win-rm-list-json.ps1 diff --git a/AGENTS.md b/AGENTS.md index 1346facc9..b9b9138b1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -174,6 +174,20 @@ Check `.gitnexus/meta.json` `stats.embeddings` (0 = none). A plain `analyze` no | Tools/resources/schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` | | CLI commands (index, status, clean, wiki) | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` | +## Hook env knobs + +The Claude Code hook (`gitnexus/hooks/claude/gitnexus-hook.cjs` and the mirrored plugin copy under `gitnexus-claude-plugin/hooks/`) honours these env vars. Defaults work for normal installations; set them only to override resolution. All path overrides ignore values that do not exist on disk and fall through to the standard resolution chain. + +| Env var | Type | Default | Purpose | +|---------|------|---------|---------| +| `GITNEXUS_HOOK_CLI_PATH` | path | resolved via package layout / `require.resolve` | Override path to the `gitnexus` CLI entry the hook spawns for `augment`. | +| `GITNEXUS_HOOK_LSOF_PATH` | path | `lsof` on `PATH` (with `/usr/bin/lsof`, `/usr/sbin/lsof`, `/sbin/lsof` fallbacks) | Override POSIX `lsof` location for the DB-lock probe. | +| `GITNEXUS_HOOK_PS_PATH` | path | `ps` on `PATH` (with `/bin/ps`, `/usr/bin/ps` fallbacks) | Override POSIX `ps` location. | +| `GITNEXUS_HOOK_POWERSHELL_PATH` | path | `%SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe` (then `SysWOW64`, then `powershell.exe` on `PATH`) | Override Windows PowerShell location used by the Restart-Manager probe. | +| `GITNEXUS_HOOK_LINUX_PROC_BUDGET_MS` | integer ms | `1200` | Max wall-clock for the Linux `/proc` fd scan before bailing out to the `lsof` fallback. | +| `GITNEXUS_HOOK_RM_TARGET` | path | derived | Restart-Manager target file (the LadybugDB path under `.gitnexus/`). Set internally by the hook; rarely overridden manually. | +| `GITNEXUS_DEBUG` | boolean (`1`/`true`) | unset | Verbose stderr from the hook: prints discarded augment-stderr prefixes and one-shot `.ps1` load-failure warnings. | + ## Repo reference diff --git a/gitnexus-claude-plugin/hooks/gitnexus-hook.js b/gitnexus-claude-plugin/hooks/gitnexus-hook.js index 245d34043..7ff03e430 100644 --- a/gitnexus-claude-plugin/hooks/gitnexus-hook.js +++ b/gitnexus-claude-plugin/hooks/gitnexus-hook.js @@ -15,6 +15,7 @@ const fs = require('fs'); const path = require('path'); const { spawnSync } = require('child_process'); const { acquireHookSlot } = require('./hook-lock.js'); +const { hasGitNexusDbLockedByGitNexusServer } = require('./hook-db-lock-probe.cjs'); /** * Read JSON input from stdin synchronously. @@ -103,6 +104,28 @@ function findGitNexusDir(startDir) { return null; } +function hasGitNexusServerOwner(gitNexusDir) { + return hasGitNexusDbLockedByGitNexusServer(path.join(gitNexusDir, 'lbug'), process.pid); +} + +function extractAugmentContext(stderr) { + const output = (stderr || '').trim(); + const marker = output.indexOf('[GitNexus]'); + const debug = process.env.GITNEXUS_DEBUG === '1' || process.env.GITNEXUS_DEBUG === 'true'; + if (debug && output.length > 0) { + // Emit the FULL discarded prefix (everything before the marker, or all of + // it when no marker is present) so suppressed diagnostics — LadybugDB lock + // warnings, parser errors, etc. — remain recoverable on the hook's own + // stderr. The untruncated payload lets operators see exactly what was + // filtered out instead of a 180-char JSON-quoted preview. + const discarded = marker === -1 ? output : output.slice(0, marker).trim(); + if (discarded.length > 0) { + process.stderr.write(`[GitNexus hook] augment stderr discarded prefix:\n${discarded}\n`); + } + } + return marker === -1 ? '' : output.slice(marker).trim(); +} + /** * Extract search pattern from tool input. */ @@ -170,6 +193,15 @@ function extractPattern(toolName, toolInput) { */ function runGitNexusCli(args, cwd, timeout) { const isWin = process.platform === 'win32'; + const hookCli = process.env.GITNEXUS_HOOK_CLI_PATH; + if (hookCli !== undefined && String(hookCli).trim() && fs.existsSync(String(hookCli))) { + return spawnSync(process.execPath, [String(hookCli), ...args], { + encoding: 'utf-8', + timeout, + cwd, + stdio: ['pipe', 'pipe', 'pipe'], + }); + } // Detect whether 'gitnexus' is on PATH (cheap check, no execution) let useDirectBinary = false; @@ -228,6 +260,10 @@ function handlePreToolUse(input) { const pattern = extractPattern(toolName, toolInput); if (!pattern || pattern.length < 3) return; + if (hasGitNexusServerOwner(gitNexusDir)) { + process.stderr.write('[GitNexus] augment skipped: MCP server owns DB\n'); + return; + } const release = acquireHookSlot(gitNexusDir); if (!release) return; @@ -236,7 +272,7 @@ function handlePreToolUse(input) { try { const child = runGitNexusCli(['augment', '--', pattern], cwd, 7000); if (!child.error && child.status === 0) { - result = child.stderr || ''; + result = extractAugmentContext(child.stderr || ''); } } catch { /* graceful failure */ @@ -244,8 +280,8 @@ function handlePreToolUse(input) { release(); } - if (result && result.trim()) { - sendHookResponse('PreToolUse', result.trim()); + if (result) { + sendHookResponse('PreToolUse', result); } } diff --git a/gitnexus-claude-plugin/hooks/hook-db-lock-probe.cjs b/gitnexus-claude-plugin/hooks/hook-db-lock-probe.cjs new file mode 100644 index 000000000..783cd0804 --- /dev/null +++ b/gitnexus-claude-plugin/hooks/hook-db-lock-probe.cjs @@ -0,0 +1,238 @@ +/** + * Cross-platform best-effort probe: does another process hold dbPath open + * with a command line that looks like a GitNexus MCP/serve server? + * + * Backends (no user-installed Sysinternals): + * - Linux: scan procfs under /proc (per-PID fd entries) via stat(2) (dev+inode); works without lsof; + * optional lsof fallback when proc scan finds nothing. + * - macOS / *BSD / etc.: trusted lsof + ps (absolute paths first). + * - Windows: Restart Manager (rstrtmgr) via bundled PowerShell script + + * Win32_Process for command lines; trusted powershell.exe under %SystemRoot%. + * + * Fail-open on most errors; fail-closed only on lsof ETIMEDOUT (Unix) or + * PowerShell ETIMEDOUT (Windows), matching the hook contract. + */ + +const fs = require('fs'); +const path = require('path'); +const { spawnSync } = require('child_process'); + +function isGitNexusServerCommand(command) { + const hasServerMode = /(?:^|\s)(mcp|serve)(?:\s|$)/.test(command); + const hasGitNexus = + /(?:^|[/\\\s])gitnexus(?:\.cmd)?(?:\s|$)/.test(command) || + /node_modules[/\\]gitnexus[/\\]/.test(command); + return hasServerMode && hasGitNexus; +} + +function resolveHookBinary(tool) { + const envKey = tool === 'lsof' ? 'GITNEXUS_HOOK_LSOF_PATH' : 'GITNEXUS_HOOK_PS_PATH'; + const fromEnv = process.env[envKey]; + if (fromEnv && String(fromEnv).trim() && fs.existsSync(String(fromEnv))) { + return String(fromEnv); + } + const candidates = + tool === 'lsof' + ? ['/usr/bin/lsof', '/usr/sbin/lsof', '/sbin/lsof', tool] + : ['/bin/ps', '/usr/bin/ps', tool]; + for (const candidate of candidates) { + if (candidate === tool) return tool; + try { + if (fs.existsSync(candidate)) return candidate; + } catch { + /* ignore */ + } + } + return tool; +} + +function resolveWindowsPowerShellPath() { + const fromEnv = process.env.GITNEXUS_HOOK_POWERSHELL_PATH; + if (fromEnv && String(fromEnv).trim() && fs.existsSync(String(fromEnv).trim())) { + return String(fromEnv).trim(); + } + const root = process.env.SystemRoot || 'C:\\Windows'; + const ps = path.join(root, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'); + if (fs.existsSync(ps)) return ps; + const psWow = path.join(root, 'SysWOW64', 'WindowsPowerShell', 'v1.0', 'powershell.exe'); + if (fs.existsSync(psWow)) return psWow; + return 'powershell.exe'; +} + +// Sentinel: +// undefined = not loaded yet (try the read) +// string = encoded PowerShell command (successful load) +// null = load attempted and failed (do not retry; warning already emitted) +let windowsRmListPsEncodedCommandCache; +let windowsRmListPsLoadFailureWarned = false; +function getWindowsRmListEncodedCommand() { + if (windowsRmListPsEncodedCommandCache !== undefined) { + return windowsRmListPsEncodedCommandCache; + } + try { + const ps1Path = path.join(__dirname, 'win-rm-list-json.ps1'); + const src = fs + .readFileSync(ps1Path, 'utf8') + .replace(/^\uFEFF/, '') + .replace(/\r\n/g, '\n'); + windowsRmListPsEncodedCommandCache = Buffer.from(src, 'utf16le').toString('base64'); + } catch (err) { + windowsRmListPsEncodedCommandCache = null; + if ( + !windowsRmListPsLoadFailureWarned && + (process.env.GITNEXUS_DEBUG === '1' || process.env.GITNEXUS_DEBUG === 'true') + ) { + windowsRmListPsLoadFailureWarned = true; + const msg = err && err.message ? String(err.message).slice(0, 200) : 'unknown'; + process.stderr.write(`[GitNexus hook] win-rm-list-json.ps1 load failed: ${msg}\n`); + } + } + return windowsRmListPsEncodedCommandCache; +} + +function hasGitNexusServerOwnerWindows(dbPathAbs, myPid) { + const encoded = getWindowsRmListEncodedCommand(); + if (!encoded) return false; + const psExe = resolveWindowsPowerShellPath(); + const r = spawnSync( + psExe, + [ + '-NoProfile', + '-NonInteractive', + '-ExecutionPolicy', + 'Bypass', + '-STA', + '-EncodedCommand', + encoded, + ], + { + encoding: 'utf-8', + timeout: 6000, + stdio: ['ignore', 'pipe', 'ignore'], + env: { ...process.env, GITNEXUS_HOOK_RM_TARGET: dbPathAbs }, + }, + ); + // ETIMEDOUT means the PowerShell probe didn't return in time; treat as 'unresponsive process holds DB' → fail-closed (skip augment). + if (r.error) return r.error.code === 'ETIMEDOUT'; + if (r.status !== 0) return false; + let rows; + try { + rows = JSON.parse(String(r.stdout || '').trim() || '[]'); + } catch { + return false; + } + if (!Array.isArray(rows)) return false; + for (const row of rows) { + const procId = Number(row.pid); + const cmd = String(row.cmd || ''); + if (!Number.isFinite(procId) || procId === myPid) continue; + if (isGitNexusServerCommand(cmd)) return true; + } + return false; +} + +function readLinuxCmdline(pidStr) { + try { + return fs.readFileSync(`/proc/${pidStr}/cmdline`, 'utf8').replace(/\0+/g, ' ').trim(); + } catch { + return ''; + } +} + +function linuxProcScanFindGitNexusServer(dbPathAbs, myPid) { + const raw = process.env.GITNEXUS_HOOK_LINUX_PROC_BUDGET_MS; + const budget = Number(raw && String(raw).trim()) ? Number.parseInt(String(raw), 10) : 1200; + const start = Date.now(); + let targetStat; + try { + targetStat = fs.statSync(dbPathAbs); + } catch { + return false; + } + let procEntries; + try { + procEntries = fs.readdirSync('/proc', { withFileTypes: true }); + } catch { + return false; + } + for (const ent of procEntries) { + if (Date.now() - start > budget) return false; + if (!ent.isDirectory() || !/^\d+$/.test(ent.name)) continue; + const pid = Number.parseInt(ent.name, 10); + if (!Number.isFinite(pid) || pid === myPid) continue; + const fdDir = path.join('/proc', ent.name, 'fd'); + let fds; + try { + fds = fs.readdirSync(fdDir); + } catch { + continue; + } + let holds = false; + for (const fd of fds) { + if (Date.now() - start > budget) return false; + try { + const st = fs.statSync(path.join(fdDir, fd)); + if (st.dev === targetStat.dev && st.ino === targetStat.ino) { + holds = true; + break; + } + } catch { + /* ignore */ + } + } + if (!holds) continue; + if (isGitNexusServerCommand(readLinuxCmdline(ent.name))) return true; + } + return false; +} + +function unixLsofPsFindGitNexusServer(dbPathAbs, myPid) { + const lsofPath = resolveHookBinary('lsof'); + const lsof = spawnSync(lsofPath, ['-nP', '-t', '--', dbPathAbs], { + encoding: 'utf-8', + timeout: 1000, + stdio: ['ignore', 'pipe', 'ignore'], + }); + if (lsof.error) return lsof.error.code === 'ETIMEDOUT'; + + const pids = (lsof.stdout || '').split(/\s+/).filter(Boolean); + const psPath = resolveHookBinary('ps'); + for (const pid of pids) { + if (Number(pid) === myPid) continue; + const ps = spawnSync(psPath, ['-p', pid, '-o', 'command='], { + encoding: 'utf-8', + timeout: 500, + stdio: ['ignore', 'pipe', 'ignore'], + }); + if (ps.error) { + if (ps.error.code === 'ETIMEDOUT') return true; + continue; + } + if (isGitNexusServerCommand(ps.stdout || '')) return true; + } + return false; +} + +/** + * @param {string} dbPath Absolute or relative path to the DB file (e.g. .../lbug). + * @param {number} myPid Current process PID (hook runner), excluded from matches. + */ +function hasGitNexusDbLockedByGitNexusServer(dbPath, myPid) { + if (!fs.existsSync(dbPath)) return false; + const dbPathAbs = path.resolve(dbPath); + + if (process.platform === 'win32') { + return hasGitNexusServerOwnerWindows(dbPathAbs, myPid); + } + + if (process.platform === 'linux') { + if (linuxProcScanFindGitNexusServer(dbPathAbs, myPid)) return true; + return unixLsofPsFindGitNexusServer(dbPathAbs, myPid); + } + + return unixLsofPsFindGitNexusServer(dbPathAbs, myPid); +} + +module.exports = { + hasGitNexusDbLockedByGitNexusServer, +}; diff --git a/gitnexus-claude-plugin/hooks/win-rm-list-json.ps1 b/gitnexus-claude-plugin/hooks/win-rm-list-json.ps1 new file mode 100644 index 000000000..5c1564e30 --- /dev/null +++ b/gitnexus-claude-plugin/hooks/win-rm-list-json.ps1 @@ -0,0 +1,76 @@ +$ErrorActionPreference = 'Stop' +$target = $env:GITNEXUS_HOOK_RM_TARGET +if ([string]::IsNullOrWhiteSpace($target)) { Write-Output '[]'; exit 0 } +$target = (Resolve-Path -LiteralPath $target).ProviderPath + +if (-not ([Management.Automation.PSTypeName]'GitNexusHookRm.Native').Type) { +Add-Type @' +using System; +using System.Runtime.InteropServices; +namespace GitNexusHookRm { + public static class Native { + public const int ErrorMoreData = 234; + [StructLayout(LayoutKind.Sequential, Pack = 4)] + public struct RM_UNIQUE_PROCESS { + public int dwProcessId; + public long ProcessStartTime; + } + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + public struct RM_PROCESS_INFO { + public RM_UNIQUE_PROCESS Process; + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 256)] + public string strAppName; + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 64)] + public string strServiceShortName; + public uint ApplicationType; + public uint AppStatus; + public uint TSSessionId; + public uint bRestartable; + } + [DllImport("rstrtmgr.dll", CharSet = CharSet.Unicode)] + public static extern int RmStartSession(out uint pSessionHandle, uint dwSessionFlags, string strSessionKey); + [DllImport("rstrtmgr.dll", CharSet = CharSet.Unicode)] + public static extern int RmRegisterResources(uint pSessionHandle, uint nFiles, string[] rgsFileNames, uint nApplications, IntPtr rgApplications, uint nServices, string[] rgsServiceNames); + [DllImport("rstrtmgr.dll")] + public static extern int RmGetList(uint dwSessionHandle, out uint pnProcInfoNeeded, ref uint pnProcInfo, [In, Out] RM_PROCESS_INFO[] rgAffectedApps, ref uint lpdwRebootReasons); + [DllImport("rstrtmgr.dll")] + public static extern int RmEndSession(uint pSessionHandle); + } +} +'@ +} + +$h = [uint32]0 +$key = [guid]::NewGuid().ToString('N') +$rmErr = [GitNexusHookRm.Native]::RmStartSession([ref]$h, 0, $key) +if ($rmErr -ne 0) { Write-Output '[]'; exit 0 } +$files = @($target) +$err = [GitNexusHookRm.Native]::RmRegisterResources($h, 1, $files, 0, [IntPtr]::Zero, 0, $null) +if ($err -ne 0) { + [void][GitNexusHookRm.Native]::RmEndSession($h) + Write-Output '[]' + exit 0 +} +$need = [uint32]0 +$n = [uint32]0 +$reboot = [uint32]0 +$err = [GitNexusHookRm.Native]::RmGetList($h, [ref]$need, [ref]$n, $null, [ref]$reboot) +if ($err -ne [GitNexusHookRm.Native]::ErrorMoreData) { + [void][GitNexusHookRm.Native]::RmEndSession($h) + Write-Output '[]' + exit 0 +} +$n = $need +$buf = New-Object GitNexusHookRm.Native+RM_PROCESS_INFO[] ([int]$n) +$err = [GitNexusHookRm.Native]::RmGetList($h, [ref]$need, [ref]$n, $buf, [ref]$reboot) +[void][GitNexusHookRm.Native]::RmEndSession($h) +if ($err -ne 0) { Write-Output '[]'; exit 0 } + +$out = @() +for ($i = 0; $i -lt [int]$n; $i++) { + $procId = $buf[$i].Process.dwProcessId + $p = Get-CimInstance -ClassName Win32_Process -Filter "ProcessId=$procId" -ErrorAction SilentlyContinue + $cmd = if ($p) { $p.CommandLine } else { '' } + $out += [PSCustomObject]@{ pid = [int]$procId; cmd = $cmd } +} +ConvertTo-Json -InputObject @($out) -Compress diff --git a/gitnexus/hooks/claude/gitnexus-hook.cjs b/gitnexus/hooks/claude/gitnexus-hook.cjs index 9541fcb50..e39fcf8e1 100755 --- a/gitnexus/hooks/claude/gitnexus-hook.cjs +++ b/gitnexus/hooks/claude/gitnexus-hook.cjs @@ -15,6 +15,7 @@ const fs = require('fs'); const path = require('path'); const { spawnSync } = require('child_process'); const { acquireHookSlot } = require('./hook-lock.cjs'); +const { hasGitNexusDbLockedByGitNexusServer } = require('./hook-db-lock-probe.cjs'); /** * Read JSON input from stdin synchronously. @@ -103,6 +104,28 @@ function findGitNexusDir(startDir) { return null; } +function hasGitNexusServerOwner(gitNexusDir) { + return hasGitNexusDbLockedByGitNexusServer(path.join(gitNexusDir, 'lbug'), process.pid); +} + +function extractAugmentContext(stderr) { + const output = (stderr || '').trim(); + const marker = output.indexOf('[GitNexus]'); + const debug = process.env.GITNEXUS_DEBUG === '1' || process.env.GITNEXUS_DEBUG === 'true'; + if (debug && output.length > 0) { + // Emit the FULL discarded prefix (everything before the marker, or all of + // it when no marker is present) so suppressed diagnostics — KuzuDB lock + // warnings, parser errors, etc. — remain recoverable on the hook's own + // stderr. The untruncated payload lets operators see exactly what was + // filtered out instead of a 180-char JSON-quoted preview. + const discarded = marker === -1 ? output : output.slice(0, marker).trim(); + if (discarded.length > 0) { + process.stderr.write(`[GitNexus hook] augment stderr discarded prefix:\n${discarded}\n`); + } + } + return marker === -1 ? '' : output.slice(marker).trim(); +} + /** * Extract search pattern from tool input. */ @@ -168,6 +191,10 @@ function extractPattern(toolName, toolInput) { * 3. Fall back to npx (returns empty string) */ function resolveCliPath() { + const fromEnv = process.env.GITNEXUS_HOOK_CLI_PATH; + if (fromEnv !== undefined && String(fromEnv).trim() && fs.existsSync(String(fromEnv))) { + return String(fromEnv); + } let cliPath = path.resolve(__dirname, '..', '..', 'dist', 'cli', 'index.js'); if (!fs.existsSync(cliPath)) { try { @@ -218,6 +245,10 @@ function handlePreToolUse(input) { const pattern = extractPattern(toolName, toolInput); if (!pattern || pattern.length < 3) return; + if (hasGitNexusServerOwner(gitNexusDir)) { + process.stderr.write('[GitNexus] augment skipped: MCP server owns DB\n'); + return; + } const release = acquireHookSlot(gitNexusDir); if (!release) return; @@ -227,7 +258,7 @@ function handlePreToolUse(input) { try { const child = runGitNexusCli(cliPath, ['augment', '--', pattern], cwd, 7000); if (!child.error && child.status === 0) { - result = child.stderr || ''; + result = extractAugmentContext(child.stderr || ''); } } catch { /* graceful failure */ @@ -235,8 +266,8 @@ function handlePreToolUse(input) { release(); } - if (result && result.trim()) { - sendHookResponse('PreToolUse', result.trim()); + if (result) { + sendHookResponse('PreToolUse', result); } } diff --git a/gitnexus/hooks/claude/hook-db-lock-probe.cjs b/gitnexus/hooks/claude/hook-db-lock-probe.cjs new file mode 100644 index 000000000..783cd0804 --- /dev/null +++ b/gitnexus/hooks/claude/hook-db-lock-probe.cjs @@ -0,0 +1,238 @@ +/** + * Cross-platform best-effort probe: does another process hold dbPath open + * with a command line that looks like a GitNexus MCP/serve server? + * + * Backends (no user-installed Sysinternals): + * - Linux: scan procfs under /proc (per-PID fd entries) via stat(2) (dev+inode); works without lsof; + * optional lsof fallback when proc scan finds nothing. + * - macOS / *BSD / etc.: trusted lsof + ps (absolute paths first). + * - Windows: Restart Manager (rstrtmgr) via bundled PowerShell script + + * Win32_Process for command lines; trusted powershell.exe under %SystemRoot%. + * + * Fail-open on most errors; fail-closed only on lsof ETIMEDOUT (Unix) or + * PowerShell ETIMEDOUT (Windows), matching the hook contract. + */ + +const fs = require('fs'); +const path = require('path'); +const { spawnSync } = require('child_process'); + +function isGitNexusServerCommand(command) { + const hasServerMode = /(?:^|\s)(mcp|serve)(?:\s|$)/.test(command); + const hasGitNexus = + /(?:^|[/\\\s])gitnexus(?:\.cmd)?(?:\s|$)/.test(command) || + /node_modules[/\\]gitnexus[/\\]/.test(command); + return hasServerMode && hasGitNexus; +} + +function resolveHookBinary(tool) { + const envKey = tool === 'lsof' ? 'GITNEXUS_HOOK_LSOF_PATH' : 'GITNEXUS_HOOK_PS_PATH'; + const fromEnv = process.env[envKey]; + if (fromEnv && String(fromEnv).trim() && fs.existsSync(String(fromEnv))) { + return String(fromEnv); + } + const candidates = + tool === 'lsof' + ? ['/usr/bin/lsof', '/usr/sbin/lsof', '/sbin/lsof', tool] + : ['/bin/ps', '/usr/bin/ps', tool]; + for (const candidate of candidates) { + if (candidate === tool) return tool; + try { + if (fs.existsSync(candidate)) return candidate; + } catch { + /* ignore */ + } + } + return tool; +} + +function resolveWindowsPowerShellPath() { + const fromEnv = process.env.GITNEXUS_HOOK_POWERSHELL_PATH; + if (fromEnv && String(fromEnv).trim() && fs.existsSync(String(fromEnv).trim())) { + return String(fromEnv).trim(); + } + const root = process.env.SystemRoot || 'C:\\Windows'; + const ps = path.join(root, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'); + if (fs.existsSync(ps)) return ps; + const psWow = path.join(root, 'SysWOW64', 'WindowsPowerShell', 'v1.0', 'powershell.exe'); + if (fs.existsSync(psWow)) return psWow; + return 'powershell.exe'; +} + +// Sentinel: +// undefined = not loaded yet (try the read) +// string = encoded PowerShell command (successful load) +// null = load attempted and failed (do not retry; warning already emitted) +let windowsRmListPsEncodedCommandCache; +let windowsRmListPsLoadFailureWarned = false; +function getWindowsRmListEncodedCommand() { + if (windowsRmListPsEncodedCommandCache !== undefined) { + return windowsRmListPsEncodedCommandCache; + } + try { + const ps1Path = path.join(__dirname, 'win-rm-list-json.ps1'); + const src = fs + .readFileSync(ps1Path, 'utf8') + .replace(/^\uFEFF/, '') + .replace(/\r\n/g, '\n'); + windowsRmListPsEncodedCommandCache = Buffer.from(src, 'utf16le').toString('base64'); + } catch (err) { + windowsRmListPsEncodedCommandCache = null; + if ( + !windowsRmListPsLoadFailureWarned && + (process.env.GITNEXUS_DEBUG === '1' || process.env.GITNEXUS_DEBUG === 'true') + ) { + windowsRmListPsLoadFailureWarned = true; + const msg = err && err.message ? String(err.message).slice(0, 200) : 'unknown'; + process.stderr.write(`[GitNexus hook] win-rm-list-json.ps1 load failed: ${msg}\n`); + } + } + return windowsRmListPsEncodedCommandCache; +} + +function hasGitNexusServerOwnerWindows(dbPathAbs, myPid) { + const encoded = getWindowsRmListEncodedCommand(); + if (!encoded) return false; + const psExe = resolveWindowsPowerShellPath(); + const r = spawnSync( + psExe, + [ + '-NoProfile', + '-NonInteractive', + '-ExecutionPolicy', + 'Bypass', + '-STA', + '-EncodedCommand', + encoded, + ], + { + encoding: 'utf-8', + timeout: 6000, + stdio: ['ignore', 'pipe', 'ignore'], + env: { ...process.env, GITNEXUS_HOOK_RM_TARGET: dbPathAbs }, + }, + ); + // ETIMEDOUT means the PowerShell probe didn't return in time; treat as 'unresponsive process holds DB' → fail-closed (skip augment). + if (r.error) return r.error.code === 'ETIMEDOUT'; + if (r.status !== 0) return false; + let rows; + try { + rows = JSON.parse(String(r.stdout || '').trim() || '[]'); + } catch { + return false; + } + if (!Array.isArray(rows)) return false; + for (const row of rows) { + const procId = Number(row.pid); + const cmd = String(row.cmd || ''); + if (!Number.isFinite(procId) || procId === myPid) continue; + if (isGitNexusServerCommand(cmd)) return true; + } + return false; +} + +function readLinuxCmdline(pidStr) { + try { + return fs.readFileSync(`/proc/${pidStr}/cmdline`, 'utf8').replace(/\0+/g, ' ').trim(); + } catch { + return ''; + } +} + +function linuxProcScanFindGitNexusServer(dbPathAbs, myPid) { + const raw = process.env.GITNEXUS_HOOK_LINUX_PROC_BUDGET_MS; + const budget = Number(raw && String(raw).trim()) ? Number.parseInt(String(raw), 10) : 1200; + const start = Date.now(); + let targetStat; + try { + targetStat = fs.statSync(dbPathAbs); + } catch { + return false; + } + let procEntries; + try { + procEntries = fs.readdirSync('/proc', { withFileTypes: true }); + } catch { + return false; + } + for (const ent of procEntries) { + if (Date.now() - start > budget) return false; + if (!ent.isDirectory() || !/^\d+$/.test(ent.name)) continue; + const pid = Number.parseInt(ent.name, 10); + if (!Number.isFinite(pid) || pid === myPid) continue; + const fdDir = path.join('/proc', ent.name, 'fd'); + let fds; + try { + fds = fs.readdirSync(fdDir); + } catch { + continue; + } + let holds = false; + for (const fd of fds) { + if (Date.now() - start > budget) return false; + try { + const st = fs.statSync(path.join(fdDir, fd)); + if (st.dev === targetStat.dev && st.ino === targetStat.ino) { + holds = true; + break; + } + } catch { + /* ignore */ + } + } + if (!holds) continue; + if (isGitNexusServerCommand(readLinuxCmdline(ent.name))) return true; + } + return false; +} + +function unixLsofPsFindGitNexusServer(dbPathAbs, myPid) { + const lsofPath = resolveHookBinary('lsof'); + const lsof = spawnSync(lsofPath, ['-nP', '-t', '--', dbPathAbs], { + encoding: 'utf-8', + timeout: 1000, + stdio: ['ignore', 'pipe', 'ignore'], + }); + if (lsof.error) return lsof.error.code === 'ETIMEDOUT'; + + const pids = (lsof.stdout || '').split(/\s+/).filter(Boolean); + const psPath = resolveHookBinary('ps'); + for (const pid of pids) { + if (Number(pid) === myPid) continue; + const ps = spawnSync(psPath, ['-p', pid, '-o', 'command='], { + encoding: 'utf-8', + timeout: 500, + stdio: ['ignore', 'pipe', 'ignore'], + }); + if (ps.error) { + if (ps.error.code === 'ETIMEDOUT') return true; + continue; + } + if (isGitNexusServerCommand(ps.stdout || '')) return true; + } + return false; +} + +/** + * @param {string} dbPath Absolute or relative path to the DB file (e.g. .../lbug). + * @param {number} myPid Current process PID (hook runner), excluded from matches. + */ +function hasGitNexusDbLockedByGitNexusServer(dbPath, myPid) { + if (!fs.existsSync(dbPath)) return false; + const dbPathAbs = path.resolve(dbPath); + + if (process.platform === 'win32') { + return hasGitNexusServerOwnerWindows(dbPathAbs, myPid); + } + + if (process.platform === 'linux') { + if (linuxProcScanFindGitNexusServer(dbPathAbs, myPid)) return true; + return unixLsofPsFindGitNexusServer(dbPathAbs, myPid); + } + + return unixLsofPsFindGitNexusServer(dbPathAbs, myPid); +} + +module.exports = { + hasGitNexusDbLockedByGitNexusServer, +}; diff --git a/gitnexus/hooks/claude/win-rm-list-json.ps1 b/gitnexus/hooks/claude/win-rm-list-json.ps1 new file mode 100644 index 000000000..5c1564e30 --- /dev/null +++ b/gitnexus/hooks/claude/win-rm-list-json.ps1 @@ -0,0 +1,76 @@ +$ErrorActionPreference = 'Stop' +$target = $env:GITNEXUS_HOOK_RM_TARGET +if ([string]::IsNullOrWhiteSpace($target)) { Write-Output '[]'; exit 0 } +$target = (Resolve-Path -LiteralPath $target).ProviderPath + +if (-not ([Management.Automation.PSTypeName]'GitNexusHookRm.Native').Type) { +Add-Type @' +using System; +using System.Runtime.InteropServices; +namespace GitNexusHookRm { + public static class Native { + public const int ErrorMoreData = 234; + [StructLayout(LayoutKind.Sequential, Pack = 4)] + public struct RM_UNIQUE_PROCESS { + public int dwProcessId; + public long ProcessStartTime; + } + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + public struct RM_PROCESS_INFO { + public RM_UNIQUE_PROCESS Process; + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 256)] + public string strAppName; + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 64)] + public string strServiceShortName; + public uint ApplicationType; + public uint AppStatus; + public uint TSSessionId; + public uint bRestartable; + } + [DllImport("rstrtmgr.dll", CharSet = CharSet.Unicode)] + public static extern int RmStartSession(out uint pSessionHandle, uint dwSessionFlags, string strSessionKey); + [DllImport("rstrtmgr.dll", CharSet = CharSet.Unicode)] + public static extern int RmRegisterResources(uint pSessionHandle, uint nFiles, string[] rgsFileNames, uint nApplications, IntPtr rgApplications, uint nServices, string[] rgsServiceNames); + [DllImport("rstrtmgr.dll")] + public static extern int RmGetList(uint dwSessionHandle, out uint pnProcInfoNeeded, ref uint pnProcInfo, [In, Out] RM_PROCESS_INFO[] rgAffectedApps, ref uint lpdwRebootReasons); + [DllImport("rstrtmgr.dll")] + public static extern int RmEndSession(uint pSessionHandle); + } +} +'@ +} + +$h = [uint32]0 +$key = [guid]::NewGuid().ToString('N') +$rmErr = [GitNexusHookRm.Native]::RmStartSession([ref]$h, 0, $key) +if ($rmErr -ne 0) { Write-Output '[]'; exit 0 } +$files = @($target) +$err = [GitNexusHookRm.Native]::RmRegisterResources($h, 1, $files, 0, [IntPtr]::Zero, 0, $null) +if ($err -ne 0) { + [void][GitNexusHookRm.Native]::RmEndSession($h) + Write-Output '[]' + exit 0 +} +$need = [uint32]0 +$n = [uint32]0 +$reboot = [uint32]0 +$err = [GitNexusHookRm.Native]::RmGetList($h, [ref]$need, [ref]$n, $null, [ref]$reboot) +if ($err -ne [GitNexusHookRm.Native]::ErrorMoreData) { + [void][GitNexusHookRm.Native]::RmEndSession($h) + Write-Output '[]' + exit 0 +} +$n = $need +$buf = New-Object GitNexusHookRm.Native+RM_PROCESS_INFO[] ([int]$n) +$err = [GitNexusHookRm.Native]::RmGetList($h, [ref]$need, [ref]$n, $buf, [ref]$reboot) +[void][GitNexusHookRm.Native]::RmEndSession($h) +if ($err -ne 0) { Write-Output '[]'; exit 0 } + +$out = @() +for ($i = 0; $i -lt [int]$n; $i++) { + $procId = $buf[$i].Process.dwProcessId + $p = Get-CimInstance -ClassName Win32_Process -Filter "ProcessId=$procId" -ErrorAction SilentlyContinue + $cmd = if ($p) { $p.CommandLine } else { '' } + $out += [PSCustomObject]@{ pid = [int]$procId; cmd = $cmd } +} +ConvertTo-Json -InputObject @($out) -Compress diff --git a/gitnexus/src/cli/setup.ts b/gitnexus/src/cli/setup.ts index 8f52e0f2d..3e7cbad8f 100644 --- a/gitnexus/src/cli/setup.ts +++ b/gitnexus/src/cli/setup.ts @@ -373,6 +373,24 @@ async function installClaudeCodeHooks(result: SetupResult): Promise { // Helper not found in source — skip } + try { + await fs.copyFile( + path.join(pluginHooksPath, 'hook-db-lock-probe.cjs'), + path.join(destHooksDir, 'hook-db-lock-probe.cjs'), + ); + } catch { + // Helper not found in source — skip + } + + try { + await fs.copyFile( + path.join(pluginHooksPath, 'win-rm-list-json.ps1'), + path.join(destHooksDir, 'win-rm-list-json.ps1'), + ); + } catch { + // Helper not found in source — skip + } + const hookPath = path.join(destHooksDir, 'gitnexus-hook.cjs').replace(/\\/g, '/'); // Escape backslashes FIRST, then quotes (CodeQL js/incomplete-sanitization). // The previous shape `replace(/"/g, '\\"')` alone would let `path\with"quote` diff --git a/gitnexus/test/unit/hooks.test.ts b/gitnexus/test/unit/hooks.test.ts index 346ee1ed6..a0ef2b8cb 100644 --- a/gitnexus/test/unit/hooks.test.ts +++ b/gitnexus/test/unit/hooks.test.ts @@ -12,6 +12,7 @@ * - shell injection: verifies no shell: true in spawnSync calls * - dispatch map: correct handler routing * - cross-platform: Windows .cmd extension handling + * - cross-platform: DB lock probe (Linux /proc, Unix lsof, Windows RM) * * Since the hooks are CJS scripts that call main() on load, we test them * by spawning them as child processes with controlled stdin JSON. @@ -45,6 +46,23 @@ const PLUGIN_HOOK_LOCK = path.resolve( 'hooks', 'hook-lock.js', ); +const CJS_HOOK_DB_PROBE = path.resolve( + __dirname, + '..', + '..', + 'hooks', + 'claude', + 'hook-db-lock-probe.cjs', +); +const PLUGIN_HOOK_DB_PROBE = path.resolve( + __dirname, + '..', + '..', + '..', + 'gitnexus-claude-plugin', + 'hooks', + 'hook-db-lock-probe.cjs', +); // ─── Test fixtures: temporary .gitnexus directory ─────────────────── @@ -109,6 +127,62 @@ function createGlobalRegistry(homeDir: string, marker: 'both' | 'registry' | 're } } +function writeExecutable(filePath: string, content: string) { + fs.writeFileSync(filePath, content, { mode: 0o755 }); +} + +function createHookToolDir(options: { + gitnexusStderr?: string; + gitnexusMarkerPath?: string; + lsofOutput?: string; + lsofOutputLines?: string[]; + psOutput?: string; + psOutputByPid?: Record; + lsofSleepMs?: number; +}) { + const binDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-hook-bin-')); + const gitnexusStderr = JSON.stringify(options.gitnexusStderr ?? ''); + const markerPath = JSON.stringify(options.gitnexusMarkerPath ?? ''); + + const fakeGitNexus = `#!/usr/bin/env node\nconst fs = require('fs');\nconst marker = ${markerPath};\nif (marker) fs.writeFileSync(marker, 'called');\nprocess.stderr.write(${gitnexusStderr});\n`; + writeExecutable(path.join(binDir, 'gitnexus'), fakeGitNexus); + writeExecutable(path.join(binDir, 'gitnexus-cli.js'), fakeGitNexus); + + const lsofOutput = + options.lsofOutputLines != null + ? options.lsofOutputLines.join('\n') + (options.lsofOutputLines.length ? '\n' : '') + : (options.lsofOutput ?? ''); + const lsofBody = + options.lsofSleepMs != null + ? `#!/usr/bin/env node\nsetTimeout(() => {}, ${Number(options.lsofSleepMs)});\n` + : `#!/usr/bin/env node\nprocess.stdout.write(${JSON.stringify(lsofOutput)});\nprocess.exit(0);\n`; + writeExecutable(path.join(binDir, 'lsof'), lsofBody); + + const psBody = + options.psOutputByPid != null + ? `#!/usr/bin/env node +const byPid = ${JSON.stringify(options.psOutputByPid)}; +const args = process.argv; +const p = args[args.indexOf('-p') + 1]; +process.stdout.write(byPid[p] ?? ''); +process.exit(0); +` + : `#!/usr/bin/env node\nprocess.stdout.write(${JSON.stringify(options.psOutput ?? '')});\nprocess.exit(0);\n`; + writeExecutable(path.join(binDir, 'ps'), psBody); + + return binDir; +} + +function hookEnv(binDir: string) { + return { + ...process.env, + PATH: `${binDir}${path.delimiter}${process.env.PATH || ''}`, + GITNEXUS_HOOK_CLI_PATH: path.join(binDir, 'gitnexus-cli.js'), + GITNEXUS_HOOK_LSOF_PATH: path.join(binDir, 'lsof'), + GITNEXUS_HOOK_PS_PATH: path.join(binDir, 'ps'), + }; +} + // ─── Both hook files should exist ─────────────────────────────────── describe('Hook files exist', () => { @@ -594,6 +668,430 @@ describe('PreToolUse concurrency guard (integration)', () => { } }); +// ─── Source: cross-platform DB lock probe module (#1493) ───────────── + +describe('Cross-platform DB lock probe (source)', () => { + for (const [label, hookPath, probePath] of [ + ['CJS', CJS_HOOK, CJS_HOOK_DB_PROBE], + ['Plugin', PLUGIN_HOOK, PLUGIN_HOOK_DB_PROBE], + ] as const) { + it(`${label} probe file exists`, () => { + expect(fs.existsSync(probePath)).toBe(true); + }); + + it(`${label} hook requires hook-db-lock-probe.cjs`, () => { + const source = fs.readFileSync(hookPath, 'utf-8'); + expect(source).toContain("require('./hook-db-lock-probe.cjs')"); + }); + + it(`${label} probe covers Linux /proc, Unix lsof, and Windows Restart Manager`, () => { + const p = fs.readFileSync(probePath, 'utf-8'); + expect(p).toContain('win-rm-list-json.ps1'); + expect(p).toContain('/proc/'); + expect(p).toContain('linuxProcScanFindGitNexusServer'); + expect(p).toContain('unixLsofPsFindGitNexusServer'); + expect(p).toContain('hasGitNexusServerOwnerWindows'); + expect(p).toContain('GITNEXUS_HOOK_LSOF_PATH'); + expect(p).toContain('GITNEXUS_HOOK_POWERSHELL_PATH'); + expect(p).toContain('GITNEXUS_HOOK_LINUX_PROC_BUDGET_MS'); + }); + } +}); + +// ─── Integration: PreToolUse augmentation filtering (#1492) ───────── + +describe('PreToolUse augmentation filtering (integration)', () => { + for (const [label, hookPath] of [ + ['CJS', CJS_HOOK], + ['Plugin', PLUGIN_HOOK], + ] as const) { + it(`${label}: emits valid GitNexus augmentation context`, () => { + const binDir = createHookToolDir({ + gitnexusStderr: '[GitNexus] 1 related symbol found:\n\nvalidateUser (src/auth.ts)\n', + }); + try { + const result = runHook( + hookPath, + { + hook_event_name: 'PreToolUse', + tool_name: 'Grep', + tool_input: { pattern: 'validateUser' }, + cwd: tmpDir, + }, + undefined, + { env: hookEnv(binDir) }, + ); + + const output = parseHookOutput(result.stdout); + expect(output).not.toBeNull(); + expect(output!.hookEventName).toBe('PreToolUse'); + expect(output!.additionalContext).toContain('[GitNexus] 1 related symbol found'); + } finally { + fs.rmSync(binDir, { recursive: true, force: true }); + } + }); + + it(`${label}: suppresses LadybugDB lock warnings from augment stderr`, () => { + const markerPath = path.join(os.tmpdir(), 'gn-hook-lockwarn-' + process.pid + '-' + label); + fs.rmSync(markerPath, { force: true }); + const binDir = createHookToolDir({ + gitnexusMarkerPath: markerPath, + gitnexusStderr: + 'GitNexus: FTS extension load failed: IO exception: Could not set lock on file : /tmp/repo/.gitnexus/lbug\n', + }); + try { + const result = runHook( + hookPath, + { + hook_event_name: 'PreToolUse', + tool_name: 'Grep', + tool_input: { pattern: 'validateUser' }, + cwd: tmpDir, + }, + undefined, + { env: hookEnv(binDir) }, + ); + + expect(result.stdout.trim()).toBe(''); + expect(fs.existsSync(markerPath)).toBe(true); + + // Finding #18: when GITNEXUS_DEBUG=1 is set, the discarded prefix is + // recoverable on the hook's stderr (not silently dropped). + const debugResult = runHook( + hookPath, + { + hook_event_name: 'PreToolUse', + tool_name: 'Grep', + tool_input: { pattern: 'validateUser' }, + cwd: tmpDir, + }, + undefined, + { env: { ...hookEnv(binDir), GITNEXUS_DEBUG: '1' } }, + ); + expect(debugResult.stderr).toContain('augment stderr discarded prefix'); + expect(debugResult.stderr).toContain('Could not set lock on file'); + } finally { + fs.rmSync(markerPath, { force: true }); + fs.rmSync(binDir, { recursive: true, force: true }); + } + }); + + it.skipIf(process.platform === 'win32')( + `${label}: skips augment when a GitNexus MCP process owns the repo DB`, + () => { + const markerPath = path.join(os.tmpdir(), `gitnexus-hook-called-${process.pid}-${label}`); + const lbugPath = path.join(gitNexusDir, 'lbug'); + fs.writeFileSync(lbugPath, ''); + fs.rmSync(markerPath, { force: true }); + const binDir = createHookToolDir({ + gitnexusMarkerPath: markerPath, + lsofOutput: '12345\n', + psOutput: 'node /tmp/node_modules/.bin/gitnexus mcp\n', + }); + try { + const result = runHook( + hookPath, + { + hook_event_name: 'PreToolUse', + tool_name: 'Grep', + tool_input: { pattern: 'validateUser' }, + cwd: tmpDir, + }, + undefined, + { env: hookEnv(binDir) }, + ); + + expect(result.stdout.trim()).toBe(''); + expect(result.status).toBe(0); + expect(result.stderr).toContain('[GitNexus] augment skipped'); + expect(fs.existsSync(markerPath)).toBe(false); + } finally { + fs.rmSync(markerPath, { force: true }); + fs.rmSync(binDir, { recursive: true, force: true }); + } + }, + ); + } +}); + +describe.skipIf(process.platform === 'win32')( + 'Ladybug DB owner guard — production-shaped ps + failure modes (#1493)', + () => { + for (const [label, hookPath] of [ + ['CJS', CJS_HOOK], + ['Plugin', PLUGIN_HOOK], + ] as const) { + it(`${label}: skips augment for real node_modules/gitnexus ps line (npx child)`, () => { + const markerPath = path.join(os.tmpdir(), `gn-hook-prodps-${process.pid}-${label}`); + const lbugPath = path.join(gitNexusDir, 'lbug'); + fs.writeFileSync(lbugPath, ''); + fs.rmSync(markerPath, { force: true }); + const binDir = createHookToolDir({ + gitnexusMarkerPath: markerPath, + lsofOutput: '99901\n', + psOutput: 'node /tmp/node_modules/gitnexus/dist/cli/index.js mcp\n', + }); + try { + const result = runHook( + hookPath, + { + hook_event_name: 'PreToolUse', + tool_name: 'Grep', + tool_input: { pattern: 'validateUser' }, + cwd: tmpDir, + }, + undefined, + { env: hookEnv(binDir) }, + ); + expect(result.stdout.trim()).toBe(''); + expect(result.status).toBe(0); + expect(result.stderr).toContain('[GitNexus] augment skipped'); + expect(fs.existsSync(markerPath)).toBe(false); + } finally { + fs.rmSync(markerPath, { force: true }); + fs.rmSync(binDir, { recursive: true, force: true }); + } + }); + + it(`${label}: npx parent command line is NOT treated as GitNexus server owner`, () => { + const markerPath = path.join(os.tmpdir(), `gn-hook-npx-${process.pid}-${label}`); + const lbugPath = path.join(gitNexusDir, 'lbug'); + fs.writeFileSync(lbugPath, ''); + fs.rmSync(markerPath, { force: true }); + const binDir = createHookToolDir({ + gitnexusMarkerPath: markerPath, + gitnexusStderr: '[GitNexus] 1 related symbol found:\n\nvalidateUser (src/auth.ts)\n', + lsofOutput: '99902\n', + psOutput: 'npx -y gitnexus@latest mcp\n', + }); + try { + const result = runHook( + hookPath, + { + hook_event_name: 'PreToolUse', + tool_name: 'Grep', + tool_input: { pattern: 'validateUser' }, + cwd: tmpDir, + }, + undefined, + { env: hookEnv(binDir) }, + ); + const output = parseHookOutput(result.stdout); + expect(output).not.toBeNull(); + expect(fs.existsSync(markerPath)).toBe(true); + } finally { + fs.rmSync(markerPath, { force: true }); + fs.rmSync(binDir, { recursive: true, force: true }); + } + }); + + it(`${label}: skips augment for gitnexus serve child`, () => { + const markerPath = path.join(os.tmpdir(), `gn-hook-serve-${process.pid}-${label}`); + const lbugPath = path.join(gitNexusDir, 'lbug'); + fs.writeFileSync(lbugPath, ''); + fs.rmSync(markerPath, { force: true }); + const binDir = createHookToolDir({ + gitnexusMarkerPath: markerPath, + lsofOutput: '99903\n', + psOutput: 'node /repo/node_modules/gitnexus/dist/cli/index.js serve\n', + }); + try { + const result = runHook( + hookPath, + { + hook_event_name: 'PreToolUse', + tool_name: 'Grep', + tool_input: { pattern: 'validateUser' }, + cwd: tmpDir, + }, + undefined, + { env: hookEnv(binDir) }, + ); + expect(result.stdout.trim()).toBe(''); + expect(result.status).toBe(0); + expect(result.stderr).toContain('[GitNexus] augment skipped'); + expect(fs.existsSync(markerPath)).toBe(false); + } finally { + fs.rmSync(markerPath, { force: true }); + fs.rmSync(binDir, { recursive: true, force: true }); + } + }); + + it(`${label}: ENOENT lsof → augment still runs (fail-open)`, () => { + const markerPath = path.join(os.tmpdir(), `gn-hook-enoent-${process.pid}-${label}`); + const lbugPath = path.join(gitNexusDir, 'lbug'); + fs.writeFileSync(lbugPath, ''); + fs.rmSync(markerPath, { force: true }); + const binDir = createHookToolDir({ + gitnexusMarkerPath: markerPath, + gitnexusStderr: '[GitNexus] 1 related symbol found:\n\nvalidateUser (src/auth.ts)\n', + lsofOutput: '', + psOutput: '', + }); + try { + const env = { + ...hookEnv(binDir), + GITNEXUS_HOOK_LSOF_PATH: path.join(binDir, '__missing_lsof__'), + }; + const result = runHook( + hookPath, + { + hook_event_name: 'PreToolUse', + tool_name: 'Grep', + tool_input: { pattern: 'validateUser' }, + cwd: tmpDir, + }, + undefined, + { env }, + ); + const output = parseHookOutput(result.stdout); + expect(output).not.toBeNull(); + expect(fs.existsSync(markerPath)).toBe(true); + } finally { + fs.rmSync(markerPath, { force: true }); + fs.rmSync(binDir, { recursive: true, force: true }); + } + }); + + it(`${label}: ETIMEDOUT lsof → augment skipped (fail-closed)`, () => { + const markerPath = path.join(os.tmpdir(), `gn-hook-etime-${process.pid}-${label}`); + const lbugPath = path.join(gitNexusDir, 'lbug'); + fs.writeFileSync(lbugPath, ''); + fs.rmSync(markerPath, { force: true }); + const binDir = createHookToolDir({ + gitnexusMarkerPath: markerPath, + lsofSleepMs: 5000, + psOutput: '', + }); + try { + const result = runHook( + hookPath, + { + hook_event_name: 'PreToolUse', + tool_name: 'Grep', + tool_input: { pattern: 'validateUser' }, + cwd: tmpDir, + }, + undefined, + { env: hookEnv(binDir) }, + ); + expect(result.stdout.trim()).toBe(''); + expect(result.status).toBe(0); + expect(result.stderr).toContain('[GitNexus] augment skipped'); + expect(fs.existsSync(markerPath)).toBe(false); + } finally { + fs.rmSync(markerPath, { force: true }); + fs.rmSync(binDir, { recursive: true, force: true }); + } + }); + + it(`${label}: non-GitNexus ps line → augment runs`, () => { + const markerPath = path.join(os.tmpdir(), `gn-hook-other-${process.pid}-${label}`); + const lbugPath = path.join(gitNexusDir, 'lbug'); + fs.writeFileSync(lbugPath, ''); + fs.rmSync(markerPath, { force: true }); + const binDir = createHookToolDir({ + gitnexusMarkerPath: markerPath, + gitnexusStderr: '[GitNexus] 1 related symbol found:\n\nvalidateUser (src/auth.ts)\n', + lsofOutput: '99904\n', + psOutput: '/usr/bin/bash -l\n', + }); + try { + const result = runHook( + hookPath, + { + hook_event_name: 'PreToolUse', + tool_name: 'Grep', + tool_input: { pattern: 'validateUser' }, + cwd: tmpDir, + }, + undefined, + { env: hookEnv(binDir) }, + ); + const output = parseHookOutput(result.stdout); + expect(output).not.toBeNull(); + expect(fs.existsSync(markerPath)).toBe(true); + } finally { + fs.rmSync(markerPath, { force: true }); + fs.rmSync(binDir, { recursive: true, force: true }); + } + }); + + it(`${label}: multiple PIDs — skip if any ps line is GitNexus MCP`, () => { + const markerPath = path.join(os.tmpdir(), `gn-hook-multi-${process.pid}-${label}`); + const lbugPath = path.join(gitNexusDir, 'lbug'); + fs.writeFileSync(lbugPath, ''); + fs.rmSync(markerPath, { force: true }); + const binDir = createHookToolDir({ + gitnexusMarkerPath: markerPath, + gitnexusStderr: '[GitNexus] 1 related symbol found:\n\nvalidateUser (src/auth.ts)\n', + lsofOutputLines: ['111', '222'], + psOutputByPid: { + '111': 'vim /tmp/x\n', + '222': 'node /x/node_modules/gitnexus/dist/cli/index.js mcp\n', + }, + }); + try { + const result = runHook( + hookPath, + { + hook_event_name: 'PreToolUse', + tool_name: 'Grep', + tool_input: { pattern: 'validateUser' }, + cwd: tmpDir, + }, + undefined, + { env: hookEnv(binDir) }, + ); + expect(result.stdout.trim()).toBe(''); + expect(result.status).toBe(0); + expect(result.stderr).toContain('[GitNexus] augment skipped'); + expect(fs.existsSync(markerPath)).toBe(false); + } finally { + fs.rmSync(markerPath, { force: true }); + fs.rmSync(binDir, { recursive: true, force: true }); + } + }); + + it(`${label}: ps ENOENT → augment runs (ignore that PID)`, () => { + const markerPath = path.join(os.tmpdir(), `gn-hook-pseno-${process.pid}-${label}`); + const lbugPath = path.join(gitNexusDir, 'lbug'); + fs.writeFileSync(lbugPath, ''); + fs.rmSync(markerPath, { force: true }); + const binDir = createHookToolDir({ + gitnexusMarkerPath: markerPath, + gitnexusStderr: '[GitNexus] 1 related symbol found:\n\nvalidateUser (src/auth.ts)\n', + lsofOutput: '99905\n', + psOutput: '', + }); + try { + const env = { + ...hookEnv(binDir), + GITNEXUS_HOOK_PS_PATH: path.join(binDir, '__missing_ps__'), + }; + const result = runHook( + hookPath, + { + hook_event_name: 'PreToolUse', + tool_name: 'Grep', + tool_input: { pattern: 'validateUser' }, + cwd: tmpDir, + }, + undefined, + { env }, + ); + const output = parseHookOutput(result.stdout); + expect(output).not.toBeNull(); + expect(fs.existsSync(markerPath)).toBe(true); + } finally { + fs.rmSync(markerPath, { force: true }); + fs.rmSync(binDir, { recursive: true, force: true }); + } + }); + } + }, +); + // ─── Integration: PostToolUse staleness detection ─────────────────── describe('PostToolUse staleness detection (integration)', () => { diff --git a/gitnexus/test/unit/setup.test.ts b/gitnexus/test/unit/setup.test.ts index 95ad261f0..bdf1cd1fd 100644 --- a/gitnexus/test/unit/setup.test.ts +++ b/gitnexus/test/unit/setup.test.ts @@ -267,6 +267,21 @@ describe('setupClaudeCode', () => { }); }); + it('copies hook-db-lock-probe.cjs and win-rm-list-json.ps1 to ~/.claude/hooks/gitnexus/', async () => { + setPlatform('linux'); + + const { setupCommand } = await import('../../src/cli/setup.js'); + await setupCommand(); + + const destHooksDir = path.join(tempHome, '.claude', 'hooks', 'gitnexus'); + await expect( + fs.access(path.join(destHooksDir, 'hook-db-lock-probe.cjs')), + ).resolves.toBeUndefined(); + await expect( + fs.access(path.join(destHooksDir, 'win-rm-list-json.ps1')), + ).resolves.toBeUndefined(); + }); + it('falls back to first line on Windows when no .cmd/.bat wrapper found', async () => { setPlatform('win32'); // Edge case: where returns only the POSIX script (no .cmd wrapper) diff --git a/gitnexus/test/utils/hook-test-helpers.ts b/gitnexus/test/utils/hook-test-helpers.ts index 6f5c5fbfd..3f519bc81 100644 --- a/gitnexus/test/utils/hook-test-helpers.ts +++ b/gitnexus/test/utils/hook-test-helpers.ts @@ -7,12 +7,14 @@ export function runHook( hookPath: string, input: Record, cwd?: string, + options: { env?: NodeJS.ProcessEnv } = {}, ): { stdout: string; stderr: string; status: number | null } { const result = spawnSync(process.execPath, [hookPath], { input: JSON.stringify(input), encoding: 'utf-8', timeout: 10000, cwd, + env: options.env, stdio: ['pipe', 'pipe', 'pipe'], }); return { From 8b2d8018bc860c68632e8d0bed8092a77352cab3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9on=20Simmons?= Date: Thu, 14 May 2026 12:26:34 -0400 Subject: [PATCH 07/13] fix(cli): tolerate read-only workspace in `ensureGitNexusIgnored` (#1549) (#1550) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(cli): tolerate read-only workspace in ensureGitNexusIgnored The documented Docker workflow mounts the host workspace at /workspace:ro and runs `gitnexus index /workspace/` against an index produced by a prior host-side `analyze`. Since PR #1248 ("keep GitNexus ignores inside .gitnexus") the index command has called `ensureGitNexusIgnored`, which unconditionally writes `/.gitnexus/.gitignore` and `/.git/info/exclude` — both fail with EROFS on the :ro bind mount even though the host already wrote the correct file during `analyze`. Two complementary changes: 1. Idempotent fast path. Read the existing .gitnexus/.gitignore content first; if it already matches the desired value (`*\n`), skip the write entirely. This is the common case for the Docker workflow and avoids touching the FS at all. 2. EROFS/EACCES tolerance. When a write is genuinely needed but the FS refuses it, log a structured warning via the existing pino logger and continue. `registerRepo` runs before `ensureGitNexusIgnored` in `indexCommand`, so the global-registry write is already committed when we get here — letting the gitignore-write failure propagate leaves the user with a registered-but-error-exited command. Three new unit tests pin the behaviour: - idempotent re-call leaves mtime untouched - ENOENT-then-correct path on a writable parent succeeds - :ro parent (simulated via chmod 0o555) does not throw, on the already-correct fast path and on the cold-create path Existing tests (61) still pass. Closes #1549. * test(storage): cover read-only ignore paths and tolerate EPERM (#1550) - Add isReadOnlyFilesystemError helper including EPERM alongside EROFS/EACCES for ensureGitNexusIgnored and ensureGitInfoExclude (Windows parity with lbug-config / bridge-db patterns). - Skip chmod-based read-only tests on win32 and uid 0; assert logger.warn on POSIX chmod denial for missing .gitignore. - Add repo-manager-ensure-ignore-readonly.test.ts with vi.mock fs/promises delegating writeFile so EROFS/EACCES/EPERM rejections are asserted with structured log path and message for both .gitignore and .git/info/exclude. Co-authored-by: Cursor * chore(autofix): apply prettier + eslint fixes via /autofix command --------- Co-authored-by: Gergő Magyar Co-authored-by: Cursor Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- gitnexus/src/storage/repo-manager.ts | 51 +++++++- ...epo-manager-ensure-ignore-readonly.test.ts | 113 ++++++++++++++++++ gitnexus/test/unit/repo-manager.test.ts | 72 ++++++++++- 3 files changed, 230 insertions(+), 6 deletions(-) create mode 100644 gitnexus/test/unit/repo-manager-ensure-ignore-readonly.test.ts diff --git a/gitnexus/src/storage/repo-manager.ts b/gitnexus/src/storage/repo-manager.ts index 456a6c143..44446e0ec 100644 --- a/gitnexus/src/storage/repo-manager.ts +++ b/gitnexus/src/storage/repo-manager.ts @@ -11,6 +11,7 @@ import { realpathSync } from 'fs'; import path from 'path'; import os from 'os'; import { getInferredRepoName, resolveRepoIdentityRoot } from './git.js'; +import { logger } from '../core/logger.js'; /** * Normalise a repo path for registry comparison across platforms @@ -281,14 +282,44 @@ export const findRepo = async (startPath: string): Promise = return null; }; +function isReadOnlyFilesystemError(err: unknown): boolean { + const code = (err as NodeJS.ErrnoException)?.code; + return code === 'EROFS' || code === 'EACCES' || code === 'EPERM'; +} + /** * Keep generated index files ignored without modifying the user's root .gitignore. */ export const ensureGitNexusIgnored = async (repoPath: string): Promise => { const gitignorePath = path.join(getStoragePath(repoPath), '.gitignore'); + const desired = '*\n'; - await fs.mkdir(path.dirname(gitignorePath), { recursive: true }); - await fs.writeFile(gitignorePath, '*\n', 'utf-8'); + // Idempotent fast path: skip the write entirely when the file already has + // the expected content. Lets this run cleanly on read-only mounts (e.g. + // the documented Docker workflow with WORKSPACE_DIR bound :ro) when an + // earlier `analyze` already created the file. See issue #1549. + try { + if ((await fs.readFile(gitignorePath, 'utf-8')) === desired) { + await ensureGitInfoExclude(repoPath); + return; + } + } catch (err: any) { + if (err?.code !== 'ENOENT') throw err; + } + + try { + await fs.mkdir(path.dirname(gitignorePath), { recursive: true }); + await fs.writeFile(gitignorePath, desired, 'utf-8'); + } catch (err: any) { + if (isReadOnlyFilesystemError(err)) { + logger.warn( + { path: gitignorePath, code: err.code }, + 'GitNexus storage filesystem is not writable; skipping .gitnexus/.gitignore. Generated files may appear as untracked in this repo locally.', + ); + } else { + throw err; + } + } await ensureGitInfoExclude(repoPath); }; @@ -304,8 +335,6 @@ const ensureGitInfoExclude = async (repoPath: string): Promise => { return; } - await fs.mkdir(path.dirname(excludePath), { recursive: true }); - let content = ''; try { content = await fs.readFile(excludePath, 'utf-8'); @@ -320,7 +349,19 @@ const ensureGitInfoExclude = async (repoPath: string): Promise => { if (excludes.includes(GITNEXUS_DIR) || excludes.includes(GITNEXUS_EXCLUDE_ENTRY)) return; const separator = content.length === 0 || content.endsWith('\n') ? '' : '\n'; - await fs.writeFile(excludePath, `${content}${separator}${GITNEXUS_EXCLUDE_ENTRY}\n`, 'utf-8'); + try { + await fs.mkdir(path.dirname(excludePath), { recursive: true }); + await fs.writeFile(excludePath, `${content}${separator}${GITNEXUS_EXCLUDE_ENTRY}\n`, 'utf-8'); + } catch (err: any) { + if (isReadOnlyFilesystemError(err)) { + logger.warn( + { path: excludePath, code: err.code }, + 'GitNexus storage filesystem is not writable; skipping .git/info/exclude update. .gitnexus/ may appear as untracked in `git status` locally.', + ); + } else { + throw err; + } + } }; // ─── Global Registry (~/.gitnexus/registry.json) ─────────────────────── diff --git a/gitnexus/test/unit/repo-manager-ensure-ignore-readonly.test.ts b/gitnexus/test/unit/repo-manager-ensure-ignore-readonly.test.ts new file mode 100644 index 000000000..8e49a01f3 --- /dev/null +++ b/gitnexus/test/unit/repo-manager-ensure-ignore-readonly.test.ts @@ -0,0 +1,113 @@ +/** + * Read-only / permission-denied write paths for ensureGitNexusIgnored (#1549, PR #1550). + * Separate from repo-manager.test.ts: Vitest cannot vi.spyOn ESM namespace exports of + * fs/promises; a delegating vi.mock is required for cross-platform mock rejects. + */ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import path from 'path'; + +const fswCtx = vi.hoisted(() => ({ + writeFileMock: vi.fn(), + realWrite: null as ((...args: unknown[]) => Promise) | null, +})); + +vi.mock('fs/promises', async (importOriginal) => { + const actual = await importOriginal(); + const d = actual.default; + fswCtx.realWrite = d.writeFile.bind(d); + fswCtx.writeFileMock.mockImplementation((...args) => fswCtx.realWrite!(...args)); + return { + default: new Proxy(d, { + get(target, prop) { + if (prop === 'writeFile') return fswCtx.writeFileMock; + const v = Reflect.get(target, prop, target) as unknown; + return typeof v === 'function' ? (v as (...args: unknown[]) => unknown).bind(target) : v; + }, + }), + }; +}); + +import fs from 'fs/promises'; +import { ensureGitNexusIgnored } from '../../src/storage/repo-manager.js'; +import { _captureLogger } from '../../src/core/logger.js'; +import { createTempDir } from '../helpers/test-db.js'; + +const samePath = (a: string, b: string) => path.normalize(a) === path.normalize(b); + +describe('ensureGitNexusIgnored — mocked writeFile (EROFS / EACCES / EPERM)', () => { + let tmpRepo: Awaited>; + + beforeEach(async () => { + tmpRepo = await createTempDir('gitnexus-ro-ignore-mock-'); + fswCtx.writeFileMock.mockClear(); + fswCtx.writeFileMock.mockImplementation((...args) => fswCtx.realWrite!(...args)); + }); + + afterEach(async () => { + await tmpRepo.cleanup(); + }); + + it.each(['EROFS', 'EACCES', 'EPERM'] as const)( + 'tolerates %s on .git/info/exclude write and logs a warn', + async (code) => { + const gitignorePath = path.join(tmpRepo.dbPath, '.gitnexus', '.gitignore'); + await fs.mkdir(path.dirname(gitignorePath), { recursive: true }); + await fs.writeFile(gitignorePath, '*\n', 'utf-8'); + + const excludePath = path.join(tmpRepo.dbPath, '.git', 'info', 'exclude'); + await fs.mkdir(path.dirname(excludePath), { recursive: true }); + await fs.writeFile(excludePath, '# empty\n', 'utf-8'); + + const cap = _captureLogger(); + fswCtx.writeFileMock.mockRejectedValueOnce(Object.assign(new Error('mock ro'), { code })); + + try { + await expect(ensureGitNexusIgnored(tmpRepo.dbPath)).resolves.not.toThrow(); + expect(fswCtx.writeFileMock).toHaveBeenCalled(); + expect( + cap + .records() + .some( + (r) => + r.level === 40 && + r.code === code && + typeof r.path === 'string' && + samePath(String(r.path), excludePath) && + String(r.msg ?? '').includes('.git/info/exclude'), + ), + ).toBe(true); + } finally { + cap.restore(); + } + }, + ); + + it.each(['EROFS', 'EACCES', 'EPERM'] as const)( + 'tolerates %s on .gitnexus/.gitignore write and logs a warn', + async (code) => { + const cap = _captureLogger(); + const gitignorePath = path.join(tmpRepo.dbPath, '.gitnexus', '.gitignore'); + + fswCtx.writeFileMock.mockRejectedValueOnce(Object.assign(new Error('mock ro'), { code })); + + try { + await expect(ensureGitNexusIgnored(tmpRepo.dbPath)).resolves.not.toThrow(); + expect(fswCtx.writeFileMock).toHaveBeenCalled(); + expect( + cap + .records() + .some( + (r) => + r.level === 40 && + r.code === code && + typeof r.path === 'string' && + samePath(String(r.path), gitignorePath) && + String(r.msg ?? '').includes('.gitnexus/.gitignore'), + ), + ).toBe(true); + } finally { + cap.restore(); + } + }, + ); +}); diff --git a/gitnexus/test/unit/repo-manager.test.ts b/gitnexus/test/unit/repo-manager.test.ts index bbd0fd50e..d3dd65a27 100644 --- a/gitnexus/test/unit/repo-manager.test.ts +++ b/gitnexus/test/unit/repo-manager.test.ts @@ -4,10 +4,11 @@ * Tests: getStoragePath, getStoragePaths, readRegistry, registerRepo, unregisterRepo * Covers hardening fixes #29 (API key file permissions) and #30 (case-insensitive paths on Windows) */ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import path from 'path'; import os from 'os'; import fs from 'fs/promises'; +import { _captureLogger } from '../../src/core/logger.js'; import { getStoragePath, getStoragePaths, @@ -73,6 +74,7 @@ describe('ensureGitNexusIgnored (#1233)', () => { }); afterEach(async () => { + vi.restoreAllMocks(); await tmpRepo.cleanup(); }); @@ -139,6 +141,74 @@ describe('ensureGitNexusIgnored (#1233)', () => { }); expect(status).toBe(''); }); + + // ─ Read-only workspace tolerance (#1549) ──────────────────────────── + // The documented Docker workflow mounts the host workspace at /workspace:ro + // and runs `gitnexus index /workspace/`. The host has already created + // the .gitnexus dir during a prior `analyze`, so the gitignore file already + // exists with the correct content — there's no real work to do. The tests + // below pin two pieces of behaviour that make that workflow work: + // (a) the function short-circuits when the file is already correct + // (no write attempt, no mtime bump); + // (b) when a write *is* needed but the FS is not writable + // (EROFS / EACCES / EPERM), the function logs and continues instead of + // throwing — so the caller's `registerRepo` work stays committed. + + it('does not re-write .gitnexus/.gitignore when it already has the desired content', async () => { + await ensureGitNexusIgnored(tmpRepo.dbPath); + const gitignorePath = path.join(tmpRepo.dbPath, '.gitnexus', '.gitignore'); + const before = await fs.stat(gitignorePath); + + await new Promise((resolve) => setTimeout(resolve, 25)); + + await ensureGitNexusIgnored(tmpRepo.dbPath); + + const after = await fs.stat(gitignorePath); + expect(after.mtimeMs).toBe(before.mtimeMs); + }); + + it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)( + 'does not throw when .gitnexus/.gitignore is already correct and the storage dir is read-only', + async () => { + await ensureGitNexusIgnored(tmpRepo.dbPath); + const storagePath = path.join(tmpRepo.dbPath, '.gitnexus'); + + await fs.chmod(storagePath, 0o555); + try { + await expect(ensureGitNexusIgnored(tmpRepo.dbPath)).resolves.not.toThrow(); + } finally { + await fs.chmod(storagePath, 0o755); + } + }, + ); + + it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)( + 'warns and continues when the storage dir is read-only and the file does not yet exist', + async () => { + const storagePath = path.join(tmpRepo.dbPath, '.gitnexus'); + await fs.mkdir(storagePath, { recursive: true }); + await fs.chmod(storagePath, 0o555); + + const cap = _captureLogger(); + try { + await expect(ensureGitNexusIgnored(tmpRepo.dbPath)).resolves.not.toThrow(); + expect( + cap + .records() + .some( + (r) => + r.level === 40 && + (r.code === 'EACCES' || r.code === 'EPERM') && + String(r.msg ?? '').includes('.gitnexus/.gitignore') && + String(r.path ?? '').includes('.gitnexus'), + ), + ).toBe(true); + } finally { + cap.restore(); + await fs.chmod(storagePath, 0o755); + } + }, + ); }); // ─── readRegistry ──────────────────────────────────────────────────── From c2193318b51623e6838092fbff88d9b8b4564dd7 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Thu, 14 May 2026 17:56:32 +0100 Subject: [PATCH 08/13] feat(cpp): Enable C++ ADL for class pointer arguments and exclude function pointers (#1592) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Initial plan * fix: unwrap cpp adl pointer argument types Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/2e9c8549-e062-410c-9ce3-66ba0a181590 * chore: tighten cpp adl function-pointer guard Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/2e9c8549-e062-410c-9ce3-66ba0a181590 * docs: clarify cpp adl implementation comments Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/2e9c8549-e062-410c-9ce3-66ba0a181590 * fix: avoid aborting cpp adl declaration scan Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/e54f1d4b-9aac-407c-9b5e-b5f3ea0534ea --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Gergő Magyar --- .../src/core/ingestion/languages/cpp/adl.ts | 35 +++++---- .../core/ingestion/languages/cpp/captures.ts | 39 ++++++---- .../cpp-adl-function-pointer-arg/app.cpp | 8 ++ .../cpp-adl-function-pointer-arg/audit.h | 5 ++ .../app.cpp | 9 +++ .../audit.h | 6 ++ .../app.cpp | 8 ++ .../audit.h | 6 ++ .../cpp-adl-pointer-to-pointer/app.cpp | 8 ++ .../cpp-adl-pointer-to-pointer/audit.h | 6 ++ .../test/integration/resolvers/cpp.test.ts | 78 +++++++++++++++++-- 11 files changed, 170 insertions(+), 38 deletions(-) create mode 100644 gitnexus/test/fixtures/lang-resolution/cpp-adl-function-pointer-arg/app.cpp create mode 100644 gitnexus/test/fixtures/lang-resolution/cpp-adl-function-pointer-arg/audit.h create mode 100644 gitnexus/test/fixtures/lang-resolution/cpp-adl-function-pointer-before-class-arg/app.cpp create mode 100644 gitnexus/test/fixtures/lang-resolution/cpp-adl-function-pointer-before-class-arg/audit.h create mode 100644 gitnexus/test/fixtures/lang-resolution/cpp-adl-function-pointer-class-return-arg/app.cpp create mode 100644 gitnexus/test/fixtures/lang-resolution/cpp-adl-function-pointer-class-return-arg/audit.h create mode 100644 gitnexus/test/fixtures/lang-resolution/cpp-adl-pointer-to-pointer/app.cpp create mode 100644 gitnexus/test/fixtures/lang-resolution/cpp-adl-pointer-to-pointer/audit.h diff --git a/gitnexus/src/core/ingestion/languages/cpp/adl.ts b/gitnexus/src/core/ingestion/languages/cpp/adl.ts index 112502a74..4c7b034b6 100644 --- a/gitnexus/src/core/ingestion/languages/cpp/adl.ts +++ b/gitnexus/src/core/ingestion/languages/cpp/adl.ts @@ -1,5 +1,5 @@ /** - * C++ argument-dependent lookup (ADL / Koenig lookup) — V1. + * C++ argument-dependent lookup (ADL / Koenig lookup). * * When ordinary unqualified lookup fails for a free-call site, ADL also * considers candidates declared in the **associated namespaces** of the @@ -13,17 +13,18 @@ * `using` anything. With V1 ADL: `audit::record` is discovered via * `audit::Event`'s associated namespace. * - * ## V1 boundary + * ## Current boundary * - * V1 covers ONE associated-entity rule: an argument that's a directly-named + * The current implementation covers ONE associated-entity rule: an argument that's a directly-named * class type (`audit::Event e`) contributes its **direct enclosing - * namespace** to the candidate set. Anything else — pointer/reference + * namespace** to the candidate set. V2 extends that one step to + * pointer-typed class args (`audit::Event* p`, `audit::Event** pp`): + * they contribute the pointee class's enclosing namespace too. Reference * arguments, function-pointer arguments, template specializations, - * base-class associated namespaces — is V2 closure work and is - * deliberately excluded. The `cpp-adl-pointer-arg-boundary` fixture - * locks the exclusion in CI. + * base-class associated namespaces, and the rest of the full closure are + * still deliberately excluded. * - * V1 also short-circuits to ADL only when ordinary lookup is empty + * The current implementation also short-circuits to ADL only when ordinary lookup is empty * (`findCallableBindingInScope` returned undefined). ISO C++ would * normally merge ADL candidates with ordinary-lookup candidates and * run overload resolution over the union; V1 defers that merge to V2. @@ -60,16 +61,16 @@ import { } from '../../scope-resolution/passes/overload-narrowing.js'; /** - * Per-argument shape information collected at capture time. ADL only - * fires for arguments where `simpleClassName !== ''` AND `!isPointer` - * AND `!isReference` — i.e., directly-named class-type values. + * Per-argument shape information collected at capture time. ADL fires for + * arguments where `simpleClassName !== ''` AND `!isReference`, including + * class pointers whose declarator chain resolves to a named class type. */ export interface CppAdlArgInfo { /** Simple class-like type name (last segment of qualified name); empty * for primitives, literals, function pointers, template specs, etc. */ readonly simpleClassName: string; - /** True when the variable's declarator was a `pointer_declarator`. V1 - * excludes pointer-typed args (closure rules deferred to V2). */ + /** True when the variable's declarator contained one or more + * `pointer_declarator` wrappers. */ readonly isPointer: boolean; /** True when the variable's declarator was a `reference_declarator`. */ readonly isReference: boolean; @@ -151,8 +152,8 @@ export function populateCppAssociatedNamespaces(parsed: ParsedFile): void { * * Fires only when: * - the call site is not in `noAdlSites` (parenthesized form), AND - * - at least one argument is a directly-named class type (not pointer, - * not reference, not literal/primitive). + * - at least one argument resolves to a named class type (value or + * pointer, but not reference, function pointer, literal, or primitive). */ export function pickCppAdlCandidates( site: { @@ -170,11 +171,11 @@ export function pickCppAdlCandidates( const args = argInfoBySite.get(key); if (args === undefined || args.length === 0) return undefined; - // Collect associated namespace QNames from every value-class-typed arg. + // Collect associated namespace QNames from every participating class-typed arg. const associatedNamespaces = new Set(); for (const arg of args) { if (arg.simpleClassName === '') continue; - if (arg.isPointer || arg.isReference) continue; + if (arg.isReference) continue; const classDef = findCppClassDefBySimpleName(arg.simpleClassName, scopes); if (classDef === undefined) continue; const nsQName = classToNamespaceQualifiedName.get(classDef.nodeId); diff --git a/gitnexus/src/core/ingestion/languages/cpp/captures.ts b/gitnexus/src/core/ingestion/languages/cpp/captures.ts index 325742bfb..17b6962aa 100644 --- a/gitnexus/src/core/ingestion/languages/cpp/captures.ts +++ b/gitnexus/src/core/ingestion/languages/cpp/captures.ts @@ -720,14 +720,15 @@ function isParenthesizedFunctionCall(callNode: SyntaxNode): boolean { /** * Per-argument ADL classification: walk each argument of a free call and - * decide whether it's a directly-named class type (V1 ADL fires) or - * something V1 excludes (pointer, reference, primitive, literal, function - * pointer, template specialization). + * decide whether it resolves to a directly-named class or class-pointer + * type (ADL fires) or to an excluded shape such as a reference, function + * pointer, primitive, literal, or template specialization. * - * V1 only fires for value class-typed args: `void f(N::S); N::S s; f(s);`. - * Pointer args (`N::S* p; f(p);`) intentionally return `simpleClassName=''` - * to lock the V1 boundary — the `cpp-adl-pointer-arg-boundary` fixture - * regression-tests this. + * Class-typed values and class pointers (`N::S`, `N::S*`, `N::S**`) all + * preserve the pointee class name for associated-namespace lookup. + * Function pointers remain excluded even when their return type names a + * class, because the associated entity is the pointed-to function type, + * not the return type. */ function inferCppCallAdlArgs(callNode: SyntaxNode): CppAdlArgInfo[] { const argList = callNode.childForFieldName('arguments'); @@ -792,15 +793,23 @@ function lookupAdlIdentifierType(identNode: SyntaxNode): CppAdlArgInfo { // Unwrap declarator chain to find pointer/reference markers and the // variable name. `init_declarator > pointer_declarator > identifier` - // means pointer-typed; `init_declarator > reference_declarator > ...` - // means reference-typed; bare `init_declarator > identifier` is value. + // means pointer-typed; repeated pointer wrappers still count as pointer + // typed; `init_declarator > reference_declarator > ...` means + // reference-typed; bare `init_declarator > identifier` is value. + // Function-pointer wrappers (`pointer_declarator > function_declarator`) + // must not contribute ADL associated namespaces. let isPointer = false; let isReference = false; + let isFunctionPointer = false; let inner: SyntaxNode = declarator; let nameText: string | null = null; let safety = 16; // bound walk depth defensively while (safety-- > 0) { if (inner.type === 'pointer_declarator') { + if (findFirstDescendantOfType(inner, 'function_declarator') !== null) { + isFunctionPointer = true; + break; + } isPointer = true; const next = inner.childForFieldName('declarator'); if (next === null) break; @@ -828,11 +837,15 @@ function lookupAdlIdentifierType(identNode: SyntaxNode): CppAdlArgInfo { inner = next; continue; } + if (inner.type === 'function_declarator') { + isFunctionPointer = true; + break; + } // Reached the leaf — usually `identifier`. Take its text. nameText = inner.text; break; } - if (nameText !== varName) continue; + if (isFunctionPointer || nameText !== varName) continue; const simpleClassName = extractAdlSimpleTypeName(typeNode); return { simpleClassName, isPointer, isReference }; @@ -841,9 +854,9 @@ function lookupAdlIdentifierType(identNode: SyntaxNode): CppAdlArgInfo { } /** Extract the simple class-like type name from a `type:` field node. - * Returns '' for primitives, template specializations, function pointers, - * and any other shape V1 ADL doesn't support — those args are excluded - * from associated-namespace closure. */ + * Returns '' for primitives, template specializations, and any other + * unsupported type-only shape. Function pointers are filtered at the + * declarator level in `lookupAdlIdentifierType`. */ function extractAdlSimpleTypeName(typeNode: SyntaxNode): string { if (typeNode.type === 'primitive_type') return ''; if (typeNode.type === 'sized_type_specifier') return ''; diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-adl-function-pointer-arg/app.cpp b/gitnexus/test/fixtures/lang-resolution/cpp-adl-function-pointer-arg/app.cpp new file mode 100644 index 000000000..b9f99d201 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-adl-function-pointer-arg/app.cpp @@ -0,0 +1,8 @@ +#include "audit.h" + +namespace app { + void run() { + void (*g)(); + record(g); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-adl-function-pointer-arg/audit.h b/gitnexus/test/fixtures/lang-resolution/cpp-adl-function-pointer-arg/audit.h new file mode 100644 index 000000000..a0283cabc --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-adl-function-pointer-arg/audit.h @@ -0,0 +1,5 @@ +#pragma once + +namespace audit { + void record(void (*g)()); +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-adl-function-pointer-before-class-arg/app.cpp b/gitnexus/test/fixtures/lang-resolution/cpp-adl-function-pointer-before-class-arg/app.cpp new file mode 100644 index 000000000..9e6cfd188 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-adl-function-pointer-before-class-arg/app.cpp @@ -0,0 +1,9 @@ +#include "audit.h" + +namespace app { + void run() { + void (*fp)(); + audit::Event e; + record(e); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-adl-function-pointer-before-class-arg/audit.h b/gitnexus/test/fixtures/lang-resolution/cpp-adl-function-pointer-before-class-arg/audit.h new file mode 100644 index 000000000..2c9803a3d --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-adl-function-pointer-before-class-arg/audit.h @@ -0,0 +1,6 @@ +#pragma once + +namespace audit { + struct Event {}; + void record(Event e); +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-adl-function-pointer-class-return-arg/app.cpp b/gitnexus/test/fixtures/lang-resolution/cpp-adl-function-pointer-class-return-arg/app.cpp new file mode 100644 index 000000000..0cf4ee3bb --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-adl-function-pointer-class-return-arg/app.cpp @@ -0,0 +1,8 @@ +#include "audit.h" + +namespace app { + void run() { + audit::Event (*factory)(); + record(factory); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-adl-function-pointer-class-return-arg/audit.h b/gitnexus/test/fixtures/lang-resolution/cpp-adl-function-pointer-class-return-arg/audit.h new file mode 100644 index 000000000..a2438fe5e --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-adl-function-pointer-class-return-arg/audit.h @@ -0,0 +1,6 @@ +#pragma once + +namespace audit { + struct Event {}; + void record(Event (*factory)()); +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-adl-pointer-to-pointer/app.cpp b/gitnexus/test/fixtures/lang-resolution/cpp-adl-pointer-to-pointer/app.cpp new file mode 100644 index 000000000..33508836e --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-adl-pointer-to-pointer/app.cpp @@ -0,0 +1,8 @@ +#include "audit.h" + +namespace app { + void run() { + audit::Event** pp; + record(pp); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-adl-pointer-to-pointer/audit.h b/gitnexus/test/fixtures/lang-resolution/cpp-adl-pointer-to-pointer/audit.h new file mode 100644 index 000000000..2f719362c --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-adl-pointer-to-pointer/audit.h @@ -0,0 +1,6 @@ +#pragma once + +namespace audit { + struct Event {}; + void record(Event** e); +} diff --git a/gitnexus/test/integration/resolvers/cpp.test.ts b/gitnexus/test/integration/resolvers/cpp.test.ts index 25a7f9de7..eafa76cf9 100644 --- a/gitnexus/test/integration/resolvers/cpp.test.ts +++ b/gitnexus/test/integration/resolvers/cpp.test.ts @@ -2057,7 +2057,7 @@ describe('C++ ADL — parenthesized name suppresses ADL', () => { }); }); -describe('C++ ADL — pointer-arg V1 boundary', () => { +describe('C++ ADL — pointer arg unwrapping', () => { let result: PipelineResult; beforeAll(async () => { @@ -2067,19 +2067,81 @@ describe('C++ ADL — pointer-arg V1 boundary', () => { ); }, 60000); - it('record(p) where p is audit::Event* emits zero CALLS — V1 ADL excludes pointer args', () => { + it('record(p) where p is audit::Event* resolves to audit::record via ADL', () => { + const calls = getRelationships(result, 'CALLS'); + const recordCalls = calls.filter((c) => c.source === 'run' && c.target === 'record'); + expect(recordCalls.length).toBe(1); + expect(recordCalls[0].targetFilePath).toContain('audit.h'); + }); +}); + +describe('C++ ADL — function pointer args do not participate', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'cpp-adl-function-pointer-arg'), + () => {}, + ); + }, 60000); + + it('record(g) where g is void (*)() emits zero CALLS edges', () => { const calls = getRelationships(result, 'CALLS'); const recordCalls = calls.filter((c) => c.source === 'run' && c.target === 'record'); - // Exact .toBe(0): V1 ADL covers only directly-named class-type values - // (per plan 2026-05-13-001 R4). Pointer-typed args fall under - // associated-entity closure rules deferred to V2. This fixture locks - // the boundary in CI so the implementer cannot accidentally extend - // V1 to include pointer types. Real ISO C++ would resolve via V2 - // closure; matching that requires the V2 follow-up plan. expect(recordCalls.length).toBe(0); }); }); +describe('C++ ADL — preceding function-pointer declarations do not block class args', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'cpp-adl-function-pointer-before-class-arg'), + () => {}, + ); + }, 60000); + + it('record(e) still resolves via ADL when an earlier declaration is void (*)()', () => { + const calls = getRelationships(result, 'CALLS'); + const recordCalls = calls.filter((c) => c.source === 'run' && c.target === 'record'); + expect(recordCalls.length).toBe(1); + expect(recordCalls[0].targetFilePath).toContain('audit.h'); + }); +}); + +describe('C++ ADL — class-returning function pointer args do not participate', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'cpp-adl-function-pointer-class-return-arg'), + () => {}, + ); + }, 60000); + + it('record(factory) where factory is audit::Event (*)() emits zero CALLS edges', () => { + const calls = getRelationships(result, 'CALLS'); + const recordCalls = calls.filter((c) => c.source === 'run' && c.target === 'record'); + expect(recordCalls.length).toBe(0); + }); +}); + +describe('C++ ADL — pointer-to-pointer args participate', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'cpp-adl-pointer-to-pointer'), () => {}); + }, 60000); + + it('record(pp) where pp is audit::Event** resolves to audit::record via ADL', () => { + const calls = getRelationships(result, 'CALLS'); + const recordCalls = calls.filter((c) => c.source === 'run' && c.target === 'record'); + expect(recordCalls.length).toBe(1); + expect(recordCalls[0].targetFilePath).toContain('audit.h'); + }); +}); + describe('C++ ADL — int/long-collision overloads suppress via OVERLOAD_AMBIGUOUS', () => { let result: PipelineResult; From b00ba2ab4755acb3eb20ee92232bd4091ee36352 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Thu, 14 May 2026 18:18:36 +0100 Subject: [PATCH 09/13] feat(cpp): resolve template-body `this->` + `using ns::name` calls in scope resolver (#1590) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Initial plan * fix(cpp): resolve this-> and using-name calls in template bodies Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/d9d91945-f19c-4fd2-9b52-b0ebc9aa34b6 Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * fix(cpp): treat duplicate using-name hits as ambiguous Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/d9d91945-f19c-4fd2-9b52-b0ebc9aa34b6 Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * chore(autofix): apply prettier + eslint fixes via /autofix command * fix(cpp): gate this-receiver path and harden overload semantics Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/030a1842-c698-460d-ae2a-95037e6def73 Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * test(cpp): add positive this-> overload case and document field shadowing Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/030a1842-c698-460d-ae2a-95037e6def73 Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * test(cpp): skip new template-this assertions in legacy parity lane Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/27002f6e-6331-41e3-8175-9d9e4691927c Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> Co-authored-by: Gergő Magyar Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .../ingestion/languages/cpp/scope-resolver.ts | 33 ++++++ .../contract/scope-resolver.ts | 12 +++ .../passes/receiver-bound-calls.ts | 83 ++++++++++++++ .../derived.h | 3 + .../helpers.h | 1 + .../cpp-two-phase-paired/base.h | 6 ++ .../cpp-two-phase-paired/derived.h | 14 +++ .../base.h | 6 ++ .../derived.h | 16 +++ .../cpp-two-phase-this-qualified/base.h | 1 + .../cpp-two-phase-this-qualified/derived.h | 3 + .../test/integration/resolvers/cpp.test.ts | 102 ++++++++++++++++-- .../test/integration/resolvers/helpers.ts | 9 ++ 13 files changed, 281 insertions(+), 8 deletions(-) create mode 100644 gitnexus/test/fixtures/lang-resolution/cpp-two-phase-paired/base.h create mode 100644 gitnexus/test/fixtures/lang-resolution/cpp-two-phase-paired/derived.h create mode 100644 gitnexus/test/fixtures/lang-resolution/cpp-two-phase-this-name-hiding-arity/base.h create mode 100644 gitnexus/test/fixtures/lang-resolution/cpp-two-phase-this-name-hiding-arity/derived.h diff --git a/gitnexus/src/core/ingestion/languages/cpp/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/cpp/scope-resolver.ts index 02aaff3bf..431717d27 100644 --- a/gitnexus/src/core/ingestion/languages/cpp/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/cpp/scope-resolver.ts @@ -36,6 +36,10 @@ import { resolveCppQualifiedNamespaceMember, } from './inline-namespaces.js'; import { populateCppRangeBindings } from './range-bindings.js'; +import { + isOverloadAmbiguousAfterNormalization, + narrowOverloadCandidates, +} from '../../scope-resolution/passes/overload-narrowing.js'; /** * C++ `ScopeResolver` registered in `SCOPE_RESOLVERS` and consumed by @@ -178,6 +182,8 @@ export const cppScopeResolver: ScopeResolver = { // for cross-file propagation and compound-receiver chain resolution. // cppBindingScopeFor hoists @type-binding.return to Module scope. hoistTypeBindingsToModule: true, + // Enable receiver-bound explicit-`this` fallback only for C++. + resolveThisViaEnclosingClass: true, // The `isFileLocalDef` hook on the global free-call fallback names // file-local linkage historically, but semantically gates "logically // invisible cross-file" defs. C++ extends this to also reject class- @@ -219,6 +225,33 @@ export const cppScopeResolver: ScopeResolver = { // V1 limitation: only direct enclosing-namespace closure for value // class-typed args; pointer/reference/template-spec args excluded. resolveAdlCandidates: (site, callerParsed, scopes, parsedFiles) => { + // `using ns::name;` introduces `name` into ordinary unqualified lookup. + // For template-class method bodies, lexical scope walks can miss this + // named-using visibility; recover by resolving the imported namespace + // member directly when the local call name matches a named using import. + const usingNamedHits: SymbolDefinition[] = []; + const seenUsing = new Set(); + for (const imp of callerParsed.parsedImports) { + if (imp.kind !== 'named') continue; + if (imp.localName !== site.name) continue; + const member = resolveCppQualifiedNamespaceMember( + imp.targetRaw, + imp.importedName, + parsedFiles, + scopes, + ); + if (member === undefined) continue; + if (seenUsing.has(member.nodeId)) continue; + seenUsing.add(member.nodeId); + usingNamedHits.push(member); + } + if (usingNamedHits.length > 0) { + const narrowed = narrowOverloadCandidates(usingNamedHits, site.arity, site.argumentTypes); + if (isOverloadAmbiguousAfterNormalization(narrowed, site.arity)) return 'ambiguous'; + if (narrowed.length === 1) return narrowed[0]; + if (narrowed.length > 1) return 'ambiguous'; + } + const result = pickCppAdlCandidates(site, callerParsed, scopes, parsedFiles); if (result === ADL_AMBIGUOUS) return 'ambiguous'; return result; 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 d29b6efa8..36856e6a8 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts @@ -627,6 +627,18 @@ export interface ScopeResolver { parsedFiles: readonly ParsedFile[], ) => SymbolDefinition | undefined; + /** + * Enable the receiver-bound Case 0.5 fallback for explicit `this` + * receivers (`this->m()` / `this.m()`) that resolves against the + * enclosing class + MRO even when no explicit `this` typeBinding is + * present in scope. + * + * Keep disabled for languages where the existing type-binding path + * (Case 4) already handles `this` correctly and overload ambiguity + * suppression must remain unchanged. + */ + readonly resolveThisViaEnclosingClass?: boolean; + /** * Optional post-finalize hook to inject cross-file bindings that * aren't modeled via explicit imports. Runs after 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 80ff3a200..5aff78261 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 @@ -72,6 +72,7 @@ type ReceiverBoundProviderSubset = Pick< | 'unwrapCollectionAccessor' | 'hoistTypeBindingsToModule' | 'resolveQualifiedReceiverMember' + | 'resolveThisViaEnclosingClass' >; function normalizeTemplateArgToken(value: string): string { @@ -321,6 +322,88 @@ export function emitReceiverBoundCalls( } } + // ── Case 0.5: implicit `this` receiver ─────────────────────── + // C++ `this->member()` (and same-shape receivers in other OO + // languages) should resolve against the enclosing class + MRO + // even when there is no explicit `this` typeBinding in scope. + if (provider.resolveThisViaEnclosingClass === true && receiverName === 'this') { + const enclosingClass = findEnclosingClassDef(site.inScope, scopes); + if (enclosingClass !== undefined) { + const chain = [ + enclosingClass.nodeId, + ...scopes.methodDispatch.mroFor(enclosingClass.nodeId), + ]; + let memberDef: SymbolDefinition | undefined; + let ambiguous = false; + let hiddenByName = false; + for (const ownerId of chain) { + const methodOverloads = model.methods.lookupAllByOwner(ownerId, memberName); + if (methodOverloads.length > 0) { + const narrowed = narrowOverloadCandidates( + methodOverloads, + site.arity, + site.argumentTypes, + ); + if (isOverloadAmbiguousAfterNormalization(narrowed, site.arity)) { + ambiguous = true; + break; + } + if (narrowed.length === 0) { + // C++ name hiding: if the derived class declares `f`, base-class + // overloads named `f` are hidden for member lookup + // ([basic.lookup.classref]). A non-viable derived overload set + // therefore terminates lookup instead of falling through to base. + hiddenByName = true; + break; + } + memberDef = narrowed[0] ?? methodOverloads[0]; + break; + } + + // Field/property lookup intentionally runs only after the method + // lookup above: in C++ member-name lookup, functions with this + // name hide same-named base members; we therefore prefer method + // candidates first and only target a field when no methods with + // this name exist on the current owner. + memberDef = model.fields.lookupFieldByOwner(ownerId, memberName); + if (memberDef !== undefined) { + break; + } + } + if (ambiguous) { + handledSites.add(siteKey); + continue; + } + if (hiddenByName) { + handledSites.add(siteKey); + continue; + } + if (memberDef !== undefined) { + const reason = + site.kind === 'write' || site.kind === 'read' + ? site.kind + : memberDef.filePath !== parsed.filePath + ? 'import-resolved' + : 'global'; + const confidence = site.kind === 'write' || site.kind === 'read' ? 1.0 : 0.85; + const ok = tryEmitEdge( + graph, + scopes, + nodeLookup, + site, + memberDef, + reason, + seen, + confidence, + collapse, + ); + if (ok) emitted++; + handledSites.add(siteKey); + continue; + } + } + } + // ── Case 1: namespace receiver ─────────────────────────────── const targetFiles = namespaceTargets.get(receiverName); if (targetFiles !== undefined) { diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-two-phase-namespace-free-call-inside-template/derived.h b/gitnexus/test/fixtures/lang-resolution/cpp-two-phase-namespace-free-call-inside-template/derived.h index ef57810fc..e3ea61977 100644 --- a/gitnexus/test/fixtures/lang-resolution/cpp-two-phase-namespace-free-call-inside-template/derived.h +++ b/gitnexus/test/fixtures/lang-resolution/cpp-two-phase-namespace-free-call-inside-template/derived.h @@ -3,9 +3,12 @@ #include "base.h" #include "helpers.h" +using utils::ns_helper_2; + template struct D : Base { void g() { utils::ns_helper(); + ns_helper_2(); } }; diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-two-phase-namespace-free-call-inside-template/helpers.h b/gitnexus/test/fixtures/lang-resolution/cpp-two-phase-namespace-free-call-inside-template/helpers.h index 5e291aba6..4bd7a9833 100644 --- a/gitnexus/test/fixtures/lang-resolution/cpp-two-phase-namespace-free-call-inside-template/helpers.h +++ b/gitnexus/test/fixtures/lang-resolution/cpp-two-phase-namespace-free-call-inside-template/helpers.h @@ -2,4 +2,5 @@ namespace utils { void ns_helper(); + void ns_helper_2(); } diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-two-phase-paired/base.h b/gitnexus/test/fixtures/lang-resolution/cpp-two-phase-paired/base.h new file mode 100644 index 000000000..84bc1a954 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-two-phase-paired/base.h @@ -0,0 +1,6 @@ +#pragma once + +template +struct Base { + void f(); +}; diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-two-phase-paired/derived.h b/gitnexus/test/fixtures/lang-resolution/cpp-two-phase-paired/derived.h new file mode 100644 index 000000000..c7cc1a53b --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-two-phase-paired/derived.h @@ -0,0 +1,14 @@ +#pragma once + +#include "base.h" + +template +struct Derived : Base { + void g_unqualified() { + f(); + } + + void g_this() { + this->f(); + } +}; diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-two-phase-this-name-hiding-arity/base.h b/gitnexus/test/fixtures/lang-resolution/cpp-two-phase-this-name-hiding-arity/base.h new file mode 100644 index 000000000..84bc1a954 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-two-phase-this-name-hiding-arity/base.h @@ -0,0 +1,6 @@ +#pragma once + +template +struct Base { + void f(); +}; diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-two-phase-this-name-hiding-arity/derived.h b/gitnexus/test/fixtures/lang-resolution/cpp-two-phase-this-name-hiding-arity/derived.h new file mode 100644 index 000000000..ad867e820 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-two-phase-this-name-hiding-arity/derived.h @@ -0,0 +1,16 @@ +#pragma once + +#include "base.h" + +template +struct Derived : Base { + void f(int); + + void g() { + this->f(); + } + + void g_ok() { + this->f(42); + } +}; diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-two-phase-this-qualified/base.h b/gitnexus/test/fixtures/lang-resolution/cpp-two-phase-this-qualified/base.h index 1c7084ee6..286f877a1 100644 --- a/gitnexus/test/fixtures/lang-resolution/cpp-two-phase-this-qualified/base.h +++ b/gitnexus/test/fixtures/lang-resolution/cpp-two-phase-this-qualified/base.h @@ -3,5 +3,6 @@ template struct Base { void f(); + void base_method(); int i; }; diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-two-phase-this-qualified/derived.h b/gitnexus/test/fixtures/lang-resolution/cpp-two-phase-this-qualified/derived.h index 5c13c1737..9b154a429 100644 --- a/gitnexus/test/fixtures/lang-resolution/cpp-two-phase-this-qualified/derived.h +++ b/gitnexus/test/fixtures/lang-resolution/cpp-two-phase-this-qualified/derived.h @@ -7,6 +7,9 @@ struct Derived : Base { void g() { this->f(); } + void k() { + this->base_method(); + } int h() { return this->i; } diff --git a/gitnexus/test/integration/resolvers/cpp.test.ts b/gitnexus/test/integration/resolvers/cpp.test.ts index eafa76cf9..cd95cfae7 100644 --- a/gitnexus/test/integration/resolvers/cpp.test.ts +++ b/gitnexus/test/integration/resolvers/cpp.test.ts @@ -1972,14 +1972,100 @@ describe('C++ two-phase template lookup — dependent base suppression', () => { }); }); -// NOTE: positive guards (this->f() resolves, non-dependent-base unqualified -// f() resolves, namespace-qualified utils::ns_helper() resolves) inside -// template bodies are documented gaps in C++ template-context resolution -// independent of U3's dependent-base suppression. The U3 core asserts only -// the negative behavior (dependent-base members are NOT bound by unqualified -// calls); the positive cases would require additional `this` type-binding -// and template-body member-lookup work tracked separately. See plan -// 2026-05-13-001 follow-ups. +describe('C++ two-phase template lookup — positive this-qualified calls', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'cpp-two-phase-this-qualified'), + () => {}, + ); + }, 60000); + + it('Derived::g() -> this->f() resolves to f (1 edge)', () => { + const calls = getRelationships(result, 'CALLS'); + const thisCalls = calls.filter((c) => c.source === 'g' && c.target === 'f'); + expect(thisCalls.length).toBe(1); + expect(thisCalls[0].targetFilePath).toContain('base.h'); + }); + + it('Derived::k() -> this->base_method() resolves via EXTENDS chain (1 edge)', () => { + const calls = getRelationships(result, 'CALLS'); + const inheritedCalls = calls.filter((c) => c.source === 'k' && c.target === 'base_method'); + expect(inheritedCalls.length).toBe(1); + expect(inheritedCalls[0].targetFilePath).toContain('base.h'); + }); +}); + +describe('C++ two-phase template lookup — paired unqualified + this-qualified in one fixture', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'cpp-two-phase-paired'), () => {}); + }, 60000); + + it('Derived::g_unqualified() -> f() does NOT bind to Base::f', () => { + const calls = getRelationships(result, 'CALLS'); + const leaks = calls.filter((c) => c.source === 'g_unqualified' && c.target === 'f'); + expect(leaks.length).toBe(0); + }); + + it('Derived::g_this() -> this->f() resolves to Base::f (1 edge)', () => { + const calls = getRelationships(result, 'CALLS'); + const resolved = calls.filter((c) => c.source === 'g_this' && c.target === 'f'); + expect(resolved.length).toBe(1); + expect(resolved[0].targetFilePath).toContain('base.h'); + }); +}); + +describe('C++ two-phase template lookup — namespace calls inside template body', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'cpp-two-phase-namespace-free-call-inside-template'), + () => {}, + ); + }, 60000); + + it('D::g() -> utils::ns_helper() resolves (1 edge)', () => { + const calls = getRelationships(result, 'CALLS'); + const qualifiedCalls = calls.filter((c) => c.source === 'g' && c.target === 'ns_helper'); + expect(qualifiedCalls.length).toBe(1); + expect(qualifiedCalls[0].targetFilePath).toContain('helpers.h'); + }); + + it('D::g() -> ns_helper_2() resolves after using-declaration (1 edge)', () => { + const calls = getRelationships(result, 'CALLS'); + const usingCalls = calls.filter((c) => c.source === 'g' && c.target === 'ns_helper_2'); + expect(usingCalls.length).toBe(1); + expect(usingCalls[0].targetFilePath).toContain('helpers.h'); + }); +}); + +describe('C++ two-phase template lookup — this-> name-hiding arity mismatch', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'cpp-two-phase-this-name-hiding-arity'), + () => {}, + ); + }, 60000); + + it('Derived::g() -> this->f() emits zero CALLS edges when only hidden derived overload is arity-incompatible', () => { + const calls = getRelationships(result, 'CALLS'); + const fCalls = calls.filter((c) => c.source === 'g' && c.target === 'f'); + expect(fCalls.length).toBe(0); + }); + + it('Derived::g_ok() -> this->f(42) resolves to derived overload (1 edge)', () => { + const calls = getRelationships(result, 'CALLS'); + const fCalls = calls.filter((c) => c.source === 'g_ok' && c.target === 'f'); + expect(fCalls.length).toBe(1); + expect(fCalls[0].targetFilePath).toContain('derived.h'); + }); +}); // --------------------------------------------------------------------------- // U3 cross-file namespace variant: Base lives in a different file AND diff --git a/gitnexus/test/integration/resolvers/helpers.ts b/gitnexus/test/integration/resolvers/helpers.ts index 5149e3e69..396e7905b 100644 --- a/gitnexus/test/integration/resolvers/helpers.ts +++ b/gitnexus/test/integration/resolvers/helpers.ts @@ -168,6 +168,15 @@ const LEGACY_RESOLVER_PARITY_EXPECTED_FAILURES: Readonly and List', 'callSave() in each specialization resolves to its own save()', 'save specialization bodies route to their own sibling method', + // PR #1590 follow-up: explicit `this->` resolution in template class + // bodies and paired two-phase assertions are scope-resolver-only. + // Legacy DAG lacks this receiver-bound template semantics and + // dependent-base suppression parity for these shapes. + 'Derived::g() -> this->f() resolves to f (1 edge)', + 'Derived::k() -> this->base_method() resolves via EXTENDS chain (1 edge)', + 'Derived::g_unqualified() -> f() does NOT bind to Base::f', + 'Derived::g_this() -> this->f() resolves to Base::f (1 edge)', + 'Derived::g() -> this->f() emits zero CALLS edges when only hidden derived overload is arity-incompatible', ]), }; From cdac8a691ae6c58fee9a64ae5e9442d776e5fa7c Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Thu, 14 May 2026 20:25:12 +0100 Subject: [PATCH 10/13] feat: C++ ADL V2: include class-typed reference args (incl. rvalue refs) in associated-namespace lookup (#1595) --- .../src/core/ingestion/languages/cpp/adl.ts | 24 ++++----- .../core/ingestion/languages/cpp/captures.ts | 15 +++--- .../cpp-adl-reference-arg-boundary/app.cpp | 21 ++++++++ .../cpp-adl-reference-arg-boundary/audit.h | 5 ++ .../cpp-adl-reference-arg-boundary/record.h | 8 +++ .../cpp-adl-rvalue-ref/app.cpp | 9 ++++ .../cpp-adl-rvalue-ref/audit.h | 5 ++ .../cpp-adl-rvalue-ref/record-rvalue.h | 7 +++ .../test/integration/resolvers/cpp.test.ts | 50 ++++++++++++++++++- 9 files changed, 119 insertions(+), 25 deletions(-) create mode 100644 gitnexus/test/fixtures/lang-resolution/cpp-adl-reference-arg-boundary/app.cpp create mode 100644 gitnexus/test/fixtures/lang-resolution/cpp-adl-reference-arg-boundary/audit.h create mode 100644 gitnexus/test/fixtures/lang-resolution/cpp-adl-reference-arg-boundary/record.h create mode 100644 gitnexus/test/fixtures/lang-resolution/cpp-adl-rvalue-ref/app.cpp create mode 100644 gitnexus/test/fixtures/lang-resolution/cpp-adl-rvalue-ref/audit.h create mode 100644 gitnexus/test/fixtures/lang-resolution/cpp-adl-rvalue-ref/record-rvalue.h diff --git a/gitnexus/src/core/ingestion/languages/cpp/adl.ts b/gitnexus/src/core/ingestion/languages/cpp/adl.ts index 4c7b034b6..8d6c1563d 100644 --- a/gitnexus/src/core/ingestion/languages/cpp/adl.ts +++ b/gitnexus/src/core/ingestion/languages/cpp/adl.ts @@ -18,11 +18,11 @@ * The current implementation covers ONE associated-entity rule: an argument that's a directly-named * class type (`audit::Event e`) contributes its **direct enclosing * namespace** to the candidate set. V2 extends that one step to - * pointer-typed class args (`audit::Event* p`, `audit::Event** pp`): - * they contribute the pointee class's enclosing namespace too. Reference - * arguments, function-pointer arguments, template specializations, - * base-class associated namespaces, and the rest of the full closure are - * still deliberately excluded. + * pointer-typed and reference-typed class args (`audit::Event* p`, + * `audit::Event& r`, `audit::Event&& rr`): they contribute the pointee / + * referred class's enclosing namespace too. Function-pointer arguments, + * template specializations, base-class associated namespaces, and the + * rest of the full closure are still deliberately excluded. * * The current implementation also short-circuits to ADL only when ordinary lookup is empty * (`findCallableBindingInScope` returned undefined). ISO C++ would @@ -62,18 +62,13 @@ import { /** * Per-argument shape information collected at capture time. ADL fires for - * arguments where `simpleClassName !== ''` AND `!isReference`, including - * class pointers whose declarator chain resolves to a named class type. + * arguments where `simpleClassName !== ''`, including class pointers and + * references whose declarator chain resolves to a named class type. */ export interface CppAdlArgInfo { /** Simple class-like type name (last segment of qualified name); empty * for primitives, literals, function pointers, template specs, etc. */ readonly simpleClassName: string; - /** True when the variable's declarator contained one or more - * `pointer_declarator` wrappers. */ - readonly isPointer: boolean; - /** True when the variable's declarator was a `reference_declarator`. */ - readonly isReference: boolean; } const argInfoBySite = new Map(); @@ -152,8 +147,8 @@ export function populateCppAssociatedNamespaces(parsed: ParsedFile): void { * * Fires only when: * - the call site is not in `noAdlSites` (parenthesized form), AND - * - at least one argument resolves to a named class type (value or - * pointer, but not reference, function pointer, literal, or primitive). + * - at least one argument resolves to a named class type (value, + * pointer, or reference; but not function pointer, literal, or primitive). */ export function pickCppAdlCandidates( site: { @@ -175,7 +170,6 @@ export function pickCppAdlCandidates( const associatedNamespaces = new Set(); for (const arg of args) { if (arg.simpleClassName === '') continue; - if (arg.isReference) continue; const classDef = findCppClassDefBySimpleName(arg.simpleClassName, scopes); if (classDef === undefined) continue; const nsQName = classToNamespaceQualifiedName.get(classDef.nodeId); diff --git a/gitnexus/src/core/ingestion/languages/cpp/captures.ts b/gitnexus/src/core/ingestion/languages/cpp/captures.ts index 17b6962aa..75ef30804 100644 --- a/gitnexus/src/core/ingestion/languages/cpp/captures.ts +++ b/gitnexus/src/core/ingestion/languages/cpp/captures.ts @@ -743,7 +743,7 @@ function inferCppCallAdlArgs(callNode: SyntaxNode): CppAdlArgInfo[] { return out; } -const EMPTY_ADL_ARG: CppAdlArgInfo = { simpleClassName: '', isPointer: false, isReference: false }; +const EMPTY_ADL_ARG: CppAdlArgInfo = { simpleClassName: '' }; function classifyAdlArg(argNode: SyntaxNode): CppAdlArgInfo { // Literals and primitive-shaped expressions never have associated namespaces. @@ -794,12 +794,11 @@ function lookupAdlIdentifierType(identNode: SyntaxNode): CppAdlArgInfo { // Unwrap declarator chain to find pointer/reference markers and the // variable name. `init_declarator > pointer_declarator > identifier` // means pointer-typed; repeated pointer wrappers still count as pointer - // typed; `init_declarator > reference_declarator > ...` means - // reference-typed; bare `init_declarator > identifier` is value. + // typed; `init_declarator > reference_declarator > ...` (or + // `rvalue_reference_declarator`) means reference-typed; bare + // `init_declarator > identifier` is value. // Function-pointer wrappers (`pointer_declarator > function_declarator`) // must not contribute ADL associated namespaces. - let isPointer = false; - let isReference = false; let isFunctionPointer = false; let inner: SyntaxNode = declarator; let nameText: string | null = null; @@ -810,14 +809,12 @@ function lookupAdlIdentifierType(identNode: SyntaxNode): CppAdlArgInfo { isFunctionPointer = true; break; } - isPointer = true; const next = inner.childForFieldName('declarator'); if (next === null) break; inner = next; continue; } - if (inner.type === 'reference_declarator') { - isReference = true; + if (inner.type === 'reference_declarator' || inner.type === 'rvalue_reference_declarator') { // reference_declarator has a single child (the inner declarator). let next: SyntaxNode | null = null; for (let j = 0; j < inner.namedChildCount; j++) { @@ -848,7 +845,7 @@ function lookupAdlIdentifierType(identNode: SyntaxNode): CppAdlArgInfo { if (isFunctionPointer || nameText !== varName) continue; const simpleClassName = extractAdlSimpleTypeName(typeNode); - return { simpleClassName, isPointer, isReference }; + return { simpleClassName }; } return EMPTY_ADL_ARG; } diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-adl-reference-arg-boundary/app.cpp b/gitnexus/test/fixtures/lang-resolution/cpp-adl-reference-arg-boundary/app.cpp new file mode 100644 index 000000000..e32733d9b --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-adl-reference-arg-boundary/app.cpp @@ -0,0 +1,21 @@ +#include "audit.h" + +namespace app { + void runRef() { + audit::Event e; + audit::Event& s = e; + record(s); + } + + void runConstRef() { + audit::Event e; + const audit::Event& constEventRef = e; + recordConst(constEventRef); + } + + void runPrimitiveRef() { + int n = 0; + int& r = n; + note(r); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-adl-reference-arg-boundary/audit.h b/gitnexus/test/fixtures/lang-resolution/cpp-adl-reference-arg-boundary/audit.h new file mode 100644 index 000000000..4b4600381 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-adl-reference-arg-boundary/audit.h @@ -0,0 +1,5 @@ +#pragma once + +namespace audit { + struct Event {}; +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-adl-reference-arg-boundary/record.h b/gitnexus/test/fixtures/lang-resolution/cpp-adl-reference-arg-boundary/record.h new file mode 100644 index 000000000..590ac5a87 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-adl-reference-arg-boundary/record.h @@ -0,0 +1,8 @@ +#pragma once + +#include "audit.h" + +namespace audit { + void record(Event& e); + void recordConst(const Event& e); +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-adl-rvalue-ref/app.cpp b/gitnexus/test/fixtures/lang-resolution/cpp-adl-rvalue-ref/app.cpp new file mode 100644 index 000000000..8a5f57ba9 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-adl-rvalue-ref/app.cpp @@ -0,0 +1,9 @@ +#include "audit.h" + +namespace app { + void runRvalueRef() { + audit::Event e; + audit::Event&& rr = static_cast(e); + record(rr); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-adl-rvalue-ref/audit.h b/gitnexus/test/fixtures/lang-resolution/cpp-adl-rvalue-ref/audit.h new file mode 100644 index 000000000..4b4600381 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-adl-rvalue-ref/audit.h @@ -0,0 +1,5 @@ +#pragma once + +namespace audit { + struct Event {}; +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-adl-rvalue-ref/record-rvalue.h b/gitnexus/test/fixtures/lang-resolution/cpp-adl-rvalue-ref/record-rvalue.h new file mode 100644 index 000000000..ae3cf7462 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-adl-rvalue-ref/record-rvalue.h @@ -0,0 +1,7 @@ +#pragma once + +#include "audit.h" + +namespace audit { + void record(Event&& e); +} diff --git a/gitnexus/test/integration/resolvers/cpp.test.ts b/gitnexus/test/integration/resolvers/cpp.test.ts index cd95cfae7..7bc040182 100644 --- a/gitnexus/test/integration/resolvers/cpp.test.ts +++ b/gitnexus/test/integration/resolvers/cpp.test.ts @@ -2103,7 +2103,7 @@ describe('C++ two-phase template lookup — cross-file namespace variant', () => // Free-function calls with class-typed arguments must consider candidates // declared in the argument's enclosing namespace (associated namespace). // V1 boundary: only direct enclosing-namespace closure for value class- -// typed args; pointer / reference / template-spec args excluded. +// typed args; pointer and reference args included, template-spec args excluded. // --------------------------------------------------------------------------- describe('C++ ADL — basic associated-namespace closure', () => { @@ -2161,6 +2161,54 @@ describe('C++ ADL — pointer arg unwrapping', () => { }); }); +describe('C++ ADL — reference arg unwrapping', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'cpp-adl-reference-arg-boundary'), + () => {}, + ); + }, 60000); + + it('record(s) where s is audit::Event& resolves to audit::record via ADL', () => { + const calls = getRelationships(result, 'CALLS'); + const recordCalls = calls.filter((c) => c.source === 'runRef' && c.target === 'record'); + expect(recordCalls.length).toBe(1); + expect(recordCalls[0].targetFilePath).toContain('record.h'); + }); + + it('recordConst(cs) where cs is const audit::Event& resolves via ADL', () => { + const calls = getRelationships(result, 'CALLS'); + const recordCalls = calls.filter( + (c) => c.source === 'runConstRef' && c.target === 'recordConst', + ); + expect(recordCalls.length).toBe(1); + expect(recordCalls[0].targetFilePath).toContain('record.h'); + }); + + it('note(r) where r is int& emits zero CALLS edges (primitive ref)', () => { + const calls = getRelationships(result, 'CALLS'); + const noteCalls = calls.filter((c) => c.source === 'runPrimitiveRef' && c.target === 'note'); + expect(noteCalls.length).toBe(0); + }); +}); + +describe('C++ ADL — rvalue reference args participate', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'cpp-adl-rvalue-ref'), () => {}); + }, 60000); + + it('record(rr) where rr is audit::Event&& resolves to audit::record via ADL', () => { + const calls = getRelationships(result, 'CALLS'); + const recordCalls = calls.filter((c) => c.source === 'runRvalueRef' && c.target === 'record'); + expect(recordCalls.length).toBe(1); + expect(recordCalls[0].targetFilePath).toContain('record-rvalue.h'); + }); +}); + describe('C++ ADL — function pointer args do not participate', () => { let result: PipelineResult; From 5ee11223301c89e49f0e7a8cfd73bb8f427fa3ab Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 14 May 2026 22:28:31 +0100 Subject: [PATCH 11/13] chore(deps)(deps-dev): bump vitest from 4.1.5 to 4.1.6 in /gitnexus (#1605) --- gitnexus/package-lock.json | 288 ++++++++++++++++++------------------- 1 file changed, 144 insertions(+), 144 deletions(-) diff --git a/gitnexus/package-lock.json b/gitnexus/package-lock.json index 15831e54d..b367a3251 100644 --- a/gitnexus/package-lock.json +++ b/gitnexus/package-lock.json @@ -142,9 +142,9 @@ } }, "node_modules/@emnapi/core": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz", - "integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==", + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", "dev": true, "license": "MIT", "optional": true, @@ -1572,9 +1572,9 @@ } }, "node_modules/@oxc-project/types": { - "version": "0.126.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.126.0.tgz", - "integrity": "sha512-oGfVtjAgwQVVpfBrbtk4e1XDyWHRFta6BS3GWVzrF8xYBT2VGQAk39yJS/wFSMrZqoiCU4oghT3Ch0HaHGIHcQ==", + "version": "0.130.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.130.0.tgz", + "integrity": "sha512-ibD2usx9JRu7f5pu2tMKMI4cpA4NgXJQoYRP4pQ7Pxmn1l6k/53qWtQWZayhYy3X4QZkt90Ot+mJEaeXouio6Q==", "dev": true, "license": "MIT", "funding": { @@ -1652,9 +1652,9 @@ "license": "BSD-3-Clause" }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.0-rc.16", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.16.tgz", - "integrity": "sha512-rhY3k7Bsae9qQfOtph2Pm2jZEA+s8Gmjoz4hhmx70K9iMQ/ddeae+xhRQcM5IuVx5ry1+bGfkvMn7D6MJggVSA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.1.tgz", + "integrity": "sha512-fJI3I0r3C3Oj/zdBCpaCmBRZYf07xpaq4yCfDDoSFm+beWNzbIl26puW8RraUdugoJw/95zerNOn6jasAhzSmg==", "cpu": [ "arm64" ], @@ -1669,9 +1669,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.0-rc.16", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.16.tgz", - "integrity": "sha512-rNz0yK078yrNn3DrdgN+PKiMOW8HfQ92jQiXxwX8yW899ayV00MLVdaCNeVBhG/TbH3ouYVObo8/yrkiectkcQ==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.1.tgz", + "integrity": "sha512-cKnAhWEsV7TPcA/5EAteDp6KcJZBQ2G+BqE7zayMMi7kMvwRsbv7WT9aOnn0WNl4SKEIf43vjS31iUPu80nzXg==", "cpu": [ "arm64" ], @@ -1686,9 +1686,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.0-rc.16", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.16.tgz", - "integrity": "sha512-r/OmdR00HmD4i79Z//xO06uEPOq5hRXdhw7nzkxQxwSavs3PSHa1ijntdpOiZ2mzOQ3fVVu8C1M19FoNM+dMUQ==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.1.tgz", + "integrity": "sha512-YKrVwQjIRBPo+5G/u03wGjbdy4q7pyzCe93DK9VJ7zkVmeg8LJ7GbgsiHWdR4xSoe4CAXRD7Bcjgbtr64bkXNg==", "cpu": [ "x64" ], @@ -1703,9 +1703,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.0-rc.16", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.16.tgz", - "integrity": "sha512-KcRE5w8h0OnjUatG8pldyD14/CQ5Phs1oxfR+3pKDjboHRo9+MkqQaiIZlZRpsxC15paeXme/I127tUa9TXJ6g==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.1.tgz", + "integrity": "sha512-z/oBsREo46SsFqBwYtFe0kpJeBijAT48O/WXLI4suiCLBkr03RTtTJMCzSdDd2znlh8VJizL09XVkQgk8IZonw==", "cpu": [ "x64" ], @@ -1720,9 +1720,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.0-rc.16", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.16.tgz", - "integrity": "sha512-bT0guA1bpxEJ/ZhTRniQf7rNF8ybvXOuWbNIeLABaV5NGjx4EtOWBTSRGWFU9ZWVkPOZ+HNFP8RMcBokBiZ0Kg==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.1.tgz", + "integrity": "sha512-ik8q7GM11zxvYxFc2PeDcT6TBvhCQMaUxfph/M5l9sKuTs/Sjg3L+Byw0F7w0ZVLBZmx30P+gG0ECzzN+MFcmQ==", "cpu": [ "arm" ], @@ -1737,9 +1737,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.0-rc.16", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.16.tgz", - "integrity": "sha512-+tHktCHWV8BDQSjemUqm/Jl/TPk3QObCTIjmdDy/nlupcujZghmKK2962LYrqFpWu+ai01AN/REOH3NEpqvYQg==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.1.tgz", + "integrity": "sha512-QoSx2EkyrrdZ6kcyE8stqZ62t0Yra8Fs5ia9lOxJrh6TMQJK7gQKmscdTHf7pOXKREKrVwOtJcQG3qVSfc866A==", "cpu": [ "arm64" ], @@ -1754,9 +1754,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.0-rc.16", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.16.tgz", - "integrity": "sha512-3fPzdREH806oRLxpTWW1Gt4tQHs0TitZFOECB2xzCFLPKnSOy90gwA7P29cksYilFO6XVRY1kzga0cL2nRjKPg==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.1.tgz", + "integrity": "sha512-uwNwFpwKeNiZawfAWBgg0VIztPTV3ihhh1vV334h9ivnNLorxnQMU6Fz8wG1Zb4Qh9LC1/MkcyT3YlDXG3Rsgg==", "cpu": [ "arm64" ], @@ -1771,9 +1771,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.0-rc.16", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.16.tgz", - "integrity": "sha512-EKwI1tSrLs7YVw+JPJT/G2dJQ1jl9qlTTTEG0V2Ok/RdOenRfBw2PQdLPyjhIu58ocdBfP7vIRN/pvMsPxs/AQ==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.1.tgz", + "integrity": "sha512-zY1bul7OWr7DFBiJ++wofXvnr8B45ce3QsQUhKrIhXsygAh7bTkwyeM1bi1a2g5C/yC/N8TZyGDEoMfm/l9mpg==", "cpu": [ "ppc64" ], @@ -1788,9 +1788,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.0-rc.16", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.16.tgz", - "integrity": "sha512-Uknladnb3Sxqu6SEcqBldQyJUpk8NleooZEc0MbRBJ4inEhRYWZX0NJu12vNf2mqAq7gsofAxHrGghiUYjhaLQ==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.1.tgz", + "integrity": "sha512-0frlsT/f4Ft6I7SMESTKnF3cZsdicQn1dCMkF/jT9wDLE+gGoiQfv1nmT9e+s7s/fekvvy6tZM2jHvI2tkbJDQ==", "cpu": [ "s390x" ], @@ -1805,9 +1805,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.0-rc.16", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.16.tgz", - "integrity": "sha512-FIb8+uG49sZBtLTn+zt1AJ20TqVcqWeSIyoVt0or7uAWesgKaHbiBh6OpA/k9v0LTt+PTrb1Lao133kP4uVxkg==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.1.tgz", + "integrity": "sha512-XABVmGp9Tg0WspTVvwduTc4fpqy6JnAUrSQe6OuyqD/03nI7r0O9OWUkMIwFrjKAIqolvqoA4ZrJppgwE0Gxmw==", "cpu": [ "x64" ], @@ -1822,9 +1822,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.0-rc.16", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.16.tgz", - "integrity": "sha512-RuERhF9/EgWxZEXYWCOaViUWHIboceK4/ivdtQ3R0T44NjLkIIlGIAVAuCddFxsZ7vnRHtNQUrt2vR2n2slB2w==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.1.tgz", + "integrity": "sha512-bV4fzswuzVcKD90o/VM6QqKxnxlDq0g2BISDLNVmxrnhpv1DDbyPhCIjYfvzYLV+MvkKKnQt2Q6AO86SEBULUQ==", "cpu": [ "x64" ], @@ -1839,9 +1839,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.0-rc.16", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.16.tgz", - "integrity": "sha512-mXcXnvd9GpazCxeUCCnZ2+YF7nut+ZOEbE4GtaiPtyY6AkhZWbK70y1KK3j+RDhjVq5+U8FySkKRb/+w0EeUwA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.1.tgz", + "integrity": "sha512-/Mh0Zhq3OP7fVs0kcQHZP6lZEthMGTaSf8UBQYSFEZDWGXXlEC+nJ6EqenaK2t4LBXMe3A+K/G2BVXXdtOr4PQ==", "cpu": [ "arm64" ], @@ -1856,9 +1856,9 @@ } }, "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.0-rc.16", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.16.tgz", - "integrity": "sha512-3Q2KQxnC8IJOLqXmUMoYwyIPZU9hzRbnHaoV3Euz+VVnjZKcY8ktnNP8T9R4/GGQtb27C/UYKABxesKWb8lsvQ==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.1.tgz", + "integrity": "sha512-+1xc9X45l8ufsBAm6Gjvx2qDRIY9lTVt0cgWNcJ+1gdhXvkbxePA60yRTwSTuXL09CMhyJmjpV7E3NoyxbqFQQ==", "cpu": [ "wasm32" ], @@ -1866,8 +1866,8 @@ "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "1.9.2", - "@emnapi/runtime": "1.9.2", + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", "@napi-rs/wasm-runtime": "^1.1.4" }, "engines": { @@ -1875,9 +1875,9 @@ } }, "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz", - "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==", + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", "dev": true, "license": "MIT", "optional": true, @@ -1886,9 +1886,9 @@ } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.0-rc.16", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.16.tgz", - "integrity": "sha512-tj7XRemQcOcFwv7qhpUxMTBbI5mWMlE4c1Omhg5+h8GuLXzyj8HviYgR+bB2DMDgRqUE+jiDleqSCRjx4aYk/Q==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.1.tgz", + "integrity": "sha512-1D+UqZdfnuR+Jy1GgMJwi85bD40H21uNmOPRWQhw4oRSuolZ/B5rixZ45DK2KXOTCvmVCecauWgEhbw8bI7tOw==", "cpu": [ "arm64" ], @@ -1903,9 +1903,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.0-rc.16", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.16.tgz", - "integrity": "sha512-PH5DRZT+F4f2PTXRXR8uJxnBq2po/xFtddyabTJVJs/ZYVHqXPEgNIr35IHTEa6bpa0Q8Awg+ymkTaGnKITw4g==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.1.tgz", + "integrity": "sha512-INAycaWuhlOK3wk4mRHGsdgwYWmd9cChdPdE9bwWmy6rn9VqVNYNFGhOdXrofXUxwHIncSiPNb8tNm8knDVIeQ==", "cpu": [ "x64" ], @@ -1920,9 +1920,9 @@ } }, "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-rc.16", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.16.tgz", - "integrity": "sha512-45+YtqxLYKDWQouLKCrpIZhke+nXxhsw+qAHVzHDVwttyBlHNBVs2K25rDXrZzhpTp9w1FlAlvweV1H++fdZoA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", "dev": true, "license": "MIT" }, @@ -1941,9 +1941,9 @@ "license": "MIT" }, "node_modules/@tybys/wasm-util": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", - "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", "dev": true, "license": "MIT", "optional": true, @@ -2132,14 +2132,14 @@ } }, "node_modules/@vitest/coverage-v8": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.5.tgz", - "integrity": "sha512-38C0/Ddb7HcRG0Z4/DUem8x57d2p9jYgp18mkaYswEOQBGsI1CG4f/hjm0ZCeaJfWhSZ4k7jgs29V1Zom7Ki9A==", + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.6.tgz", + "integrity": "sha512-36l628fQ/9a/8ihy97eOtEnvWQEdqULQOJtcaxtoNq0G1w3Mxd4szSahOaMM9/NGyZ+hyKcMtIW/WIxq0XQViQ==", "dev": true, "license": "MIT", "dependencies": { "@bcoe/v8-coverage": "^1.0.2", - "@vitest/utils": "4.1.5", + "@vitest/utils": "4.1.6", "ast-v8-to-istanbul": "^1.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", @@ -2153,8 +2153,8 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/browser": "4.1.5", - "vitest": "4.1.5" + "@vitest/browser": "4.1.6", + "vitest": "4.1.6" }, "peerDependenciesMeta": { "@vitest/browser": { @@ -2163,16 +2163,16 @@ } }, "node_modules/@vitest/expect": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.5.tgz", - "integrity": "sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==", + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.6.tgz", + "integrity": "sha512-7EHDquPthALSV0jhhjgEW8FXaviMx7rSqu8W6oqCoAuOhKov814P99QDV1pxMA3QPv21YudvJngIhjrNI4opLg==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.5", - "@vitest/utils": "4.1.5", + "@vitest/spy": "4.1.6", + "@vitest/utils": "4.1.6", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" }, @@ -2181,13 +2181,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.5.tgz", - "integrity": "sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw==", + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.6.tgz", + "integrity": "sha512-MCFc63czMjEInOlcY2cpQCvCN+KgbAn+60xu9cMgP4sKaLC5JNAKw7JH8QdAnoAC88hW1IiSNZ+GgVXlN1UcMQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.5", + "@vitest/spy": "4.1.6", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -2208,9 +2208,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.5.tgz", - "integrity": "sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==", + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.6.tgz", + "integrity": "sha512-h5SxD/IzNhZYnrSZRsUZQIC+vD0GY8cUvq0iwsmkFKixRCKLLWqCXa/FIQ4S1R+sI+PGoojkHsdNrbZiM9Qpgw==", "dev": true, "license": "MIT", "dependencies": { @@ -2221,13 +2221,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.5.tgz", - "integrity": "sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ==", + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.6.tgz", + "integrity": "sha512-nOPCmn2+yD0ZNmKdsXGv/UxMMWbMuKeD6GyYncNwdkYDxpQvrPSKYj2rWuDjC2Y4b6w6hjip5dBKFzEUuZe3vA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.5", + "@vitest/utils": "4.1.6", "pathe": "^2.0.3" }, "funding": { @@ -2235,14 +2235,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.5.tgz", - "integrity": "sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ==", + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.6.tgz", + "integrity": "sha512-YhsdE6xAVfTDmzjxL2ZDUvjj+ZsgyOKe+TdQzqkD72wIOmHka8NuGQ6NpTNZv9D2Z63fbwWKJPeVpEw4EQgYxw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.5", - "@vitest/utils": "4.1.5", + "@vitest/pretty-format": "4.1.6", + "@vitest/utils": "4.1.6", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -2251,9 +2251,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.5.tgz", - "integrity": "sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==", + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.6.tgz", + "integrity": "sha512-JFKxMx6udhwKh/Ldo270e17QX710vgunMkuPAvXjHSvC6oqLWAHhVhjg/I71q0u0CBSErIODV1Kjv0FQNSWjdg==", "dev": true, "license": "MIT", "funding": { @@ -2261,13 +2261,13 @@ } }, "node_modules/@vitest/utils": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.5.tgz", - "integrity": "sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==", + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.6.tgz", + "integrity": "sha512-FxIY+U81R3LGKCxaHHFRQ5+g6/iRgGLmeHWdp2Amj4ljQRrEIWHmZyDfDYBRZlpyqA7qKxtS9DD1dhk8RnRIVQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.5", + "@vitest/pretty-format": "4.1.6", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" }, @@ -4151,9 +4151,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", "dev": true, "funding": [ { @@ -4498,9 +4498,9 @@ "license": "MIT" }, "node_modules/postcss": { - "version": "8.5.10", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", - "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==", + "version": "8.5.14", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", + "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", "dev": true, "funding": [ { @@ -4703,14 +4703,14 @@ } }, "node_modules/rolldown": { - "version": "1.0.0-rc.16", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.16.tgz", - "integrity": "sha512-rzi5WqKzEZw3SooTt7cgm4eqIoujPIyGcJNGFL7iPEuajQw7vxMHUkXylu4/vhCkJGXsgRmxqMKXUpT6FEgl0g==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.1.tgz", + "integrity": "sha512-X0KQHljNnEkWNqqiz9zJrGunh1B0HgOxLXvnFpCOcadzcy5qohZ3tqMEUg00vncoRovXuK3ZqCT9KnnKzoInFQ==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.126.0", - "@rolldown/pluginutils": "1.0.0-rc.16" + "@oxc-project/types": "=0.130.0", + "@rolldown/pluginutils": "^1.0.0" }, "bin": { "rolldown": "bin/cli.mjs" @@ -4719,21 +4719,21 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.0-rc.16", - "@rolldown/binding-darwin-arm64": "1.0.0-rc.16", - "@rolldown/binding-darwin-x64": "1.0.0-rc.16", - "@rolldown/binding-freebsd-x64": "1.0.0-rc.16", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.16", - "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.16", - "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.16", - "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.16", - "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.16", - "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.16", - "@rolldown/binding-linux-x64-musl": "1.0.0-rc.16", - "@rolldown/binding-openharmony-arm64": "1.0.0-rc.16", - "@rolldown/binding-wasm32-wasi": "1.0.0-rc.16", - "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.16", - "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.16" + "@rolldown/binding-android-arm64": "1.0.1", + "@rolldown/binding-darwin-arm64": "1.0.1", + "@rolldown/binding-darwin-x64": "1.0.1", + "@rolldown/binding-freebsd-x64": "1.0.1", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.1", + "@rolldown/binding-linux-arm64-gnu": "1.0.1", + "@rolldown/binding-linux-arm64-musl": "1.0.1", + "@rolldown/binding-linux-ppc64-gnu": "1.0.1", + "@rolldown/binding-linux-s390x-gnu": "1.0.1", + "@rolldown/binding-linux-x64-gnu": "1.0.1", + "@rolldown/binding-linux-x64-musl": "1.0.1", + "@rolldown/binding-openharmony-arm64": "1.0.1", + "@rolldown/binding-wasm32-wasi": "1.0.1", + "@rolldown/binding-win32-arm64-msvc": "1.0.1", + "@rolldown/binding-win32-x64-msvc": "1.0.1" } }, "node_modules/router": { @@ -5612,16 +5612,16 @@ } }, "node_modules/vite": { - "version": "8.0.9", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.9.tgz", - "integrity": "sha512-t7g7GVRpMXjNpa67HaVWI/8BWtdVIQPCL2WoozXXA7LBGEFK4AkkKkHx2hAQf5x1GZSlcmEDPkVLSGahxnEEZw==", + "version": "8.0.13", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.13.tgz", + "integrity": "sha512-MFtjBYgzmSxmgA4RAfjIyXWpGe1oALnjgUTzzV7QLx/TKxCzjtMH6Fd9/eVK+5Fg1qNoz5VAwsmMs/NofrmJvw==", "dev": true, "license": "MIT", "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", - "postcss": "^8.5.10", - "rolldown": "1.0.0-rc.16", + "postcss": "^8.5.14", + "rolldown": "1.0.1", "tinyglobby": "^0.2.16" }, "bin": { @@ -5638,7 +5638,7 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.1.0", + "@vitejs/devtools": "^0.1.18", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", @@ -5690,19 +5690,19 @@ } }, "node_modules/vitest": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.5.tgz", - "integrity": "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==", + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.6.tgz", + "integrity": "sha512-6lvjbS3p9b4CrdCmguzbh2/4uoXhGE2q71R4OX5sqF9R1bo9Xd6fGrMAfvp5wnCzlBnFVdCOp6onuTQVbo8iUQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.5", - "@vitest/mocker": "4.1.5", - "@vitest/pretty-format": "4.1.5", - "@vitest/runner": "4.1.5", - "@vitest/snapshot": "4.1.5", - "@vitest/spy": "4.1.5", - "@vitest/utils": "4.1.5", + "@vitest/expect": "4.1.6", + "@vitest/mocker": "4.1.6", + "@vitest/pretty-format": "4.1.6", + "@vitest/runner": "4.1.6", + "@vitest/snapshot": "4.1.6", + "@vitest/spy": "4.1.6", + "@vitest/utils": "4.1.6", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", @@ -5730,12 +5730,12 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.5", - "@vitest/browser-preview": "4.1.5", - "@vitest/browser-webdriverio": "4.1.5", - "@vitest/coverage-istanbul": "4.1.5", - "@vitest/coverage-v8": "4.1.5", - "@vitest/ui": "4.1.5", + "@vitest/browser-playwright": "4.1.6", + "@vitest/browser-preview": "4.1.6", + "@vitest/browser-webdriverio": "4.1.6", + "@vitest/coverage-istanbul": "4.1.6", + "@vitest/coverage-v8": "4.1.6", + "@vitest/ui": "4.1.6", "happy-dom": "*", "jsdom": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" From 7fbf30201845a9e8d25b045e10f61d5a6775d75f Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Fri, 15 May 2026 05:04:42 +0100 Subject: [PATCH 12/13] feat: C++ ADL V2: include template-specialization associated namespaces (with nested template args) (#1596) --- .../src/core/ingestion/languages/cpp/adl.ts | 71 +++++-- .../core/ingestion/languages/cpp/captures.ts | 173 +++++++++++++++++- .../ingestion/languages/cpp/scope-resolver.ts | 6 +- .../cpp-adl-template-args/app.cpp | 23 +++ .../cpp-adl-template-args/audit.h | 19 ++ .../test/integration/resolvers/cpp.test.ts | 41 ++++- 6 files changed, 307 insertions(+), 26 deletions(-) create mode 100644 gitnexus/test/fixtures/lang-resolution/cpp-adl-template-args/app.cpp create mode 100644 gitnexus/test/fixtures/lang-resolution/cpp-adl-template-args/audit.h diff --git a/gitnexus/src/core/ingestion/languages/cpp/adl.ts b/gitnexus/src/core/ingestion/languages/cpp/adl.ts index 8d6c1563d..bb28c3853 100644 --- a/gitnexus/src/core/ingestion/languages/cpp/adl.ts +++ b/gitnexus/src/core/ingestion/languages/cpp/adl.ts @@ -15,14 +15,14 @@ * * ## Current boundary * - * The current implementation covers ONE associated-entity rule: an argument that's a directly-named - * class type (`audit::Event e`) contributes its **direct enclosing - * namespace** to the candidate set. V2 extends that one step to - * pointer-typed and reference-typed class args (`audit::Event* p`, - * `audit::Event& r`, `audit::Event&& rr`): they contribute the pointee / - * referred class's enclosing namespace too. Function-pointer arguments, - * template specializations, base-class associated namespaces, and the - * rest of the full closure are still deliberately excluded. + * The current implementation covers class-typed arguments (value, pointer, + * and reference) and template specializations with explicit type arguments: + * - `audit::Event e`, `audit::Event* p`, `audit::Event** pp` + * - `audit::Event& r`, `audit::Event&& rr` + * - `std::vector` (template namespace + template-arg namespaces) + * + * Function-pointer arguments, base-class associated namespaces, and the rest + * of the full closure are still deliberately excluded. * * The current implementation also short-circuits to ADL only when ordinary lookup is empty * (`findCallableBindingInScope` returned undefined). ISO C++ would @@ -67,8 +67,20 @@ import { */ export interface CppAdlArgInfo { /** Simple class-like type name (last segment of qualified name); empty - * for primitives, literals, function pointers, template specs, etc. */ + * for primitives, literals, function pointers, etc. */ readonly simpleClassName: string; + /** Template's own simple class-like name (e.g. `vector` for + * `std::vector`), empty when arg type is not a template spec. */ + readonly templateSimpleClassName: string; + /** Template's own enclosing namespace (dot-qualified, e.g. `std`), empty + * when unavailable / unqualified. */ + readonly templateNamespace: string; + /** Class-like names extracted from explicit type template arguments, + * recursively bounded. */ + readonly templateArgClassNames: readonly string[]; + /** Enclosing namespaces extracted from explicit type template arguments, + * recursively bounded. */ + readonly templateArgNamespaces: readonly string[]; } const argInfoBySite = new Map(); @@ -169,11 +181,7 @@ export function pickCppAdlCandidates( // Collect associated namespace QNames from every participating class-typed arg. const associatedNamespaces = new Set(); for (const arg of args) { - if (arg.simpleClassName === '') continue; - const classDef = findCppClassDefBySimpleName(arg.simpleClassName, scopes); - if (classDef === undefined) continue; - const nsQName = classToNamespaceQualifiedName.get(classDef.nodeId); - if (nsQName !== undefined) associatedNamespaces.add(nsQName); + collectAssociatedNamespacesForAdlArg(arg, scopes, associatedNamespaces); } if (associatedNamespaces.size === 0) return undefined; @@ -222,6 +230,41 @@ export function pickCppAdlCandidates( return ADL_AMBIGUOUS; } +function collectAssociatedNamespacesForAdlArg( + arg: CppAdlArgInfo, + scopes: ScopeResolutionIndexes, + associatedNamespaces: Set, +): void { + // For template args this may be the template name itself (e.g. `vector`); + // simple-name lookup can match project classes with the same name (known + // V1/V2 simplification). + addAssociatedNamespaceForClassName(arg.simpleClassName, scopes, associatedNamespaces); + + // Includes template-owner namespaces (e.g. `std` in std::vector). If + // that surfaces extra candidates, ADL_AMBIGUOUS suppression below prevents + // arbitrary edge emission. + if (arg.templateNamespace.length > 0) associatedNamespaces.add(arg.templateNamespace); + + for (const ns of arg.templateArgNamespaces) { + if (ns.length > 0) associatedNamespaces.add(ns); + } + for (const className of arg.templateArgClassNames) { + addAssociatedNamespaceForClassName(className, scopes, associatedNamespaces); + } +} + +function addAssociatedNamespaceForClassName( + simpleClassName: string, + scopes: ScopeResolutionIndexes, + associatedNamespaces: Set, +): void { + if (simpleClassName.length === 0) return; + const classDef = findCppClassDefBySimpleName(simpleClassName, scopes); + if (classDef === undefined) return; + const nsQName = classToNamespaceQualifiedName.get(classDef.nodeId); + if (nsQName !== undefined) associatedNamespaces.add(nsQName); +} + /** Walk upward from a Class scope, finding the innermost enclosing * Namespace scope, and return that namespace's qualified name (dot- * joined, outermost-first). Returns '' when the class has no enclosing diff --git a/gitnexus/src/core/ingestion/languages/cpp/captures.ts b/gitnexus/src/core/ingestion/languages/cpp/captures.ts index 75ef30804..338e9992b 100644 --- a/gitnexus/src/core/ingestion/languages/cpp/captures.ts +++ b/gitnexus/src/core/ingestion/languages/cpp/captures.ts @@ -720,12 +720,14 @@ function isParenthesizedFunctionCall(callNode: SyntaxNode): boolean { /** * Per-argument ADL classification: walk each argument of a free call and - * decide whether it resolves to a directly-named class or class-pointer - * type (ADL fires) or to an excluded shape such as a reference, function - * pointer, primitive, literal, or template specialization. + * classify its declared type for associated-namespace lookup. * - * Class-typed values and class pointers (`N::S`, `N::S*`, `N::S**`) all - * preserve the pointee class name for associated-namespace lookup. + * Value/pointer/reference class-typed args and template specializations + * with explicit type arguments contribute; function pointers, primitives, + * literals, and other unsupported shapes produce an empty result. + * + * Class-typed values/pointers/references (`N::S`, `N::S*`, `N::S&`) all + * preserve the class name for associated-namespace lookup. * Function pointers remain excluded even when their return type names a * class, because the associated entity is the pointed-to function type, * not the return type. @@ -743,7 +745,14 @@ function inferCppCallAdlArgs(callNode: SyntaxNode): CppAdlArgInfo[] { return out; } -const EMPTY_ADL_ARG: CppAdlArgInfo = { simpleClassName: '' }; +const ADL_TEMPLATE_RECURSION_MAX_DEPTH = 8; +const EMPTY_ADL_ARG: CppAdlArgInfo = { + simpleClassName: '', + templateSimpleClassName: '', + templateNamespace: '', + templateArgClassNames: [], + templateArgNamespaces: [], +}; function classifyAdlArg(argNode: SyntaxNode): CppAdlArgInfo { // Literals and primitive-shaped expressions never have associated namespaces. @@ -845,29 +854,175 @@ function lookupAdlIdentifierType(identNode: SyntaxNode): CppAdlArgInfo { if (isFunctionPointer || nameText !== varName) continue; const simpleClassName = extractAdlSimpleTypeName(typeNode); - return { simpleClassName }; + const { + templateSimpleClassName, + templateNamespace, + templateArgClassNames, + templateArgNamespaces, + } = extractAdlTemplateInfo(typeNode); + return { + simpleClassName, + templateSimpleClassName, + templateNamespace, + templateArgClassNames, + templateArgNamespaces, + }; } return EMPTY_ADL_ARG; } /** Extract the simple class-like type name from a `type:` field node. - * Returns '' for primitives, template specializations, and any other + * Returns '' for primitives and any other * unsupported type-only shape. Function pointers are filtered at the * declarator level in `lookupAdlIdentifierType`. */ function extractAdlSimpleTypeName(typeNode: SyntaxNode): string { + if (typeNode.type === 'type_descriptor') { + const innerType = typeNode.childForFieldName('type'); + if (innerType !== null) return extractAdlSimpleTypeName(innerType); + for (let i = 0; i < typeNode.childCount; i++) { + const child = typeNode.child(i); + if (child === null) continue; + if ( + child.type === 'type_identifier' || + child.type === 'qualified_identifier' || + child.type === 'template_type' + ) { + return extractAdlSimpleTypeName(child); + } + } + return ''; + } if (typeNode.type === 'primitive_type') return ''; if (typeNode.type === 'sized_type_specifier') return ''; if (typeNode.type === 'type_identifier') return typeNode.text; + if (typeNode.type === 'template_type') { + const nameNode = typeNode.childForFieldName('name'); + if (nameNode !== null) return extractAdlSimpleTypeName(nameNode); + const id = findFirstDescendantOfType(typeNode, 'type_identifier'); + return id !== null ? id.text : ''; + } if (typeNode.type === 'qualified_identifier') { const nameNode = typeNode.childForFieldName('name'); if (nameNode !== null) return extractAdlSimpleTypeName(nameNode); const id = findFirstDescendantOfType(typeNode, 'type_identifier'); return id !== null ? id.text : ''; } - // template_type (e.g. `vector`), function pointers, decltype — V1 excludes. + // Function pointers, decltype, etc — unsupported for ADL participation. return ''; } +function extractAdlTypeNamespace(typeNode: SyntaxNode): string { + if (typeNode.type === 'type_descriptor') { + const innerType = typeNode.childForFieldName('type'); + if (innerType !== null) return extractAdlTypeNamespace(innerType); + for (let i = 0; i < typeNode.childCount; i++) { + const child = typeNode.child(i); + if (child === null) continue; + if ( + child.type === 'qualified_identifier' || + child.type === 'template_type' || + child.type === 'type_identifier' + ) { + return extractAdlTypeNamespace(child); + } + } + return ''; + } + if (typeNode.type === 'template_type') { + const nameNode = typeNode.childForFieldName('name'); + return nameNode !== null ? extractAdlTypeNamespace(nameNode) : ''; + } + if (typeNode.type === 'qualified_identifier') { + const scope = typeNode.childForFieldName('scope'); + if (scope !== null) return normalizeCppNamespaceQName(scope.text); + return extractNamespaceFromQualifiedText(typeNode.text); + } + return ''; +} + +function extractAdlTemplateInfo(typeNode: SyntaxNode): { + templateSimpleClassName: string; + templateNamespace: string; + templateArgClassNames: string[]; + templateArgNamespaces: string[]; +} { + const templateTypeNode = findTemplateTypeNode(typeNode); + if (templateTypeNode === null) { + return { + templateSimpleClassName: '', + templateNamespace: '', + templateArgClassNames: [], + templateArgNamespaces: [], + }; + } + const templateArgClassNames: string[] = []; + const templateArgNamespaces: string[] = []; + collectAdlTemplateArgs(templateTypeNode, 0, templateArgClassNames, templateArgNamespaces); + return { + templateSimpleClassName: extractAdlSimpleTypeName(templateTypeNode), + templateNamespace: extractAdlTypeNamespace(typeNode), + templateArgClassNames, + templateArgNamespaces, + }; +} + +function collectAdlTemplateArgs( + templateTypeNode: SyntaxNode, + depth: number, + outClassNames: string[], + outNamespaces: string[], +): void { + if (depth >= ADL_TEMPLATE_RECURSION_MAX_DEPTH) return; + if (templateTypeNode.type !== 'template_type') return; + + const argList = + templateTypeNode.childForFieldName('arguments') ?? + findChildOfType(templateTypeNode, ['template_argument_list']); + if (argList === null) return; + + for (let i = 0; i < argList.namedChildCount; i++) { + const arg = argList.namedChild(i); + if (arg === null || arg.type !== 'type_descriptor') continue; + const simpleClassName = extractAdlSimpleTypeName(arg); + if (simpleClassName.length > 0) outClassNames.push(simpleClassName); + const ns = extractAdlTypeNamespace(arg); + if (ns.length > 0) outNamespaces.push(ns); + + const nestedType = arg.childForFieldName('type'); + const nestedTemplate = nestedType !== null ? findTemplateTypeNode(nestedType) : null; + if (nestedTemplate !== null) { + collectAdlTemplateArgs(nestedTemplate, depth + 1, outClassNames, outNamespaces); + } + } +} + +function findTemplateTypeNode(typeNode: SyntaxNode): SyntaxNode | null { + if (typeNode.type === 'template_type') return typeNode; + if (typeNode.type === 'type_descriptor') { + const innerType = typeNode.childForFieldName('type'); + if (innerType !== null) return findTemplateTypeNode(innerType); + return null; + } + if (typeNode.type === 'qualified_identifier') { + const nameNode = typeNode.childForFieldName('name'); + if (nameNode !== null) return findTemplateTypeNode(nameNode); + return null; + } + return null; +} + +function normalizeCppNamespaceQName(text: string): string { + const normalized = text.replace(/^::/, '').replace(/::$/, '').replace(/::/g, '.'); + return normalized; +} + +function extractNamespaceFromQualifiedText(text: string): string { + const cleaned = text.replace(/\s+/g, ''); + const idx = cleaned.lastIndexOf('::'); + if (idx <= 0) return ''; + return normalizeCppNamespaceQName(cleaned.slice(0, idx)); +} + /** * Check if a C++ function_definition or declaration has `static` storage class. */ diff --git a/gitnexus/src/core/ingestion/languages/cpp/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/cpp/scope-resolver.ts index 431717d27..e91b14428 100644 --- a/gitnexus/src/core/ingestion/languages/cpp/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/cpp/scope-resolver.ts @@ -222,8 +222,10 @@ export const cppScopeResolver: ScopeResolver = { // C++ argument-dependent / Koenig lookup (U2 of plan 2026-05-13-001). // Fires after `findCallableBindingInScope` returns undefined; surfaces // candidates from the associated namespaces of class-typed arguments. - // V1 limitation: only direct enclosing-namespace closure for value - // class-typed args; pointer/reference/template-spec args excluded. + // Current boundary: class-typed value/pointer/reference args and template + // specializations with explicit type arguments contribute associated + // namespaces. Function-pointer args, base-class associated namespaces, + // and full ordinary+ADL merge remain excluded. resolveAdlCandidates: (site, callerParsed, scopes, parsedFiles) => { // `using ns::name;` introduces `name` into ordinary unqualified lookup. // For template-class method bodies, lexical scope walks can miss this diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-adl-template-args/app.cpp b/gitnexus/test/fixtures/lang-resolution/cpp-adl-template-args/app.cpp new file mode 100644 index 000000000..5673ee229 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-adl-template-args/app.cpp @@ -0,0 +1,23 @@ +#include "audit.h" + +namespace app { + void run() { + std::vector v; + apply(v); + } + + void runNested() { + std::map> m; + applyNested(m); + } + + void runArray() { + std::array a; + applyArray(a); + } + + void runStdConflict() { + std::vector v; + applyStdConflict(v); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-adl-template-args/audit.h b/gitnexus/test/fixtures/lang-resolution/cpp-adl-template-args/audit.h new file mode 100644 index 000000000..e59392f89 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-adl-template-args/audit.h @@ -0,0 +1,19 @@ +#pragma once + +#include +#include +#include +#include + +namespace N { + struct T {}; + + void apply(std::vector v); + void applyNested(std::map> m); + void applyArray(std::array a); + void applyStdConflict(std::vector v); +} + +namespace std { + void applyStdConflict(vector v); +} diff --git a/gitnexus/test/integration/resolvers/cpp.test.ts b/gitnexus/test/integration/resolvers/cpp.test.ts index 7bc040182..0c818b1f0 100644 --- a/gitnexus/test/integration/resolvers/cpp.test.ts +++ b/gitnexus/test/integration/resolvers/cpp.test.ts @@ -2103,7 +2103,9 @@ describe('C++ two-phase template lookup — cross-file namespace variant', () => // Free-function calls with class-typed arguments must consider candidates // declared in the argument's enclosing namespace (associated namespace). // V1 boundary: only direct enclosing-namespace closure for value class- -// typed args; pointer and reference args included, template-spec args excluded. +// typed args; pointer/reference args and template specializations with +// explicit type arguments included. Function pointers and base-class +// associated namespaces remain excluded. // --------------------------------------------------------------------------- describe('C++ ADL — basic associated-namespace closure', () => { @@ -2276,6 +2278,43 @@ describe('C++ ADL — pointer-to-pointer args participate', () => { }); }); +describe('C++ ADL — template specialization args contribute associated namespaces', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'cpp-adl-template-args'), () => {}); + }, 60000); + + it('apply(v) where v is std::vector resolves to N::apply via ADL template-arg namespace', () => { + const calls = getRelationships(result, 'CALLS'); + const applyCalls = calls.filter((c) => c.source === 'run' && c.target === 'apply'); + expect(applyCalls.length).toBe(1); + expect(applyCalls[0].targetFilePath).toContain('audit.h'); + }); + + it('applyNested(m) where m is std::map> resolves via nested template-arg namespace', () => { + const calls = getRelationships(result, 'CALLS'); + const applyCalls = calls.filter((c) => c.source === 'runNested' && c.target === 'applyNested'); + expect(applyCalls.length).toBe(1); + expect(applyCalls[0].targetFilePath).toContain('audit.h'); + }); + + it('applyArray(a) where a is std::array resolves to N::applyArray (non-type arg ignored)', () => { + const calls = getRelationships(result, 'CALLS'); + const applyCalls = calls.filter((c) => c.source === 'runArray' && c.target === 'applyArray'); + expect(applyCalls.length).toBe(1); + expect(applyCalls[0].targetFilePath).toContain('audit.h'); + }); + + it('applyStdConflict(v) is suppressed when ADL surfaces both N and std candidates', () => { + const calls = getRelationships(result, 'CALLS'); + const applyCalls = calls.filter( + (c) => c.source === 'runStdConflict' && c.target === 'applyStdConflict', + ); + expect(applyCalls.length).toBe(0); + }); +}); + describe('C++ ADL — int/long-collision overloads suppress via OVERLOAD_AMBIGUOUS', () => { let result: PipelineResult; From 813acd7ec540943c44c94714544f33e3dbaeb675 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Fri, 15 May 2026 07:54:23 +0100 Subject: [PATCH 13/13] feat: C++ ADL V2: include base-class associated namespaces via MRO (#1597) * Initial plan * fix(cpp): include base-class namespaces in ADL candidate selection Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/7df6f692-1af9-43e6-82de-099ed43a60cb Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * test(cpp): clarify ADL base-namespace test names Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/7df6f692-1af9-43e6-82de-099ed43a60cb Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * test(cpp): remove stale legacy parity expected-failure entry Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/1a55a5e8-ae91-44bc-9b21-9324cdfea3de Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * test(cpp): assert base-namespace ADL tests are not parity skips Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/b3726b70-e797-4f37-955d-7d61fd28d338 Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * fix(cpp): avoid MRO amplification on ambiguous class-name ADL lookup Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/b3726b70-e797-4f37-955d-7d61fd28d338 Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * test(cpp): strengthen ADL base-namespace target identity assertions Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/b3726b70-e797-4f37-955d-7d61fd28d338 Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * test(cpp): add ADL negative cases for anonymous and unresolved bases Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/b3726b70-e797-4f37-955d-7d61fd28d338 Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * test(cpp): fix anonymous-base parity expectation and formatting Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/6a2e3cf9-beea-435c-8494-6a7a00af0f1e * fix(cpp): propagate unnamed-namespace members through #include in registry-primary resolver Anonymous-namespace contents in a header (e.g. `namespace { void f(); }`) are reachable by unqualified lookup in any TU that #includes the header per ISO C++ [basic.namespace.anon]/1 (the unnamed namespace behaves as if a `using namespace unique;` is inserted into the enclosing scope, with per-TU `unique`). The registry-primary path was filtering these defs out of `expandCppWildcardNames` via both the structural Namespace-owner check and the `isFileLocal` mark, so `hidden_probe(d)` from a TU including the header resolved to nothing while the legacy DAG returned the correct edge. Track anonymous-`namespace_definition` source ranges at capture time, resolve them to ScopeIds in `populateOwners` (parallels inline-namespace handling), and exempt those scopes from the two wildcard-expansion filters plus the `populateCppNonGloballyVisible` structural set. `markFileLocal` is preserved so the global free-call fallback still blocks cross-TU leaks for files that do NOT #include the declaring file (cpp-anon-ns-cross-file guard still passes). --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> Co-authored-by: Gergo Magyar --- .../src/core/ingestion/languages/cpp/adl.ts | 38 +++++--- .../core/ingestion/languages/cpp/captures.ts | 33 +++++-- .../languages/cpp/file-local-linkage.ts | 90 ++++++++++++++++++- .../ingestion/languages/cpp/scope-resolver.ts | 8 +- .../app.cpp | 14 +++ .../base_lib.h | 11 +++ .../app.cpp | 16 ++++ .../base_lib.h | 6 ++ .../app.cpp | 22 +++++ .../base_lib.h | 20 +++++ .../test/integration/resolvers/cpp.test.ts | 86 ++++++++++++++++++ .../test/integration/resolvers/helpers.ts | 6 -- .../resolver-parity-expected-failures.test.ts | 15 ++++ 13 files changed, 337 insertions(+), 28 deletions(-) create mode 100644 gitnexus/test/fixtures/lang-resolution/cpp-adl-base-associated-namespaces-collision/app.cpp create mode 100644 gitnexus/test/fixtures/lang-resolution/cpp-adl-base-associated-namespaces-collision/base_lib.h create mode 100644 gitnexus/test/fixtures/lang-resolution/cpp-adl-base-associated-namespaces-negative/app.cpp create mode 100644 gitnexus/test/fixtures/lang-resolution/cpp-adl-base-associated-namespaces-negative/base_lib.h create mode 100644 gitnexus/test/fixtures/lang-resolution/cpp-adl-base-associated-namespaces/app.cpp create mode 100644 gitnexus/test/fixtures/lang-resolution/cpp-adl-base-associated-namespaces/base_lib.h diff --git a/gitnexus/src/core/ingestion/languages/cpp/adl.ts b/gitnexus/src/core/ingestion/languages/cpp/adl.ts index bb28c3853..fd65ae51e 100644 --- a/gitnexus/src/core/ingestion/languages/cpp/adl.ts +++ b/gitnexus/src/core/ingestion/languages/cpp/adl.ts @@ -21,8 +21,9 @@ * - `audit::Event& r`, `audit::Event&& rr` * - `std::vector` (template namespace + template-arg namespaces) * - * Function-pointer arguments, base-class associated namespaces, and the rest - * of the full closure are still deliberately excluded. + * Function-pointer arguments and the rest of the full closure are still + * deliberately excluded. V2 additionally walks class ancestors (via MRO), + * so base-class enclosing namespaces also contribute associated namespaces. * * The current implementation also short-circuits to ADL only when ordinary lookup is empty * (`findCallableBindingInScope` returned undefined). ISO C++ would @@ -259,10 +260,19 @@ function addAssociatedNamespaceForClassName( associatedNamespaces: Set, ): void { if (simpleClassName.length === 0) return; - const classDef = findCppClassDefBySimpleName(simpleClassName, scopes); - if (classDef === undefined) return; + const classLookup = findCppClassDefBySimpleName(simpleClassName, scopes); + if (classLookup === undefined) return; + const { classDef, ambiguous } = classLookup; const nsQName = classToNamespaceQualifiedName.get(classDef.nodeId); if (nsQName !== undefined) associatedNamespaces.add(nsQName); + // Preserve V1 collision behavior for the direct class namespace, but avoid + // amplifying a same-simple-name collision by walking an arbitrary class's + // full MRO chain. + if (ambiguous) return; + for (const ancestorDefId of scopes.methodDispatch.mroFor(classDef.nodeId)) { + const ancestorNsQName = classToNamespaceQualifiedName.get(ancestorDefId); + if (ancestorNsQName !== undefined) associatedNamespaces.add(ancestorNsQName); + } } /** Walk upward from a Class scope, finding the innermost enclosing @@ -357,17 +367,25 @@ function findNamespaceDefInScope(scope: { } /** Find a class-like def by simple name across the workspace. V1 - * arbitrary-pick on collisions (multiple classes share the simple name); - * C++ ADL strictness would require full type-driven lookup, but V1 - * trades that for simplicity. */ + * still arbitrary-picks the first class on collisions (multiple classes + * share the simple name), but reports the collision so callers can avoid + * amplifying that uncertainty (for example by skipping MRO expansion). + * C++ ADL strictness would require full type-driven lookup. */ function findCppClassDefBySimpleName( simpleName: string, scopes: ScopeResolutionIndexes, -): SymbolDefinition | undefined { +): { classDef: SymbolDefinition; ambiguous: boolean } | undefined { + let firstMatch: SymbolDefinition | undefined; for (const def of scopes.defs.byId.values()) { if (def.type !== 'Class' && def.type !== 'Struct' && def.type !== 'Interface') continue; const simple = def.qualifiedName?.split('.').pop() ?? def.qualifiedName ?? ''; - if (simple === simpleName) return def; + if (simple !== simpleName) continue; + if (firstMatch === undefined) { + firstMatch = def; + continue; + } + return { classDef: firstMatch, ambiguous: true }; } - return undefined; + if (firstMatch === undefined) return undefined; + return { classDef: firstMatch, ambiguous: false }; } diff --git a/gitnexus/src/core/ingestion/languages/cpp/captures.ts b/gitnexus/src/core/ingestion/languages/cpp/captures.ts index 338e9992b..a60ad1c1a 100644 --- a/gitnexus/src/core/ingestion/languages/cpp/captures.ts +++ b/gitnexus/src/core/ingestion/languages/cpp/captures.ts @@ -10,7 +10,7 @@ import { getTreeSitterBufferSize } from '../../constants.js'; import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js'; import { splitCppInclude, splitCppUsingDecl } from './import-decomposer.js'; import { computeCppDeclarationArity, computeCppCallArity } from './arity-metadata.js'; -import { markFileLocal } from './file-local-linkage.js'; +import { markCppAnonymousNamespaceRange, markFileLocal } from './file-local-linkage.js'; import { markCppDependentBase } from './two-phase-lookup.js'; import { markCppAdlSiteArgs, markCppAdlSiteNoAdl, type CppAdlArgInfo } from './adl.js'; import { markCppInlineNamespaceRange } from './inline-namespaces.js'; @@ -200,20 +200,37 @@ export function emitCppScopeCaptures( // namespace's source range so `populateCppInlineNamespaceScopes` // (during populateOwners) can match it back to the corresponding // Namespace scope. - if (grouped['@declaration.namespace'] !== undefined) { - const anchor = grouped['@declaration.namespace']!; - const nsNode = findNodeAtRange(tree.rootNode, anchor.range, 'namespace_definition'); - if (nsNode !== null && isInlineNamespace(nsNode)) { + // `@declaration.namespace` fires only for NAMED namespaces (the query + // requires a `name: (namespace_identifier)` child). Use the unconditional + // `@scope.namespace` capture so the anonymous-namespace branch also runs. + const namespaceScopeAnchor = grouped['@declaration.namespace'] ?? grouped['@scope.namespace']; + if (namespaceScopeAnchor !== undefined) { + const nsNode = findNodeAtRange( + tree.rootNode, + namespaceScopeAnchor.range, + 'namespace_definition', + ); + if (nsNode !== null) { // Range coords stored in the shared Range shape use 1-based // line numbers (see `ast-helpers.ts` rangeForNode where // `startPosition.row + 1` is applied). Match that convention so - // `populateCppInlineNamespaceScopes` can join against `Scope.range`. - markCppInlineNamespaceRange(filePath, { + // the populators can join against `Scope.range`. + const nsRange = { startLine: nsNode.startPosition.row + 1, startCol: nsNode.startPosition.column, endLine: nsNode.endPosition.row + 1, endCol: nsNode.endPosition.column, - }); + }; + if (isInlineNamespace(nsNode)) { + markCppInlineNamespaceRange(filePath, nsRange); + } + // Anonymous namespace: `namespace_definition` with no `name` field. + // Recorded so `expandCppWildcardNames` can propagate its members + // to including TUs even though their names are also `markFileLocal`'d + // (which blocks the global free-call fallback's cross-file path). + if ((nsNode.childForFieldName?.('name') ?? null) === null) { + markCppAnonymousNamespaceRange(filePath, nsRange); + } } } diff --git a/gitnexus/src/core/ingestion/languages/cpp/file-local-linkage.ts b/gitnexus/src/core/ingestion/languages/cpp/file-local-linkage.ts index e46327558..dd5fc8c0a 100644 --- a/gitnexus/src/core/ingestion/languages/cpp/file-local-linkage.ts +++ b/gitnexus/src/core/ingestion/languages/cpp/file-local-linkage.ts @@ -35,6 +35,33 @@ const fileLocalNames = new Map>(); */ const nonGloballyVisibleNodeIds = new Map>(); +/** + * Per-file set of source-range keys identifying `namespace { ... }` blocks. + * Resolved to `ScopeId`s in `populateCppAnonymousNamespaceScopes` and + * consumed via `isCppAnonymousNamespaceScope`. + * + * Anonymous namespaces have file-local linkage but, unlike `static`, their + * members propagate to any TU that `#include`s the declaring file — each + * including TU gets its own internal-linkage copy. So for wildcard import + * expansion (`expandCppWildcardNames`) we treat anonymous-namespace owned + * defs as if declared at the enclosing scope. Cross-file unqualified + * lookup that does NOT go through `#include` is still blocked by the + * `isFileLocal` mark recorded on the def's name. + */ +const anonymousNamespaceRangesByFile = new Map>(); +const anonymousNamespaceScopeIds = new Set(); + +interface RangeKeyShape { + readonly startLine: number; + readonly startCol: number; + readonly endLine: number; + readonly endCol: number; +} + +function rangeKey(r: RangeKeyShape): string { + return `${r.startLine}:${r.startCol}:${r.endLine}:${r.endCol}`; +} + /** Record a symbol name as file-local (static or anonymous namespace). */ export function markFileLocal(filePath: string, name: string): void { let names = fileLocalNames.get(filePath); @@ -50,10 +77,51 @@ export function isFileLocal(filePath: string, name: string): boolean { return fileLocalNames.get(filePath)?.has(name) ?? false; } +/** Capture-time: record an anonymous `namespace_definition` source range. */ +export function markCppAnonymousNamespaceRange(filePath: string, range: RangeKeyShape): void { + let set = anonymousNamespaceRangesByFile.get(filePath); + if (set === undefined) { + set = new Set(); + anonymousNamespaceRangesByFile.set(filePath, set); + } + set.add(rangeKey(range)); +} + +/** Predicate consumed by `populateCppNonGloballyVisible` and + * `expandCppWildcardNames` to exempt anonymous-namespace scopes from + * the cross-file unqualified-lookup exclusion that applies to ordinary + * named namespaces. */ +export function isCppAnonymousNamespaceScope(scopeId: ScopeId): boolean { + return anonymousNamespaceScopeIds.has(scopeId); +} + /** Clear tracked file-local names (call at start of each resolution pass). */ export function clearFileLocalNames(): void { fileLocalNames.clear(); nonGloballyVisibleNodeIds.clear(); + anonymousNamespaceRangesByFile.clear(); + anonymousNamespaceScopeIds.clear(); +} + +/** Resolve recorded anonymous-namespace source ranges to `ScopeId`s. + * Must run inside `populateOwners` BEFORE `populateCppNonGloballyVisible` + * consults the resolved set. */ +export function populateCppAnonymousNamespaceScopes(parsed: { + readonly filePath: string; + readonly scopes: readonly { + readonly id: ScopeId; + readonly kind: string; + readonly range: RangeKeyShape; + }[]; +}): void { + const ranges = anonymousNamespaceRangesByFile.get(parsed.filePath); + if (ranges === undefined || ranges.size === 0) return; + for (const scope of parsed.scopes) { + if (scope.kind !== 'Namespace') continue; + if (ranges.has(rangeKey(scope.range))) { + anonymousNamespaceScopeIds.add(scope.id); + } + } } /** @@ -87,6 +155,13 @@ export function populateCppNonGloballyVisible(parsed: { // ISO C++ `[namespace.def]/p4`. Skip them here so cross-file // unqualified lookup can still see their callable defs. if (scope.kind === 'Namespace' && isCppInlineNamespaceScope(scope.id)) continue; + // Anonymous namespaces give internal linkage but their contents are + // visible at the enclosing scope within the same TU and propagate to + // any TU that `#include`s the declaring file. The `isFileLocal` mark + // (recorded on the def's name in this file) still blocks cross-file + // unqualified lookup that does not go through #include, so dropping + // the structural visibility exclusion here is safe. + if (scope.kind === 'Namespace' && anonymousNamespaceScopeIds.has(scope.id)) continue; for (const def of scope.ownedDefs) { set.add(def.nodeId); } @@ -191,9 +266,18 @@ export function expandCppWildcardNames( // including TU. When the owning scope is unknown we default to // include (preserves prior behavior for any def whose structural // ownership wasn't recorded in `Scope.ownedDefs`). + // + // Anonymous namespaces are exempt: their members propagate to the + // enclosing scope of any TU that #includes the declaring file (each + // including TU gets its own internal-linkage copy per ISO C++). const ownerScope = ownerScopeByNodeId.get(def.nodeId); + const ownerIsAnonymousNamespace = + ownerScope !== undefined && + ownerScope.kind === 'Namespace' && + anonymousNamespaceScopeIds.has(ownerScope.id); if ( ownerScope !== undefined && + !ownerIsAnonymousNamespace && (ownerScope.kind === 'Namespace' || ownerScope.kind === 'Class') ) { continue; @@ -201,7 +285,11 @@ export function expandCppWildcardNames( const name = simpleName(def); if (name === '') continue; - if (isFileLocal(target.filePath, name)) continue; + // Same exemption for the `isFileLocal` mark — anonymous-namespace + // names are recorded as file-local to suppress the global free-call + // fallback's cross-file leak, but they MUST still propagate through + // wildcard import expansion to including TUs. + if (!ownerIsAnonymousNamespace && isFileLocal(target.filePath, name)) continue; if (seen.has(name)) continue; seen.add(name); names.push(name); diff --git a/gitnexus/src/core/ingestion/languages/cpp/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/cpp/scope-resolver.ts index e91b14428..36bba7514 100644 --- a/gitnexus/src/core/ingestion/languages/cpp/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/cpp/scope-resolver.ts @@ -16,6 +16,7 @@ import { expandCppWildcardNames, isFileLocal, clearFileLocalNames, + populateCppAnonymousNamespaceScopes, populateCppNonGloballyVisible, isCppDefGloballyVisible, } from './file-local-linkage.js'; @@ -97,10 +98,11 @@ export const cppScopeResolver: ScopeResolver = { populateOwners: (parsed: ParsedFile) => { populateClassOwnedMembers(parsed); - // Resolve inline-namespace ranges (recorded at capture time) to - // ScopeIds BEFORE `populateCppNonGloballyVisible` runs, so the - // inline-namespace exemption sees the populated Set. + // Resolve inline- and anonymous-namespace ranges (recorded at capture + // time) to ScopeIds BEFORE `populateCppNonGloballyVisible` runs, so + // both exemptions see the populated Sets. populateCppInlineNamespaceScopes(parsed); + populateCppAnonymousNamespaceScopes(parsed); // Track namespace-nested and class-nested defs so the global free-call // fallback and wildcard expansion can suppress them as unqualified // cross-file callables. diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-adl-base-associated-namespaces-collision/app.cpp b/gitnexus/test/fixtures/lang-resolution/cpp-adl-base-associated-namespaces-collision/app.cpp new file mode 100644 index 000000000..dfde8899f --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-adl-base-associated-namespaces-collision/app.cpp @@ -0,0 +1,14 @@ +#include "base_lib.h" + +namespace app { + struct Token : base_one::Base {}; + + void run() { + Token t; + collide(t); + } +} + +namespace other { + struct Token : base_two::Base {}; +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-adl-base-associated-namespaces-collision/base_lib.h b/gitnexus/test/fixtures/lang-resolution/cpp-adl-base-associated-namespaces-collision/base_lib.h new file mode 100644 index 000000000..684f45aeb --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-adl-base-associated-namespaces-collision/base_lib.h @@ -0,0 +1,11 @@ +#pragma once + +namespace base_one { + struct Base {}; + void collide(Base); +} + +namespace base_two { + struct Base {}; + void collide(Base); +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-adl-base-associated-namespaces-negative/app.cpp b/gitnexus/test/fixtures/lang-resolution/cpp-adl-base-associated-namespaces-negative/app.cpp new file mode 100644 index 000000000..fe5185b3d --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-adl-base-associated-namespaces-negative/app.cpp @@ -0,0 +1,16 @@ +#include "base_lib.h" + +namespace app { + struct HiddenDerived : HiddenBase {}; + struct MissingDerived : missing_ns::UnknownBase {}; + + void run_hidden() { + HiddenDerived d; + hidden_probe(d); + } + + void run_missing() { + MissingDerived d; + unresolved_probe(d); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-adl-base-associated-namespaces-negative/base_lib.h b/gitnexus/test/fixtures/lang-resolution/cpp-adl-base-associated-namespaces-negative/base_lib.h new file mode 100644 index 000000000..64f282ccf --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-adl-base-associated-namespaces-negative/base_lib.h @@ -0,0 +1,6 @@ +#pragma once + +namespace { + struct HiddenBase {}; + void hidden_probe(HiddenBase); +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-adl-base-associated-namespaces/app.cpp b/gitnexus/test/fixtures/lang-resolution/cpp-adl-base-associated-namespaces/app.cpp new file mode 100644 index 000000000..3fc28f632 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-adl-base-associated-namespaces/app.cpp @@ -0,0 +1,22 @@ +#include "base_lib.h" + +namespace app { + struct Derived : base_lib::Base {}; + struct MultiLevel : middle_lib::Mid {}; + struct DiamondDerived : diamond_lib::LeftBranch, diamond_lib::RightBranch {}; + + void run_single() { + Derived d; + log(d); + } + + void run_multi() { + MultiLevel m; + trace(m); + } + + void run_diamond() { + DiamondDerived d; + ping(d); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-adl-base-associated-namespaces/base_lib.h b/gitnexus/test/fixtures/lang-resolution/cpp-adl-base-associated-namespaces/base_lib.h new file mode 100644 index 000000000..c3abf7d44 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-adl-base-associated-namespaces/base_lib.h @@ -0,0 +1,20 @@ +#pragma once + +namespace base_lib { + struct Base {}; + void log(Base); + + struct Root {}; + void trace(Root); +} + +namespace middle_lib { + struct Mid : base_lib::Root {}; +} + +namespace diamond_lib { + struct DiamondBase {}; + struct LeftBranch : DiamondBase {}; + struct RightBranch : DiamondBase {}; + void ping(DiamondBase); +} diff --git a/gitnexus/test/integration/resolvers/cpp.test.ts b/gitnexus/test/integration/resolvers/cpp.test.ts index 0c818b1f0..6e58c0a54 100644 --- a/gitnexus/test/integration/resolvers/cpp.test.ts +++ b/gitnexus/test/integration/resolvers/cpp.test.ts @@ -2127,6 +2127,92 @@ describe('C++ ADL — basic associated-namespace closure', () => { }); }); +describe('C++ ADL — base-class associated namespaces', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'cpp-adl-base-associated-namespaces'), + () => {}, + ); + }, 60000); + + it('resolves log(d) to base_lib::log via ADL when Derived inherits from base_lib::Base', () => { + const calls = getRelationships(result, 'CALLS'); + const logCalls = calls.filter((c) => c.source === 'run_single' && c.target === 'log'); + expect(logCalls.length).toBe(1); + expect(logCalls[0].targetFilePath).toContain('base_lib.h'); + const targetNode = result.graph.getNode(logCalls[0].rel.targetId); + expect(logCalls[0].rel.targetId).toBe('Function:base_lib.h:log'); + expect(targetNode?.properties.parameterTypes).toEqual(['Base']); + }); + + it('resolves trace(m) via full MRO walk when MultiLevel inherits via middle_lib::Mid -> base_lib::Root', () => { + const calls = getRelationships(result, 'CALLS'); + const traceCalls = calls.filter((c) => c.source === 'run_multi' && c.target === 'trace'); + expect(traceCalls.length).toBe(1); + expect(traceCalls[0].targetFilePath).toContain('base_lib.h'); + const targetNode = result.graph.getNode(traceCalls[0].rel.targetId); + expect(traceCalls[0].rel.targetId).toBe('Function:base_lib.h:trace'); + expect(targetNode?.properties.parameterTypes).toEqual(['Root']); + }); + + it('diamond inheritance contributes base namespace once (no duplicate/crash)', () => { + const calls = getRelationships(result, 'CALLS'); + const pingCalls = calls.filter((c) => c.source === 'run_diamond' && c.target === 'ping'); + expect(pingCalls.length).toBe(1); + expect(pingCalls[0].targetFilePath).toContain('base_lib.h'); + const targetNode = result.graph.getNode(pingCalls[0].rel.targetId); + expect(pingCalls[0].rel.targetId).toBe('Function:base_lib.h:ping'); + expect(targetNode?.properties.parameterTypes).toEqual(['DiamondBase']); + }); +}); + +describe('C++ ADL — base-class namespace MRO with simple-name class collisions', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'cpp-adl-base-associated-namespaces-collision'), + () => {}, + ); + }, 60000); + + it('does NOT emit CALLS for collide(t) when class-name lookup is ambiguous', () => { + const calls = getRelationships(result, 'CALLS'); + const collideCalls = calls.filter((c) => c.source === 'run' && c.target === 'collide'); + expect(collideCalls.length).toBe(0); + }); +}); + +describe('C++ ADL — base-class namespace mapping skips anonymous/unresolved bases', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'cpp-adl-base-associated-namespaces-negative'), + () => {}, + ); + }, 60000); + + it('hidden_probe(d) still resolves via ordinary lookup when declaration is visible', () => { + const calls = getRelationships(result, 'CALLS'); + const hiddenProbeCalls = calls.filter( + (c) => c.source === 'run_hidden' && c.target === 'hidden_probe', + ); + expect(hiddenProbeCalls.length).toBe(1); + expect(hiddenProbeCalls[0].targetFilePath).toContain('base_lib.h'); + }); + + it('unresolved_probe(d) emits zero CALLS when base class cannot be resolved', () => { + const calls = getRelationships(result, 'CALLS'); + const unresolvedProbeCalls = calls.filter( + (c) => c.source === 'run_missing' && c.target === 'unresolved_probe', + ); + expect(unresolvedProbeCalls.length).toBe(0); + }); +}); + describe('C++ ADL — parenthesized name suppresses ADL', () => { let result: PipelineResult; diff --git a/gitnexus/test/integration/resolvers/helpers.ts b/gitnexus/test/integration/resolvers/helpers.ts index 396e7905b..63d59e3c9 100644 --- a/gitnexus/test/integration/resolvers/helpers.ts +++ b/gitnexus/test/integration/resolvers/helpers.ts @@ -122,12 +122,6 @@ const LEGACY_RESOLVER_PARITY_EXPECTED_FAILURES: Readonly::g() -> f() does NOT bind to Base::f (dependent base)', - // The legacy DAG path has no V1/V2 ADL boundary — pointer-typed - // arguments resolve via the workspace-wide simple-name walk. The - // scope-resolver V1 ADL pass excludes pointer args (closure rules - // deferred to V2) per plan 2026-05-13-001 U2 / R4. Scope-resolver- - // only correctness win; backporting is out of scope. - 'record(p) where p is audit::Event* emits zero CALLS — V1 ADL excludes pointer args', // The legacy DAG path has no ADL_AMBIGUOUS suppression sentinel. // When ADL surfaces multiple overloads that collide after C++ // int/long normalization, legacy picks the first match arbitrarily. diff --git a/gitnexus/test/unit/scope-resolution/resolver-parity-expected-failures.test.ts b/gitnexus/test/unit/scope-resolution/resolver-parity-expected-failures.test.ts index eae68e09d..7a830efa8 100644 --- a/gitnexus/test/unit/scope-resolution/resolver-parity-expected-failures.test.ts +++ b/gitnexus/test/unit/scope-resolution/resolver-parity-expected-failures.test.ts @@ -7,6 +7,11 @@ import { const csharpNamespaceRootImportTest = 'emits the using-import edge App/Program.cs -> Models/User.cs through the scope-resolution path'; +const cppBaseNamespaceAdlTests = [ + 'resolves log(d) to base_lib::log via ADL when Derived inherits from base_lib::Base', + 'resolves trace(m) via full MRO walk when MultiLevel inherits via middle_lib::Mid -> base_lib::Root', + 'diamond inheritance contributes base namespace once (no duplicate/crash)', +] as const; describe('resolver parity expected legacy failures', () => { it('uses the same env var convention as the parity workflow', () => { @@ -44,4 +49,14 @@ describe('resolver parity expected legacy failures', () => { ), ).toBe(false); }); + + it('does not mark cpp base-namespace ADL coverage as expected failures in legacy parity', () => { + for (const testName of cppBaseNamespaceAdlTests) { + expect( + isLegacyResolverParityExpectedFailure('cpp', testName, { + REGISTRY_PRIMARY_CPP: '0', + }), + ).toBe(false); + } + }); });