diff --git a/gitnexus/src/core/ingestion/languages/c-cpp.ts b/gitnexus/src/core/ingestion/languages/c-cpp.ts index 58e59fe6f..c010e0e15 100644 --- a/gitnexus/src/core/ingestion/languages/c-cpp.ts +++ b/gitnexus/src/core/ingestion/languages/c-cpp.ts @@ -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; } diff --git a/gitnexus/src/core/ingestion/languages/cpp/adl.ts b/gitnexus/src/core/ingestion/languages/cpp/adl.ts index fd65ae51e..0bcda9322 100644 --- a/gitnexus/src/core/ingestion/languages/cpp/adl.ts +++ b/gitnexus/src/core/ingestion/languages/cpp/adl.ts @@ -21,14 +21,28 @@ * - `audit::Event& r`, `audit::Event&& rr` * - `std::vector` (template namespace + template-arg namespaces) * - * Function-pointer arguments and the rest of the full closure are still - * deliberately excluded. V2 additionally walks class ancestors (via MRO), - * so base-class enclosing namespaces also contribute associated namespaces. + * V2 additionally walks class ancestors (via MRO), so base-class enclosing + * namespaces also contribute associated namespaces. * - * 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. + * **GitNexus approximation (not strict ISO C++ ADL):** passing a qualified + * function reference like `utils::worker` contributes `utils` to the associated + * set, enabling resolution of unqualified calls like `with_callback(utils::worker)` + * to `utils::with_callback`. Under ISO C++ `[basic.lookup.argdep]`, associated + * entities for function-type arguments come from the **parameter types and return + * type** of each function in the overload set — NOT the function's enclosing + * namespace. For `void worker()`, the standard-compliant associated set is empty. + * GitNexus instead contributes the enclosing namespace of any Function/Method + * def whose simple name matches, because it enables the dominant real-world ADL + * pattern at reasonable precision cost. + * + * For qualified refs (e.g. `utils::worker`) the namespace is confirmed via a + * workspace lookup (only contributed when a Function/Method named `worker` exists + * in `utils`). For unqualified refs the workspace is searched for any Function + * def with that simple name. Locally-declared function-pointer variables + * (e.g. `void (*g)()`) and function parameters are excluded from this path. + * + * ADL candidates are merged with ordinary unqualified-lookup candidates + * in the free-call fallback before overload narrowing. * * ## Parenthesized-name suppression * @@ -56,15 +70,13 @@ 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 * arguments where `simpleClassName !== ''`, including class pointers and * references whose declarator chain resolves to a named class type. + * Free-function reference arguments use `functionRefText`. */ export interface CppAdlArgInfo { /** Simple class-like type name (last segment of qualified name); empty @@ -82,19 +94,21 @@ export interface CppAdlArgInfo { /** Enclosing namespaces extracted from explicit type template arguments, * recursively bounded. */ readonly templateArgNamespaces: readonly string[]; + /** When set, the arg is a potential free-function reference (not a locally- + * declared function-pointer variable or function parameter). Contains the + * identifier text as written in source (e.g. `"utils::worker"` or + * `"worker"`). GitNexus approximation: the function's enclosing namespace + * is contributed to the ADL associated set. For qualified refs a workspace + * lookup confirms a Function/Method with that simple name exists in the + * namespace before contributing; for unqualified refs every namespace + * containing a matching Function/Method def is contributed. */ + readonly functionRefText?: string; } const argInfoBySite = new Map(); const noAdlSites = new Set(); const classToNamespaceQualifiedName = new Map(); -/** 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}`; } @@ -147,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 @@ -166,29 +190,33 @@ 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); if (args === undefined || args.length === 0) return undefined; - // Collect associated namespace QNames from every participating class-typed arg. + // Collect associated namespace QNames from every participating class-typed arg + // and from function-reference args. const associatedNamespaces = new Set(); for (const arg of args) { collectAssociatedNamespacesForAdlArg(arg, scopes, associatedNamespaces); + if (arg.functionRefText !== undefined) { + collectFunctionRefNamespaces(arg.functionRefText, parsedFiles, associatedNamespaces); + } } if (associatedNamespaces.size === 0) return undefined; // 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(); for (const parsed of parsedFiles) { @@ -197,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; @@ -213,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( @@ -242,8 +314,8 @@ function collectAssociatedNamespacesForAdlArg( addAssociatedNamespaceForClassName(arg.simpleClassName, scopes, associatedNamespaces); // Includes template-owner namespaces (e.g. `std` in std::vector). 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) { @@ -366,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) { @@ -389,3 +470,71 @@ function findCppClassDefBySimpleName( if (firstMatch === undefined) return undefined; return { classDef: firstMatch, ambiguous: false }; } + +/** + * Contribute associated namespaces for a function-reference argument. + * + * - **Qualified refs** (`utils::worker`, `outer::inner::fn`): the namespace + * is extracted from the qualifier text (converting `::` to `.` for dot-joined + * QName matching). A workspace lookup then **verifies** that a Function or + * Method def named `worker` (the simple name after the last `::`) actually + * exists in the extracted namespace. This prevents false positives from + * namespace-qualified variables, enum values, and static data members, which + * also produce `qualified_identifier` AST nodes in tree-sitter-cpp (the + * AST node type alone does not distinguish functions from non-function names). + * - **Unqualified refs** (`worker`): the workspace is searched for any + * Function/Method def whose simple name matches. Every distinct enclosing + * namespace found is added — overloads across the same namespace produce + * a single entry; GitNexus does not select a specific overload at this stage. + */ +function collectFunctionRefNamespaces( + refText: string, + parsedFiles: readonly ParsedFile[], + out: Set, +): void { + const colonIdx = refText.lastIndexOf('::'); + if (colonIdx !== -1) { + // Qualified ref: extract namespace prefix and normalise :: → dot notation. + const nsText = refText.slice(0, colonIdx).replace(/::/g, '.'); + if (nsText === '') return; + const simpleName = refText.slice(colonIdx + 2); + // Verify that a Function/Method named `simpleName` exists in `nsText`. + // Without this guard every `a::b` qualified_identifier arg (variable, + // enum value, static member, type alias) would blindly contribute `a` + // to the associated set and risk a false-positive CALLS edge. + for (const parsed of parsedFiles) { + const scopesById = new Map(); + for (const sc of parsed.scopes) scopesById.set(sc.id, sc); + for (const scope of parsed.scopes) { + if (scope.kind !== 'Namespace') continue; + if (computeNamespaceQName(scope, scopesById) !== nsText) continue; + for (const def of scope.ownedDefs) { + if (def.type !== 'Function' && def.type !== 'Method') continue; + const simple = def.qualifiedName?.split('.').pop() ?? def.qualifiedName ?? ''; + if (simple === simpleName) { + out.add(nsText); + return; // Namespace confirmed; no need to scan further files. + } + } + } + } + return; + } + + // Unqualified: search all namespace scopes for a Function def with this + // simple name and contribute its enclosing namespace. + for (const parsed of parsedFiles) { + const scopesById = new Map(); + for (const sc of parsed.scopes) scopesById.set(sc.id, sc); + for (const scope of parsed.scopes) { + if (scope.kind !== 'Namespace') continue; + for (const def of scope.ownedDefs) { + if (def.type !== 'Function' && def.type !== 'Method') continue; + const simple = def.qualifiedName?.split('.').pop() ?? def.qualifiedName ?? ''; + if (simple !== refText) continue; + const nsQName = computeNamespaceQName(scope, scopesById); + if (nsQName !== '') out.add(nsQName); + } + } + } +} diff --git a/gitnexus/src/core/ingestion/languages/cpp/captures.ts b/gitnexus/src/core/ingestion/languages/cpp/captures.ts index a60ad1c1a..5c74950bb 100644 --- a/gitnexus/src/core/ingestion/languages/cpp/captures.ts +++ b/gitnexus/src/core/ingestion/languages/cpp/captures.ts @@ -785,17 +785,91 @@ function classifyAdlArg(argNode: SyntaxNode): CppAdlArgInfo { ) { return EMPTY_ADL_ARG; } + // Qualified expression (a::b) — may be a function, variable, enum value, + // or static member. Record as a potential function reference; resolution + // time verifies via workspace lookup that a Function/Method with this simple + // name exists in the extracted namespace before contributing to the set. + if (argNode.type === 'qualified_identifier') { + return { + simpleClassName: '', + templateSimpleClassName: '', + templateNamespace: '', + templateArgClassNames: [], + templateArgNamespaces: [], + functionRefText: argNode.text, + }; + } // Variable reference — look up its declared type (preserving pointer / // reference / qualified-name shape; the existing arity-narrowing helper // strips this info). if (argNode.type === 'identifier') { - return lookupAdlIdentifierType(argNode); + const result = lookupAdlIdentifierType(argNode); + if (result === null) { + // Not found in the local compound_statement scope — could be a + // free-function reference (unqualified name, namespace scope). + return { + simpleClassName: '', + templateSimpleClassName: '', + templateNamespace: '', + templateArgClassNames: [], + templateArgNamespaces: [], + functionRefText: argNode.text, + }; + } + return result; } // Other shapes (calls, member access, operators) — V1 unsupported. return EMPTY_ADL_ARG; } -function lookupAdlIdentifierType(identNode: SyntaxNode): CppAdlArgInfo { +/** + * Returns `true` when `varName` appears as a parameter name in the nearest + * enclosing `function_definition` or `function_declarator` that contains + * `identNode`. Parameters live in `parameter_list` (a sibling of the + * `compound_statement`), so the `compound_statement`-local declaration scan + * in `lookupAdlIdentifierType` would not find them — causing them to be + * mistakenly classified as potential free-function references. + * + * In tree-sitter-cpp a `function_definition` does NOT expose `parameters` + * as a direct named field; parameters live inside the nested + * `function_declarator`. For `function_declarator` nodes the `parameters` + * field IS direct. Both cases are handled below. + */ +function isIdentifierAFunctionParameter(identNode: SyntaxNode, varName: string): boolean { + let node: SyntaxNode | null = identNode.parent; + let safety = 64; + while (node !== null && safety-- > 0) { + let params: SyntaxNode | null = null; + if (node.type === 'function_declarator') { + // parameters is a direct field on function_declarator. + params = node.childForFieldName('parameters'); + } else if (node.type === 'function_definition') { + // function_definition carries parameters inside its `declarator` field + // (which is a function_declarator). Walk through it. + const decl = node.childForFieldName('declarator'); + if (decl !== null && decl.type === 'function_declarator') { + params = decl.childForFieldName('parameters'); + } + } + if (params !== null) { + for (let i = 0; i < params.namedChildCount; i++) { + const param = params.namedChild(i); + if (param === null) continue; + const declNode = param.childForFieldName('declarator'); + if (declNode === null) continue; + const leafName = extractDeclaratorLeafName(declNode); + if (leafName === varName) return true; + } + // Only check the immediately enclosing function — do not climb further. + break; + } + if (node.type === 'translation_unit') break; + node = node.parent; + } + return false; +} + +function lookupAdlIdentifierType(identNode: SyntaxNode): CppAdlArgInfo | null { const varName = identNode.text; let scope: SyntaxNode | null = identNode.parent; while ( @@ -805,8 +879,17 @@ function lookupAdlIdentifierType(identNode: SyntaxNode): CppAdlArgInfo { ) { scope = scope.parent; } - if (scope === null) return EMPTY_ADL_ARG; + if (scope === null) return null; + // Function parameters live in the enclosing function's `parameter_list`, + // NOT inside the `compound_statement`, so the declaration scan below would + // never find them and would return `null` — incorrectly triggering the + // free-function-reference path. Check the parameter_list first. + if (isIdentifierAFunctionParameter(identNode, varName)) { + return EMPTY_ADL_ARG; + } + + let foundAsLocalFunctionPointer = false; for (let i = 0; i < scope.childCount; i++) { const stmt = scope.child(i); if (stmt === null || stmt.type !== 'declaration') continue; @@ -833,6 +916,9 @@ function lookupAdlIdentifierType(identNode: SyntaxNode): CppAdlArgInfo { if (inner.type === 'pointer_declarator') { if (findFirstDescendantOfType(inner, 'function_declarator') !== null) { isFunctionPointer = true; + // Extract the name from within the function-pointer declarator chain + // so `foundAsLocalFunctionPointer` can detect a matching declaration. + nameText = extractDeclaratorLeafName(inner); break; } const next = inner.childForFieldName('declarator'); @@ -862,12 +948,21 @@ function lookupAdlIdentifierType(identNode: SyntaxNode): CppAdlArgInfo { } if (inner.type === 'function_declarator') { isFunctionPointer = true; + // Extract the name from the inner declarator (e.g. `(*g)` in `void (*g)()`). + const innerDecl = inner.childForFieldName('declarator'); + if (innerDecl !== null) nameText = extractDeclaratorLeafName(innerDecl); break; } // Reached the leaf — usually `identifier`. Take its text. nameText = inner.text; break; } + if (nameText === varName && isFunctionPointer) { + // Explicitly declared as a function-pointer variable — must not be + // treated as a free-function reference by the caller. + foundAsLocalFunctionPointer = true; + continue; + } if (isFunctionPointer || nameText !== varName) continue; const simpleClassName = extractAdlSimpleTypeName(typeNode); @@ -885,7 +980,22 @@ function lookupAdlIdentifierType(identNode: SyntaxNode): CppAdlArgInfo { templateArgNamespaces, }; } - return EMPTY_ADL_ARG; + // If the identifier was found in local scope as a function-pointer variable, + // return EMPTY_ADL_ARG so the caller does NOT treat it as a free-function + // reference. Otherwise return null to indicate "not in local scope". + // + // Known limitation (Finding 4): variables whose type is a typedef/using alias + // for a function-pointer type are NOT detected here. For example: + // using Callback = void (*)(); + // Callback g; + // foo(g); // `g`'s declarator is `identifier` with type `Callback` + // The declarator has no `pointer_declarator` wrapper, so `isFunctionPointer` + // stays false and `extractAdlSimpleTypeName` returns `"Callback"`. ADL then + // looks for a class named `Callback`; if none exists, this degrades to + // EMPTY_ADL_ARG (class not found → no namespace contributed). If a class + // named `Callback` does exist, a spurious namespace contribution could occur. + // Risk is low in practice; a future fix should resolve the typedef/alias chain. + return foundAsLocalFunctionPointer ? EMPTY_ADL_ARG : null; } /** Extract the simple class-like type name from a `type:` field node. @@ -1040,6 +1150,29 @@ function extractNamespaceFromQualifiedText(text: string): string { return normalizeCppNamespaceQName(cleaned.slice(0, idx)); } +/** + * Walk a declarator node chain, unwrapping pointer/reference/function/ + * parenthesized wrappers, and return the text of the innermost identifier. + * Returns `null` when no identifier is found within `safety` steps. + * Used by `lookupAdlIdentifierType` to extract the variable name from + * function-pointer declarator trees such as `(*g)()` in `void (*g)()`. + */ +function extractDeclaratorLeafName(node: SyntaxNode): string | null { + let cur: SyntaxNode = node; + let safety = 16; + while (safety-- > 0) { + if (cur.type === 'identifier' || cur.type === 'type_identifier') return cur.text; + // Common wrapper nodes — follow the 'declarator' field when present. + const next = + cur.childForFieldName('declarator') ?? + // parenthesized_declarator: single named child + (cur.type === 'parenthesized_declarator' ? cur.namedChild(0) : null); + if (next === null) return null; + cur = next; + } + return null; +} + /** * Check if a C++ function_definition or declaration has `static` storage class. */ diff --git a/gitnexus/src/core/ingestion/languages/cpp/inline-namespaces.ts b/gitnexus/src/core/ingestion/languages/cpp/inline-namespaces.ts index c08402a85..604c50c0b 100644 --- a/gitnexus/src/core/ingestion/languages/cpp/inline-namespaces.ts +++ b/gitnexus/src/core/ingestion/languages/cpp/inline-namespaces.ts @@ -29,6 +29,10 @@ 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'; interface RangeKey { readonly startLine: number; @@ -95,15 +99,17 @@ export function isCppInlineNamespaceScope(scopeId: ScopeId): boolean { * Returns the most specific (innermost) match — for `outer::foo()` * where `inline namespace v1` declares `foo`, returns `v1::foo`. When * multiple inline-namespace children declare the same name, ISO C++ - * leaves the call ambiguous; V1 returns the first match in source - * order (stable across runs). + * leaves the call ambiguous; returns `'ambiguous'` so the caller + * suppresses edge emission rather than picking arbitrarily (#1564). */ export function resolveCppQualifiedNamespaceMember( receiverName: string, memberName: string, parsedFiles: readonly ParsedFile[], _scopes: ScopeResolutionIndexes, -): SymbolDefinition | undefined { +): SymbolDefinition | 'ambiguous' | undefined { + const allHits: SymbolDefinition[] = []; + const seenNodeId = new Set(); for (const parsed of parsedFiles) { const scopesById = new Map(); for (const sc of parsed.scopes) scopesById.set(sc.id, sc); @@ -113,19 +119,45 @@ export function resolveCppQualifiedNamespaceMember( if (nsDef === undefined) continue; const nsName = nsDef.qualifiedName?.split('.').pop() ?? nsDef.qualifiedName ?? ''; if (nsName !== receiverName) continue; - // Found a matching namespace scope in this file. Collect the - // member transitively through any inline-namespace children. - const hit = findMemberInNamespaceTransitive(scope, scopesById, memberName); - if (hit !== undefined) return hit; + // Found a matching namespace scope in this file. Collect ALL + // members transitively through any inline-namespace children. + const hits = findMemberInNamespaceTransitive(scope, scopesById, memberName); + for (const hit of hits) { + if (seenNodeId.has(hit.nodeId)) continue; + seenNodeId.add(hit.nodeId); + allHits.push(hit); + } } } - return undefined; + if (allHits.length === 0) return undefined; + if (allHits.length === 1) return allHits[0]; + + // Multi-candidate: the `resolveQualifiedReceiverMember` hook has no + // access to call-site arity or argument types, so + // `narrowOverloadCandidates` cannot actually narrow here — the call + // with `(allHits, undefined, undefined)` is effectively a pass-through. + // We retain it so that `isOverloadAmbiguousAfterNormalization` can + // still detect int/long-style normalization collisions on this path, + // but for any multi-hit case where candidates have genuinely distinct + // signatures (e.g. `foo(int)` vs `foo(double)` in different inline + // children), we conservatively suppress rather than pick arbitrarily. + // A future enhancement could thread call-site argument info through + // the `resolveQualifiedReceiverMember` contract to enable real + // narrowing here. + const narrowed = narrowOverloadCandidates(allHits, undefined, undefined); + if (narrowed.length === 1) return narrowed[0]; + if (narrowed.length === 0) return undefined; + if (isOverloadAmbiguousAfterNormalization(narrowed, undefined)) return 'ambiguous'; + // Multiple surviving candidates (distinct signatures) — conservative + // suppress because we lack call-site info to disambiguate. + return 'ambiguous'; } /** Recursively search a namespace scope and any inline-namespace - * descendants for a callable def with the given simple name. Non-inline + * descendants for callable defs with the given simple name. Non-inline * nested namespaces are NOT traversed — they require explicit - * qualification (`outer::nested::foo`). */ + * qualification (`outer::nested::foo`). Returns ALL matches so the + * caller can detect same-name ambiguity across inline children (#1564). */ function findMemberInNamespaceTransitive( scope: { readonly id: ScopeId; @@ -142,22 +174,23 @@ function findMemberInNamespaceTransitive( } >, memberName: string, -): SymbolDefinition | undefined { +): SymbolDefinition[] { + const results: SymbolDefinition[] = []; // Check this scope's own ownedDefs first. 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 === memberName) return def; + if (simple === memberName) results.push(def); } // Descend into inline-namespace children. for (const childScope of scopesById.values()) { if (childScope.parent !== scope.id) continue; if (childScope.kind !== 'Namespace') continue; if (!inlineNamespaceScopeIds.has(childScope.id)) continue; - const hit = findMemberInNamespaceTransitive(childScope, scopesById, memberName); - if (hit !== undefined) return hit; + const childHits = findMemberInNamespaceTransitive(childScope, scopesById, memberName); + for (const hit of childHits) results.push(hit); } - return undefined; + return results; } function findNamespaceDefInScope(scope: { diff --git a/gitnexus/src/core/ingestion/languages/cpp/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/cpp/scope-resolver.ts index 36bba7514..52c575cf4 100644 --- a/gitnexus/src/core/ingestion/languages/cpp/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/cpp/scope-resolver.ts @@ -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 @@ -244,21 +235,26 @@ export const cppScopeResolver: ScopeResolver = { parsedFiles, scopes, ); - if (member === undefined) continue; + if (member === undefined || member === 'ambiguous') continue; if (seenUsing.has(member.nodeId)) continue; 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(); + 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). 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 36856e6a8..315471de1 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts @@ -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 @@ -616,8 +615,9 @@ export interface ScopeResolver { * * Receiver-bound-calls invokes this hook AFTER Case 1 (namespace * imports) and AFTER Case 2 (class-name receiver) fail to resolve. - * Returns the target def, or `undefined` to fall through to the - * remaining cases. + * Returns the target def, `'ambiguous'` when multiple inline-namespace + * children declare the same name (suppresses edge emission), or + * `undefined` to fall through to the remaining cases. */ readonly resolveQualifiedReceiverMember?: ( receiverName: string, @@ -625,7 +625,7 @@ export interface ScopeResolver { callerScope: ScopeId, scopes: ScopeResolutionIndexes, parsedFiles: readonly ParsedFile[], - ) => SymbolDefinition | undefined; + ) => SymbolDefinition | 'ambiguous' | undefined; /** * Enable the receiver-bound Case 0.5 fallback for explicit `this` 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 2dc20d2ef..d9e4cdaa3 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 @@ -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(); + 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, diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts index 5aff78261..92b4c1ac1 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts @@ -445,6 +445,12 @@ export function emitReceiverBoundCalls( scopes, parsedFiles, ); + if (memberDef === 'ambiguous') { + // Same-name ambiguity across inline-namespace children (#1564): + // suppress edge emission, mark site handled. + handledSites.add(siteKey); + continue; + } if (memberDef !== undefined) { const ok = tryEmitEdge( graph, diff --git a/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts b/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts index 3e347cd4e..6e087d4f7 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts @@ -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(); 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(); + 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(); + 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(); + 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 }; } /** diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-adl-block-scope-decl-blocks/app.cpp b/gitnexus/test/fixtures/lang-resolution/cpp-adl-block-scope-decl-blocks/app.cpp new file mode 100644 index 000000000..16dd2b539 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-adl-block-scope-decl-blocks/app.cpp @@ -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); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-adl-block-scope-decl-blocks/audit.h b/gitnexus/test/fixtures/lang-resolution/cpp-adl-block-scope-decl-blocks/audit.h new file mode 100644 index 000000000..2c9803a3d --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-adl-block-scope-decl-blocks/audit.h @@ -0,0 +1,6 @@ +#pragma once + +namespace audit { + struct Event {}; + void record(Event e); +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-adl-enum-arg/app.cpp b/gitnexus/test/fixtures/lang-resolution/cpp-adl-enum-arg/app.cpp new file mode 100644 index 000000000..91d83d5e2 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-adl-enum-arg/app.cpp @@ -0,0 +1,8 @@ +#include "color.h" + +namespace app { + void run() { + color::Channel ch = color::Channel::R; + serialize(ch); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-adl-enum-arg/color.h b/gitnexus/test/fixtures/lang-resolution/cpp-adl-enum-arg/color.h new file mode 100644 index 000000000..49136c43a --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-adl-enum-arg/color.h @@ -0,0 +1,6 @@ +#pragma once + +namespace color { + enum class Channel { R, G, B }; + void serialize(Channel c); +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-adl-free-func-ref-overloaded/app.cpp b/gitnexus/test/fixtures/lang-resolution/cpp-adl-free-func-ref-overloaded/app.cpp new file mode 100644 index 000000000..1a51ca699 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-adl-free-func-ref-overloaded/app.cpp @@ -0,0 +1,7 @@ +#include "utils.h" + +namespace caller { + void run() { + with_callback(utils::worker); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-adl-free-func-ref-overloaded/utils.h b/gitnexus/test/fixtures/lang-resolution/cpp-adl-free-func-ref-overloaded/utils.h new file mode 100644 index 000000000..53b82ca1e --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-adl-free-func-ref-overloaded/utils.h @@ -0,0 +1,7 @@ +#pragma once + +namespace utils { + void worker(); + void worker(int n); + void with_callback(int n); +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-adl-free-func-ref/app.cpp b/gitnexus/test/fixtures/lang-resolution/cpp-adl-free-func-ref/app.cpp new file mode 100644 index 000000000..1a51ca699 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-adl-free-func-ref/app.cpp @@ -0,0 +1,7 @@ +#include "utils.h" + +namespace caller { + void run() { + with_callback(utils::worker); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-adl-free-func-ref/utils.h b/gitnexus/test/fixtures/lang-resolution/cpp-adl-free-func-ref/utils.h new file mode 100644 index 000000000..33467d7ce --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-adl-free-func-ref/utils.h @@ -0,0 +1,6 @@ +#pragma once + +namespace utils { + void worker(); + void with_callback(int n); +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-adl-hidden-friend/app.cpp b/gitnexus/test/fixtures/lang-resolution/cpp-adl-hidden-friend/app.cpp new file mode 100644 index 000000000..58849adaf --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-adl-hidden-friend/app.cpp @@ -0,0 +1,8 @@ +#include "lib.h" + +namespace app { + void run() { + lib::Foo f; + process(f); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-adl-hidden-friend/lib.h b/gitnexus/test/fixtures/lang-resolution/cpp-adl-hidden-friend/lib.h new file mode 100644 index 000000000..3cc8042b4 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-adl-hidden-friend/lib.h @@ -0,0 +1,7 @@ +#pragma once + +namespace lib { + struct Foo { + friend void process(Foo& f) {} + }; +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-adl-inline-ns-expansion/app.cpp b/gitnexus/test/fixtures/lang-resolution/cpp-adl-inline-ns-expansion/app.cpp new file mode 100644 index 000000000..7fc21a0be --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-adl-inline-ns-expansion/app.cpp @@ -0,0 +1,8 @@ +#include "audit.h" + +namespace app { + void run() { + audit::Event e; + record(e); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-adl-inline-ns-expansion/audit.h b/gitnexus/test/fixtures/lang-resolution/cpp-adl-inline-ns-expansion/audit.h new file mode 100644 index 000000000..dacb11c15 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-adl-inline-ns-expansion/audit.h @@ -0,0 +1,13 @@ +#pragma once + +namespace audit { + struct Event {}; + + inline namespace v1 { + void record(Event e); + } +} + +namespace other { + void record(int x); +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-adl-inner-callable-outer-noncallable/app.cpp b/gitnexus/test/fixtures/lang-resolution/cpp-adl-inner-callable-outer-noncallable/app.cpp new file mode 100644 index 000000000..4eebdfd2f --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-adl-inner-callable-outer-noncallable/app.cpp @@ -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); + } + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-adl-inner-callable-outer-noncallable/data.h b/gitnexus/test/fixtures/lang-resolution/cpp-adl-inner-callable-outer-noncallable/data.h new file mode 100644 index 000000000..129f476b3 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-adl-inner-callable-outer-noncallable/data.h @@ -0,0 +1,6 @@ +#pragma once + +namespace data { + struct Pair {}; + void swap(Pair& a, Pair& b); +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-adl-local-fp-shadows-free-func/app.cpp b/gitnexus/test/fixtures/lang-resolution/cpp-adl-local-fp-shadows-free-func/app.cpp new file mode 100644 index 000000000..1fb977e3b --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-adl-local-fp-shadows-free-func/app.cpp @@ -0,0 +1,13 @@ +#include "audit.h" + +namespace app { + void run() { + // `g` is a locally-declared function-pointer variable. audit::g() also + // exists in the workspace. The local-fp guard (foundAsLocalFunctionPointer) + // must detect `g` as a function-pointer variable declaration and return + // EMPTY_ADL_ARG, preventing the workspace scan that would otherwise find + // audit::g and contribute `audit` to the ADL associated set. + void (*g)(); + record(g); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-adl-local-fp-shadows-free-func/audit.h b/gitnexus/test/fixtures/lang-resolution/cpp-adl-local-fp-shadows-free-func/audit.h new file mode 100644 index 000000000..46df335ef --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-adl-local-fp-shadows-free-func/audit.h @@ -0,0 +1,11 @@ +#pragma once + +namespace audit { + // A free function named `g` exists in the workspace. Without the local-fp + // guard, a locally-declared `void (*g)()` variable would fall through to + // EMPTY_ADL_ARG and not be treated as a free-function ref — but this test + // specifically verifies that the local fp variable shadows the workspace + // function of the same name and no namespace is contributed. + void g(); + void record(void (*fn)()); +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-adl-merge-nonempty-ordinary/app.cpp b/gitnexus/test/fixtures/lang-resolution/cpp-adl-merge-nonempty-ordinary/app.cpp new file mode 100644 index 000000000..3b3628694 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-adl-merge-nonempty-ordinary/app.cpp @@ -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); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-adl-merge-nonempty-ordinary/data.h b/gitnexus/test/fixtures/lang-resolution/cpp-adl-merge-nonempty-ordinary/data.h new file mode 100644 index 000000000..129f476b3 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-adl-merge-nonempty-ordinary/data.h @@ -0,0 +1,6 @@ +#pragma once + +namespace data { + struct Pair {}; + void swap(Pair& a, Pair& b); +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-adl-merged-narrow-zero/alpha.h b/gitnexus/test/fixtures/lang-resolution/cpp-adl-merged-narrow-zero/alpha.h new file mode 100644 index 000000000..84b6fb028 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-adl-merged-narrow-zero/alpha.h @@ -0,0 +1,6 @@ +#pragma once + +namespace alpha { + struct Token {}; + void probe(Token t); +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-adl-merged-narrow-zero/app.cpp b/gitnexus/test/fixtures/lang-resolution/cpp-adl-merged-narrow-zero/app.cpp new file mode 100644 index 000000000..1c0088054 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-adl-merged-narrow-zero/app.cpp @@ -0,0 +1,8 @@ +#include "alpha.h" + +namespace app { + void run() { + alpha::Token t; + probe(t, 42); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-adl-non-function-blocks/app.cpp b/gitnexus/test/fixtures/lang-resolution/cpp-adl-non-function-blocks/app.cpp new file mode 100644 index 000000000..8a1e1063f --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-adl-non-function-blocks/app.cpp @@ -0,0 +1,10 @@ +#include "audit.h" + +namespace app { + int record = 0; + + void run() { + audit::Event e; + record(e); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-adl-non-function-blocks/audit.h b/gitnexus/test/fixtures/lang-resolution/cpp-adl-non-function-blocks/audit.h new file mode 100644 index 000000000..2c9803a3d --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-adl-non-function-blocks/audit.h @@ -0,0 +1,6 @@ +#pragma once + +namespace audit { + struct Event {}; + void record(Event e); +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-adl-param-not-free-func-ref/app.cpp b/gitnexus/test/fixtures/lang-resolution/cpp-adl-param-not-free-func-ref/app.cpp new file mode 100644 index 000000000..be2028739 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-adl-param-not-free-func-ref/app.cpp @@ -0,0 +1,14 @@ +#include "utils.h" + +namespace caller { + // `callback` is a plain int parameter, not a function reference. + // Without the fix: `callback` is not found in the compound_statement + // (parameters live in parameter_list) → lookupAdlIdentifierType returns null + // → treated as free-function ref → workspace scan finds utils::callback + // → `utils` added to ADL set → run_with resolves to utils::run_with (false positive). + // With the fix: isIdentifierAFunctionParameter detects `callback` in the + // parameter_list → returns EMPTY_ADL_ARG → no namespace contributed → 0 CALLS edges. + void run(int callback) { + run_with(callback); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-adl-param-not-free-func-ref/utils.h b/gitnexus/test/fixtures/lang-resolution/cpp-adl-param-not-free-func-ref/utils.h new file mode 100644 index 000000000..e25ac913f --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-adl-param-not-free-func-ref/utils.h @@ -0,0 +1,10 @@ +#pragma once + +namespace utils { + // A function named `callback` exists in the `utils` namespace. Without the + // parameter-list guard, passing a function *parameter* also named `callback` + // would trigger a workspace scan, find utils::callback, contribute `utils` + // to the ADL set, and emit a false-positive CALLS edge to utils::run_with. + void callback(); + void run_with(int n); +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-adl-qualified-variable-arg/app.cpp b/gitnexus/test/fixtures/lang-resolution/cpp-adl-qualified-variable-arg/app.cpp new file mode 100644 index 000000000..44dcf8618 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-adl-qualified-variable-arg/app.cpp @@ -0,0 +1,14 @@ +#include "data.h" + +namespace caller { + // data::value is a namespace-qualified VARIABLE, not a function. + // ADL must NOT contribute `data` to the associated namespace set — the + // argument type is `int`, which has no associated namespaces in ISO C++. + // GitNexus guards: collectFunctionRefNamespaces verifies a Function/Method + // named `value` exists in `data` before contributing. Since `data::value` + // is a variable (not a function), `data` is NOT added, and process() is + // not resolved via ADL. + void run() { + process(data::value); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-adl-qualified-variable-arg/data.h b/gitnexus/test/fixtures/lang-resolution/cpp-adl-qualified-variable-arg/data.h new file mode 100644 index 000000000..37cfefd9d --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-adl-qualified-variable-arg/data.h @@ -0,0 +1,6 @@ +#pragma once + +namespace data { + extern int value; + void process(int n); +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-adl-unqualified-ref-collision/app.cpp b/gitnexus/test/fixtures/lang-resolution/cpp-adl-unqualified-ref-collision/app.cpp new file mode 100644 index 000000000..7512873f3 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-adl-unqualified-ref-collision/app.cpp @@ -0,0 +1,14 @@ +#include "lib.h" + +namespace caller { + void run() { + // Unqualified `worker` — not in local compound_statement scope → treated + // as a potential free-function reference. The workspace scan finds + // 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). + // Merged-narrowing ambiguity suppression in free-call-fallback emits + // zero CALLS edges rather than picking one arbitrarily. + run_with(worker); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-adl-unqualified-ref-collision/lib.h b/gitnexus/test/fixtures/lang-resolution/cpp-adl-unqualified-ref-collision/lib.h new file mode 100644 index 000000000..aa1daa331 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-adl-unqualified-ref-collision/lib.h @@ -0,0 +1,12 @@ +#pragma once + +namespace alpha { + // `worker` exists in both alpha and beta namespaces. + void worker(); + void run_with(void (*fn)()); +} + +namespace beta { + void worker(); + void run_with(void (*fn)()); +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-inline-namespace-ambiguous-diff-sigs/caller.cpp b/gitnexus/test/fixtures/lang-resolution/cpp-inline-namespace-ambiguous-diff-sigs/caller.cpp new file mode 100644 index 000000000..fcb416b39 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-inline-namespace-ambiguous-diff-sigs/caller.cpp @@ -0,0 +1,5 @@ +#include "lib.h" + +void run() { + outer::foo(42); +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-inline-namespace-ambiguous-diff-sigs/lib.h b/gitnexus/test/fixtures/lang-resolution/cpp-inline-namespace-ambiguous-diff-sigs/lib.h new file mode 100644 index 000000000..c0ebbdfac --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-inline-namespace-ambiguous-diff-sigs/lib.h @@ -0,0 +1,10 @@ +#pragma once + +namespace outer { + inline namespace v1 { + void foo(int x); + } + inline namespace v2 { + void foo(double y); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-inline-namespace-ambiguous/caller.cpp b/gitnexus/test/fixtures/lang-resolution/cpp-inline-namespace-ambiguous/caller.cpp new file mode 100644 index 000000000..f2aa44454 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-inline-namespace-ambiguous/caller.cpp @@ -0,0 +1,5 @@ +#include "lib.h" + +void run() { + outer::foo(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-inline-namespace-ambiguous/lib.h b/gitnexus/test/fixtures/lang-resolution/cpp-inline-namespace-ambiguous/lib.h new file mode 100644 index 000000000..54bfb229d --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-inline-namespace-ambiguous/lib.h @@ -0,0 +1,10 @@ +#pragma once + +namespace outer { + inline namespace v1 { + void foo(); + } + inline namespace v2 { + void foo(); + } +} diff --git a/gitnexus/test/integration/resolvers/cpp.test.ts b/gitnexus/test/integration/resolvers/cpp.test.ts index 6e58c0a54..de0ad9632 100644 --- a/gitnexus/test/integration/resolvers/cpp.test.ts +++ b/gitnexus/test/integration/resolvers/cpp.test.ts @@ -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,252 @@ 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. +// +// GitNexus approximation (not strict ISO C++ ADL): when a qualified_identifier +// like `utils::worker` is passed as an argument, GitNexus contributes the +// enclosing namespace (`utils`) to the associated set, provided a Function or +// Method named `worker` is found in the `utils` namespace at resolution time. +// Under ISO C++ [basic.lookup.argdep] the associated entities for a function-type +// argument come from the parameter types and return type of the overload set — +// NOT the function's enclosing namespace. For `void worker()`, the standard- +// compliant associated set is empty. The approximation captures the dominant +// real-world pattern (pass a utility function → find its sibling) at the cost +// of potential false positives when an unrelated function with the same simple +// name exists in the same namespace (bounded by the workspace-function lookup). +// --------------------------------------------------------------------------- + +describe('C++ ADL — qualified free-function reference contributes its namespace', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'cpp-adl-free-func-ref'), () => {}); + }, 60000); + + it('with_callback(utils::worker) resolves to utils::with_callback via ADL', () => { + const calls = getRelationships(result, 'CALLS'); + const cbCalls = calls.filter((c) => c.source === 'run' && c.target === 'with_callback'); + // Ordinary lookup inside caller::run finds nothing (no `using`, no local + // declaration). utils::worker is a qualified_identifier argument, so ADL + // contributes `utils` to the associated-namespace set. utils::with_callback + // is then discovered as the sole candidate. + expect(cbCalls.length).toBe(1); + expect(cbCalls[0].targetFilePath).toContain('utils.h'); + }); +}); + +describe('C++ ADL — overloaded free-function reference does not crash', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'cpp-adl-free-func-ref-overloaded'), + () => {}, + ); + }, 60000); + + it('with_callback(utils::worker) with overloaded utils::worker still resolves utils::with_callback via ADL', () => { + const calls = getRelationships(result, 'CALLS'); + const cbCalls = calls.filter((c) => c.source === 'run' && c.target === 'with_callback'); + // utils::worker has two overloads (worker() and worker(int)). V1 + // simplification: contribute the namespace if ANY overload exists in the + // workspace, regardless of which one would be selected. The namespace + // `utils` is still added, and utils::with_callback is discovered. + expect(cbCalls.length).toBe(1); + expect(cbCalls[0].targetFilePath).toContain('utils.h'); + }); +}); + +describe('C++ ADL — namespace-qualified variable arg does NOT contribute namespace', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'cpp-adl-qualified-variable-arg'), + () => {}, + ); + }, 60000); + + it('process(data::value) emits zero CALLS edges — data::value is a variable, not a function', () => { + const calls = getRelationships(result, 'CALLS'); + const processCalls = calls.filter((c) => c.source === 'run' && c.target === 'process'); + // data::value is a namespace-qualified integer variable. tree-sitter-cpp + // produces a qualified_identifier AST node regardless of whether `value` + // denotes a function, variable, enum, or static member. The GitNexus guard + // in collectFunctionRefNamespaces verifies that a Function/Method named + // `value` exists in the `data` namespace before contributing it. Since + // `data::value` is an int variable, `data` is never added to the associated + // set, so data::process is never found as an ADL candidate. + expect(processCalls.length).toBe(0); + }); +}); + +describe('C++ ADL — function parameter does NOT trigger free-function-ref ADL', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'cpp-adl-param-not-free-func-ref'), + () => {}, + ); + }, 60000); + + it('run_with(callback) emits zero CALLS edges when callback is a parameter, not a function reference', () => { + const calls = getRelationships(result, 'CALLS'); + const runWithCalls = calls.filter((c) => c.source === 'run' && c.target === 'run_with'); + // `callback` is an int parameter of `caller::run`. Function parameters + // live in the parameter_list, not in the compound_statement, so the + // local-scope declaration scan would not find it and would return null — + // previously misclassifying it as an unqualified free-function reference. + // The workspace contains utils::callback(), so the scan would find it and + // contribute `utils` to the ADL set, emitting a false-positive CALLS edge + // to utils::run_with. isIdentifierAFunctionParameter now catches this and + // returns EMPTY_ADL_ARG, preventing the workspace scan entirely. + expect(runWithCalls.length).toBe(0); + }); +}); + // --------------------------------------------------------------------------- // U5 (follow-up plan 2026-05-13-001): inline namespace transitive walking. // `inline namespace v1 { ... }` makes its members reachable through the @@ -2432,6 +2689,51 @@ describe('C++ ADL — int/long-collision overloads suppress via OVERLOAD_AMBIGUO // `resolveQualifiedReceiverMember` hook on the ScopeResolver contract. // --------------------------------------------------------------------------- +describe('C++ ADL — local function-pointer var shadows same-named free function', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'cpp-adl-local-fp-shadows-free-func'), + () => {}, + ); + }, 60000); + + it('record(g) emits zero CALLS edges even though audit::g() exists in the workspace', () => { + const calls = getRelationships(result, 'CALLS'); + const recordCalls = calls.filter((c) => c.source === 'run' && c.target === 'record'); + // `g` is a locally-declared `void (*g)()` variable. `audit::g()` also + // exists in the workspace. Without the foundAsLocalFunctionPointer guard, + // `g` would not be detected in the compound_statement (it IS there, but + // as a function-pointer declarator), and the workspace scan would find + // audit::g, contribute `audit` to the ADL set, and emit a false-positive + // CALLS edge to audit::record. The guard correctly returns EMPTY_ADL_ARG, + // so no namespace is contributed and no edge is emitted. + expect(recordCalls.length).toBe(0); + }); +}); + +describe('C++ ADL — unqualified free-function ref with namespace collision', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'cpp-adl-unqualified-ref-collision'), + () => {}, + ); + }, 60000); + + it('run_with(worker) emits zero CALLS edges when worker exists in two namespaces', () => { + const calls = getRelationships(result, 'CALLS'); + 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 → merged narrowing suppression → + // zero CALLS edges (suppressed rather than arbitrary pick). + expect(runWithCalls.length).toBe(0); + }); +}); + describe('C++ inline namespace — outer::foo resolves to inline child', () => { let result: PipelineResult; @@ -2474,6 +2776,47 @@ describe('C++ inline namespace — versioned (v1 inline, v0 not)', () => { }); }); +describe('C++ inline namespace — ambiguous same-name across inline children (#1564)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'cpp-inline-namespace-ambiguous'), + () => {}, + ); + }, 60000); + + it('outer::foo() emits zero CALLS edges when v1 and v2 both declare foo', () => { + const calls = getRelationships(result, 'CALLS'); + const fooCalls = calls.filter((c) => c.source === 'run' && c.target === 'foo'); + // ISO C++ leaves this ambiguous — both inline namespace children declare + // the same name. The resolver must suppress rather than pick arbitrarily. + expect(fooCalls.length).toBe(0); + }); +}); + +describe('C++ inline namespace — ambiguous distinct signatures (conservative suppress)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'cpp-inline-namespace-ambiguous-diff-sigs'), + () => {}, + ); + }, 60000); + + it('outer::foo(42) emits zero CALLS edges when v1 declares foo(int) and v2 declares foo(double)', () => { + const calls = getRelationships(result, 'CALLS'); + const fooCalls = calls.filter((c) => c.source === 'run' && c.target === 'foo'); + // Even though the two overloads have distinct signatures and a compiler + // could disambiguate via argument types, the `resolveQualifiedReceiverMember` + // hook lacks call-site arity/argument-type information, so multi-hit cases + // are conservatively suppressed. Documents the limitation noted in + // inline-namespaces.ts (Finding 1 of Claude review on #1600). + expect(fooCalls.length).toBe(0); + }); +}); + describe('C++ inline namespace — nested (STL __1-style)', () => { let result: PipelineResult; @@ -2522,6 +2865,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), diff --git a/gitnexus/test/integration/resolvers/helpers.ts b/gitnexus/test/integration/resolvers/helpers.ts index 63d59e3c9..ae2a75f04 100644 --- a/gitnexus/test/integration/resolvers/helpers.ts +++ b/gitnexus/test/integration/resolvers/helpers.ts @@ -122,14 +122,18 @@ const LEGACY_RESOLVER_PARITY_EXPECTED_FAILURES: Readonly::g() -> f() does NOT bind to Base::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- @@ -171,6 +175,34 @@ const LEGACY_RESOLVER_PARITY_EXPECTED_FAILURES: Readonly::g_unqualified() -> f() does NOT bind to Base::f', 'Derived::g_this() -> this->f() resolves to Base::f (1 edge)', 'Derived::g() -> this->f() emits zero CALLS edges when only hidden derived overload is arity-incompatible', + // The legacy DAG path has no inline-namespace same-name ambiguity + // detection. When two inline children declare the same name, the + // legacy path picks an arbitrary match. The scope-resolver returns + // 'ambiguous' and suppresses edge emission. Scope-resolver-only + // correctness win (#1564); backporting to legacy is out of scope. + 'outer::foo() emits zero CALLS edges when v1 and v2 both declare foo', + // Distinct-signature inline-namespace ambiguity: `foo(int)` in v1 and + // `foo(double)` in v2. The scope-resolver conservatively suppresses + // because `resolveQualifiedReceiverMember` lacks call-site argument + // types. Legacy DAG has no inline-namespace resolver. Scope-resolver- + // only correctness win (#1600 / Claude review Finding 1). + 'outer::foo(42) emits zero CALLS edges when v1 declares foo(int) and v2 declares foo(double)', + // PR #1598: ADL free-function reference arg negative fixtures rely on + // scope-resolver-only correctness. The legacy DAG falls back to + // `pickUniqueGlobalCallable` which resolves the callee by simple-name + // workspace lookup, ignoring argument analysis. These fixtures expect + // zero CALLS edges (the registry-primary path correctly avoids a false- + // positive), but the legacy path emits one edge via the global fallback. + // 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', ]), };