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.
This commit is contained in:
Gergo Magyar 2026-05-13 15:45:49 +01:00
parent 0837329aae
commit 6a1e2a9ed0
7 changed files with 133 additions and 5 deletions

View file

@ -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())

View file

@ -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,

View file

@ -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 ──────────────────────────────────────────────────────
/**

View file

@ -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()`),

View file

@ -0,0 +1,5 @@
#include "singleton.h"
void run() {
Singleton::getInstance();
}

View file

@ -0,0 +1,6 @@
#pragma once
class Singleton {
public:
static Singleton* getInstance();
};

View file

@ -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');
});
});