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).
This commit is contained in:
Gergo Magyar 2026-05-13 18:42:09 +01:00
parent f7580f6ab3
commit 10aa731b21
16 changed files with 503 additions and 2 deletions

View file

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

View file

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

View 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);
}

View file

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

View file

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

View file

@ -0,0 +1,7 @@
#pragma once
template<class T>
struct Base {
void f();
int i;
};

View file

@ -0,0 +1,13 @@
#pragma once
#include "base.h"
template<class T>
struct Derived : Base<T> {
void g() {
f();
}
int h() {
return i;
}
};

View file

@ -0,0 +1,6 @@
#pragma once
template<class T>
struct Base {
void unused();
};

View file

@ -0,0 +1,11 @@
#pragma once
#include "base.h"
#include "helpers.h"
template<class T>
struct D : Base<T> {
void g() {
utils::ns_helper();
}
};

View file

@ -0,0 +1,5 @@
#pragma once
namespace utils {
void ns_helper();
}

View file

@ -0,0 +1,5 @@
#pragma once
struct ConcreteBase {
void f();
};

View file

@ -0,0 +1,10 @@
#pragma once
#include "concrete-base.h"
template<class T>
struct Derived : ConcreteBase {
void g() {
f();
}
};

View file

@ -0,0 +1,7 @@
#pragma once
template<class T>
struct Base {
void f();
int i;
};

View 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;
}
};

View file

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

View file

@ -115,6 +115,13 @@ const LEGACY_RESOLVER_PARITY_EXPECTED_FAILURES: Readonly<Record<string, Readonly
// 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)',
]),
};