Merge branch 'main' into feat/wiki

This commit is contained in:
Gergő Magyar 2026-05-15 15:50:05 +01:00 • committed by GitHub
commit 94c240acc9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 657 additions and 131 deletions

View file

@ -312,12 +312,19 @@ const cCppExtractFunctionName = (
return { funcName, label };
};
/** Check if a C/C++ function_definition is inside a class or struct body.
/** Check if a C/C++ function_definition is inside a class or struct body
* (and NOT a friend declaration).
* Used by cppLabelOverride to skip duplicate function captures
* that are already covered by definition.method queries. */
* that are already covered by definition.method queries.
* Friend functions are free functions defined inside class bodies —
* they must NOT be skipped (ISO C++ hidden-friend idiom). */
function isCppInsideClassOrStruct(functionNode: SyntaxNode): boolean {
let ancestor: SyntaxNode | null = functionNode?.parent ?? null;
while (ancestor) {
// Friend declarations: the function_definition is wrapped in
// `friend_declaration` → `field_declaration_list` → class_specifier.
// These are free functions, not methods — don't skip them.
if (ancestor.type === 'friend_declaration') return false;
if (ancestor.type === 'class_specifier' || ancestor.type === 'struct_specifier') return true;
ancestor = ancestor.parent;
}

View file

@ -41,10 +41,8 @@
* def with that simple name. Locally-declared function-pointer variables
* (e.g. `void (*g)()`) and function parameters are excluded from this path.
*
* The current implementation 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.
* ADL candidates are merged with ordinary unqualified-lookup candidates
* in the free-call fallback before overload narrowing.
*
* ## Parenthesized-name suppression
*
@ -72,10 +70,7 @@
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';
import { isCppInlineNamespaceScope } from './inline-namespaces.js';
/**
* Per-argument shape information collected at capture time. ADL fires for
@ -114,13 +109,6 @@ 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}`;
}
@ -173,16 +161,26 @@ export function populateCppAssociatedNamespaces(parsed: ParsedFile): void {
classToNamespaceQualifiedName.set(def.nodeId, nsQName);
}
}
// Enum defs live in Namespace scopes directly (not inside Class scopes).
// Map each Enum def to its enclosing namespace so ADL on enum-typed
// arguments contributes the correct associated namespace.
for (const scope of parsed.scopes) {
if (scope.kind !== 'Namespace') continue;
const nsQName = computeNamespaceQName(scope, scopesById);
if (nsQName === '') continue;
for (const def of scope.ownedDefs) {
if (def.type !== 'Enum') 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.
* ADL candidate collector. Returns:
* - `readonly SymbolDefinition[]` — ADL candidates to merge with
* ordinary unqualified lookup candidates.
* - `undefined` — no ADL candidates.
*
* Fires only when:
* - the call site is not in `noAdlSites` (parenthesized form), AND
@ -192,14 +190,12 @@ export function populateCppAssociatedNamespaces(parsed: ParsedFile): void {
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 {
): readonly SymbolDefinition[] | undefined {
const key = siteKey(callerParsed.filePath, site.atRange.startLine, site.atRange.startCol);
if (noAdlSites.has(key)) return undefined;
const args = argInfoBySite.get(key);
@ -219,6 +215,8 @@ export function pickCppAdlCandidates(
// 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.
// ISO C++: inline namespaces are transparent — candidates in inline
// children of an associated namespace are also ADL-reachable.
const candidates: SymbolDefinition[] = [];
const seenKey = new Set<string>();
for (const parsed of parsedFiles) {
@ -227,7 +225,17 @@ export function pickCppAdlCandidates(
for (const scope of parsed.scopes) {
if (scope.kind !== 'Namespace') continue;
const qName = computeNamespaceQName(scope, scopesById);
if (!associatedNamespaces.has(qName)) continue;
if (!associatedNamespaces.has(qName)) {
// Check if this is an inline-namespace child of an associated NS.
// ISO C++ inline namespaces are transparent for ADL: if the outer
// namespace is in the associated set, candidates in the inline child
// are also reachable.
if (!isCppInlineNamespaceScope(scope.id)) continue;
const parentScope = scope.parent !== null ? scopesById.get(scope.parent) : undefined;
if (parentScope === undefined || parentScope.kind !== 'Namespace') continue;
const parentQName = computeNamespaceQName(parentScope, scopesById);
if (!associatedNamespaces.has(parentQName)) continue;
}
for (const def of scope.ownedDefs) {
if (def.type !== 'Function' && def.type !== 'Method' && def.type !== 'Constructor') {
continue;
@ -243,22 +251,56 @@ export function pickCppAdlCandidates(
candidates.push(def);
}
}
// ISO C++ `[basic.lookup.argdep]` §2: hidden friend functions declared
// inside a class body are visible via ADL when the class is an associated
// class. Scan Class scopes whose enclosing namespace is in the associated
// set for callable ownedDefs matching the call name. This enables the
// canonical "hidden friend" idiom:
// struct Foo { friend void swap(Foo&, Foo&) {} };
for (const scope of parsed.scopes) {
if (scope.kind !== 'Class') continue;
// Check if ANY class def in this scope has an associated namespace.
let isAssociatedClass = false;
for (const def of scope.ownedDefs) {
if (def.type !== 'Class' && def.type !== 'Struct' && def.type !== 'Interface') continue;
const nsQName = classToNamespaceQualifiedName.get(def.nodeId);
if (nsQName !== undefined && associatedNamespaces.has(nsQName)) {
isAssociatedClass = true;
break;
}
}
if (!isAssociatedClass) continue;
// Also scan Function scopes that are direct children of this class
// scope — friend function definitions create their own Function scope
// underneath the Class scope.
for (const childScope of parsed.scopes) {
if (childScope.parent !== scope.id) continue;
if (childScope.kind !== 'Function') continue;
for (const def of childScope.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;
if (seenKey.has(def.nodeId)) continue;
seenKey.add(def.nodeId);
candidates.push(def);
}
}
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;
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;
return candidates;
}
function collectAssociatedNamespacesForAdlArg(
@ -272,8 +314,8 @@ function collectAssociatedNamespacesForAdlArg(
addAssociatedNamespaceForClassName(arg.simpleClassName, scopes, associatedNamespaces);
// Includes template-owner namespaces (e.g. `std` in std::vector<T>). If
// that surfaces extra candidates, ADL_AMBIGUOUS suppression below prevents
// arbitrary edge emission.
// that surfaces extra candidates, merged-candidate overload narrowing in
// free-call-fallback suppresses arbitrary edge emission.
if (arg.templateNamespace.length > 0) associatedNamespaces.add(arg.templateNamespace);
for (const ns of arg.templateArgNamespaces) {
@ -396,18 +438,27 @@ function findNamespaceDefInScope(scope: {
return undefined;
}
/** Find a class-like def by simple name across the workspace. V1
* still arbitrary-picks the first class on collisions (multiple classes
/** Find a class-like or enum def by simple name across the workspace.
* V1 still arbitrary-picks the first match on collisions (multiple defs
* share the simple name), but reports the collision so callers can avoid
* amplifying that uncertainty (for example by skipping MRO expansion).
* C++ ADL strictness would require full type-driven lookup. */
* C++ ADL strictness would require full type-driven lookup.
*
* ISO C++ `[basic.lookup.argdep]` §2: enumerations contribute their
* enclosing namespace to the associated set, just like class types. */
function findCppClassDefBySimpleName(
simpleName: string,
scopes: ScopeResolutionIndexes,
): { classDef: SymbolDefinition; ambiguous: boolean } | undefined {
let firstMatch: SymbolDefinition | undefined;
for (const def of scopes.defs.byId.values()) {
if (def.type !== 'Class' && def.type !== 'Struct' && def.type !== 'Interface') continue;
if (
def.type !== 'Class' &&
def.type !== 'Struct' &&
def.type !== 'Interface' &&
def.type !== 'Enum'
)
continue;
const simple = def.qualifiedName?.split('.').pop() ?? def.qualifiedName ?? '';
if (simple !== simpleName) continue;
if (firstMatch === undefined) {

View file

@ -25,22 +25,13 @@ import {
clearCppDependentBases,
isCppDependentBaseMember,
} from './two-phase-lookup.js';
import {
populateCppAssociatedNamespaces,
clearCppAdlState,
pickCppAdlCandidates,
ADL_AMBIGUOUS,
} from './adl.js';
import { populateCppAssociatedNamespaces, clearCppAdlState, pickCppAdlCandidates } from './adl.js';
import {
clearCppInlineNamespaces,
populateCppInlineNamespaceScopes,
resolveCppQualifiedNamespaceMember,
} from './inline-namespaces.js';
import { populateCppRangeBindings } from './range-bindings.js';
import {
isOverloadAmbiguousAfterNormalization,
narrowOverloadCandidates,
} from '../../scope-resolution/passes/overload-narrowing.js';
/**
* C++ `ScopeResolver` registered in `SCOPE_RESOLVERS` and consumed by
@ -222,12 +213,12 @@ export const cppScopeResolver: ScopeResolver = {
},
// 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.
// Contributes candidates from associated namespaces of class-typed
// arguments; caller merges with ordinary unqualified lookup candidates.
// Current boundary: class-typed value/pointer/reference args and template
// specializations with explicit type arguments contribute associated
// namespaces. Function-pointer args, base-class associated namespaces,
// and full ordinary+ADL merge remain excluded.
// namespaces. Function-pointer args and full conversion-ranking remain
// excluded.
resolveAdlCandidates: (site, callerParsed, scopes, parsedFiles) => {
// `using ns::name;` introduces `name` into ordinary unqualified lookup.
// For template-class method bodies, lexical scope walks can miss this
@ -249,16 +240,21 @@ export const cppScopeResolver: ScopeResolver = {
seenUsing.add(member.nodeId);
usingNamedHits.push(member);
}
if (usingNamedHits.length > 0) {
const narrowed = narrowOverloadCandidates(usingNamedHits, site.arity, site.argumentTypes);
if (isOverloadAmbiguousAfterNormalization(narrowed, site.arity)) return 'ambiguous';
if (narrowed.length === 1) return narrowed[0];
if (narrowed.length > 1) return 'ambiguous';
const adlHits = pickCppAdlCandidates(site, callerParsed, scopes, parsedFiles);
if (usingNamedHits.length === 0) return adlHits;
if (adlHits === undefined || adlHits.length === 0) return usingNamedHits;
const merged: SymbolDefinition[] = [];
const seen = new Set<string>();
for (const hit of usingNamedHits) {
seen.add(hit.nodeId);
merged.push(hit);
}
const result = pickCppAdlCandidates(site, callerParsed, scopes, parsedFiles);
if (result === ADL_AMBIGUOUS) return 'ambiguous';
return result;
for (const hit of adlHits) {
if (seen.has(hit.nodeId)) continue;
seen.add(hit.nodeId);
merged.push(hit);
}
return merged;
},
// C++ qualified namespace-member resolution (U5 of plan 2026-05-13-001).

View file

@ -576,16 +576,15 @@ export interface ScopeResolver {
* 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`).
* Runs in the free-call fallback alongside ordinary unqualified lookup.
* The fallback merges ordinary candidates with ADL candidates and applies
* overload narrowing over the union.
*
* The hook inspects the call site's argument types, computes the
* associated namespace set, and returns either:
* - an array of candidate `SymbolDefinition`s to add to the
* ordinary-lookup candidate pool.
* - `undefined` when ADL contributes no candidates.
*
* Languages without C++-style ADL leave this undefined. The
* cross-language contract is "additive tier" — defining the hook never
@ -601,7 +600,7 @@ export interface ScopeResolver {
callerParsed: ParsedFile,
scopes: ScopeResolutionIndexes,
parsedFiles: readonly ParsedFile[],
) => SymbolDefinition | 'ambiguous' | undefined;
) => readonly SymbolDefinition[] | undefined;
/**
* Optional resolver for qualified-receiver member calls where the

View file

@ -24,8 +24,15 @@ import type { SemanticModel } from '../../model/semantic-model.js';
import type { WorkspaceResolutionIndex } from '../workspace-index.js';
import type { GraphNodeLookup } from '../graph-bridge/node-lookup.js';
import { resolveCallerGraphId, resolveDefGraphId } from '../graph-bridge/ids.js';
import { findCallableBindingInScope, findClassBindingInScope } from '../scope/walkers.js';
import { narrowOverloadCandidates } from './overload-narrowing.js';
import {
findCallableBindingInScope,
findCallableBindingsAndAdlBlocker,
findClassBindingInScope,
} from '../scope/walkers.js';
import {
isOverloadAmbiguousAfterNormalization,
narrowOverloadCandidates,
} from './overload-narrowing.js';
export function emitFreeCallFallback(
graph: KnowledgeGraph,
@ -55,7 +62,7 @@ export function emitFreeCallFallback(
callerParsed: ParsedFile,
scopes: ScopeResolutionIndexes,
parsedFiles: readonly ParsedFile[],
) => SymbolDefinition | 'ambiguous' | undefined;
) => readonly SymbolDefinition[] | undefined;
} = {},
): number {
let emitted = 0;
@ -86,41 +93,80 @@ export function emitFreeCallFallback(
fnDef = pickImplicitThisOverload(site, scopes, workspaceIndex, model);
}
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;
if (options.resolveAdlCandidates === undefined) {
fnDef = findCallableBindingInScope(site.inScope, site.name, scopes);
} else {
// ISO C++ `[basic.lookup.unqual]` §7: ADL is suppressed when
// ordinary lookup finds a non-function name (variable, class, enum)
// or a block-scope function declaration (not via using-declaration)
// at the nearest scope where the name exists.
const {
callables: ordinary,
nonCallableFound,
blockScopeDeclFound,
} = findCallableBindingsAndAdlBlocker(site.inScope, site.name, scopes);
const adlSuppressed = nonCallableFound || blockScopeDeclFound;
const adl = adlSuppressed
? undefined
: options.resolveAdlCandidates(
{
name: site.name,
arity: site.arity,
argumentTypes: site.argumentTypes,
atRange: { startLine: site.atRange.startLine, startCol: site.atRange.startCol },
},
parsed,
scopes,
parsedFiles,
);
// Preserve existing ordinary-lookup behavior when ADL contributed
// no candidates.
if (adl === undefined || adl.length === 0) {
fnDef = ordinary[0];
} else {
const siteKey = `${parsed.filePath}:${site.atRange.startLine}:${site.atRange.startCol}`;
const merged: SymbolDefinition[] = [];
const seen = new Set<string>();
const push = (defs: readonly SymbolDefinition[]): void => {
for (const d of defs) {
if (seen.has(d.nodeId)) continue;
seen.add(d.nodeId);
merged.push(d);
}
};
push(ordinary);
push(adl);
const narrowed = narrowOverloadCandidates(merged, site.arity, site.argumentTypes);
if (narrowed.length === 1) {
fnDef = narrowed[0];
} else if (narrowed.length === 0) {
// ADL contributed candidates, but none survived arity/type
// narrowing. Treat as handled to avoid global-name fallback
// binding to the same mismatched symbol by simple-name
// uniqueness.
handledSites.add(siteKey);
continue;
} else if (narrowed.length > 1) {
// Suppress ambiguous overload calls (emit zero edges) when
// merged ordinary+ADL candidate sets cannot be disambiguated.
if (isOverloadAmbiguousAfterNormalization(narrowed, site.arity)) {
handledSites.add(siteKey);
continue;
}
// Multiple survivors remain but no conversion-ranking step
// exists yet; suppress instead of picking arbitrarily.
handledSites.add(siteKey);
continue;
}
}
}
}
// 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
// caught by findCallableBindingInScope above before reaching here.
// usually caught by nearest-scope lookup before reaching here.
if (fnDef === undefined && options.allowGlobalFallback === true) {
fnDef = pickUniqueGlobalCallable(
site.name,

View file

@ -226,33 +226,132 @@ export function findCallableBindingInScope(
callableName: string,
scopes: ScopeResolutionIndexes,
): SymbolDefinition | undefined {
return findAllCallableBindingsInScope(startScope, callableName, scopes)[0];
}
/**
* Look up all callable bindings (Function/Method/Constructor) by name
* from the nearest scope in the chain that binds `callableName`.
*
* Preserves the original scope-walk boundary used by
* `findCallableBindingInScope`: once any callable binding is found in a
* scope, outer scopes are not consulted.
*/
export function findAllCallableBindingsInScope(
startScope: ScopeId,
callableName: string,
scopes: ScopeResolutionIndexes,
): readonly SymbolDefinition[] {
let currentId: ScopeId | null = startScope;
const visited = new Set<ScopeId>();
while (currentId !== null) {
if (visited.has(currentId)) return undefined;
if (visited.has(currentId)) return [];
visited.add(currentId);
const scope = scopes.scopeTree.getScope(currentId);
if (scope === undefined) return undefined;
if (scope === undefined) return [];
const out: SymbolDefinition[] = [];
const seen = new Set<string>();
const pushCallable = (def: SymbolDefinition): void => {
if (def.type !== 'Function' && def.type !== 'Method' && def.type !== 'Constructor') return;
if (seen.has(def.nodeId)) return;
seen.add(def.nodeId);
out.push(def);
};
const localBindings = scope.bindings.get(callableName);
if (localBindings !== undefined) {
for (const b of localBindings) {
if (b.def.type === 'Function' || b.def.type === 'Method' || b.def.type === 'Constructor') {
return b.def;
}
pushCallable(b.def);
}
}
const importedBindings = lookupBindingsAt(currentId, callableName, scopes);
for (const b of importedBindings) {
if (b.def.type === 'Function' || b.def.type === 'Method' || b.def.type === 'Constructor') {
return b.def;
pushCallable(b.def);
}
if (out.length > 0) return out;
currentId = scope.parent;
}
return [];
}
/**
* ISO C++ `[basic.lookup.unqual]` §7: ADL is suppressed when ordinary
* unqualified lookup finds:
* - a name that is NOT a function or function template, OR
* - a block-scope function declaration that is NOT a using-declaration.
*
* Combined walker that stops at the **nearest scope** where `name` has any
* binding (callable or non-callable) and returns:
* - `callables`: Function/Method/Constructor defs found at that scope
* - `nonCallableFound`: a non-function binding was present (variable, class, etc.)
* - `blockScopeDeclFound`: a callable was found at a Function or Block scope
* (block-scope function declaration that blocks ADL)
*
* One pass, one stop — no divergence between callable collection and blocker
* detection.
*/
export function findCallableBindingsAndAdlBlocker(
startScope: ScopeId,
name: string,
scopes: ScopeResolutionIndexes,
): {
callables: readonly SymbolDefinition[];
nonCallableFound: boolean;
blockScopeDeclFound: boolean;
} {
let currentId: ScopeId | null = startScope;
const visited = new Set<ScopeId>();
while (currentId !== null) {
if (visited.has(currentId))
return { callables: [], nonCallableFound: false, blockScopeDeclFound: false };
visited.add(currentId);
const scope = scopes.scopeTree.getScope(currentId);
if (scope === undefined)
return { callables: [], nonCallableFound: false, blockScopeDeclFound: false };
const callables: SymbolDefinition[] = [];
const seen = new Set<string>();
let nonCallableFound = false;
let anyBinding = false;
const process = (def: SymbolDefinition): void => {
anyBinding = true;
if (def.type === 'Function' || def.type === 'Method' || def.type === 'Constructor') {
if (!seen.has(def.nodeId)) {
seen.add(def.nodeId);
callables.push(def);
}
} else {
nonCallableFound = true;
}
};
const localBindings = scope.bindings.get(name);
if (localBindings !== undefined) {
for (const b of localBindings) {
process(b.def);
}
}
const importedBindings = lookupBindingsAt(currentId, name, scopes);
for (const b of importedBindings) {
process(b.def);
}
if (anyBinding) {
// ISO C++: a block-scope function declaration (Function or Block scope)
// that is NOT a using-declaration blocks ADL. If we found callables at
// a function/block scope, ADL must be suppressed.
const blockScopeDeclFound =
callables.length > 0 && (scope.kind === 'Function' || scope.kind === 'Block');
return { callables, nonCallableFound, blockScopeDeclFound };
}
currentId = scope.parent;
}
return undefined;
return { callables: [], nonCallableFound: false, blockScopeDeclFound: false };
}
/**

View file

@ -0,0 +1,13 @@
#include "audit.h"
namespace app {
void run() {
// Block-scope function declaration (not via using-declaration).
// Per ISO C++ [basic.lookup.argdep], this suppresses ADL — even
// though `e` is audit::Event, audit::record should NOT be discovered.
void record(int);
audit::Event e;
record(e);
}
}

View file

@ -0,0 +1,6 @@
#pragma once
namespace audit {
struct Event {};
void record(Event e);
}

View file

@ -0,0 +1,8 @@
#include "color.h"
namespace app {
void run() {
color::Channel ch = color::Channel::R;
serialize(ch);
}
}

View file

@ -0,0 +1,6 @@
#pragma once
namespace color {
enum class Channel { R, G, B };
void serialize(Channel c);
}

View file

@ -0,0 +1,8 @@
#include "lib.h"
namespace app {
void run() {
lib::Foo f;
process(f);
}
}

View file

@ -0,0 +1,7 @@
#pragma once
namespace lib {
struct Foo {
friend void process(Foo& f) {}
};
}

View file

@ -0,0 +1,8 @@
#include "audit.h"
namespace app {
void run() {
audit::Event e;
record(e);
}
}

View file

@ -0,0 +1,13 @@
#pragma once
namespace audit {
struct Event {};
inline namespace v1 {
void record(Event e);
}
}
namespace other {
void record(int x);
}

View file

@ -0,0 +1,21 @@
#include "data.h"
// Outer namespace has a non-callable `swap` (variable).
namespace app {
int swap = 0;
namespace inner {
// Inner namespace re-declares `swap` as a function.
void swap(int, int);
void run() {
data::Pair a, b;
// Ordinary lookup finds `inner::swap(int,int)` first (callable at
// nearest scope). The outer `app::swap` variable should NOT suppress
// ADL because ordinary lookup stopped at `inner` scope where a
// callable was found. ADL contributes `data::swap(Pair&,Pair&)` which
// wins via argTypes narrowing.
swap(a, b);
}
}
}

View file

@ -0,0 +1,6 @@
#pragma once
namespace data {
struct Pair {};
void swap(Pair& a, Pair& b);
}

View file

@ -0,0 +1,11 @@
#include "data.h"
namespace app {
void swap(int a, int b);
void run() {
data::Pair a;
data::Pair b;
swap(a, b);
}
}

View file

@ -0,0 +1,6 @@
#pragma once
namespace data {
struct Pair {};
void swap(Pair& a, Pair& b);
}

View file

@ -0,0 +1,6 @@
#pragma once
namespace alpha {
struct Token {};
void probe(Token t);
}

View file

@ -0,0 +1,8 @@
#include "alpha.h"
namespace app {
void run() {
alpha::Token t;
probe(t, 42);
}
}

View file

@ -0,0 +1,10 @@
#include "audit.h"
namespace app {
int record = 0;
void run() {
audit::Event e;
record(e);
}
}

View file

@ -0,0 +1,6 @@
#pragma once
namespace audit {
struct Event {};
void record(Event e);
}

View file

@ -7,7 +7,7 @@ namespace caller {
// worker() in BOTH alpha and beta namespaces, so BOTH are added to the
// associated set. run_with() exists in both namespaces as well, so the
// lookup yields two candidates (alpha::run_with, beta::run_with).
// GitNexus ADL_AMBIGUOUS sentinel suppresses the edge — the caller emits
// Merged-narrowing ambiguity suppression in free-call-fallback emits
// zero CALLS edges rather than picking one arbitrarily.
run_with(worker);
}

View file

@ -2127,6 +2127,24 @@ describe('C++ ADL — basic associated-namespace closure', () => {
});
});
describe('C++ ADL — merges with non-empty ordinary lookup', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'cpp-adl-merge-nonempty-ordinary'),
() => {},
);
}, 60000);
it('swap(a, b) prefers data::swap(Pair&, Pair&) over app::swap(int, int)', () => {
const calls = getRelationships(result, 'CALLS');
const swapCalls = calls.filter((c) => c.source === 'run' && c.target === 'swap');
expect(swapCalls.length).toBe(1);
expect(swapCalls[0].targetFilePath).toContain('data.h');
});
});
describe('C++ ADL — base-class associated namespaces', () => {
let result: PipelineResult;
@ -2417,13 +2435,146 @@ describe('C++ ADL — int/long-collision overloads suppress via OVERLOAD_AMBIGUO
// '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.
// the collision in merged ordinary+ADL narrowing, so fallback 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);
});
});
describe('C++ ADL — merged narrowing to zero suppresses global fallback', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'cpp-adl-merged-narrow-zero'), () => {});
}, 60000);
it('probe(t, 42) emits zero CALLS when ADL contributes only arity-mismatched candidates', () => {
const calls = getRelationships(result, 'CALLS');
const probeCalls = calls.filter((c) => c.source === 'run' && c.target === 'probe');
// ADL surfaces alpha::probe(Token), but call arity is 2 (`probe(t, 42)`),
// so merged overload narrowing yields zero survivors. The site is treated
// as handled and must NOT fall through to global simple-name fallback.
expect(probeCalls.length).toBe(0);
});
});
// ---------------------------------------------------------------------------
// ADL V2 — ISO C++ `[basic.lookup.argdep]` §2: enum types contribute their
// enclosing namespace to the associated set, just like class types.
// ---------------------------------------------------------------------------
describe('C++ ADL — enum-typed argument contributes enclosing namespace', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'cpp-adl-enum-arg'), () => {});
}, 60000);
it('serialize(ch) where ch is color::Channel resolves to color::serialize via ADL', () => {
const calls = getRelationships(result, 'CALLS');
const serializeCalls = calls.filter((c) => c.source === 'run' && c.target === 'serialize');
// Exactly 1: ordinary lookup in app::run finds nothing for `serialize`.
// ADL surfaces color::serialize because color::Channel's enclosing
// namespace is `color`. Before the enum gap fix, this was 0.
expect(serializeCalls.length).toBe(1);
expect(serializeCalls[0].targetFilePath).toContain('color.h');
});
});
// ---------------------------------------------------------------------------
// ADL V2 — ISO C++ `[basic.lookup.argdep]` §2: "hidden friend" functions
// declared inside a class body are visible via ADL. They are not namespace-
// scope declarations (owned by the class scope in tree-sitter-cpp), so they
// require scanning associated class scopes in addition to namespace scopes.
// ---------------------------------------------------------------------------
describe('C++ ADL — hidden friend function visible via ADL', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'cpp-adl-hidden-friend'), () => {});
}, 60000);
it('process(f) where f is lib::Foo resolves to hidden friend process(Foo&) via ADL', () => {
const calls = getRelationships(result, 'CALLS');
const processCalls = calls.filter((c) => c.source === 'run' && c.target === 'process');
// Exactly 1: process(Foo&) is a hidden friend declared inside Foo's
// class body. Ordinary namespace-scope lookup won't find it — only ADL
// scanning the associated class's ownedDefs can surface it.
expect(processCalls.length).toBe(1);
expect(processCalls[0].targetFilePath).toContain('lib.h');
});
});
// ---------------------------------------------------------------------------
// ADL V2 — ISO C++ `[basic.lookup.unqual]` §7: non-function ordinary lookup
// result blocks ADL. If the name resolves to a variable/class/enum in scope,
// ADL does not fire even if class-typed arguments are present.
// ---------------------------------------------------------------------------
describe('C++ ADL — non-function ordinary lookup suppresses ADL', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'cpp-adl-non-function-blocks'),
() => {},
);
}, 60000);
it('record(e) emits zero CALLS when a variable named record exists in scope', () => {
const calls = getRelationships(result, 'CALLS');
const recordCalls = calls.filter((c) => c.source === 'run' && c.target === 'record');
// ISO C++: `int record = 0;` in namespace app means ordinary lookup
// finds a non-function entity. ADL should be suppressed — even though
// `e` is audit::Event, audit::record should NOT be discovered.
expect(recordCalls.length).toBe(0);
});
});
describe('C++ ADL — inner callable + outer non-callable: ADL not suppressed', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'cpp-adl-inner-callable-outer-noncallable'),
() => {},
);
}, 60000);
it('swap(a,b) resolves to data::swap when inner scope has callable swap and outer has variable', () => {
const calls = getRelationships(result, 'CALLS');
const swapCalls = calls.filter((c) => c.source === 'run' && c.target === 'swap');
// Ordinary lookup finds `inner::swap(int,int)` at the nearest scope.
// The outer `app::swap` (variable) does NOT suppress ADL because
// ordinary lookup stopped at the inner scope. ADL contributes
// data::swap(Pair&,Pair&) which wins via argTypes narrowing.
expect(swapCalls.length).toBe(1);
expect(swapCalls[0].targetFilePath).toContain('data.h');
});
});
describe('C++ ADL — block-scope function declaration suppresses ADL', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'cpp-adl-block-scope-decl-blocks'),
() => {},
);
}, 60000);
it('record(e) emits zero CALLS when a block-scope function declaration exists', () => {
const calls = getRelationships(result, 'CALLS');
const recordCalls = calls.filter((c) => c.source === 'run' && c.target === 'record');
// ISO C++ [basic.lookup.argdep]: a block-scope function declaration
// (not via using-declaration) suppresses ADL — even though `e` is
// audit::Event, audit::record should NOT be discovered.
expect(recordCalls.length).toBe(0);
});
});
// ---------------------------------------------------------------------------
// ADL V2 — free-function reference args contribute their namespace.
//
@ -2577,8 +2728,8 @@ describe('C++ ADL — unqualified free-function ref with namespace collision', (
const runWithCalls = calls.filter((c) => c.source === 'run' && c.target === 'run_with');
// Unqualified `worker` → workspace scan finds alpha::worker and beta::worker.
// Both alpha and beta are added to the associated set. run_with() exists in
// both namespaces → two candidates → ADL_AMBIGUOUS sentinel → zero CALLS
// edges (suppressed rather than arbitrary pick).
// both namespaces → two candidates → merged narrowing suppression →
// zero CALLS edges (suppressed rather than arbitrary pick).
expect(runWithCalls.length).toBe(0);
});
});
@ -2673,6 +2824,29 @@ describe('C++ inline namespace — ADL participation', () => {
});
});
describe('C++ ADL — inline namespace expansion in associated set', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'cpp-adl-inline-ns-expansion'),
() => {},
);
}, 60000);
it('record(e) resolves to audit::v1::record when Event is in outer audit and record is in inline v1 (arity-disambiguated from other::record(int))', () => {
const calls = getRelationships(result, 'CALLS');
const recordCalls = calls.filter((c) => c.source === 'run' && c.target === 'record');
// ISO C++: inline namespaces are transparent — candidates in
// `audit::v1` are visible as if declared at `audit` level. With a
// competing `other::record(int)` (different arity), the merged
// ordinary+ADL overload narrowing must select `audit::v1::record(Event)`
// since it's the only arity-matching candidate for `record(e)`.
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),

View file

@ -122,14 +122,18 @@ const LEGACY_RESOLVER_PARITY_EXPECTED_FAILURES: Readonly<Record<string, Readonly
// (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 ADL_AMBIGUOUS suppression sentinel.
// The legacy DAG path does not apply merged ordinary+ADL narrowing
// with ambiguity suppression.
// 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
// The scope-resolver path suppresses in free-call-fallback after
// merged-candidate overload narrowing. 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)',
// Legacy DAG path does not merge ordinary and ADL candidate sets for
// non-empty ordinary lookup, so it misses ADL's better-match overload.
'swap(a, b) prefers data::swap(Pair&, Pair&) over app::swap(int, int)',
// 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-
@ -180,6 +184,13 @@ const LEGACY_RESOLVER_PARITY_EXPECTED_FAILURES: Readonly<Record<string, Readonly
// Scope-resolver-only correctness wins; backporting is out of scope.
'process(data::value) emits zero CALLS edges \u2014 data::value is a variable, not a function',
'run_with(callback) emits zero CALLS edges when callback is a parameter, not a function reference',
// PR #1599 adversarial review findings: nearest-scope ADL blocker
// semantics and block-scope function declaration ADL suppression are
// scope-resolver-only. The legacy DAG has no scope-aware ADL blocker
// detection; it falls back to `pickUniqueGlobalCallable`. Scope-
// resolver-only correctness wins; backporting is out of scope.
'swap(a,b) resolves to data::swap when inner scope has callable swap and outer has variable',
'record(e) emits zero CALLS when a block-scope function declaration exists',
]),
};