From 6a1e2a9ed0e57f1bb68fe79c7e0a2e9801acbece Mon Sep 17 00:00:00 2001 From: Gergo Magyar Date: Wed, 13 May 2026 15:45:49 +0100 Subject: [PATCH] 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. --- .../src/core/ingestion/languages/cpp/query.ts | 8 +++- .../ingestion/languages/cpp/scope-resolver.ts | 42 +++++++++++++++++-- .../contract/scope-resolver.ts | 38 +++++++++++++++++ .../passes/receiver-bound-calls.ts | 10 ++++- .../caller.cpp | 5 +++ .../singleton.h | 6 +++ .../test/integration/resolvers/cpp.test.ts | 29 +++++++++++++ 7 files changed, 133 insertions(+), 5 deletions(-) create mode 100644 gitnexus/test/fixtures/lang-resolution/cpp-namespace-qualified-not-super/caller.cpp create mode 100644 gitnexus/test/fixtures/lang-resolution/cpp-namespace-qualified-not-super/singleton.h diff --git a/gitnexus/src/core/ingestion/languages/cpp/query.ts b/gitnexus/src/core/ingestion/languages/cpp/query.ts index 7d9e46e83..4e451617d 100644 --- a/gitnexus/src/core/ingestion/languages/cpp/query.ts +++ b/gitnexus/src/core/ingestion/languages/cpp/query.ts @@ -406,9 +406,15 @@ const CPP_SCOPE_QUERY = ` (call_expression function: (identifier) @reference.name) @reference.call.free -;; ─── References — qualified calls (Namespace::func()) ─────────────── +;; ─── 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()) ─────── diff --git a/gitnexus/src/core/ingestion/languages/cpp/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/cpp/scope-resolver.ts index 6e0371b95..8c5f4ccde 100644 --- a/gitnexus/src/core/ingestion/languages/cpp/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/cpp/scope-resolver.ts @@ -1,4 +1,5 @@ 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'; @@ -76,9 +77,44 @@ export const cppScopeResolver: ScopeResolver = { populateCppNonGloballyVisible(parsed); }, - isSuperReceiver: (text) => - // C++ super patterns: explicit base class call `Base::method()` - /^[A-Z]\w*::/.test(text), + // 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) => { + // Extract LHS of `::`. C++ super calls take the form `Base::method()` + // where `Base` is a direct or indirect base of the caller's + // enclosing class. Anything not in `Class::` form is not a super + // call. + const sepIdx = text.indexOf('::'); + if (sepIdx <= 0) return false; + const lhs = text.slice(0, sepIdx).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, diff --git a/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts b/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts index 99e7145ae..2662d071d 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts @@ -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 `::<...>` (or another super- + * form the language recognizes), AND + * - `` resolves (via scope chain) to a class-like def, AND + * - that class is in the MRO of the caller's enclosing class. + * + * Returns `false` for namespace-qualified calls, unresolved names, + * class-qualified calls where the class is NOT in the caller's MRO, + * and any text the simple `isSuperReceiver` hook also rejects. + */ + readonly isSuperReceiverInContext?: ( + receiverText: string, + callerScope: ScopeId, + scopes: ScopeResolutionIndexes, + ) => boolean; + // ─── Optional toggles ────────────────────────────────────────────────────── /** diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts index e4178373c..30c7cb888 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts @@ -59,6 +59,7 @@ import { narrowOverloadCandidates, isOverloadAmbiguousAfterNormalization } from type ReceiverBoundProviderSubset = Pick< ScopeResolver, | 'isSuperReceiver' + | 'isSuperReceiverInContext' | 'fieldFallbackOnMethodLookup' | 'collapseMemberCallsByCallerTarget' | 'unwrapCollectionAccessor' @@ -162,7 +163,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()`), diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-namespace-qualified-not-super/caller.cpp b/gitnexus/test/fixtures/lang-resolution/cpp-namespace-qualified-not-super/caller.cpp new file mode 100644 index 000000000..d1edc41c4 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-namespace-qualified-not-super/caller.cpp @@ -0,0 +1,5 @@ +#include "singleton.h" + +void run() { + Singleton::getInstance(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-namespace-qualified-not-super/singleton.h b/gitnexus/test/fixtures/lang-resolution/cpp-namespace-qualified-not-super/singleton.h new file mode 100644 index 000000000..aa2e255e6 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-namespace-qualified-not-super/singleton.h @@ -0,0 +1,6 @@ +#pragma once + +class Singleton { +public: + static Singleton* getInstance(); +}; diff --git a/gitnexus/test/integration/resolvers/cpp.test.ts b/gitnexus/test/integration/resolvers/cpp.test.ts index 30ff25181..1d6a3b7dc 100644 --- a/gitnexus/test/integration/resolvers/cpp.test.ts +++ b/gitnexus/test/integration/resolvers/cpp.test.ts @@ -1825,3 +1825,32 @@ describe('C++ using-namespace std smoke test', () => { 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'); + }); +});