mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-08-28 05:25:25 +00:00
* 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<List<int>> — 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 <iostream> — 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<T>::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<T> deriving from Base<T>, 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<T>` → `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<T>::method()` and `outer::v1::Base<T>::f()`. Three fixtures + four tests: - `cpp-phase5-u1-u3-qualified-base-call`: `template<class T> struct Derived : Base<T>` with `Base<T>::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<T>` as a super candidate, but the empty-MRO terminates without false edges. - `cpp-phase5-u2-u3-adl-from-derived`: `Derived : Base<T>` 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<class T> struct Derived : outer::v1::Base<T>` where `v1` is inline. Unqualified `f()` inside `Derived<T>::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 <zhoudeng.hwj@alibaba-inc.com> Co-authored-by: Gergo Magyar <gergomagyar@icloud.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
This commit is contained in:
parent
e9349ce66a
commit
e01f0912bc
87 changed files with 5445 additions and 24 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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<string> = 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).
|
||||
});
|
||||
|
|
|
|||
335
gitnexus/src/core/ingestion/languages/cpp/adl.ts
Normal file
335
gitnexus/src/core/ingestion/languages/cpp/adl.ts
Normal file
|
|
@ -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<string, readonly CppAdlArgInfo[]>();
|
||||
const noAdlSites = new Set<string>();
|
||||
const classToNamespaceQualifiedName = new Map<string, string>();
|
||||
|
||||
/** 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<ScopeId, (typeof parsed.scopes)[number]>();
|
||||
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<string>();
|
||||
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<string>();
|
||||
for (const parsed of parsedFiles) {
|
||||
const scopesById = new Map<ScopeId, (typeof parsed.scopes)[number]>();
|
||||
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;
|
||||
}
|
||||
185
gitnexus/src/core/ingestion/languages/cpp/arity-metadata.ts
Normal file
185
gitnexus/src/core/ingestion/languages/cpp/arity-metadata.ts
Normal file
|
|
@ -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<typename... Ts> 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<List<int>> → 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<string, string> = {
|
||||
'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;
|
||||
}
|
||||
35
gitnexus/src/core/ingestion/languages/cpp/arity.ts
Normal file
35
gitnexus/src/core/ingestion/languages/cpp/arity.ts
Normal file
|
|
@ -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';
|
||||
}
|
||||
832
gitnexus/src/core/ingestion/languages/cpp/captures.ts
Normal file
832
gitnexus/src/core/ingestion/languages/cpp/captures.ts
Normal file
|
|
@ -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<ReturnType<typeof getCppParser>['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<string>();
|
||||
|
||||
for (const m of rawMatches) {
|
||||
const grouped: Record<string, Capture> = {};
|
||||
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<T>`) 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<string> {
|
||||
const names = new Set<string>();
|
||||
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<int N>`).
|
||||
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<template<class> 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<SyntaxNode> {
|
||||
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<T>` where `T` is a template parameter), OR
|
||||
* - it contains a `typename`, `decltype`, or `template_template_parameter`
|
||||
* shape (conservatively treated as dependent).
|
||||
*
|
||||
* Non-dependent: `Base<int>`, `ConcreteBase`, `Base<MyConcrete>` where
|
||||
* `MyConcrete` is not a template parameter.
|
||||
*/
|
||||
function isBaseDependent(baseNode: SyntaxNode, templateParams: Set<string>): 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<int>`), 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;
|
||||
}
|
||||
214
gitnexus/src/core/ingestion/languages/cpp/file-local-linkage.ts
Normal file
214
gitnexus/src/core/ingestion/languages/cpp/file-local-linkage.ts
Normal file
|
|
@ -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<string, Set<string>>();
|
||||
|
||||
/**
|
||||
* 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<string, Set<string>>();
|
||||
|
||||
/** 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<string>();
|
||||
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<string>();
|
||||
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<string, Scope>();
|
||||
for (const scope of target.scopes) {
|
||||
for (const ownedDef of scope.ownedDefs) {
|
||||
ownerScopeByNodeId.set(ownedDef.nodeId, scope);
|
||||
}
|
||||
}
|
||||
|
||||
const seen = new Set<string>();
|
||||
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 ?? '';
|
||||
}
|
||||
53
gitnexus/src/core/ingestion/languages/cpp/header-scan.ts
Normal file
53
gitnexus/src/core/ingestion/languages/cpp/header-scan.ts
Normal file
|
|
@ -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<string> {
|
||||
const headers = new Set<string>();
|
||||
walk(repoPath, repoPath, headers);
|
||||
return headers;
|
||||
}
|
||||
|
||||
function walk(dir: string, root: string, out: Set<string>): 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, '/'));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
120
gitnexus/src/core/ingestion/languages/cpp/import-decomposer.ts
Normal file
120
gitnexus/src/core/ingestion/languages/cpp/import-decomposer.ts
Normal file
|
|
@ -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<string, Capture> = {
|
||||
'@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 <name>;
|
||||
// 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 <qualified_identifier>; (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),
|
||||
};
|
||||
}
|
||||
18
gitnexus/src/core/ingestion/languages/cpp/import-target.ts
Normal file
18
gitnexus/src/core/ingestion/languages/cpp/import-target.ts
Normal file
|
|
@ -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>,
|
||||
): string | null {
|
||||
return resolveCImportTarget(targetRaw, fromFile, allFilePaths);
|
||||
}
|
||||
16
gitnexus/src/core/ingestion/languages/cpp/index.ts
Normal file
16
gitnexus/src/core/ingestion/languages/cpp/index.ts
Normal file
|
|
@ -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';
|
||||
170
gitnexus/src/core/ingestion/languages/cpp/inline-namespaces.ts
Normal file
170
gitnexus/src/core/ingestion/languages/cpp/inline-namespaces.ts
Normal file
|
|
@ -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<string, Set<string>>();
|
||||
const inlineNamespaceScopeIds = new Set<ScopeId>();
|
||||
|
||||
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<ScopeId, (typeof parsed.scopes)[number]>();
|
||||
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;
|
||||
}
|
||||
112
gitnexus/src/core/ingestion/languages/cpp/interpret.ts
Normal file
112
gitnexus/src/core/ingestion/languages/cpp/interpret.ts
Normal file
|
|
@ -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<List<int>> → 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;
|
||||
}
|
||||
38
gitnexus/src/core/ingestion/languages/cpp/merge-bindings.ts
Normal file
38
gitnexus/src/core/ingestion/languages/cpp/merge-bindings.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import type { BindingRef } from 'gitnexus-shared';
|
||||
|
||||
const TIER: Record<BindingRef['origin'], number> = {
|
||||
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<string>();
|
||||
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;
|
||||
});
|
||||
}
|
||||
462
gitnexus/src/core/ingestion/languages/cpp/query.ts
Normal file
462
gitnexus/src/core/ingestion/languages/cpp/query.ts
Normal file
|
|
@ -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<Dog>()) ─
|
||||
;; 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<Dog>()) ───────
|
||||
;; 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<T>()) ────────────────────────
|
||||
(call_expression
|
||||
function: (template_function
|
||||
name: (identifier) @reference.name)) @reference.call.free
|
||||
|
||||
;; Note: Ns::func<T>() 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<Parser['setLanguage']>[0]);
|
||||
}
|
||||
return _parser;
|
||||
}
|
||||
|
||||
export function getCppScopeQuery(): Parser.Query {
|
||||
if (_query === null) {
|
||||
_query = new Parser.Query(CPP as Parameters<Parser['setLanguage']>[0], CPP_SCOPE_QUERY);
|
||||
}
|
||||
return _query;
|
||||
}
|
||||
255
gitnexus/src/core/ingestion/languages/cpp/range-bindings.ts
Normal file
255
gitnexus/src/core/ingestion/languages/cpp/range-bindings.ts
Normal file
|
|
@ -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<string, string>;
|
||||
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<typeof parser.parse> | 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<string, TypeRef>;
|
||||
|
||||
// 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<User>` text needed for element-type
|
||||
* extraction.
|
||||
*/
|
||||
function buildParamTemplateMap(rootNode: TsNode): Map<string, string> {
|
||||
const map = new Map<string, string>();
|
||||
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>` → `User`
|
||||
* - `std::vector<User>` → `User`
|
||||
* - `map<std::string, User>` → `User` (last template arg)
|
||||
* - `map<string, User>` → `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<string, Scope>,
|
||||
): 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;
|
||||
}
|
||||
230
gitnexus/src/core/ingestion/languages/cpp/scope-resolver.ts
Normal file
230
gitnexus/src/core/ingestion/languages/cpp/scope-resolver.ts
Normal file
|
|
@ -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<User>` ≡ `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<string> | 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<T>::method()`, text is `Base<T>`). 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<T>::method()` → `Base`; `outer::v1::Base<T>` →
|
||||
// `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<T>::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),
|
||||
};
|
||||
79
gitnexus/src/core/ingestion/languages/cpp/simple-hooks.ts
Normal file
79
gitnexus/src/core/ingestion/languages/cpp/simple-hooks.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
133
gitnexus/src/core/ingestion/languages/cpp/two-phase-lookup.ts
Normal file
133
gitnexus/src/core/ingestion/languages/cpp/two-phase-lookup.ts
Normal file
|
|
@ -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<T>::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<className, Set<dependentBaseSimpleName>>
|
||||
*/
|
||||
const dependentBasesByFile = new Map<string, Map<string, Set<string>>>();
|
||||
|
||||
/**
|
||||
* 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<string, Set<string>>();
|
||||
|
||||
/**
|
||||
* 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<string, string>();
|
||||
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<T>::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);
|
||||
}
|
||||
|
|
@ -72,6 +72,7 @@ export const MIGRATED_LANGUAGES: ReadonlySet<SupportedLanguages> = new Set<Suppo
|
|||
SupportedLanguages.TypeScript,
|
||||
SupportedLanguages.Go,
|
||||
SupportedLanguages.C,
|
||||
SupportedLanguages.CPlusPlus,
|
||||
SupportedLanguages.PHP,
|
||||
]);
|
||||
|
||||
|
|
|
|||
|
|
@ -432,9 +432,47 @@ export interface ScopeResolver {
|
|||
* `/^super\s*\(/.test(t)`. Java returns `t === 'super'`. C++ may
|
||||
* also need `this` capture. Languages without inheritance return
|
||||
* constant `false`.
|
||||
*
|
||||
* For languages where the answer depends on caller context (e.g.
|
||||
* C++, where `Base::method()` is a super call ONLY when `Base` is
|
||||
* actually a base of the caller's enclosing class, and namespace-
|
||||
* qualified calls like `Singleton::getInstance()` must NOT be
|
||||
* misclassified), implement the optional `isSuperReceiverInContext`
|
||||
* variant below. The receiver-bound-calls pass prefers the context-
|
||||
* aware variant when both are defined.
|
||||
*/
|
||||
isSuperReceiver(receiverText: string): boolean;
|
||||
|
||||
/**
|
||||
* Optional context-aware variant of `isSuperReceiver`. When defined,
|
||||
* the receiver-bound-calls pass prefers this hook over the simple
|
||||
* `isSuperReceiver(text)` form. Languages where super classification
|
||||
* is purely text-driven (Python, Java, PHP) omit this hook and the
|
||||
* simple form is used unchanged.
|
||||
*
|
||||
* C++ uses this to distinguish `Base::method()` (super call when
|
||||
* `Base` is in the caller's MRO) from `Singleton::getInstance()`
|
||||
* (ordinary namespace-qualified call). Without this, the regex
|
||||
* heuristic `/^[A-Z]\w*::/` misclassifies any uppercase-qualified
|
||||
* call as a super-receiver call and routes it through the wrong
|
||||
* resolution branch.
|
||||
*
|
||||
* Returns `true` ONLY when:
|
||||
* - the receiver text parses as `<Name>::<...>` (or another super-
|
||||
* form the language recognizes), AND
|
||||
* - `<Name>` 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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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<SupportedLanguages, ScopeResolver> = n
|
|||
[SupportedLanguages.Go, goScopeResolver],
|
||||
[SupportedLanguages.Java, javaScopeResolver],
|
||||
[SupportedLanguages.C, cScopeResolver],
|
||||
[SupportedLanguages.CPlusPlus, cppScopeResolver],
|
||||
[SupportedLanguages.PHP, phpScopeResolver],
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -308,6 +308,7 @@ export function runScopeResolution(
|
|||
allowGlobalFallback: provider.allowGlobalFreeCallFallback === true,
|
||||
isFileLocalDef: provider.isFileLocalDef,
|
||||
isCallableVisibleFromCaller: provider.isCallableVisibleFromCaller,
|
||||
resolveAdlCandidates: provider.resolveAdlCandidates,
|
||||
},
|
||||
);
|
||||
const { emitted, skipped } = emitReferencesViaLookup(
|
||||
|
|
|
|||
|
|
@ -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 } : {}),
|
||||
});
|
||||
}
|
||||
|
|
|
|||
7
gitnexus/test/fixtures/lang-resolution/cpp-adl-ambiguous/alpha.h
vendored
Normal file
7
gitnexus/test/fixtures/lang-resolution/cpp-adl-ambiguous/alpha.h
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
#pragma once
|
||||
|
||||
namespace alpha {
|
||||
struct Token {};
|
||||
void process(Token t, int n);
|
||||
void process(Token t, long n);
|
||||
}
|
||||
8
gitnexus/test/fixtures/lang-resolution/cpp-adl-ambiguous/app.cpp
vendored
Normal file
8
gitnexus/test/fixtures/lang-resolution/cpp-adl-ambiguous/app.cpp
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
#include "alpha.h"
|
||||
|
||||
namespace app {
|
||||
void run() {
|
||||
alpha::Token t;
|
||||
process(t, 42);
|
||||
}
|
||||
}
|
||||
8
gitnexus/test/fixtures/lang-resolution/cpp-adl-basic/app.cpp
vendored
Normal file
8
gitnexus/test/fixtures/lang-resolution/cpp-adl-basic/app.cpp
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
#include "audit.h"
|
||||
|
||||
namespace app {
|
||||
void run() {
|
||||
audit::Event e;
|
||||
record(e);
|
||||
}
|
||||
}
|
||||
6
gitnexus/test/fixtures/lang-resolution/cpp-adl-basic/audit.h
vendored
Normal file
6
gitnexus/test/fixtures/lang-resolution/cpp-adl-basic/audit.h
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
#pragma once
|
||||
|
||||
namespace audit {
|
||||
struct Event {};
|
||||
void record(Event e);
|
||||
}
|
||||
8
gitnexus/test/fixtures/lang-resolution/cpp-adl-pointer-arg-boundary/app.cpp
vendored
Normal file
8
gitnexus/test/fixtures/lang-resolution/cpp-adl-pointer-arg-boundary/app.cpp
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
#include "audit.h"
|
||||
|
||||
namespace app {
|
||||
void run() {
|
||||
audit::Event* p;
|
||||
record(p);
|
||||
}
|
||||
}
|
||||
6
gitnexus/test/fixtures/lang-resolution/cpp-adl-pointer-arg-boundary/audit.h
vendored
Normal file
6
gitnexus/test/fixtures/lang-resolution/cpp-adl-pointer-arg-boundary/audit.h
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
#pragma once
|
||||
|
||||
namespace audit {
|
||||
struct Event {};
|
||||
void record(Event* e);
|
||||
}
|
||||
8
gitnexus/test/fixtures/lang-resolution/cpp-adl-suppressed-parens/app.cpp
vendored
Normal file
8
gitnexus/test/fixtures/lang-resolution/cpp-adl-suppressed-parens/app.cpp
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
#include "audit.h"
|
||||
|
||||
namespace app {
|
||||
void run() {
|
||||
audit::Event e;
|
||||
(record)(e);
|
||||
}
|
||||
}
|
||||
6
gitnexus/test/fixtures/lang-resolution/cpp-adl-suppressed-parens/audit.h
vendored
Normal file
6
gitnexus/test/fixtures/lang-resolution/cpp-adl-suppressed-parens/audit.h
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
#pragma once
|
||||
|
||||
namespace audit {
|
||||
struct Event {};
|
||||
void record(Event e);
|
||||
}
|
||||
5
gitnexus/test/fixtures/lang-resolution/cpp-anon-ns-cross-file/caller.cpp
vendored
Normal file
5
gitnexus/test/fixtures/lang-resolution/cpp-anon-ns-cross-file/caller.cpp
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
void worker();
|
||||
|
||||
void run() {
|
||||
worker();
|
||||
}
|
||||
7
gitnexus/test/fixtures/lang-resolution/cpp-anon-ns-cross-file/helper.cpp
vendored
Normal file
7
gitnexus/test/fixtures/lang-resolution/cpp-anon-ns-cross-file/helper.cpp
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
namespace {
|
||||
void worker() {}
|
||||
}
|
||||
|
||||
void helper_entry() {
|
||||
worker();
|
||||
}
|
||||
7
gitnexus/test/fixtures/lang-resolution/cpp-anon-ns-same-file-visible/helper.cpp
vendored
Normal file
7
gitnexus/test/fixtures/lang-resolution/cpp-anon-ns-same-file-visible/helper.cpp
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
namespace {
|
||||
void w() {}
|
||||
}
|
||||
|
||||
void run() {
|
||||
w();
|
||||
}
|
||||
5
gitnexus/test/fixtures/lang-resolution/cpp-include-no-class-leak/caller.cpp
vendored
Normal file
5
gitnexus/test/fixtures/lang-resolution/cpp-include-no-class-leak/caller.cpp
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
#include "user.h"
|
||||
|
||||
void run() {
|
||||
save();
|
||||
}
|
||||
6
gitnexus/test/fixtures/lang-resolution/cpp-include-no-class-leak/user.h
vendored
Normal file
6
gitnexus/test/fixtures/lang-resolution/cpp-include-no-class-leak/user.h
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
#pragma once
|
||||
|
||||
class User {
|
||||
public:
|
||||
void save();
|
||||
};
|
||||
5
gitnexus/test/fixtures/lang-resolution/cpp-include-no-namespace-leak/caller.cpp
vendored
Normal file
5
gitnexus/test/fixtures/lang-resolution/cpp-include-no-namespace-leak/caller.cpp
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
#include "lib.h"
|
||||
|
||||
void run() {
|
||||
foo();
|
||||
}
|
||||
5
gitnexus/test/fixtures/lang-resolution/cpp-include-no-namespace-leak/lib.h
vendored
Normal file
5
gitnexus/test/fixtures/lang-resolution/cpp-include-no-namespace-leak/lib.h
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
#pragma once
|
||||
|
||||
namespace ns {
|
||||
void foo();
|
||||
}
|
||||
8
gitnexus/test/fixtures/lang-resolution/cpp-inline-namespace-adl-participation/app.cpp
vendored
Normal file
8
gitnexus/test/fixtures/lang-resolution/cpp-inline-namespace-adl-participation/app.cpp
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
#include "audit.h"
|
||||
|
||||
namespace app {
|
||||
void run() {
|
||||
audit::Event e;
|
||||
record(e);
|
||||
}
|
||||
}
|
||||
8
gitnexus/test/fixtures/lang-resolution/cpp-inline-namespace-adl-participation/audit.h
vendored
Normal file
8
gitnexus/test/fixtures/lang-resolution/cpp-inline-namespace-adl-participation/audit.h
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
#pragma once
|
||||
|
||||
namespace audit {
|
||||
inline namespace v1 {
|
||||
struct Event {};
|
||||
void record(Event e);
|
||||
}
|
||||
}
|
||||
5
gitnexus/test/fixtures/lang-resolution/cpp-inline-namespace-nested/caller.cpp
vendored
Normal file
5
gitnexus/test/fixtures/lang-resolution/cpp-inline-namespace-nested/caller.cpp
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
#include "lib.h"
|
||||
|
||||
void run() {
|
||||
outer::foo();
|
||||
}
|
||||
9
gitnexus/test/fixtures/lang-resolution/cpp-inline-namespace-nested/lib.h
vendored
Normal file
9
gitnexus/test/fixtures/lang-resolution/cpp-inline-namespace-nested/lib.h
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
#pragma once
|
||||
|
||||
namespace outer {
|
||||
inline namespace v1 {
|
||||
inline namespace experimental {
|
||||
void foo();
|
||||
}
|
||||
}
|
||||
}
|
||||
5
gitnexus/test/fixtures/lang-resolution/cpp-inline-namespace-unqualified/caller.cpp
vendored
Normal file
5
gitnexus/test/fixtures/lang-resolution/cpp-inline-namespace-unqualified/caller.cpp
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
#include "lib.h"
|
||||
|
||||
void run() {
|
||||
outer::foo();
|
||||
}
|
||||
7
gitnexus/test/fixtures/lang-resolution/cpp-inline-namespace-unqualified/lib.h
vendored
Normal file
7
gitnexus/test/fixtures/lang-resolution/cpp-inline-namespace-unqualified/lib.h
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
#pragma once
|
||||
|
||||
namespace outer {
|
||||
inline namespace v1 {
|
||||
void foo();
|
||||
}
|
||||
}
|
||||
5
gitnexus/test/fixtures/lang-resolution/cpp-inline-namespace-versioned/caller.cpp
vendored
Normal file
5
gitnexus/test/fixtures/lang-resolution/cpp-inline-namespace-versioned/caller.cpp
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
#include "lib.h"
|
||||
|
||||
void run() {
|
||||
outer::foo();
|
||||
}
|
||||
10
gitnexus/test/fixtures/lang-resolution/cpp-inline-namespace-versioned/lib.h
vendored
Normal file
10
gitnexus/test/fixtures/lang-resolution/cpp-inline-namespace-versioned/lib.h
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
#pragma once
|
||||
|
||||
namespace outer {
|
||||
inline namespace v1 {
|
||||
void foo();
|
||||
}
|
||||
namespace v0 {
|
||||
void foo();
|
||||
}
|
||||
}
|
||||
5
gitnexus/test/fixtures/lang-resolution/cpp-namespace-qualified-not-super/caller.cpp
vendored
Normal file
5
gitnexus/test/fixtures/lang-resolution/cpp-namespace-qualified-not-super/caller.cpp
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
#include "singleton.h"
|
||||
|
||||
void run() {
|
||||
Singleton::getInstance();
|
||||
}
|
||||
6
gitnexus/test/fixtures/lang-resolution/cpp-namespace-qualified-not-super/singleton.h
vendored
Normal file
6
gitnexus/test/fixtures/lang-resolution/cpp-namespace-qualified-not-super/singleton.h
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
#pragma once
|
||||
|
||||
class Singleton {
|
||||
public:
|
||||
static Singleton* getInstance();
|
||||
};
|
||||
6
gitnexus/test/fixtures/lang-resolution/cpp-overload-default-arg-ambiguous/caller.cpp
vendored
Normal file
6
gitnexus/test/fixtures/lang-resolution/cpp-overload-default-arg-ambiguous/caller.cpp
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
#include "service.h"
|
||||
|
||||
void run() {
|
||||
S s;
|
||||
s.f(1);
|
||||
}
|
||||
4
gitnexus/test/fixtures/lang-resolution/cpp-overload-default-arg-ambiguous/service.cpp
vendored
Normal file
4
gitnexus/test/fixtures/lang-resolution/cpp-overload-default-arg-ambiguous/service.cpp
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
#include "service.h"
|
||||
|
||||
void S::f(int) {}
|
||||
void S::f(int, int) {}
|
||||
7
gitnexus/test/fixtures/lang-resolution/cpp-overload-default-arg-ambiguous/service.h
vendored
Normal file
7
gitnexus/test/fixtures/lang-resolution/cpp-overload-default-arg-ambiguous/service.h
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
#pragma once
|
||||
|
||||
class S {
|
||||
public:
|
||||
void f(int);
|
||||
void f(int, int = 0);
|
||||
};
|
||||
6
gitnexus/test/fixtures/lang-resolution/cpp-overload-int-long/caller.cpp
vendored
Normal file
6
gitnexus/test/fixtures/lang-resolution/cpp-overload-int-long/caller.cpp
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
#include "service.h"
|
||||
|
||||
void run() {
|
||||
Service s;
|
||||
s.process(42);
|
||||
}
|
||||
4
gitnexus/test/fixtures/lang-resolution/cpp-overload-int-long/service.cpp
vendored
Normal file
4
gitnexus/test/fixtures/lang-resolution/cpp-overload-int-long/service.cpp
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
#include "service.h"
|
||||
|
||||
void Service::process(int x) {}
|
||||
void Service::process(long x) {}
|
||||
7
gitnexus/test/fixtures/lang-resolution/cpp-overload-int-long/service.h
vendored
Normal file
7
gitnexus/test/fixtures/lang-resolution/cpp-overload-int-long/service.h
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
#pragma once
|
||||
|
||||
class Service {
|
||||
public:
|
||||
void process(int x);
|
||||
void process(long x);
|
||||
};
|
||||
13
gitnexus/test/fixtures/lang-resolution/cpp-phase5-u1-u3-qualified-base-call/classes.h
vendored
Normal file
13
gitnexus/test/fixtures/lang-resolution/cpp-phase5-u1-u3-qualified-base-call/classes.h
vendored
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
#pragma once
|
||||
|
||||
template<class T>
|
||||
struct Base {
|
||||
void method();
|
||||
};
|
||||
|
||||
template<class T>
|
||||
struct Derived : Base<T> {
|
||||
void g() {
|
||||
Base<T>::method();
|
||||
}
|
||||
};
|
||||
6
gitnexus/test/fixtures/lang-resolution/cpp-phase5-u2-u3-adl-from-derived/audit.h
vendored
Normal file
6
gitnexus/test/fixtures/lang-resolution/cpp-phase5-u2-u3-adl-from-derived/audit.h
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
#pragma once
|
||||
|
||||
namespace audit {
|
||||
struct Event {};
|
||||
void record(Event e);
|
||||
}
|
||||
8
gitnexus/test/fixtures/lang-resolution/cpp-phase5-u2-u3-adl-from-derived/base.h
vendored
Normal file
8
gitnexus/test/fixtures/lang-resolution/cpp-phase5-u2-u3-adl-from-derived/base.h
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
#pragma once
|
||||
|
||||
#include "audit.h"
|
||||
|
||||
template<class T>
|
||||
struct Base {
|
||||
void record(audit::Event e);
|
||||
};
|
||||
11
gitnexus/test/fixtures/lang-resolution/cpp-phase5-u2-u3-adl-from-derived/derived.h
vendored
Normal file
11
gitnexus/test/fixtures/lang-resolution/cpp-phase5-u2-u3-adl-from-derived/derived.h
vendored
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
#pragma once
|
||||
|
||||
#include "base.h"
|
||||
|
||||
template<class T>
|
||||
struct Derived : Base<T> {
|
||||
void g() {
|
||||
audit::Event e;
|
||||
record(e);
|
||||
}
|
||||
};
|
||||
10
gitnexus/test/fixtures/lang-resolution/cpp-phase5-u3-u5-inline-base/base.h
vendored
Normal file
10
gitnexus/test/fixtures/lang-resolution/cpp-phase5-u3-u5-inline-base/base.h
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
#pragma once
|
||||
|
||||
namespace outer {
|
||||
inline namespace v1 {
|
||||
template<class T>
|
||||
struct Base {
|
||||
void f();
|
||||
};
|
||||
}
|
||||
}
|
||||
10
gitnexus/test/fixtures/lang-resolution/cpp-phase5-u3-u5-inline-base/derived.h
vendored
Normal file
10
gitnexus/test/fixtures/lang-resolution/cpp-phase5-u3-u5-inline-base/derived.h
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
#pragma once
|
||||
|
||||
#include "base.h"
|
||||
|
||||
template<class T>
|
||||
struct Derived : outer::v1::Base<T> {
|
||||
void g() {
|
||||
f();
|
||||
}
|
||||
};
|
||||
7
gitnexus/test/fixtures/lang-resolution/cpp-two-phase-dependent-base/base.h
vendored
Normal file
7
gitnexus/test/fixtures/lang-resolution/cpp-two-phase-dependent-base/base.h
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
#pragma once
|
||||
|
||||
template<class T>
|
||||
struct Base {
|
||||
void f();
|
||||
int i;
|
||||
};
|
||||
13
gitnexus/test/fixtures/lang-resolution/cpp-two-phase-dependent-base/derived.h
vendored
Normal file
13
gitnexus/test/fixtures/lang-resolution/cpp-two-phase-dependent-base/derived.h
vendored
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
#pragma once
|
||||
|
||||
#include "base.h"
|
||||
|
||||
template<class T>
|
||||
struct Derived : Base<T> {
|
||||
void g() {
|
||||
f();
|
||||
}
|
||||
int h() {
|
||||
return i;
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
#pragma once
|
||||
|
||||
template<class T>
|
||||
struct Base {
|
||||
void unused();
|
||||
};
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
#pragma once
|
||||
|
||||
#include "base.h"
|
||||
#include "helpers.h"
|
||||
|
||||
template<class T>
|
||||
struct D : Base<T> {
|
||||
void g() {
|
||||
utils::ns_helper();
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
#pragma once
|
||||
|
||||
namespace utils {
|
||||
void ns_helper();
|
||||
}
|
||||
5
gitnexus/test/fixtures/lang-resolution/cpp-two-phase-non-dependent-base/concrete-base.h
vendored
Normal file
5
gitnexus/test/fixtures/lang-resolution/cpp-two-phase-non-dependent-base/concrete-base.h
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
#pragma once
|
||||
|
||||
struct ConcreteBase {
|
||||
void f();
|
||||
};
|
||||
10
gitnexus/test/fixtures/lang-resolution/cpp-two-phase-non-dependent-base/derived.h
vendored
Normal file
10
gitnexus/test/fixtures/lang-resolution/cpp-two-phase-non-dependent-base/derived.h
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
#pragma once
|
||||
|
||||
#include "concrete-base.h"
|
||||
|
||||
template<class T>
|
||||
struct Derived : ConcreteBase {
|
||||
void g() {
|
||||
f();
|
||||
}
|
||||
};
|
||||
7
gitnexus/test/fixtures/lang-resolution/cpp-two-phase-this-qualified/base.h
vendored
Normal file
7
gitnexus/test/fixtures/lang-resolution/cpp-two-phase-this-qualified/base.h
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
#pragma once
|
||||
|
||||
template<class T>
|
||||
struct Base {
|
||||
void f();
|
||||
int i;
|
||||
};
|
||||
13
gitnexus/test/fixtures/lang-resolution/cpp-two-phase-this-qualified/derived.h
vendored
Normal file
13
gitnexus/test/fixtures/lang-resolution/cpp-two-phase-this-qualified/derived.h
vendored
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
#pragma once
|
||||
|
||||
#include "base.h"
|
||||
|
||||
template<class T>
|
||||
struct Derived : Base<T> {
|
||||
void g() {
|
||||
this->f();
|
||||
}
|
||||
int h() {
|
||||
return this->i;
|
||||
}
|
||||
};
|
||||
5
gitnexus/test/fixtures/lang-resolution/cpp-using-namespace-conflict/a.h
vendored
Normal file
5
gitnexus/test/fixtures/lang-resolution/cpp-using-namespace-conflict/a.h
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
#pragma once
|
||||
|
||||
namespace a {
|
||||
void foo();
|
||||
}
|
||||
5
gitnexus/test/fixtures/lang-resolution/cpp-using-namespace-conflict/b.h
vendored
Normal file
5
gitnexus/test/fixtures/lang-resolution/cpp-using-namespace-conflict/b.h
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
#pragma once
|
||||
|
||||
namespace b {
|
||||
void foo();
|
||||
}
|
||||
9
gitnexus/test/fixtures/lang-resolution/cpp-using-namespace-conflict/caller.cpp
vendored
Normal file
9
gitnexus/test/fixtures/lang-resolution/cpp-using-namespace-conflict/caller.cpp
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
#include "a.h"
|
||||
#include "b.h"
|
||||
|
||||
using namespace a;
|
||||
using namespace b;
|
||||
|
||||
void run() {
|
||||
foo();
|
||||
}
|
||||
9
gitnexus/test/fixtures/lang-resolution/cpp-using-namespace-std-smoke/caller.cpp
vendored
Normal file
9
gitnexus/test/fixtures/lang-resolution/cpp-using-namespace-std-smoke/caller.cpp
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
#include "std-shim.h"
|
||||
|
||||
using namespace std;
|
||||
|
||||
void project_helper();
|
||||
|
||||
void run() {
|
||||
project_helper();
|
||||
}
|
||||
1
gitnexus/test/fixtures/lang-resolution/cpp-using-namespace-std-smoke/helper.cpp
vendored
Normal file
1
gitnexus/test/fixtures/lang-resolution/cpp-using-namespace-std-smoke/helper.cpp
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
void project_helper() {}
|
||||
13
gitnexus/test/fixtures/lang-resolution/cpp-using-namespace-std-smoke/std-shim.h
vendored
Normal file
13
gitnexus/test/fixtures/lang-resolution/cpp-using-namespace-std-smoke/std-shim.h
vendored
Normal file
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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 <iostream> — 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<T>::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<T>::g() -> f() does NOT bind to Base<T>::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<T>::h() -> i does NOT bind to Base<T>::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<T>::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<T>::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<T>` 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<T>::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<T> (inline)', () => {
|
||||
let result: PipelineResult;
|
||||
|
||||
beforeAll(async () => {
|
||||
result = await runPipelineFromRepo(
|
||||
path.join(FIXTURES, 'cpp-phase5-u3-u5-inline-base'),
|
||||
() => {},
|
||||
);
|
||||
}, 60000);
|
||||
|
||||
it('unqualified f() inside Derived<T>::g() does NOT bind to outer::v1::Base<T>::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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -90,6 +90,74 @@ const LEGACY_RESOLVER_PARITY_EXPECTED_FAILURES: Readonly<Record<string, Readonly
|
|||
'binds the call to alpha/services/sync.py, not omega',
|
||||
'lex tiebreak still picks alpha/services/sync.py with reversed file-write order',
|
||||
]),
|
||||
cpp: new Set<string>([
|
||||
// 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<T>::g() -> f() does NOT bind to Base<T>::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<T>::method() does NOT mis-route to a class method outside the MRO',
|
||||
'unqualified f() inside Derived<T>::g() does NOT bind to outer::v1::Base<T>::f (dependent base across inline namespace)',
|
||||
]),
|
||||
};
|
||||
|
||||
type ResolverParityEnv = Readonly<Record<string, string | undefined>>;
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
168
gitnexus/test/unit/scope-resolution/cpp/cpp-arity.test.ts
Normal file
168
gitnexus/test/unit/scope-resolution/cpp/cpp-arity.test.ts
Normal file
|
|
@ -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> = {}): 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');
|
||||
});
|
||||
});
|
||||
426
gitnexus/test/unit/scope-resolution/cpp/cpp-captures.test.ts
Normal file
426
gitnexus/test/unit/scope-resolution/cpp/cpp-captures.test.ts
Normal file
|
|
@ -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 <vector>
|
||||
void f() {
|
||||
std::vector<int> 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 <typename T> 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 <typename T> 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 <iostream>', (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);
|
||||
});
|
||||
});
|
||||
161
gitnexus/test/unit/scope-resolution/cpp/cpp-imports.test.ts
Normal file
161
gitnexus/test/unit/scope-resolution/cpp/cpp-imports.test.ts
Normal file
|
|
@ -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 <iostream>"', () => {
|
||||
const node = parseNode('#include <iostream>', '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();
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue