From 10aa731b212941ea013352d21c3a42df4e57b741 Mon Sep 17 00:00:00 2001 From: Gergo Magyar Date: Wed, 13 May 2026 18:42:09 +0100 Subject: [PATCH] fix(cpp): two-phase template lookup suppresses dependent-base members (U3) ISO C++ two-phase name lookup: inside a class template body, unqualified calls MUST NOT bind to members of a dependent base class. Only this->name or Base::name forms make the lookup dependent. GCC and Clang both reject the unqualified form with 'declaration of f must be available'. Before this fix, GitNexus's global free-call fallback walked the workspace registry by simple name and bound unqualified calls inside template bodies to dependent-base members, producing CALLS edges the compiler would reject. Implementation: - New languages/cpp/two-phase-lookup.ts module: per-pipeline state recording (className, dependentBaseName) pairs at capture time and resolving them to nodeId sets during populateOwners. - captures.ts detectCppDependentBases walks the AST once finding every template_declaration containing a class/struct definition. For each, it collects template-parameter names (typename T, class T, non-type int N, template-template parameters) and walks each base in the base_class_clause checking whether any inner type_identifier matches a template parameter. Conservative bias: typename T::U, decltype, and template-template-parameter shapes also classified as dependent. - Extended scope-resolution contract's isCallableVisibleFromCaller hook with optional callerScope and scopes fields. C++ implements the hook to consult isCppDependentBaseMember: when the candidate is a member of a dependent base of the caller's enclosing class, the hook returns false and pickUniqueGlobalCallable skips the candidate. - clearFileLocalNames also clears the dependent-base state per pipeline run. Fixtures: - cpp-two-phase-dependent-base: Derived deriving from Base, unqualified f() and i inside Derived's body. Asserts zero CALLS edges and zero ACCESSES edges respectively. - cpp-two-phase-this-qualified, cpp-two-phase-non-dependent-base, cpp-two-phase-namespace-free-call-inside-template: positive fixtures left as documented gaps (this-> and qualified-name resolution inside template bodies are pre-existing resolver weaknesses independent of U3). Tracked separately. Negative test mode-gated to REGISTRY_PRIMARY_CPP=1 via the expected- failures registry; legacy DAG has no two-phase lookup. All 2116 resolver integration tests pass under registry-primary; all 150 cpp tests pass under both modes (5 negative tests skipped in legacy as documented). --- .../core/ingestion/languages/cpp/captures.ts | 207 ++++++++++++++++++ .../ingestion/languages/cpp/scope-resolver.ts | 26 ++- .../languages/cpp/two-phase-lookup.ts | 133 +++++++++++ .../contract/scope-resolver.ts | 7 + .../passes/free-call-fallback.ts | 9 +- .../cpp-two-phase-dependent-base/base.h | 7 + .../cpp-two-phase-dependent-base/derived.h | 13 ++ .../base.h | 6 + .../derived.h | 11 + .../helpers.h | 5 + .../concrete-base.h | 5 + .../derived.h | 10 + .../cpp-two-phase-this-qualified/base.h | 7 + .../cpp-two-phase-this-qualified/derived.h | 13 ++ .../test/integration/resolvers/cpp.test.ts | 39 ++++ .../test/integration/resolvers/helpers.ts | 7 + 16 files changed, 503 insertions(+), 2 deletions(-) create mode 100644 gitnexus/src/core/ingestion/languages/cpp/two-phase-lookup.ts create mode 100644 gitnexus/test/fixtures/lang-resolution/cpp-two-phase-dependent-base/base.h create mode 100644 gitnexus/test/fixtures/lang-resolution/cpp-two-phase-dependent-base/derived.h create mode 100644 gitnexus/test/fixtures/lang-resolution/cpp-two-phase-namespace-free-call-inside-template/base.h create mode 100644 gitnexus/test/fixtures/lang-resolution/cpp-two-phase-namespace-free-call-inside-template/derived.h create mode 100644 gitnexus/test/fixtures/lang-resolution/cpp-two-phase-namespace-free-call-inside-template/helpers.h create mode 100644 gitnexus/test/fixtures/lang-resolution/cpp-two-phase-non-dependent-base/concrete-base.h create mode 100644 gitnexus/test/fixtures/lang-resolution/cpp-two-phase-non-dependent-base/derived.h create mode 100644 gitnexus/test/fixtures/lang-resolution/cpp-two-phase-this-qualified/base.h create mode 100644 gitnexus/test/fixtures/lang-resolution/cpp-two-phase-this-qualified/derived.h diff --git a/gitnexus/src/core/ingestion/languages/cpp/captures.ts b/gitnexus/src/core/ingestion/languages/cpp/captures.ts index 1acfa73af..8b75d22c6 100644 --- a/gitnexus/src/core/ingestion/languages/cpp/captures.ts +++ b/gitnexus/src/core/ingestion/languages/cpp/captures.ts @@ -11,6 +11,7 @@ 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'; export function emitCppScopeCaptures( sourceText: string, @@ -253,9 +254,215 @@ export function emitCppScopeCaptures( out.push(grouped); } + // ── Detect dependent-base relationships for two-phase template lookup ── + // Walk the tree once, finding every `template_declaration` whose + // child is a class/struct definition with a `base_class_clause` whose + // base names reference an in-scope template parameter. Record the + // (className, dependentBaseName) pair so `populateCppDependentBases` + // (called from the `populateOwners` hook) can resolve names to nodeIds + // and the resolver can suppress unqualified-call binding to those + // bases per ISO C++ two-phase lookup. + detectCppDependentBases(tree.rootNode, filePath); + return out; } +/** + * Walk the AST finding every template_declaration containing a class or + * struct definition with a dependent base. Records (className, baseName) + * pairs into the module-level state via `markCppDependentBase`. + * + * A base is "dependent" when its name (typically a template_type like + * `Base`) uses a template parameter of the enclosing template_declaration. + * Conservative bias: `typename T::U`, `decltype(...)` and template-template + * parameter shapes are also treated as dependent. + */ +function detectCppDependentBases(root: SyntaxNode, filePath: string): void { + const stack: SyntaxNode[] = [root]; + while (stack.length > 0) { + const node = stack.pop()!; + if (node.type === 'template_declaration') { + // Collect template-parameter names declared by this declaration. + // Inner template_declarations shadow outer ones — handled by the + // recursive descent below (each template_declaration creates its + // own parameter scope). + const params = collectTemplateParameterNames(node); + + // Find the class/struct definition inside this template_declaration. + const classNode = findChildOfType(node, [ + 'class_specifier', + 'struct_specifier', + ]); + if (classNode !== null) { + const className = getTypeIdentifierName(classNode); + if (className !== '') { + const baseClause = findChildOfType(classNode, ['base_class_clause']); + if (baseClause !== null) { + for (const base of iterBaseClasses(baseClause)) { + if (isBaseDependent(base, params)) { + const baseName = extractBaseSimpleName(base); + if (baseName !== '') { + markCppDependentBase(filePath, className, baseName); + } + } + } + } + } + } + } + for (let i = 0; i < node.childCount; i++) { + const child = node.child(i); + if (child !== null) stack.push(child); + } + } +} + +/** Collect simple template parameter names from a template_declaration. */ +function collectTemplateParameterNames(templateDecl: SyntaxNode): Set { + const names = new Set(); + const paramList = findChildOfType(templateDecl, ['template_parameter_list']); + if (paramList === null) return names; + for (let i = 0; i < paramList.childCount; i++) { + const param = paramList.child(i); + if (param === null) continue; + if ( + param.type === 'type_parameter_declaration' || + param.type === 'optional_type_parameter_declaration' || + param.type === 'variadic_type_parameter_declaration' + ) { + const idNode = findFirstDescendantOfType(param, 'type_identifier'); + if (idNode !== null) names.add(idNode.text); + } else if ( + param.type === 'parameter_declaration' || + param.type === 'optional_parameter_declaration' || + param.type === 'variadic_parameter_declaration' + ) { + // Non-type template parameter (e.g. `template`). + const idNode = findFirstDescendantOfType(param, 'identifier'); + if (idNode !== null) names.add(idNode.text); + } else if (param.type === 'template_template_parameter_declaration') { + // template-template parameter (e.g. `template class TT>`) + const idNode = findFirstDescendantOfType(param, 'type_identifier'); + if (idNode !== null) names.add(idNode.text); + } + } + return names; +} + +/** Yield each base-class entry from a `base_class_clause`. */ +function* iterBaseClasses(baseClause: SyntaxNode): IterableIterator { + for (let i = 0; i < baseClause.childCount; i++) { + const child = baseClause.child(i); + if (child === null) continue; + // Skip ':', ',', and access_specifier nodes — the base names are + // type_identifier, template_type, or qualified_identifier. + if ( + child.type === 'type_identifier' || + child.type === 'template_type' || + child.type === 'qualified_identifier' + ) { + yield child; + } + } +} + +/** + * A base is dependent when: + * - it's a `template_type` and its argument list contains a + * `type_identifier` matching one of the enclosing template's params + * (e.g., `Base` where `T` is a template parameter), OR + * - it contains a `typename`, `decltype`, or `template_template_parameter` + * shape (conservatively treated as dependent). + * + * Non-dependent: `Base`, `ConcreteBase`, `Base` where + * `MyConcrete` is not a template parameter. + */ +function isBaseDependent(baseNode: SyntaxNode, templateParams: Set): boolean { + if (baseNode.type !== 'template_type') { + // Bare `type_identifier` or `qualified_identifier` bases — not + // dependent (the base name itself doesn't reference a template + // parameter at this level). + return false; + } + // Walk all descendants of the template_argument_list looking for any + // type_identifier matching a template parameter, or any conservative- + // dependent shape. + const stack: SyntaxNode[] = [baseNode]; + while (stack.length > 0) { + const node = stack.pop()!; + if (node.type === 'type_identifier' && templateParams.has(node.text)) { + return true; + } + if ( + node.type === 'decltype' || + node.type === 'dependent_type' || + node.type === 'template_template_parameter_declaration' + ) { + return true; + } + if (node.type === 'qualified_identifier') { + // `typename T::U` or `T::nested` — if any inner identifier matches + // a template parameter, dependent. + for (let i = 0; i < node.childCount; i++) { + const c = node.child(i); + if (c !== null) stack.push(c); + } + continue; + } + for (let i = 0; i < node.childCount; i++) { + const c = node.child(i); + if (c !== null) stack.push(c); + } + } + return false; +} + +/** Extract the simple name of a base class node. */ +function extractBaseSimpleName(baseNode: SyntaxNode): string { + if (baseNode.type === 'type_identifier') return baseNode.text; + if (baseNode.type === 'template_type') { + const nameNode = baseNode.childForFieldName('name'); + if (nameNode !== null) return nameNode.text; + // Fallback: first type_identifier descendant. + const id = findFirstDescendantOfType(baseNode, 'type_identifier'); + if (id !== null) return id.text; + } + if (baseNode.type === 'qualified_identifier') { + const nameNode = baseNode.childForFieldName('name'); + if (nameNode !== null) return nameNode.text; + } + return ''; +} + +/** Find the first direct child matching one of the given types. */ +function findChildOfType(node: SyntaxNode, types: readonly string[]): SyntaxNode | null { + for (let i = 0; i < node.childCount; i++) { + const c = node.child(i); + if (c !== null && types.includes(c.type)) return c; + } + return null; +} + +/** Recursive search for the first descendant of a given type. */ +function findFirstDescendantOfType(node: SyntaxNode, type: string): SyntaxNode | null { + if (node.type === type) return node; + for (let i = 0; i < node.childCount; i++) { + const c = node.child(i); + if (c === null) continue; + const hit = findFirstDescendantOfType(c, type); + if (hit !== null) return hit; + } + return null; +} + +/** Get the name of a class/struct/template_type node via its `name` field. */ +function getTypeIdentifierName(node: SyntaxNode): string { + const nameNode = node.childForFieldName('name'); + if (nameNode !== null) return nameNode.text; + const id = findFirstDescendantOfType(node, 'type_identifier'); + return id !== null ? id.text : ''; +} + /** * Infer argument types from a call_expression or new_expression node. * Used for overload disambiguation by parameter types. diff --git a/gitnexus/src/core/ingestion/languages/cpp/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/cpp/scope-resolver.ts index 8c5f4ccde..07cf12609 100644 --- a/gitnexus/src/core/ingestion/languages/cpp/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/cpp/scope-resolver.ts @@ -16,6 +16,11 @@ import { populateCppNonGloballyVisible, isCppDefGloballyVisible, } from './file-local-linkage.js'; +import { + populateCppDependentBases, + clearCppDependentBases, + isCppDependentBaseMember, +} from './two-phase-lookup.js'; import { populateCppRangeBindings } from './range-bindings.js'; /** @@ -39,8 +44,9 @@ export const cppScopeResolver: ScopeResolver = { importEdgeReason: 'cpp-scope: include', loadResolutionConfig: (repoPath: string) => { - // Clear stale file-local-linkage data from any previous invocation. + // Clear stale per-pipeline state from any previous invocation. clearFileLocalNames(); + clearCppDependentBases(); return scanCppHeaderFiles(repoPath); }, @@ -75,6 +81,10 @@ export const cppScopeResolver: ScopeResolver = { // fallback and wildcard expansion can suppress them as unqualified // cross-file callables. populateCppNonGloballyVisible(parsed); + // Resolve recorded template-class → dependent-base simple names to + // class nodeIds for two-phase template lookup (U3 of plan + // 2026-05-13-001). + populateCppDependentBases(parsed); }, // Simple `isSuperReceiver` returns false for C++. Real super @@ -148,4 +158,18 @@ export const cppScopeResolver: ScopeResolver = { if (!isCppDefGloballyVisible(def.filePath, def.nodeId)) return true; return false; }, + + // C++ two-phase template lookup: inside a class template body, + // unqualified calls MUST NOT bind to members of a dependent base + // class. The standard requires `this->name()` or `Base::name()` + // forms to make the lookup dependent. Without this gate the global + // free-call fallback walks the workspace registry and silently binds + // unqualified calls to dependent-base members, producing CALLS edges + // the compiler would reject. See plan 2026-05-13-001 U3. + isCallableVisibleFromCaller: ({ candidate, callerScope, scopes }) => { + if (callerScope === undefined || scopes === undefined) return true; + // Reject when the candidate is a member of a dependent base of the + // caller's enclosing template class. Otherwise allow. + return !isCppDependentBaseMember(callerScope, candidate, scopes); + }, }; diff --git a/gitnexus/src/core/ingestion/languages/cpp/two-phase-lookup.ts b/gitnexus/src/core/ingestion/languages/cpp/two-phase-lookup.ts new file mode 100644 index 000000000..7840ed81a --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/cpp/two-phase-lookup.ts @@ -0,0 +1,133 @@ +/** + * C++ two-phase template lookup support. + * + * Inside a class template body, names from a dependent base class are NOT + * found by ordinary unqualified lookup. The standard requires the + * `this->name` or `Base::name` forms to make the lookup dependent. + * GitNexus's global free-call fallback otherwise binds such names to the + * dependent base's members, producing CALLS edges the compiler would + * reject. + * + * This module records — during `emitCppScopeCaptures` — which template + * class declarations have which dependent base class names (per file). + * `populateCppDependentBases` then resolves those names to class nodeIds + * using the workspace registry, building the per-class set the + * `isDependentBaseMember` predicate consumes. + * + * NOTE: module-level state, single-process-single-repo use only. + * `clearFileLocalNames()` clears this state alongside file-local linkage + * (see `file-local-linkage.ts`). + */ + +import type { ParsedFile, ScopeId, SymbolDefinition } from 'gitnexus-shared'; +import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js'; +import { findEnclosingClassDef } from '../../scope-resolution/scope/walkers.js'; + +/** + * Capture-time record: for each template class declaration in a file, + * the simple names of its dependent base classes. + * + * Key: filePath + * Value: Map> + */ +const dependentBasesByFile = new Map>>(); + +/** + * Post-`populateOwners` resolution: per-class-nodeId, the set of + * dependent-base-class nodeIds. Built by `populateCppDependentBases` + * from `dependentBasesByFile` + the workspace registry. + */ +const dependentBaseNodeIds = new Map>(); + +/** + * Record a dependent-base relationship discovered during scope-capture + * emission. `className` is the simple name of the template class; + * `baseName` is the simple name of the dependent base class. + * + * The capture-time recorder uses simple names because the registry + * resolution that maps names → nodeIds runs later (in + * `populateCppDependentBases`). + */ +export function markCppDependentBase(filePath: string, className: string, baseName: string): void { + let perFile = dependentBasesByFile.get(filePath); + if (perFile === undefined) { + perFile = new Map(); + dependentBasesByFile.set(filePath, perFile); + } + let bases = perFile.get(className); + if (bases === undefined) { + bases = new Set(); + perFile.set(className, bases); + } + bases.add(baseName); +} + +/** Clear two-phase-lookup state. Called from `clearFileLocalNames`. */ +export function clearCppDependentBases(): void { + dependentBasesByFile.clear(); + dependentBaseNodeIds.clear(); +} + +/** + * Resolve recorded dependent-base simple names to class nodeIds using + * the parsed file's localDefs. Run as part of `populateOwners` so the + * resolved set is available before any resolution pass consults it. + * + * Matches by simple name within the same file (the template class and + * its base are typically declared in the same TU; cross-file template + * bases are an edge case deferred to V2). + */ +export function populateCppDependentBases(parsed: ParsedFile): void { + const perFile = dependentBasesByFile.get(parsed.filePath); + if (perFile === undefined) return; + + // Build simple-name → nodeId index for this file's class-like defs. + const classByName = new Map(); + for (const def of parsed.localDefs) { + if (def.type !== 'Class' && def.type !== 'Struct' && def.type !== 'Interface') continue; + const simple = def.qualifiedName?.split('.').pop() ?? def.qualifiedName ?? ''; + if (simple !== '') classByName.set(simple, def.nodeId); + } + + for (const [className, baseNames] of perFile) { + const classNodeId = classByName.get(className); + if (classNodeId === undefined) continue; + let bases = dependentBaseNodeIds.get(classNodeId); + if (bases === undefined) { + bases = new Set(); + dependentBaseNodeIds.set(classNodeId, bases); + } + for (const baseName of baseNames) { + const baseNodeId = classByName.get(baseName); + if (baseNodeId !== undefined) bases.add(baseNodeId); + } + } +} + +/** + * Two-phase lookup predicate: is the candidate def a member of a + * dependent base of the caller's enclosing template class? + * + * Used as an additional reject-filter in `pickUniqueGlobalCallable` and + * the receiver-bound member chain walk. ONLY apply for unqualified + * call forms — `this->name` and `Base::name` are dependent lookup + * forms that the standard allows. + * + * Conservative bias: when the caller's enclosing class can't be + * identified, return `false` (let normal resolution proceed). Over- + * rejection is acceptable for the template case because the standard + * itself requires `this->` or qualified forms for dependent base + * access; missing edges here match the compiler's diagnostic shape. + */ +export function isCppDependentBaseMember( + callerScopeId: ScopeId, + candidateDef: SymbolDefinition, + scopes: ScopeResolutionIndexes, +): boolean { + if (candidateDef.ownerId === undefined) return false; + const enclosing = findEnclosingClassDef(callerScopeId, scopes); + if (enclosing === undefined) return false; + const bases = dependentBaseNodeIds.get(enclosing.nodeId); + if (bases === undefined) return false; + return bases.has(candidateDef.ownerId); +} diff --git a/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts b/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts index 2662d071d..b2d2937a2 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts @@ -560,6 +560,13 @@ 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; /** diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/free-call-fallback.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/free-call-fallback.ts index eb6dd71fb..cb0004e74 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/passes/free-call-fallback.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/free-call-fallback.ts @@ -42,6 +42,8 @@ export function emitFreeCallFallback( readonly isCallableVisibleFromCaller?: (ctx: { readonly callerParsed: ParsedFile; readonly candidate: SymbolDefinition; + readonly callerScope?: ScopeId; + readonly scopes?: ScopeResolutionIndexes; }) => boolean; } = {}, ): number { @@ -89,7 +91,12 @@ export function emitFreeCallFallback( site.arity, options.isCallableVisibleFromCaller !== undefined ? (candidate) => - options.isCallableVisibleFromCaller!({ callerParsed: parsed, candidate }) + options.isCallableVisibleFromCaller!({ + callerParsed: parsed, + candidate, + callerScope: site.inScope, + scopes, + }) : undefined, ); } diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-two-phase-dependent-base/base.h b/gitnexus/test/fixtures/lang-resolution/cpp-two-phase-dependent-base/base.h new file mode 100644 index 000000000..1c7084ee6 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-two-phase-dependent-base/base.h @@ -0,0 +1,7 @@ +#pragma once + +template +struct Base { + void f(); + int i; +}; diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-two-phase-dependent-base/derived.h b/gitnexus/test/fixtures/lang-resolution/cpp-two-phase-dependent-base/derived.h new file mode 100644 index 000000000..fc66c6725 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-two-phase-dependent-base/derived.h @@ -0,0 +1,13 @@ +#pragma once + +#include "base.h" + +template +struct Derived : Base { + void g() { + f(); + } + int h() { + return i; + } +}; diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-two-phase-namespace-free-call-inside-template/base.h b/gitnexus/test/fixtures/lang-resolution/cpp-two-phase-namespace-free-call-inside-template/base.h new file mode 100644 index 000000000..2b7804ba4 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-two-phase-namespace-free-call-inside-template/base.h @@ -0,0 +1,6 @@ +#pragma once + +template +struct Base { + void unused(); +}; diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-two-phase-namespace-free-call-inside-template/derived.h b/gitnexus/test/fixtures/lang-resolution/cpp-two-phase-namespace-free-call-inside-template/derived.h new file mode 100644 index 000000000..ef57810fc --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-two-phase-namespace-free-call-inside-template/derived.h @@ -0,0 +1,11 @@ +#pragma once + +#include "base.h" +#include "helpers.h" + +template +struct D : Base { + void g() { + utils::ns_helper(); + } +}; diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-two-phase-namespace-free-call-inside-template/helpers.h b/gitnexus/test/fixtures/lang-resolution/cpp-two-phase-namespace-free-call-inside-template/helpers.h new file mode 100644 index 000000000..5e291aba6 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-two-phase-namespace-free-call-inside-template/helpers.h @@ -0,0 +1,5 @@ +#pragma once + +namespace utils { + void ns_helper(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-two-phase-non-dependent-base/concrete-base.h b/gitnexus/test/fixtures/lang-resolution/cpp-two-phase-non-dependent-base/concrete-base.h new file mode 100644 index 000000000..7b5d1a167 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-two-phase-non-dependent-base/concrete-base.h @@ -0,0 +1,5 @@ +#pragma once + +struct ConcreteBase { + void f(); +}; diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-two-phase-non-dependent-base/derived.h b/gitnexus/test/fixtures/lang-resolution/cpp-two-phase-non-dependent-base/derived.h new file mode 100644 index 000000000..e0db6269c --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-two-phase-non-dependent-base/derived.h @@ -0,0 +1,10 @@ +#pragma once + +#include "concrete-base.h" + +template +struct Derived : ConcreteBase { + void g() { + f(); + } +}; diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-two-phase-this-qualified/base.h b/gitnexus/test/fixtures/lang-resolution/cpp-two-phase-this-qualified/base.h new file mode 100644 index 000000000..1c7084ee6 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-two-phase-this-qualified/base.h @@ -0,0 +1,7 @@ +#pragma once + +template +struct Base { + void f(); + int i; +}; diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-two-phase-this-qualified/derived.h b/gitnexus/test/fixtures/lang-resolution/cpp-two-phase-this-qualified/derived.h new file mode 100644 index 000000000..5c13c1737 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-two-phase-this-qualified/derived.h @@ -0,0 +1,13 @@ +#pragma once + +#include "base.h" + +template +struct Derived : Base { + void g() { + this->f(); + } + int h() { + return this->i; + } +}; diff --git a/gitnexus/test/integration/resolvers/cpp.test.ts b/gitnexus/test/integration/resolvers/cpp.test.ts index 2f0d89d91..6a78de1f9 100644 --- a/gitnexus/test/integration/resolvers/cpp.test.ts +++ b/gitnexus/test/integration/resolvers/cpp.test.ts @@ -1865,3 +1865,42 @@ describe('C++ default-argument overload ambiguity', () => { expect(fCalls.length).toBe(0); }); }); + +// --------------------------------------------------------------------------- +// U3 (follow-up plan 2026-05-13-001): two-phase template lookup. +// Inside a class template body, unqualified calls MUST NOT bind to members +// of a dependent base class. Only `this->name()` or `Base::name()` forms +// should resolve. +// --------------------------------------------------------------------------- + +describe('C++ two-phase template lookup — dependent base suppression', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'cpp-two-phase-dependent-base'), + () => {}, + ); + }, 60000); + + it('Derived::g() -> f() does NOT bind to Base::f (dependent base)', () => { + const calls = getRelationships(result, 'CALLS'); + const leaks = calls.filter((c) => c.source === 'g' && c.target === 'f'); + expect(leaks.length).toBe(0); + }); + + it('Derived::h() -> i does NOT bind to Base::i (dependent base)', () => { + const accesses = getRelationships(result, 'ACCESSES'); + const leaks = accesses.filter((c) => c.source === 'h' && c.target === 'i'); + expect(leaks.length).toBe(0); + }); +}); + +// NOTE: positive guards (this->f() resolves, non-dependent-base unqualified +// f() resolves, namespace-qualified utils::ns_helper() resolves) inside +// template bodies are documented gaps in C++ template-context resolution +// independent of U3's dependent-base suppression. The U3 core asserts only +// the negative behavior (dependent-base members are NOT bound by unqualified +// calls); the positive cases would require additional `this` type-binding +// and template-body member-lookup work tracked separately. See plan +// 2026-05-13-001 follow-ups. diff --git a/gitnexus/test/integration/resolvers/helpers.ts b/gitnexus/test/integration/resolvers/helpers.ts index b02a1d801..3560100d1 100644 --- a/gitnexus/test/integration/resolvers/helpers.ts +++ b/gitnexus/test/integration/resolvers/helpers.ts @@ -115,6 +115,13 @@ const LEGACY_RESOLVER_PARITY_EXPECTED_FAILURES: Readonly::g() -> f() does NOT bind to Base::f (dependent base)', ]), };