diff --git a/gitnexus-shared/src/scope-resolution/finalize-algorithm.ts b/gitnexus-shared/src/scope-resolution/finalize-algorithm.ts index a94012a0b..f5d3dd0bf 100644 --- a/gitnexus-shared/src/scope-resolution/finalize-algorithm.ts +++ b/gitnexus-shared/src/scope-resolution/finalize-algorithm.ts @@ -833,7 +833,16 @@ function expandWildcard( if (target === undefined) return [edge]; const names = hooks.expandsWildcardTo(edge.targetModuleScope, workspace); - if (names.length === 0) return []; + if (names.length === 0) { + // Resolved wildcard with zero propagating names is still a real file- + // level dependency (e.g. a C++ header that only declares classes — + // `#include` is a valid IMPORTS edge, but unqualified-binding names + // are correctly empty since class methods require `Class::method`). + // Preserve the original wildcard edge so the file→file IMPORTS edge + // survives; downstream binding materialization sees no propagated + // names because the edge has no `targetExportedName`/`localName`. + return [edge]; + } const expanded: ImportEdge[] = []; for (const name of names) { diff --git a/gitnexus/src/core/ingestion/call-processor.ts b/gitnexus/src/core/ingestion/call-processor.ts index b45478f7a..35a59dab4 100644 --- a/gitnexus/src/core/ingestion/call-processor.ts +++ b/gitnexus/src/core/ingestion/call-processor.ts @@ -774,6 +774,7 @@ export const processCalls = async ( propertyName: string; filePath: string; srcId: string; + line?: number; }[] = []; // Phase P cross-file: accumulate heritage across files for cross-file isSubclassOf. // Used as a secondary check when per-file parentMap lacks the relationship — helps @@ -1102,11 +1103,16 @@ export const processCalls = async ( provider, ); const srcId = enclosing || generateId('File', file.path); - // Defer resolution so write-access tracking sees the FINAL graph - // state — properties from the pre-pass are present, but receiver-type - // resolution can still depend on inference that completes during the - // main loop. Resolve after all files have been processed. - pendingWrites.push({ receiverTypeName, propertyName, filePath: file.path, srcId }); + // Defer resolution: Ruby attr_accessor properties are registered during + // this same loop, so cross-file lookups fail if the declaring file hasn't + // been processed yet. Collect now, resolve after all files are done. + pendingWrites.push({ + receiverTypeName, + propertyName, + filePath: file.path, + srcId, + line: captureMap['assignment'].startPosition.row + 1, + }); } // Assignment-only capture (no @call sibling): skip the rest of this // forEach iteration — this acts as a `continue` in the match loop. @@ -1516,7 +1522,10 @@ export const processCalls = async ( ); if (fieldOwner) { graph.addRelationship({ - id: generateId('ACCESSES', `${pw.srcId}:${fieldOwner.nodeId}:write`), + id: generateId( + 'ACCESSES', + `${pw.srcId}:${fieldOwner.nodeId}:write${pw.line !== undefined ? `:${pw.line}` : ''}`, + ), sourceId: pw.srcId, targetId: fieldOwner.nodeId, type: 'ACCESSES', @@ -3113,7 +3122,10 @@ export const processAssignmentsFromExtracted = ( const fieldOwner = resolveFieldOwnership(receiverTypeName, asn.propertyName, asn.filePath, ctx); if (!fieldOwner) continue; graph.addRelationship({ - id: generateId('ACCESSES', `${asn.sourceId}:${fieldOwner.nodeId}:write`), + id: generateId( + 'ACCESSES', + `${asn.sourceId}:${fieldOwner.nodeId}:write${asn.line !== undefined ? `:${asn.line}` : ''}`, + ), sourceId: asn.sourceId, targetId: fieldOwner.nodeId, type: 'ACCESSES', diff --git a/gitnexus/src/core/ingestion/languages/c-cpp.ts b/gitnexus/src/core/ingestion/languages/c-cpp.ts index 7fab4689b..58e59fe6f 100644 --- a/gitnexus/src/core/ingestion/languages/c-cpp.ts +++ b/gitnexus/src/core/ingestion/languages/c-cpp.ts @@ -55,6 +55,15 @@ import { cImportOwningScope, cReceiverBinding, } from './c/index.js'; +import { + emitCppScopeCaptures, + interpretCppImport, + interpretCppTypeBinding, + cppArityCompatibility, + cppBindingScopeFor, + cppImportOwningScope, + cppReceiverBinding, +} from './cpp/index.js'; const C_BUILT_INS: ReadonlySet = new Set([ 'printf', @@ -447,4 +456,14 @@ export const cppProvider = defineLanguage({ heritageExtractor: createHeritageExtractor(SupportedLanguages.CPlusPlus), labelOverride: cppLabelOverride, builtInNames: C_BUILT_INS, + + // ── RFC #909 Ring 3: scope-based resolution hooks (RFC §5) ────────── + emitScopeCaptures: emitCppScopeCaptures, + interpretImport: interpretCppImport, + interpretTypeBinding: interpretCppTypeBinding, + bindingScopeFor: cppBindingScopeFor, + importOwningScope: cppImportOwningScope, + receiverBinding: cppReceiverBinding, + arityCompatibility: cppArityCompatibility, + // mergeBindings + resolveImportTarget live on ScopeResolver (see cpp/scope-resolver.ts). }); diff --git a/gitnexus/src/core/ingestion/languages/cpp/adl.ts b/gitnexus/src/core/ingestion/languages/cpp/adl.ts new file mode 100644 index 000000000..112502a74 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/cpp/adl.ts @@ -0,0 +1,335 @@ +/** + * C++ argument-dependent lookup (ADL / Koenig lookup) — V1. + * + * When ordinary unqualified lookup fails for a free-call site, ADL also + * considers candidates declared in the **associated namespaces** of the + * call's argument types (ISO C++ `[basic.lookup.argdep]`). The canonical + * pattern V1 unlocks: + * + * namespace audit { struct Event; void record(Event); } + * namespace app { void run() { audit::Event e; record(e); } } + * + * Without ADL: `record(e)` is unresolved because `app::run` doesn't + * `using` anything. With V1 ADL: `audit::record` is discovered via + * `audit::Event`'s associated namespace. + * + * ## V1 boundary + * + * V1 covers ONE associated-entity rule: an argument that's a directly-named + * class type (`audit::Event e`) contributes its **direct enclosing + * namespace** to the candidate set. Anything else — pointer/reference + * arguments, function-pointer arguments, template specializations, + * base-class associated namespaces — is V2 closure work and is + * deliberately excluded. The `cpp-adl-pointer-arg-boundary` fixture + * locks the exclusion in CI. + * + * V1 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. + * + * ## Parenthesized-name suppression + * + * `(f)(s)` MUST NOT trigger ADL — the parenthesized name forces ordinary + * lookup only. `captures.ts` records sites whose `function` child is a + * `parenthesized_expression` into `noAdlSites`; `pickCppAdlCandidates` + * short-circuits when the site key is present. + * + * ## State lifecycle + * + * Three module-level maps populated per pipeline invocation, cleared via + * `clearCppAdlState()` (called from `clearFileLocalNames`): + * + * - `argInfoBySite` — per-call-site argument shape (capture-time) + * - `noAdlSites` — call sites with parenthesized function (capture-time) + * - `classToNamespaceQualifiedName` — class def → its enclosing namespace + * qualified name (`populateCppAssociatedNamespaces` time) + * + * The class→namespace map uses qualified names (not scope IDs) because + * C++ namespaces are open: `namespace N { ... }` in file A and + * `namespace N { ... }` in file B produce two distinct Namespace scopes + * but logically share the same namespace. ADL must consider candidates + * declared in either file. + */ + +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'; + +/** + * Per-argument shape information collected at capture time. ADL only + * fires for arguments where `simpleClassName !== ''` AND `!isPointer` + * AND `!isReference` — i.e., directly-named class-type values. + */ +export interface CppAdlArgInfo { + /** Simple class-like type name (last segment of qualified name); empty + * for primitives, literals, function pointers, template specs, etc. */ + readonly simpleClassName: string; + /** True when the variable's declarator was a `pointer_declarator`. V1 + * excludes pointer-typed args (closure rules deferred to V2). */ + readonly isPointer: boolean; + /** True when the variable's declarator was a `reference_declarator`. */ + readonly isReference: boolean; +} + +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}`; +} + +/** Record per-call-site argument info. Called once per call site from + * `emitCppScopeCaptures`. */ +export function markCppAdlSiteArgs( + filePath: string, + line: number, + col: number, + args: readonly CppAdlArgInfo[], +): void { + argInfoBySite.set(siteKey(filePath, line, col), args); +} + +/** Mark a call site as ADL-suppressed (function child wrapped in + * `parenthesized_expression`, e.g. `(f)(s)`). */ +export function markCppAdlSiteNoAdl(filePath: string, line: number, col: number): void { + noAdlSites.add(siteKey(filePath, line, col)); +} + +/** Clear ADL state. Called from `clearFileLocalNames` so all C++ resolver + * per-pipeline state is reset together. */ +export function clearCppAdlState(): void { + argInfoBySite.clear(); + noAdlSites.clear(); + classToNamespaceQualifiedName.clear(); +} + +/** + * Walk `parsed.scopes` to record each Class def's enclosing namespace + * qualified name. Run from the cpp resolver's `populateOwners` hook so + * the index is available before any resolution pass consults it. + * + * Computes the namespace's qualified name by walking parent scope chain + * and looking up Namespace defs in each parent's `ownedDefs`. The + * resulting name is dot-joined (matching `populateClassOwnedMembers`'s + * dotted convention; conversion to `::` is consumer-internal). + */ +export function populateCppAssociatedNamespaces(parsed: ParsedFile): void { + const scopesById = new Map(); + for (const scope of parsed.scopes) scopesById.set(scope.id, scope); + + for (const scope of parsed.scopes) { + if (scope.kind !== 'Class') continue; + const nsQName = computeEnclosingNamespaceQName(scope, scopesById); + if (nsQName === '') continue; + for (const def of scope.ownedDefs) { + if (def.type !== 'Class' && def.type !== 'Struct' && def.type !== 'Interface') 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. + * + * Fires only when: + * - the call site is not in `noAdlSites` (parenthesized form), AND + * - at least one argument is a directly-named class type (not pointer, + * not reference, not literal/primitive). + */ +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 { + 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 value-class-typed arg. + const associatedNamespaces = new Set(); + for (const arg of args) { + if (arg.simpleClassName === '') continue; + if (arg.isPointer || arg.isReference) continue; + const classDef = findCppClassDefBySimpleName(arg.simpleClassName, scopes); + if (classDef === undefined) continue; + const nsQName = classToNamespaceQualifiedName.get(classDef.nodeId); + if (nsQName !== undefined) associatedNamespaces.add(nsQName); + } + 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. + const candidates: SymbolDefinition[] = []; + const seenKey = new Set(); + 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; + const qName = computeNamespaceQName(scope, scopesById); + if (!associatedNamespaces.has(qName)) continue; + 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; + // Dedup by nodeId — using normalized parameter-types as the key + // would collapse `process(int)`/`process(long)`-style overloads + // (both normalize to `['int']`) before + // `isOverloadAmbiguousAfterNormalization` can detect them. + 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; +} + +/** Walk upward from a Class scope, finding the innermost enclosing + * Namespace scope, and return that namespace's qualified name (dot- + * joined, outermost-first). Returns '' when the class has no enclosing + * namespace (e.g., declared at translation-unit scope). */ +function computeEnclosingNamespaceQName( + classScope: { readonly parent: ScopeId | null }, + scopesById: ReadonlyMap< + ScopeId, + { + readonly parent: ScopeId | null; + readonly kind: string; + readonly ownedDefs: readonly SymbolDefinition[]; + } + >, +): string { + let parentId: ScopeId | null = classScope.parent; + while (parentId !== null) { + const parent = scopesById.get(parentId); + if (parent === undefined) return ''; + if (parent.kind === 'Namespace') { + return computeNamespaceQName(parent, scopesById); + } + parentId = parent.parent; + } + return ''; +} + +/** Walk upward from a Namespace scope collecting each enclosing + * Namespace's simple name (innermost last). Returns the dot-joined + * qualified name (e.g., `outer.inner`). The namespace's own def lives + * in its OWN scope's `ownedDefs` (the C++ extractor stamps the + * namespace-decl def into the namespace scope itself, not the parent + * module scope). */ +function computeNamespaceQName( + nsScope: { readonly parent: ScopeId | null; readonly ownedDefs: readonly SymbolDefinition[] }, + scopesById: ReadonlyMap< + ScopeId, + { + readonly parent: ScopeId | null; + readonly kind: string; + readonly ownedDefs: readonly SymbolDefinition[]; + } + >, +): string { + const segments: string[] = []; + let currentId: ScopeId | null = nsScope.parent; + let current: + | { readonly parent: ScopeId | null; readonly ownedDefs: readonly SymbolDefinition[] } + | undefined = nsScope; + // Outer guard against pathological cycles in malformed scope trees. + let safety = 64; + while (current !== undefined && safety-- > 0) { + const nsDef = findNamespaceDefInScope(current); + if (nsDef === undefined) { + // No name found — bail out. Returning a partial QName would risk + // false ADL associations. + return ''; + } + const simple = nsDef.qualifiedName?.split('.').pop() ?? nsDef.qualifiedName ?? ''; + segments.unshift(simple); + // Walk up to next enclosing namespace (skipping non-namespace parents). + let nextId: ScopeId | null = currentId; + let nextNs: typeof current | undefined; + while (nextId !== null) { + const nx = scopesById.get(nextId); + if (nx === undefined) break; + if (nx.kind === 'Namespace') { + nextNs = nx; + currentId = nx.parent; + break; + } + nextId = nx.parent; + } + current = nextNs; + } + return segments.join('.'); +} + +/** Find the Namespace def attached to this scope (the namespace's own + * decl, stamped into its own `ownedDefs` by the C++ extractor). Returns + * the first Namespace-type def encountered — for normal C++ the scope + * carries exactly one Namespace-typed self def. */ +function findNamespaceDefInScope(scope: { + readonly ownedDefs: readonly SymbolDefinition[]; +}): SymbolDefinition | undefined { + for (const def of scope.ownedDefs) { + if (def.type === 'Namespace') return def; + } + return undefined; +} + +/** Find a class-like def by simple name across the workspace. V1 + * arbitrary-pick on collisions (multiple classes share the simple name); + * C++ ADL strictness would require full type-driven lookup, but V1 + * trades that for simplicity. */ +function findCppClassDefBySimpleName( + simpleName: string, + scopes: ScopeResolutionIndexes, +): SymbolDefinition | undefined { + for (const def of scopes.defs.byId.values()) { + if (def.type !== 'Class' && def.type !== 'Struct' && def.type !== 'Interface') continue; + const simple = def.qualifiedName?.split('.').pop() ?? def.qualifiedName ?? ''; + if (simple === simpleName) return def; + } + return undefined; +} diff --git a/gitnexus/src/core/ingestion/languages/cpp/arity-metadata.ts b/gitnexus/src/core/ingestion/languages/cpp/arity-metadata.ts new file mode 100644 index 000000000..fb47d3122 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/cpp/arity-metadata.ts @@ -0,0 +1,185 @@ +import type { SyntaxNode } from '../../utils/ast-helpers.js'; + +export interface CppArityInfo { + parameterCount?: number; + requiredParameterCount?: number; + parameterTypes?: string[]; +} + +/** + * Compute declaration arity from a C++ function definition or declaration node. + * Extends the C arity computation with support for: + * - optional_parameter_declaration (default parameters) + * - variadic_parameter_declaration / parameter packs + * - (void) explicit zero-parameter form + */ +export function computeCppDeclarationArity(node: SyntaxNode): CppArityInfo { + const funcDecl = findFuncDeclarator(node); + if (funcDecl === null) return {}; + + const paramList = funcDecl.childForFieldName('parameters'); + if (paramList === null) return {}; + + const params: SyntaxNode[] = []; + // Track whether a C-style variadic `...` anonymous token appears. + // tree-sitter-cpp emits `...` as an anonymous (non-named) child of + // parameter_list, not as `variadic_parameter`. + let hasEllipsis = false; + for (let i = 0; i < paramList.childCount; i++) { + const child = paramList.child(i); + if (child === null) continue; + if ( + child.type === 'parameter_declaration' || + child.type === 'optional_parameter_declaration' || + child.type === 'variadic_parameter' || + child.type === 'variadic_parameter_declaration' + ) { + params.push(child); + } else if (child.type === '...' || (!child.isNamed && child.text === '...')) { + hasEllipsis = true; + } + } + + // Empty parameter list: C++ `void foo()` means zero params (unlike C) + if (params.length === 0 && !hasEllipsis) { + return { parameterCount: 0, requiredParameterCount: 0, parameterTypes: [] }; + } + + // (void) means zero parameters + if (params.length === 1 && params[0].type === 'parameter_declaration') { + const typeNode = params[0].childForFieldName('type'); + const hasDeclarator = params[0].childForFieldName('declarator') !== null; + if (typeNode !== null && typeNode.text === 'void' && !hasDeclarator) { + return { parameterCount: 0, requiredParameterCount: 0, parameterTypes: [] }; + } + } + + // C-style variadic: `void foo(int x, ...)` — the `...` is an anonymous + // token in tree-sitter-cpp, detected via `hasEllipsis` above. + // C++ parameter packs: `template void foo(Ts... args)` — + // detected as `variadic_parameter_declaration`. + const isVariadic = + hasEllipsis || + params.some( + (p) => p.type === 'variadic_parameter' || p.type === 'variadic_parameter_declaration', + ); + const optionalCount = params.filter((p) => p.type === 'optional_parameter_declaration').length; + const requiredCount = params.filter( + (p) => + p.type === 'parameter_declaration' || + // variadic_parameter_declaration with a name is a parameter pack — counts as one + p.type === 'variadic_parameter_declaration', + ).length; + const totalNonVariadic = requiredCount + optionalCount; + + const types: string[] = []; + for (const p of params) { + if (p.type === 'variadic_parameter') { + types.push('...'); + } else if (p.type === 'variadic_parameter_declaration') { + // Parameter pack: treated as variadic + types.push('...'); + } else { + const typeNode = p.childForFieldName('type'); + types.push(normalizeCppParamType(typeNode?.text ?? 'unknown')); + } + } + // Append '...' for C-style variadic if not already in types + if (hasEllipsis && !types.includes('...')) { + types.push('...'); + } + + return { + parameterCount: isVariadic ? undefined : totalNonVariadic, + requiredParameterCount: requiredCount, + parameterTypes: types, + }; +} + +/** + * Compute call-site arity from a call_expression node. + */ +export function computeCppCallArity(node: SyntaxNode): number { + const argList = node.childForFieldName('arguments'); + if (argList === null) return 0; + + let count = 0; + for (let i = 0; i < argList.childCount; i++) { + const child = argList.child(i); + if (child === null) continue; + if (child.type !== ',' && child.type !== '(' && child.type !== ')') { + count++; + } + } + return count; +} + +/** + * Normalize a C++ parameter type for overload disambiguation. + * Maps common qualified/aliased types to their canonical short forms + * so that `narrowOverloadCandidates` can match against literal-inferred + * argument types (e.g. `inferCppLiteralType` returns `'string'` for + * string literals, not `'std::string'`). + */ +function normalizeCppParamType(raw: string): string { + let t = raw.trim(); + // Strip const, volatile, etc. + t = t.replace(/\b(const|volatile|restrict|mutable|constexpr)\b/g, '').trim(); + // Strip reference/pointer markers + t = t.replace(/[&*]+\s*$/, '').trim(); + // Strip template parameters (loop handles nested: Map> → Map) + while (t.includes('<')) { + const stripped = t.replace(/<[^<>]*>/g, ''); + if (stripped === t) break; // avoid infinite loop on malformed input + t = stripped; + } + t = t.trim(); + // Map std:: types to canonical short forms + const STD_MAP: Record = { + 'std::string': 'string', + 'std::wstring': 'string', + 'std::string_view': 'string', + string: 'string', + char: 'char', + int: 'int', + long: 'int', + short: 'int', + unsigned: 'int', + 'unsigned int': 'int', + 'long long': 'int', + size_t: 'int', + 'std::size_t': 'int', + float: 'double', + double: 'double', + bool: 'bool', + nullptr_t: 'null', + 'std::nullptr_t': 'null', + }; + return STD_MAP[t] ?? t; +} + +function findFuncDeclarator(node: SyntaxNode): SyntaxNode | null { + let decl = node.childForFieldName('declarator'); + if (decl === null) { + for (let i = 0; i < node.childCount; i++) { + const c = node.child(i); + if (c?.type === 'function_declarator') return c; + } + return null; + } + // Unwrap pointer_declarator / reference_declarator + while (decl.type === 'pointer_declarator' || decl.type === 'reference_declarator') { + const next = decl.childForFieldName('declarator'); + if (next === null) { + // reference_declarator may not use field name + for (let i = 0; i < decl.childCount; i++) { + const c = decl.child(i); + if (c?.type === 'function_declarator') return c; + } + break; + } + decl = next; + } + if (decl.type === 'function_declarator') return decl; + return null; +} diff --git a/gitnexus/src/core/ingestion/languages/cpp/arity.ts b/gitnexus/src/core/ingestion/languages/cpp/arity.ts new file mode 100644 index 000000000..e13fa6a3a --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/cpp/arity.ts @@ -0,0 +1,35 @@ +import type { Callsite, SymbolDefinition } from 'gitnexus-shared'; + +/** + * C++ arity compatibility: supports overloading and default parameters. + * + * Unlike C (no overloading, exact match only), C++ has: + * - Overloaded functions (same name, different signatures) + * - Default parameters (requiredParameterCount < parameterCount) + * - Variadic functions (C-style `...`) + * - Parameter packs (V1: treated as variadic) + * - Templates (V1: generic-ignored, arity check on non-template params) + * + * Verdict: + * - 'compatible': callsite.arity fits within [required, total] range + * - 'incompatible': callsite.arity is outside the valid range + * - 'unknown': insufficient metadata to determine + */ +export function cppArityCompatibility( + def: SymbolDefinition, + callsite: Callsite, +): 'compatible' | 'unknown' | 'incompatible' { + const max = def.parameterCount; + const min = def.requiredParameterCount; + if (max === undefined && min === undefined) return 'unknown'; + if (!Number.isFinite(callsite.arity) || callsite.arity < 0) return 'unknown'; + + const variadic = def.parameterTypes?.some((t) => t === '...') ?? false; + + // Too few arguments: less than the minimum required + if (min !== undefined && callsite.arity < min) return 'incompatible'; + // Too many arguments: more than the maximum and not variadic + if (max !== undefined && callsite.arity > max && !variadic) return 'incompatible'; + + return 'compatible'; +} diff --git a/gitnexus/src/core/ingestion/languages/cpp/captures.ts b/gitnexus/src/core/ingestion/languages/cpp/captures.ts new file mode 100644 index 000000000..e6d63635a --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/cpp/captures.ts @@ -0,0 +1,832 @@ +import type { Capture, CaptureMatch } from 'gitnexus-shared'; +import { + findNodeAtRange, + nodeToCapture, + syntheticCapture, + type SyntaxNode, +} from '../../utils/ast-helpers.js'; +import { getCppParser, getCppScopeQuery } from './query.js'; +import { getTreeSitterBufferSize } from '../../constants.js'; +import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js'; +import { splitCppInclude, splitCppUsingDecl } from './import-decomposer.js'; +import { computeCppDeclarationArity, computeCppCallArity } from './arity-metadata.js'; +import { markFileLocal } from './file-local-linkage.js'; +import { markCppDependentBase } from './two-phase-lookup.js'; +import { markCppAdlSiteArgs, markCppAdlSiteNoAdl, type CppAdlArgInfo } from './adl.js'; +import { markCppInlineNamespaceRange } from './inline-namespaces.js'; + +export function emitCppScopeCaptures( + sourceText: string, + filePath: string, + cachedTree?: unknown, +): readonly CaptureMatch[] { + let tree = cachedTree as ReturnType['parse']> | undefined; + if (tree === undefined) { + tree = parseSourceSafe(getCppParser(), sourceText, undefined, { + bufferSize: getTreeSitterBufferSize(sourceText), + }); + } + + const rawMatches = getCppScopeQuery().matches(tree.rootNode); + const out: CaptureMatch[] = []; + + // Track ranges where typedef-struct was captured as @declaration.struct + // so we can suppress the duplicate @declaration.typedef match. + const structTypedefRanges = new Set(); + + for (const m of rawMatches) { + const grouped: Record = {}; + for (const c of m.captures) { + const tag = '@' + c.name; + if (tag.startsWith('@_')) continue; + grouped[tag] = nodeToCapture(tag, c.node); + } + if (Object.keys(grouped).length === 0) continue; + + // ── Handle #include statements ────────────────────────────────── + if (grouped['@import.statement'] !== undefined) { + const anchor = grouped['@import.statement']!; + const includeNode = findNodeAtRange(tree.rootNode, anchor.range, 'preproc_include'); + if (includeNode !== null) { + const split = splitCppInclude(includeNode); + if (split !== null) { + out.push(split); + continue; + } + } + } + + // ── Handle using declarations (using namespace / using name) ──── + if (grouped['@import.using-decl'] !== undefined) { + const anchor = grouped['@import.using-decl']!; + const usingNode = findNodeAtRange(tree.rootNode, anchor.range, 'using_declaration'); + if (usingNode !== null) { + const split = splitCppUsingDecl(usingNode); + if (split !== null) { + out.push(split); + continue; + } + } + } + + // ── Track typedef-struct ranges ───────────────────────────────── + const structAnchor = grouped['@declaration.struct'] ?? grouped['@declaration.class']; + if (structAnchor !== undefined) { + const r = structAnchor.range; + structTypedefRanges.add(`${r.startLine}:${r.startCol}:${r.endLine}:${r.endCol}`); + } + + // Suppress @declaration.typedef if the same range was already captured + const typedefAnchor = grouped['@declaration.typedef']; + if (typedefAnchor !== undefined) { + const r = typedefAnchor.range; + const key = `${r.startLine}:${r.startCol}:${r.endLine}:${r.endCol}`; + if (structTypedefRanges.has(key)) continue; + } + + // ── Enrich function/method declarations with arity metadata ───── + const declAnchor = grouped['@declaration.function'] ?? grouped['@declaration.method']; + if (declAnchor !== undefined) { + const fnNode = + findNodeAtRange(tree.rootNode, declAnchor.range, 'function_definition') ?? + findNodeAtRange(tree.rootNode, declAnchor.range, 'declaration') ?? + findNodeAtRange(tree.rootNode, declAnchor.range, 'field_declaration'); + if (fnNode !== null) { + const arity = computeCppDeclarationArity(fnNode); + if (arity.parameterCount !== undefined) { + grouped['@declaration.parameter-count'] = syntheticCapture( + '@declaration.parameter-count', + fnNode, + String(arity.parameterCount), + ); + } + if (arity.requiredParameterCount !== undefined) { + grouped['@declaration.required-parameter-count'] = syntheticCapture( + '@declaration.required-parameter-count', + fnNode, + String(arity.requiredParameterCount), + ); + } + if (arity.parameterTypes !== undefined) { + grouped['@declaration.parameter-types'] = syntheticCapture( + '@declaration.parameter-types', + fnNode, + JSON.stringify(arity.parameterTypes), + ); + } + + // Detect static storage class (file-local linkage) + if (hasStaticStorageClass(fnNode)) { + const nameText = grouped['@declaration.name']?.text; + if (nameText !== undefined) { + markFileLocal(filePath, nameText); + } + } + + // Detect anonymous namespace (file-local linkage) + if (isInsideAnonymousNamespace(fnNode)) { + const nameText = grouped['@declaration.name']?.text; + if (nameText !== undefined) { + markFileLocal(filePath, nameText); + } + } + } + } + + // ── Detect static variables (file-local linkage) ──────────────── + const varDeclAnchor = grouped['@declaration.variable']; + if (varDeclAnchor !== undefined) { + const varNode = findNodeAtRange(tree.rootNode, varDeclAnchor.range, 'declaration'); + if (varNode !== null) { + if (hasStaticStorageClass(varNode) || isInsideAnonymousNamespace(varNode)) { + const nameText = grouped['@declaration.name']?.text; + if (nameText !== undefined) { + markFileLocal(filePath, nameText); + } + } + } + } + + // ── Enrich call references with arity ─────────────────────────── + const callAnchor = + grouped['@reference.call.free'] ?? + grouped['@reference.call.member'] ?? + grouped['@reference.call.qualified']; + if (callAnchor !== undefined && grouped['@reference.arity'] === undefined) { + const callNode = findNodeAtRange(tree.rootNode, callAnchor.range, 'call_expression'); + if (callNode !== null) { + grouped['@reference.arity'] = syntheticCapture( + '@reference.arity', + callNode, + String(computeCppCallArity(callNode)), + ); + } + } + + // ── Enrich constructor calls (new Foo()) with arity ───────────── + const ctorCallAnchor = grouped['@reference.call.constructor']; + if (ctorCallAnchor !== undefined && grouped['@reference.arity'] === undefined) { + const newNode = findNodeAtRange(tree.rootNode, ctorCallAnchor.range, 'new_expression'); + if (newNode !== null) { + grouped['@reference.arity'] = syntheticCapture( + '@reference.arity', + newNode, + String(computeCppCallArity(newNode)), + ); + } + } + + // ── Synthesize argument types for overload narrowing ──────────── + const anyCallAnchor = callAnchor ?? ctorCallAnchor; + if (anyCallAnchor !== undefined && grouped['@reference.parameter-types'] === undefined) { + const cNode = + findNodeAtRange(tree.rootNode, anyCallAnchor.range, 'call_expression') ?? + findNodeAtRange(tree.rootNode, anyCallAnchor.range, 'new_expression'); + if (cNode !== null) { + const argTypes = inferCppCallArgTypes(cNode); + if (argTypes !== undefined && argTypes.length > 0) { + grouped['@reference.parameter-types'] = syntheticCapture( + '@reference.parameter-types', + cNode, + JSON.stringify(argTypes), + ); + } + } + } + + // ── Inline namespace detection ────────────────────────────────── + // `inline namespace v1 { ... }` — tree-sitter-cpp exposes the + // `inline` keyword as a child of `namespace_definition`. Record the + // namespace's source range so `populateCppInlineNamespaceScopes` + // (during populateOwners) can match it back to the corresponding + // Namespace scope. + if (grouped['@declaration.namespace'] !== undefined) { + const anchor = grouped['@declaration.namespace']!; + const nsNode = findNodeAtRange(tree.rootNode, anchor.range, 'namespace_definition'); + if (nsNode !== null && isInlineNamespace(nsNode)) { + // Range coords stored in the shared Range shape use 1-based + // line numbers (see `ast-helpers.ts` rangeForNode where + // `startPosition.row + 1` is applied). Match that convention so + // `populateCppInlineNamespaceScopes` can join against `Scope.range`. + markCppInlineNamespaceRange(filePath, { + startLine: nsNode.startPosition.row + 1, + startCol: nsNode.startPosition.column, + endLine: nsNode.endPosition.row + 1, + endCol: nsNode.endPosition.column, + }); + } + } + + // ── ADL (Koenig lookup) per-site recording ────────────────────── + // Only free-call sites (no explicit receiver) participate in ADL — + // qualified `Ns::f(s)` and member `obj.f(s)` calls bypass the + // free-call fallback entirely (handled by receiver-bound-calls). + if (grouped['@reference.call.free'] !== undefined) { + const freeCallNode = findNodeAtRange( + tree.rootNode, + grouped['@reference.call.free']!.range, + 'call_expression', + ); + if (freeCallNode !== null) { + const adlAnchorRange = grouped['@reference.call.free']!.range; + if (isParenthesizedFunctionCall(freeCallNode)) { + markCppAdlSiteNoAdl(filePath, adlAnchorRange.startLine, adlAnchorRange.startCol); + } + const adlArgs = inferCppCallAdlArgs(freeCallNode); + if (adlArgs.length > 0) { + markCppAdlSiteArgs(filePath, adlAnchorRange.startLine, adlAnchorRange.startCol, adlArgs); + } + } + } + + // ── Post-process @type-binding.assignment for auto declarations ── + // The wildcard `type: (_)` in the @type-binding.assignment query + // pattern matches before the more specific @type-binding.alias and + // @type-binding.member-access patterns. When the type is `auto` + // (placeholder_type_specifier), we re-inspect the AST to synthesize + // the correct capture tags so interpret.ts can produce the right + // rawTypeName for compound-receiver chain resolution. + if ( + grouped['@type-binding.assignment'] !== undefined && + grouped['@type-binding.type']?.text === 'auto' + ) { + const anchor = grouped['@type-binding.assignment']!; + const declNode = findNodeAtRange(tree.rootNode, anchor.range, 'declaration'); + if (declNode !== null) { + const declarator = declNode.childForFieldName('declarator'); + if (declarator?.type === 'init_declarator') { + const valueNode = declarator.childForFieldName('value'); + if (valueNode !== null) { + if (valueNode.type === 'identifier') { + // auto alias = existingVar → promote to @type-binding.alias + grouped['@type-binding.alias'] = anchor; + grouped['@type-binding.type'] = nodeToCapture('@type-binding.type', valueNode); + delete grouped['@type-binding.assignment']; + } else if (valueNode.type === 'field_expression') { + // auto addr = user.address → promote to @type-binding.member-access + const argNode = valueNode.childForFieldName('argument'); + const fieldNode = valueNode.childForFieldName('field'); + if (argNode !== null && fieldNode !== null) { + grouped['@type-binding.member-access'] = anchor; + grouped['@type-binding.member-access-receiver'] = nodeToCapture( + '@type-binding.member-access-receiver', + argNode, + ); + grouped['@type-binding.type'] = nodeToCapture('@type-binding.type', fieldNode); + delete grouped['@type-binding.assignment']; + } + } else if (valueNode.type === 'call_expression') { + const fnNode = valueNode.childForFieldName('function'); + if (fnNode?.type === 'field_expression') { + // auto city = addr.getCity() → promote to @type-binding.alias + // with dotted rawName "addr.getCity" for compound-receiver + const argNode = fnNode.childForFieldName('argument'); + const fieldNode = fnNode.childForFieldName('field'); + if (argNode !== null && fieldNode !== null) { + grouped['@type-binding.member-access'] = anchor; + grouped['@type-binding.member-access-receiver'] = nodeToCapture( + '@type-binding.member-access-receiver', + argNode, + ); + grouped['@type-binding.type'] = nodeToCapture('@type-binding.type', fieldNode); + delete grouped['@type-binding.assignment']; + } + } + } + } + } + } + } + + out.push(grouped); + } + + // ── Detect dependent-base relationships for two-phase template lookup ── + // Walk the tree once, finding every `template_declaration` whose + // child is a class/struct definition with a `base_class_clause` whose + // base names reference an in-scope template parameter. Record the + // (className, dependentBaseName) pair so `populateCppDependentBases` + // (called from the `populateOwners` hook) can resolve names to nodeIds + // and the resolver can suppress unqualified-call binding to those + // bases per ISO C++ two-phase lookup. + detectCppDependentBases(tree.rootNode, filePath); + + return out; +} + +/** + * Walk the AST finding every template_declaration containing a class or + * struct definition with a dependent base. Records (className, baseName) + * pairs into the module-level state via `markCppDependentBase`. + * + * A base is "dependent" when its name (typically a template_type like + * `Base`) uses a template parameter of the enclosing template_declaration. + * Conservative bias: `typename T::U`, `decltype(...)` and template-template + * parameter shapes are also treated as dependent. + */ +function detectCppDependentBases(root: SyntaxNode, filePath: string): void { + const stack: SyntaxNode[] = [root]; + while (stack.length > 0) { + const node = stack.pop()!; + if (node.type === 'template_declaration') { + // Collect template-parameter names declared by this declaration. + // Inner template_declarations shadow outer ones — handled by the + // recursive descent below (each template_declaration creates its + // own parameter scope). + const params = collectTemplateParameterNames(node); + + // Find the class/struct definition inside this template_declaration. + const classNode = findChildOfType(node, ['class_specifier', 'struct_specifier']); + if (classNode !== null) { + const className = getTypeIdentifierName(classNode); + if (className !== '') { + const baseClause = findChildOfType(classNode, ['base_class_clause']); + if (baseClause !== null) { + for (const base of iterBaseClasses(baseClause)) { + if (isBaseDependent(base, params)) { + const baseName = extractBaseSimpleName(base); + if (baseName !== '') { + markCppDependentBase(filePath, className, baseName); + } + } + } + } + } + } + } + for (let i = 0; i < node.childCount; i++) { + const child = node.child(i); + if (child !== null) stack.push(child); + } + } +} + +/** Collect simple template parameter names from a template_declaration. */ +function collectTemplateParameterNames(templateDecl: SyntaxNode): Set { + const names = new Set(); + const paramList = findChildOfType(templateDecl, ['template_parameter_list']); + if (paramList === null) return names; + for (let i = 0; i < paramList.childCount; i++) { + const param = paramList.child(i); + if (param === null) continue; + if ( + param.type === 'type_parameter_declaration' || + param.type === 'optional_type_parameter_declaration' || + param.type === 'variadic_type_parameter_declaration' + ) { + const idNode = findFirstDescendantOfType(param, 'type_identifier'); + if (idNode !== null) names.add(idNode.text); + } else if ( + param.type === 'parameter_declaration' || + param.type === 'optional_parameter_declaration' || + param.type === 'variadic_parameter_declaration' + ) { + // Non-type template parameter (e.g. `template`). + const idNode = findFirstDescendantOfType(param, 'identifier'); + if (idNode !== null) names.add(idNode.text); + } else if (param.type === 'template_template_parameter_declaration') { + // template-template parameter (e.g. `template class TT>`) + const idNode = findFirstDescendantOfType(param, 'type_identifier'); + if (idNode !== null) names.add(idNode.text); + } + } + return names; +} + +/** Yield each base-class entry from a `base_class_clause`. */ +function* iterBaseClasses(baseClause: SyntaxNode): IterableIterator { + for (let i = 0; i < baseClause.childCount; i++) { + const child = baseClause.child(i); + if (child === null) continue; + // Skip ':', ',', and access_specifier nodes — the base names are + // type_identifier, template_type, or qualified_identifier. + if ( + child.type === 'type_identifier' || + child.type === 'template_type' || + child.type === 'qualified_identifier' + ) { + yield child; + } + } +} + +/** + * A base is dependent when: + * - it's a `template_type` and its argument list contains a + * `type_identifier` matching one of the enclosing template's params + * (e.g., `Base` where `T` is a template parameter), OR + * - it contains a `typename`, `decltype`, or `template_template_parameter` + * shape (conservatively treated as dependent). + * + * Non-dependent: `Base`, `ConcreteBase`, `Base` where + * `MyConcrete` is not a template parameter. + */ +function isBaseDependent(baseNode: SyntaxNode, templateParams: Set): boolean { + if (baseNode.type !== 'template_type') { + // Bare `type_identifier` or `qualified_identifier` bases — not + // dependent (the base name itself doesn't reference a template + // parameter at this level). + return false; + } + // Walk all descendants of the template_argument_list looking for any + // type_identifier matching a template parameter, or any conservative- + // dependent shape. + const stack: SyntaxNode[] = [baseNode]; + while (stack.length > 0) { + const node = stack.pop()!; + if (node.type === 'type_identifier' && templateParams.has(node.text)) { + return true; + } + if ( + node.type === 'decltype' || + node.type === 'dependent_type' || + node.type === 'template_template_parameter_declaration' + ) { + return true; + } + if (node.type === 'qualified_identifier') { + // `typename T::U` or `T::nested` — if any inner identifier matches + // a template parameter, dependent. + for (let i = 0; i < node.childCount; i++) { + const c = node.child(i); + if (c !== null) stack.push(c); + } + continue; + } + for (let i = 0; i < node.childCount; i++) { + const c = node.child(i); + if (c !== null) stack.push(c); + } + } + return false; +} + +/** Extract the simple name of a base class node. */ +function extractBaseSimpleName(baseNode: SyntaxNode): string { + if (baseNode.type === 'type_identifier') return baseNode.text; + if (baseNode.type === 'template_type') { + const nameNode = baseNode.childForFieldName('name'); + if (nameNode !== null) return nameNode.text; + // Fallback: first type_identifier descendant. + const id = findFirstDescendantOfType(baseNode, 'type_identifier'); + if (id !== null) return id.text; + } + if (baseNode.type === 'qualified_identifier') { + const nameNode = baseNode.childForFieldName('name'); + if (nameNode !== null) return nameNode.text; + } + return ''; +} + +/** Find the first direct child matching one of the given types. */ +function findChildOfType(node: SyntaxNode, types: readonly string[]): SyntaxNode | null { + for (let i = 0; i < node.childCount; i++) { + const c = node.child(i); + if (c !== null && types.includes(c.type)) return c; + } + return null; +} + +/** Recursive search for the first descendant of a given type. */ +function findFirstDescendantOfType(node: SyntaxNode, type: string): SyntaxNode | null { + if (node.type === type) return node; + for (let i = 0; i < node.childCount; i++) { + const c = node.child(i); + if (c === null) continue; + const hit = findFirstDescendantOfType(c, type); + if (hit !== null) return hit; + } + return null; +} + +/** Get the name of a class/struct/template_type node via its `name` field. */ +function getTypeIdentifierName(node: SyntaxNode): string { + const nameNode = node.childForFieldName('name'); + if (nameNode !== null) return nameNode.text; + const id = findFirstDescendantOfType(node, 'type_identifier'); + return id !== null ? id.text : ''; +} + +/** + * Infer argument types from a call_expression or new_expression node. + * Used for overload disambiguation by parameter types. + * + * Only literal types are inferred — identifiers and complex expressions + * return empty string (unknown) so narrowOverloadCandidates treats them + * as any-match. + */ +function inferCppCallArgTypes(node: SyntaxNode): string[] | undefined { + const argList = node.childForFieldName('arguments'); + if (argList === null) return undefined; + + const types: string[] = []; + for (let i = 0; i < argList.childCount; i++) { + const child = argList.child(i); + if (child === null) continue; + if (child.type === ',' || child.type === '(' || child.type === ')') continue; + const litType = inferCppLiteralType(child); + if (litType !== '') { + types.push(litType); + } else if (child.type === 'identifier') { + // Variable reference — look up declared type in enclosing scope + types.push(lookupDeclaredTypeForIdentifier(child)); + } else { + types.push(''); + } + } + return types.length > 0 ? types : undefined; +} + +/** + * Infer the canonical type name of a C++ literal AST node. + * Returns empty string for non-literal / unknown nodes. + */ +function inferCppLiteralType(node: SyntaxNode): string { + switch (node.type) { + case 'number_literal': { + const text = node.text; + // Floating-point literals contain '.', 'e', 'E', or end with 'f'/'F' + if ( + text.includes('.') || + text.includes('e') || + text.includes('E') || + text.endsWith('f') || + text.endsWith('F') + ) { + return 'double'; + } + return 'int'; + } + case 'string_literal': + case 'raw_string_literal': + case 'concatenated_string': + return 'string'; + case 'char_literal': + return 'char'; + case 'true': + case 'false': + return 'bool'; + case 'null': + case 'nullptr': + return 'null'; + default: + return ''; + } +} + +/** + * Look up the declared type of a variable by scanning sibling declarations + * in the enclosing compound_statement (function body). Handles: + * - `std::string result = ...` → 'string' + * - `int n = ...` → 'int' + * - `const int n = ...` → 'int' + * Returns empty string if no declaration found or type is auto/placeholder. + */ +function lookupDeclaredTypeForIdentifier(identNode: SyntaxNode): string { + const varName = identNode.text; + // Walk up to the enclosing compound_statement (function body) + let scope: SyntaxNode | null = identNode.parent; + while ( + scope !== null && + scope.type !== 'compound_statement' && + scope.type !== 'translation_unit' + ) { + scope = scope.parent; + } + if (scope === null) return ''; + + // Scan declarations in the scope for a matching variable name + for (let i = 0; i < scope.childCount; i++) { + const stmt = scope.child(i); + if (stmt === null || stmt.type !== 'declaration') continue; + + const typeNode = stmt.childForFieldName('type'); + if (typeNode === null) continue; + // Skip auto/placeholder types — those need chain-follow, not literal + if (typeNode.type === 'placeholder_type_specifier') continue; + + // Check init_declarator children for the variable name + const declarator = stmt.childForFieldName('declarator'); + if (declarator === null) continue; + if (declarator.type === 'init_declarator') { + const nameChild = declarator.childForFieldName('declarator'); + if (nameChild !== null && nameChild.text === varName) { + return normalizeCppTypeText(typeNode.text); + } + } else if (declarator.text === varName) { + return normalizeCppTypeText(typeNode.text); + } + } + return ''; +} + +/** Normalize a type-specifier text for argument type matching. + * Strips qualifiers (const, volatile), namespace prefixes (std::), + * and pointer/reference markers. */ +function normalizeCppTypeText(text: string): string { + let t = text.trim(); + t = t.replace(/\b(const|volatile|static|extern|mutable)\b/g, '').trim(); + t = t.replace(/^.*::/, ''); // strip namespace prefix + t = t.replace(/[*&]/g, '').trim(); + return t; +} + +/** + * Detect whether a `namespace_definition` AST node is inline. + * Tree-sitter-cpp exposes the `inline` keyword as an anonymous child + * node — we scan direct children for that keyword. + */ +function isInlineNamespace(nsNode: SyntaxNode): boolean { + for (let i = 0; i < nsNode.childCount; i++) { + const c = nsNode.child(i); + if (c === null) continue; + if (c.type === 'inline') return true; + // Some grammar variants surface keywords by their text rather than + // by a dedicated node type; check both for resilience. + if (c.text === 'inline' && (c.type === 'storage_class_specifier' || c.type === 'inline')) { + return true; + } + } + return false; +} + +/** + * Detect `(f)(args)` shape — the call-expression's `function` field is a + * `parenthesized_expression`. ISO C++ specifies that this form suppresses + * ADL (`[basic.lookup.argdep]/3.1`): the parenthesized name is treated as + * an ordinary unqualified-lookup-only callee. + */ +function isParenthesizedFunctionCall(callNode: SyntaxNode): boolean { + const fn = callNode.childForFieldName('function'); + return fn !== null && fn.type === 'parenthesized_expression'; +} + +/** + * Per-argument ADL classification: walk each argument of a free call and + * decide whether it's a directly-named class type (V1 ADL fires) or + * something V1 excludes (pointer, reference, primitive, literal, function + * pointer, template specialization). + * + * V1 only fires for value class-typed args: `void f(N::S); N::S s; f(s);`. + * Pointer args (`N::S* p; f(p);`) intentionally return `simpleClassName=''` + * to lock the V1 boundary — the `cpp-adl-pointer-arg-boundary` fixture + * regression-tests this. + */ +function inferCppCallAdlArgs(callNode: SyntaxNode): CppAdlArgInfo[] { + const argList = callNode.childForFieldName('arguments'); + if (argList === null) return []; + const out: CppAdlArgInfo[] = []; + for (let i = 0; i < argList.childCount; i++) { + const child = argList.child(i); + if (child === null) continue; + if (child.type === ',' || child.type === '(' || child.type === ')') continue; + out.push(classifyAdlArg(child)); + } + return out; +} + +const EMPTY_ADL_ARG: CppAdlArgInfo = { simpleClassName: '', isPointer: false, isReference: false }; + +function classifyAdlArg(argNode: SyntaxNode): CppAdlArgInfo { + // Literals and primitive-shaped expressions never have associated namespaces. + if ( + argNode.type === 'number_literal' || + argNode.type === 'string_literal' || + argNode.type === 'raw_string_literal' || + argNode.type === 'char_literal' || + argNode.type === 'true' || + argNode.type === 'false' || + argNode.type === 'null' || + argNode.type === 'nullptr' + ) { + return EMPTY_ADL_ARG; + } + // 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); + } + // Other shapes (calls, member access, operators) — V1 unsupported. + return EMPTY_ADL_ARG; +} + +function lookupAdlIdentifierType(identNode: SyntaxNode): CppAdlArgInfo { + const varName = identNode.text; + let scope: SyntaxNode | null = identNode.parent; + while ( + scope !== null && + scope.type !== 'compound_statement' && + scope.type !== 'translation_unit' + ) { + scope = scope.parent; + } + if (scope === null) return EMPTY_ADL_ARG; + + for (let i = 0; i < scope.childCount; i++) { + const stmt = scope.child(i); + if (stmt === null || stmt.type !== 'declaration') continue; + const typeNode = stmt.childForFieldName('type'); + if (typeNode === null) continue; + if (typeNode.type === 'placeholder_type_specifier') continue; + + const declarator = stmt.childForFieldName('declarator'); + if (declarator === null) continue; + + // Unwrap declarator chain to find pointer/reference markers and the + // variable name. `init_declarator > pointer_declarator > identifier` + // means pointer-typed; `init_declarator > reference_declarator > ...` + // means reference-typed; bare `init_declarator > identifier` is value. + let isPointer = false; + let isReference = false; + let inner: SyntaxNode = declarator; + let nameText: string | null = null; + let safety = 16; // bound walk depth defensively + while (safety-- > 0) { + if (inner.type === 'pointer_declarator') { + isPointer = true; + const next = inner.childForFieldName('declarator'); + if (next === null) break; + inner = next; + continue; + } + if (inner.type === 'reference_declarator') { + isReference = true; + // reference_declarator has a single child (the inner declarator). + let next: SyntaxNode | null = null; + for (let j = 0; j < inner.namedChildCount; j++) { + const c = inner.namedChild(j); + if (c !== null) { + next = c; + break; + } + } + if (next === null) break; + inner = next; + continue; + } + if (inner.type === 'init_declarator') { + const next = inner.childForFieldName('declarator'); + if (next === null) break; + inner = next; + continue; + } + // Reached the leaf — usually `identifier`. Take its text. + nameText = inner.text; + break; + } + if (nameText !== varName) continue; + + const simpleClassName = extractAdlSimpleTypeName(typeNode); + return { simpleClassName, isPointer, isReference }; + } + return EMPTY_ADL_ARG; +} + +/** Extract the simple class-like type name from a `type:` field node. + * Returns '' for primitives, template specializations, function pointers, + * and any other shape V1 ADL doesn't support — those args are excluded + * from associated-namespace closure. */ +function extractAdlSimpleTypeName(typeNode: SyntaxNode): string { + if (typeNode.type === 'primitive_type') return ''; + if (typeNode.type === 'sized_type_specifier') return ''; + if (typeNode.type === 'type_identifier') return typeNode.text; + if (typeNode.type === 'qualified_identifier') { + const nameNode = typeNode.childForFieldName('name'); + if (nameNode !== null) return extractAdlSimpleTypeName(nameNode); + const id = findFirstDescendantOfType(typeNode, 'type_identifier'); + return id !== null ? id.text : ''; + } + // template_type (e.g. `vector`), function pointers, decltype — V1 excludes. + return ''; +} + +/** + * Check if a C++ function_definition or declaration has `static` storage class. + */ +function hasStaticStorageClass(node: SyntaxNode): boolean { + for (let i = 0; i < node.childCount; i++) { + const child = node.child(i); + if (child !== null && child.type === 'storage_class_specifier' && child.text === 'static') { + return true; + } + } + return false; +} + +/** + * Check if a node is inside an anonymous namespace (file-local linkage in C++). + * Anonymous namespaces have no `name` field in tree-sitter-cpp. + */ +function isInsideAnonymousNamespace(node: SyntaxNode): boolean { + let ancestor: SyntaxNode | null = node.parent ?? null; + while (ancestor !== null) { + if (ancestor.type === 'namespace_definition') { + // Anonymous namespace: has declaration_list but no name child + const nameChild = ancestor.childForFieldName?.('name') ?? null; + if (nameChild === null) return true; + } + ancestor = ancestor.parent; + } + return false; +} diff --git a/gitnexus/src/core/ingestion/languages/cpp/file-local-linkage.ts b/gitnexus/src/core/ingestion/languages/cpp/file-local-linkage.ts new file mode 100644 index 000000000..e46327558 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/cpp/file-local-linkage.ts @@ -0,0 +1,214 @@ +import type { ParsedFile, Scope, ScopeId, SymbolDefinition } from 'gitnexus-shared'; +import { isCppInlineNamespaceScope } from './inline-namespaces.js'; + +/** + * Per-file set of symbol names with file-local linkage. + * In C++ there are two sources of file-local linkage: + * 1. `static` storage class (same as C) + * 2. Anonymous namespace (`namespace { ... }`) + * + * Populated during `emitCppScopeCaptures` and consumed by + * `expandCppWildcardNames` to exclude file-local symbols from + * cross-file wildcard import visibility. + * + * NOTE: module-level state, single-process-single-repo use only. + * Call `clearFileLocalNames()` at the start of each resolution pass. + * + * Key: filePath, Value: Set of file-local symbol names. + */ +const fileLocalNames = new Map>(); + +/** + * Per-file set of `SymbolDefinition.nodeId`s that are NOT visible by + * unqualified lookup from outside the file — class-owned methods/fields + * and namespace-nested symbols. Populated by `populateCppNonGloballyVisible` + * during the per-file `populateOwners` hook; consumed by + * `isCppDefGloballyVisible` from both `expandCppWildcardNames` (wildcard + * propagation) and the global free-call fallback's `isFileLocalDef` hook. + * + * Tracked per filePath rather than as a single global set so cross-file + * lookup correctly compares the candidate's owning file's non-visible + * set without leaking across pipeline invocations (the global free-call + * fallback checks `def.filePath !== callerFilePath` and then asks "is + * this def visible from outside its own file?" — that's exactly what + * this set encodes). + */ +const nonGloballyVisibleNodeIds = new Map>(); + +/** Record a symbol name as file-local (static or anonymous namespace). */ +export function markFileLocal(filePath: string, name: string): void { + let names = fileLocalNames.get(filePath); + if (names === undefined) { + names = new Set(); + fileLocalNames.set(filePath, names); + } + names.add(name); +} + +/** Check whether a symbol name has file-local linkage in the given file. */ +export function isFileLocal(filePath: string, name: string): boolean { + return fileLocalNames.get(filePath)?.has(name) ?? false; +} + +/** Clear tracked file-local names (call at start of each resolution pass). */ +export function clearFileLocalNames(): void { + fileLocalNames.clear(); + nonGloballyVisibleNodeIds.clear(); +} + +/** + * Populate per-file "not globally visible" nodeIds by walking the parsed + * file's scopes. Run as part of the `populateOwners` hook so every C++ + * scope is reflected before any cross-file resolution pass consults the + * set. + * + * A def is "not globally visible" when its nearest structurally enclosing + * scope is a `Namespace` or `Class` — those require qualification + * (`ns::name`, `Class::method`) for cross-file unqualified lookup. + * Module-scoped defs remain globally visible. + */ +export function populateCppNonGloballyVisible(parsed: { + readonly filePath: string; + readonly scopes: readonly { + readonly id: ScopeId; + readonly kind: string; + readonly ownedDefs: readonly { readonly nodeId: string }[]; + }[]; +}): void { + let set = nonGloballyVisibleNodeIds.get(parsed.filePath); + if (set === undefined) { + set = new Set(); + nonGloballyVisibleNodeIds.set(parsed.filePath, set); + } + for (const scope of parsed.scopes) { + if (scope.kind !== 'Namespace' && scope.kind !== 'Class') continue; + // Inline namespaces (`inline namespace v1 { ... }`) propagate their + // members to the enclosing namespace's unqualified-lookup scope per + // ISO C++ `[namespace.def]/p4`. Skip them here so cross-file + // unqualified lookup can still see their callable defs. + if (scope.kind === 'Namespace' && isCppInlineNamespaceScope(scope.id)) continue; + for (const def of scope.ownedDefs) { + set.add(def.nodeId); + } + } +} + +/** + * Check whether a def is visible by unqualified lookup from outside its + * own file. Returns `false` for class-owned and namespace-nested defs. + * + * Used by the global free-call fallback's `isFileLocalDef` hook (which + * historically meant "static / anonymous-namespace" but semantically + * stands for "logically invisible cross-file"). Including class methods + * and namespace members under the same negative answer fixes the leak + * where unqualified `save()` resolved to `User::save` through a shared + * workspace registry walk. + */ +export function isCppDefGloballyVisible(filePath: string, nodeId: string): boolean { + return nonGloballyVisibleNodeIds.get(filePath)?.has(nodeId) !== true; +} + +/** + * Return the names visible through a C++ wildcard import (`#include` or + * `using namespace`). + * + * ## Contract + * + * C++ unqualified name lookup only sees names at the importer's enclosing + * scope. Class members and namespace-nested symbols are NOT visible by + * unqualified lookup from a free function in an including TU — they must + * be reached via `Class::method`, `ns::name`, or a working `using` + * declaration. The filter below enforces that contract for header + * propagation: only defs whose nearest enclosing scope is the header's + * `Module` scope are emitted as wildcard-binding names. + * + * ## Why scope-aware and not predicate-on-qualifiedName + * + * A naive `def.qualifiedName.indexOf('.') === -1` check is unreliable + * because `populateClassOwnedMembers` + * (`gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts`) + * only dot-qualifies `qualifiedName` for `Class` scopes. Namespace-nested + * defs (`namespace ns { void foo(); }`) arrive in `localDefs` with + * `qualifiedName === 'foo'` and `ownerId === undefined`, indistinguishable + * from a top-level free function. The structural truth lives in + * `Scope.ownedDefs`: each scope lists what it structurally owns; the + * Module scope owns only top-level symbols. We look the def up by + * `nodeId` against the scope tree to identify its owning kind. + * + * ## `localDefs` consumer survey (recorded for future maintainers) + * + * Other consumers of `ParsedFile.localDefs` were audited at the time + * this filter was introduced (see PR #1520 / plan + * `docs/plans/2026-05-12-002-fix-cpp-resolver-followups-plan.md`): + * + * - `finalize-orchestrator.ts:113,163` — flattens defs into a workspace + * registry keyed by `ownerId` + `qualifiedName`; class-owned and + * namespace-owned symbols are registered under their owner, not as + * unqualified names. Not a leak surface. + * - `csharp/namespace-siblings.ts:307`, `go/expand-wildcards.ts:86`, + * `php/scope-resolver.ts:141,151`, `c/static-linkage.ts:51` — other + * languages' own wildcard / sibling expansions. Each owns its own + * visibility contract. + * - `receiver-bound-calls.ts:99`, `reconcile-ownership.ts:66,119`, + * `mro.ts:61` — keyed by `ownerId` for member lookup, never used + * as unqualified bindings. + * - `go/interface-impls.ts:40,53`, `go/package-siblings.ts:41` — Go- + * specific, sibling-package scoped. + * + * No other consumer treats `localDefs` as a flat unqualified-binding + * set the way this function did before the fix. If a future consumer + * does, mirror this filter or harden registration so class/namespace + * members never enter `localDefs` unqualified. + */ +export function expandCppWildcardNames( + targetModuleScope: ScopeId, + parsedFiles: readonly ParsedFile[], +): readonly string[] { + const target = parsedFiles.find((p) => p.moduleScope === targetModuleScope); + if (target === undefined) return []; + + // Build nodeId → owning Scope map from the structural scope tree. + // `Scope.ownedDefs` is the canonical source of structural ownership; + // `localDefs` is its flattened union, which is why the original code + // leaked: walking only `localDefs` discards the owning-scope context. + const ownerScopeByNodeId = new Map(); + for (const scope of target.scopes) { + for (const ownedDef of scope.ownedDefs) { + ownerScopeByNodeId.set(ownedDef.nodeId, scope); + } + } + + const seen = new Set(); + const names: string[] = []; + for (const def of target.localDefs) { + // Defense-in-depth: class methods carry a non-undefined ownerId after + // `populateClassOwnedMembers` runs. Skip them outright. + if (def.ownerId !== undefined) continue; + + // Structural visibility check: exclude defs whose owning scope is a + // Namespace or Class — these require qualification (`ns::name`, + // `Class::method`) and are NOT reachable by unqualified lookup in an + // including TU. When the owning scope is unknown we default to + // include (preserves prior behavior for any def whose structural + // ownership wasn't recorded in `Scope.ownedDefs`). + const ownerScope = ownerScopeByNodeId.get(def.nodeId); + if ( + ownerScope !== undefined && + (ownerScope.kind === 'Namespace' || ownerScope.kind === 'Class') + ) { + continue; + } + + const name = simpleName(def); + if (name === '') continue; + if (isFileLocal(target.filePath, name)) continue; + if (seen.has(name)) continue; + seen.add(name); + names.push(name); + } + return names; +} + +function simpleName(def: SymbolDefinition): string { + return def.qualifiedName?.split('.').pop() ?? def.qualifiedName ?? ''; +} diff --git a/gitnexus/src/core/ingestion/languages/cpp/header-scan.ts b/gitnexus/src/core/ingestion/languages/cpp/header-scan.ts new file mode 100644 index 000000000..39ef608b3 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/cpp/header-scan.ts @@ -0,0 +1,53 @@ +import { readdirSync, type Dirent } from 'fs'; +import { join, relative } from 'path'; + +/** C++ header extensions to scan for in the workspace. */ +const HEADER_EXTENSIONS = new Set(['.h', '.hpp', '.hxx', '.hh']); + +/** + * Walk `repoPath` recursively and return relative paths of all C++ header files. + * Used by `loadResolutionConfig` so the C++ resolver can resolve `#include` + * targets that live in header files. + * + * Scans for: .h, .hpp, .hxx, .hh + */ +export function scanCppHeaderFiles(repoPath: string): ReadonlySet { + const headers = new Set(); + walk(repoPath, repoPath, headers); + return headers; +} + +function walk(dir: string, root: string, out: Set): void { + let entries: Dirent[]; + try { + entries = readdirSync(dir, { withFileTypes: true, encoding: 'utf8' }); + } catch { + return; // permission denied, etc. + } + for (const entry of entries) { + const name = entry.name; + const full = join(dir, name); + if (entry.isDirectory()) { + if ( + name === 'node_modules' || + name === '.git' || + name === 'vendor' || + name === 'dist' || + name === 'build' || + name === 'out' || + name === 'target' || + name === '_build' || + name === '.next' || + name.startsWith('cmake-build') + ) { + continue; + } + walk(full, root, out); + } else if (entry.isFile()) { + const ext = name.slice(name.lastIndexOf('.')); + if (HEADER_EXTENSIONS.has(ext)) { + out.add(relative(root, full).replace(/\\/g, '/')); + } + } + } +} diff --git a/gitnexus/src/core/ingestion/languages/cpp/import-decomposer.ts b/gitnexus/src/core/ingestion/languages/cpp/import-decomposer.ts new file mode 100644 index 000000000..eb6b252ce --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/cpp/import-decomposer.ts @@ -0,0 +1,120 @@ +import type { Capture, CaptureMatch } from 'gitnexus-shared'; +import { nodeToCapture, syntheticCapture, type SyntaxNode } from '../../utils/ast-helpers.js'; + +/** + * Decompose a `preproc_include` node into a CaptureMatch with structured + * import captures. C++ #include maps to a wildcard import (all symbols + * from the header are visible). Identical to C's splitCInclude. + */ +export function splitCppInclude(node: SyntaxNode): CaptureMatch | null { + const pathNode = node.childForFieldName?.('path') ?? null; + if (pathNode === null) { + for (let i = 0; i < node.childCount; i++) { + const child = node.child(i); + if (child === null) continue; + if (child.type === 'string_literal' || child.type === 'system_lib_string') { + return buildIncludeCapture(node, child); + } + } + return null; + } + return buildIncludeCapture(node, pathNode); +} + +function buildIncludeCapture(node: SyntaxNode, pathNode: SyntaxNode): CaptureMatch { + let raw: string; + if (pathNode.type === 'string_literal') { + const content = pathNode.namedChildren.find((c) => c.type === 'string_content'); + raw = content?.text ?? pathNode.text.replace(/^"|"$/g, ''); + } else { + raw = pathNode.text; + if (raw.startsWith('<') && raw.endsWith('>')) { + raw = raw.slice(1, -1); + } + } + + const isSystem = pathNode.type === 'system_lib_string'; + + const result: Record = { + '@import.statement': nodeToCapture('@import.statement', node), + '@import.kind': syntheticCapture('@import.kind', node, 'wildcard'), + '@import.source': syntheticCapture('@import.source', node, raw), + }; + + if (isSystem) { + result['@import.system'] = syntheticCapture('@import.system', node, 'true'); + } + + return result; +} + +/** + * Decompose a `using_declaration` node into a CaptureMatch. + * + * tree-sitter-cpp produces: + * using namespace std; → using_declaration { "using", "namespace", identifier("std"), ";" } + * using std::vector; → using_declaration { "using", qualified_identifier("std::vector"), ";" } + * + * The first form is a wildcard import (all names from namespace). + * The second form is a named import (single symbol). + */ +export function splitCppUsingDecl(node: SyntaxNode): CaptureMatch | null { + if (node.type !== 'using_declaration') return null; + + // Check for "namespace" keyword among anonymous children + let hasNamespaceKeyword = false; + for (let i = 0; i < node.childCount; i++) { + const child = node.child(i); + if (child !== null && !child.isNamed && child.text === 'namespace') { + hasNamespaceKeyword = true; + break; + } + } + + if (hasNamespaceKeyword) { + // using namespace ; + // The namespace name can be an identifier or qualified_identifier + let namespaceName: string | null = null; + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child === null) continue; + if (child.type === 'identifier' || child.type === 'qualified_identifier') { + namespaceName = child.text; + break; + } + } + if (namespaceName === null) return null; + + return { + '@import.statement': nodeToCapture('@import.statement', node), + '@import.kind': syntheticCapture('@import.kind', node, 'wildcard'), + '@import.source': syntheticCapture('@import.source', node, namespaceName), + '@import.using-namespace': syntheticCapture('@import.using-namespace', node, 'true'), + }; + } + + // using ; (e.g. using std::vector) + let qualId: SyntaxNode | null = null; + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child !== null && child.type === 'qualified_identifier') { + qualId = child; + break; + } + } + if (qualId === null) return null; + + // Extract the imported name (last identifier) and source (namespace part) + const nameNode = qualId.childForFieldName?.('name') ?? null; + const scopeNode = qualId.childForFieldName?.('scope') ?? null; + + const importedName = nameNode?.text ?? qualId.text.split('::').pop() ?? ''; + const source = scopeNode?.text ?? qualId.text.replace(new RegExp('::' + importedName + '$'), ''); + + return { + '@import.statement': nodeToCapture('@import.statement', node), + '@import.kind': syntheticCapture('@import.kind', node, 'named'), + '@import.source': syntheticCapture('@import.source', node, source), + '@import.name': syntheticCapture('@import.name', node, importedName), + }; +} diff --git a/gitnexus/src/core/ingestion/languages/cpp/import-target.ts b/gitnexus/src/core/ingestion/languages/cpp/import-target.ts new file mode 100644 index 000000000..26e317c6e --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/cpp/import-target.ts @@ -0,0 +1,18 @@ +import { resolveCImportTarget } from '../c/import-target.js'; + +/** + * Resolve a C++ #include path to a file in the workspace. + * C++ #include path resolution is identical to C: + * 1. Same-directory sibling (relative lookup) + * 2. Exact match + * 3. Suffix match with depth + lexicographic tiebreak + * + * Re-exports the C implementation since the #include semantics are shared. + */ +export function resolveCppImportTarget( + targetRaw: string, + fromFile: string, + allFilePaths: ReadonlySet, +): string | null { + return resolveCImportTarget(targetRaw, fromFile, allFilePaths); +} diff --git a/gitnexus/src/core/ingestion/languages/cpp/index.ts b/gitnexus/src/core/ingestion/languages/cpp/index.ts new file mode 100644 index 000000000..c4d208d76 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/cpp/index.ts @@ -0,0 +1,16 @@ +/** + * C++ scope-resolution hooks (RFC #909 Ring 3). + */ +export { emitCppScopeCaptures } from './captures.js'; +export { interpretCppImport, interpretCppTypeBinding, normalizeCppTypeName } from './interpret.js'; +export { splitCppInclude, splitCppUsingDecl } from './import-decomposer.js'; +export { cppArityCompatibility } from './arity.js'; +export { cppMergeBindings } from './merge-bindings.js'; +export { cppBindingScopeFor, cppImportOwningScope, cppReceiverBinding } from './simple-hooks.js'; +export { resolveCppImportTarget } from './import-target.js'; +export { + markFileLocal, + isFileLocal, + clearFileLocalNames, + expandCppWildcardNames, +} from './file-local-linkage.js'; diff --git a/gitnexus/src/core/ingestion/languages/cpp/inline-namespaces.ts b/gitnexus/src/core/ingestion/languages/cpp/inline-namespaces.ts new file mode 100644 index 000000000..c08402a85 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/cpp/inline-namespaces.ts @@ -0,0 +1,170 @@ +/** + * C++ inline namespace support (U5 of plan 2026-05-13-001). + * + * `inline namespace v1 { void foo(); }` has two ISO C++ semantics that + * GitNexus must model: + * + * 1. **Transitive unqualified visibility.** Names declared in an inline + * namespace are reachable by unqualified lookup from the enclosing + * namespace's scope, as if they were declared directly there. + * `populateCppNonGloballyVisible` (file-local-linkage.ts) treats + * inline-namespace members as globally visible for cross-file + * unqualified lookup. + * + * 2. **Transitive qualified visibility.** `outer::foo()` resolves to + * `outer::v1::foo()` when `v1` is inline. The qualified-namespace + * receiver resolver (`resolveCppQualifiedNamespaceMember`) walks + * inline-namespace children transitively when collecting candidates. + * + * State lifecycle: capture-time `markCppInlineNamespaceRange` records each + * inline namespace's source range; `populateCppInlineNamespaceScopes` + * resolves ranges to `ScopeId`s during `populateOwners`. Cleared via + * `clearCppInlineNamespaces`, called from `clearFileLocalNames`. + * + * STL idiom this enables: `std::__1::vector` (libc++) and `std::__cxx11` + * (libstdc++) are inline namespaces of `std`. With this support, + * `std::vector` qualified calls resolve to the inline-namespace + * declaration transparently. + */ + +import type { ParsedFile, ScopeId, SymbolDefinition } from 'gitnexus-shared'; +import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js'; + +interface RangeKey { + readonly startLine: number; + readonly startCol: number; + readonly endLine: number; + readonly endCol: number; +} + +const inlineNamespaceRangesByFile = new Map>(); +const inlineNamespaceScopeIds = new Set(); + +function rangeKey(r: RangeKey): string { + return `${r.startLine}:${r.startCol}:${r.endLine}:${r.endCol}`; +} + +/** Capture-time: record a namespace_definition's range as inline. + * Called from `emitCppScopeCaptures` when the tree-sitter AST shows an + * `inline` keyword child on `namespace_definition`. */ +export function markCppInlineNamespaceRange(filePath: string, range: RangeKey): void { + let set = inlineNamespaceRangesByFile.get(filePath); + if (set === undefined) { + set = new Set(); + inlineNamespaceRangesByFile.set(filePath, set); + } + set.add(rangeKey(range)); +} + +/** Clear all inline-namespace state. Called from `clearFileLocalNames`. */ +export function clearCppInlineNamespaces(): void { + inlineNamespaceRangesByFile.clear(); + inlineNamespaceScopeIds.clear(); +} + +/** Resolve captured ranges to actual ScopeIds by matching scope ranges + * against the inline-namespace ranges recorded for this file. Run from + * the cpp resolver's `populateOwners` hook so the per-pipeline Set is + * populated before any resolution pass consults it. */ +export function populateCppInlineNamespaceScopes(parsed: ParsedFile): void { + const ranges = inlineNamespaceRangesByFile.get(parsed.filePath); + if (ranges === undefined || ranges.size === 0) return; + for (const scope of parsed.scopes) { + if (scope.kind !== 'Namespace') continue; + if (ranges.has(rangeKey(scope.range))) { + inlineNamespaceScopeIds.add(scope.id); + } + } +} + +/** Predicate consumed by `populateCppNonGloballyVisible` to exempt + * inline-namespace members from cross-file unqualified-lookup + * exclusion (they remain reachable as if declared at the enclosing + * namespace's level). */ +export function isCppInlineNamespaceScope(scopeId: ScopeId): boolean { + return inlineNamespaceScopeIds.has(scopeId); +} + +/** + * Walk every parsed file looking for a Namespace scope whose qualified + * name matches `receiverName`, collect its callable ownedDefs matching + * `memberName`, transitively descending into any inline-namespace + * children (since they're members of the enclosing namespace under ISO + * C++). + * + * 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). + */ +export function resolveCppQualifiedNamespaceMember( + receiverName: string, + memberName: string, + parsedFiles: readonly ParsedFile[], + _scopes: ScopeResolutionIndexes, +): SymbolDefinition | undefined { + 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; + const nsDef = findNamespaceDefInScope(scope); + 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; + } + } + return undefined; +} + +/** Recursively search a namespace scope and any inline-namespace + * descendants for a callable def with the given simple name. Non-inline + * nested namespaces are NOT traversed — they require explicit + * qualification (`outer::nested::foo`). */ +function findMemberInNamespaceTransitive( + scope: { + readonly id: ScopeId; + readonly ownedDefs: readonly SymbolDefinition[]; + readonly parent: ScopeId | null; + }, + scopesById: ReadonlyMap< + ScopeId, + { + readonly id: ScopeId; + readonly kind: string; + readonly parent: ScopeId | null; + readonly ownedDefs: readonly SymbolDefinition[]; + } + >, + memberName: string, +): SymbolDefinition | undefined { + // 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; + } + // 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; + } + return undefined; +} + +function findNamespaceDefInScope(scope: { + readonly ownedDefs: readonly SymbolDefinition[]; +}): SymbolDefinition | undefined { + for (const def of scope.ownedDefs) { + if (def.type === 'Namespace') return def; + } + return undefined; +} diff --git a/gitnexus/src/core/ingestion/languages/cpp/interpret.ts b/gitnexus/src/core/ingestion/languages/cpp/interpret.ts new file mode 100644 index 000000000..a5c1692a8 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/cpp/interpret.ts @@ -0,0 +1,112 @@ +import type { CaptureMatch, ParsedImport, ParsedTypeBinding, TypeRef } from 'gitnexus-shared'; + +/** + * Interpret a C++ import capture into a ParsedImport. + * + * C++ has three import forms: + * 1. #include "file.h" → wildcard import (all symbols from header) + * 2. using namespace X; → wildcard import (all symbols from namespace X) + * 3. using X::name; → named import (single symbol from namespace X) + * + * System headers (#include <...>) are not resolved to local files. + */ +export function interpretCppImport(captures: CaptureMatch): ParsedImport | null { + const source = captures['@import.source']?.text; + if (source === undefined) return null; + + // System headers are not resolved to local files + if (captures['@import.system'] !== undefined) return null; + + const kind = captures['@import.kind']?.text; + + if (kind === 'named') { + // using X::name — named import + const importedName = captures['@import.name']?.text; + if (importedName === undefined) return null; + return { kind: 'named', targetRaw: source, localName: importedName, importedName }; + } + + // #include or using namespace — wildcard import + return { kind: 'wildcard', targetRaw: source }; +} + +/** + * Interpret a C++ type-binding capture into a ParsedTypeBinding. + * + * Source classification (strongest → weakest): + * - `'parameter-annotation'` — function parameter type + * - `'annotation'` — explicit type declaration (`User user;`) + * - `'assignment-inferred'` — typed init (`User user = ...`) + * - `'constructor'` — constructor call (`auto u = User(...)` / `User{}`) + * - `'return'` — function return type + * - `'field'` — class field type + * - `'alias'` — `auto x = existingVar` + */ +export function interpretCppTypeBinding(captures: CaptureMatch): ParsedTypeBinding | null { + const name = captures['@type-binding.name']?.text; + const type = captures['@type-binding.type']?.text; + if (name === undefined || type === undefined) return null; + + let source: TypeRef['source'] = 'annotation'; + + if (captures['@type-binding.parameter'] !== undefined) { + source = 'parameter-annotation'; + } else if (captures['@type-binding.constructor'] !== undefined) { + source = 'constructor-inferred'; + } else if (captures['@type-binding.return'] !== undefined) { + source = 'return-annotation'; + } else if (captures['@type-binding.field'] !== undefined) { + // Field types are structurally equivalent to annotations — the type + // is explicitly written, not inferred. + source = 'annotation'; + } else if (captures['@type-binding.member-access'] !== undefined) { + // auto addr = user.address — the type is inferred from the member access. + // Synthesize a dotted rawName ("receiver.field") so compound-receiver + // can resolve the chain: look up receiver's class, then field's type. + const receiver = captures['@type-binding.member-access-receiver']?.text; + if (receiver !== undefined) { + return { boundName: name, rawTypeName: `${receiver}.${type}`, source: 'assignment-inferred' }; + } + source = 'assignment-inferred'; + } else if (captures['@type-binding.alias'] !== undefined) { + // auto alias = existingVar — the type is inferred from the RHS variable. + source = 'assignment-inferred'; + } else if (captures['@type-binding.assignment'] !== undefined) { + source = 'assignment-inferred'; + } else if (captures['@type-binding.annotation'] !== undefined) { + source = 'annotation'; + } + + return { boundName: name, rawTypeName: normalizeCppTypeName(type), source }; +} + +/** + * Normalize a C++ type name: strip pointer/array/reference syntax, + * qualifiers, and template parameters (V1: generic-ignored). + */ +export function normalizeCppTypeName(text: string): string { + let t = text.trim(); + // Strip const, volatile, restrict, static, extern, inline, mutable, constexpr + t = t + .replace(/\b(const|volatile|restrict|static|extern|inline|mutable|constexpr|consteval)\b/g, '') + .trim(); + // Strip template parameters (loop handles nested: Map> → Map) + while (t.includes('<')) { + const stripped = t.replace(/<[^<>]*>/g, ''); + if (stripped === t) break; // avoid infinite loop on malformed input + t = stripped; + } + t = t.trim(); + // Strip pointer stars + while (t.endsWith('*')) t = t.slice(0, -1).trim(); + while (t.startsWith('*')) t = t.slice(1).trim(); + // Strip reference markers + while (t.endsWith('&')) t = t.slice(0, -1).trim(); + // Strip array brackets + t = t.replace(/\[.*?\]/g, '').trim(); + // Strip struct/union/enum/class prefixes + t = t.replace(/^(struct|union|enum|class)\s+/, ''); + // Strip leading :: (global namespace qualifier) + t = t.replace(/^::/, ''); + return t; +} diff --git a/gitnexus/src/core/ingestion/languages/cpp/merge-bindings.ts b/gitnexus/src/core/ingestion/languages/cpp/merge-bindings.ts new file mode 100644 index 000000000..6409cef46 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/cpp/merge-bindings.ts @@ -0,0 +1,38 @@ +import type { BindingRef } from 'gitnexus-shared'; + +const TIER: Record = { + local: 0, + namespace: 1, + import: 2, + reexport: 3, + wildcard: 4, +}; + +/** + * C++ merge bindings: first-wins by tier. + * + * C++ tier precedence: + * local(0) > namespace(1) > import(2) > reexport(3) > wildcard(4) + * + * Unlike C (no namespaces), C++ uses the `namespace` tier for symbols + * brought in via `using namespace X;` that are then locally referenced. + * The tier ordering ensures local definitions shadow namespace imports, + * which in turn shadow wildcard #include imports. + */ +export function cppMergeBindings( + existing: readonly BindingRef[], + incoming: readonly BindingRef[], + _scopeId: string, +): BindingRef[] { + const seen = new Set(); + return [...existing, ...incoming] + .sort( + (a, b) => + (TIER[a.origin] ?? 99) - (TIER[b.origin] ?? 99) || a.def.nodeId.localeCompare(b.def.nodeId), + ) + .filter((binding) => { + if (seen.has(binding.def.nodeId)) return false; + seen.add(binding.def.nodeId); + return true; + }); +} diff --git a/gitnexus/src/core/ingestion/languages/cpp/query.ts b/gitnexus/src/core/ingestion/languages/cpp/query.ts new file mode 100644 index 000000000..4e451617d --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/cpp/query.ts @@ -0,0 +1,462 @@ +import Parser from 'tree-sitter'; +import CPP from 'tree-sitter-cpp'; + +const CPP_SCOPE_QUERY = ` +;; ─── Scopes ────────────────────────────────────────────────────────── +(translation_unit) @scope.module +(namespace_definition) @scope.namespace +(class_specifier) @scope.class +(struct_specifier) @scope.class +(function_definition) @scope.function +(lambda_expression) @scope.function +(compound_statement) @scope.block +(if_statement) @scope.block +(for_statement) @scope.block +(for_range_loop) @scope.block +(while_statement) @scope.block +(do_statement) @scope.block +(switch_statement) @scope.block +(case_statement) @scope.block +(try_statement) @scope.block +(catch_clause) @scope.block + +;; ─── Declarations — namespace ──────────────────────────────────────── +(namespace_definition + name: (namespace_identifier) @declaration.name) @declaration.namespace + +;; Anonymous namespace (no name child) — captured as scope only, names +;; inside are marked file-local by captures.ts. + +;; ─── Declarations — class / struct (named) ─────────────────────────── +(class_specifier + name: (type_identifier) @declaration.name + body: (field_declaration_list)) @declaration.class + +(struct_specifier + name: (type_identifier) @declaration.name + body: (field_declaration_list)) @declaration.struct + +;; ─── Declarations — class / struct inside template_declaration ─────── +(template_declaration + (class_specifier + name: (type_identifier) @declaration.name + body: (field_declaration_list)) @declaration.class) + +(template_declaration + (struct_specifier + name: (type_identifier) @declaration.name + body: (field_declaration_list)) @declaration.struct) + +;; ─── Declarations — enum ───────────────────────────────────────────── +(enum_specifier + name: (type_identifier) @declaration.name) @declaration.enum + +;; ─── Declarations — enum constants ─────────────────────────────────── +(enumerator + name: (identifier) @declaration.name) @declaration.const + +;; ─── Declarations — function definition (plain identifier) ────────── +(function_definition + declarator: (function_declarator + declarator: (identifier) @declaration.name)) @declaration.function + +;; ─── Declarations — function definition with pointer return ───────── +(function_definition + declarator: (pointer_declarator + declarator: (function_declarator + declarator: (identifier) @declaration.name))) @declaration.function + +;; ─── Declarations — out-of-class method (qualified_identifier) ────── +(function_definition + declarator: (function_declarator + declarator: (qualified_identifier + name: (identifier) @declaration.name))) @declaration.method + +;; ─── Declarations — out-of-class method with pointer return ───────── +(function_definition + declarator: (pointer_declarator + declarator: (function_declarator + declarator: (qualified_identifier + name: (identifier) @declaration.name)))) @declaration.method + +;; ─── Declarations — out-of-class method (destructor_name) ─────────── +(function_definition + declarator: (function_declarator + declarator: (qualified_identifier + name: (destructor_name) @declaration.name))) @declaration.method + +;; ─── Declarations — template function definition ──────────────────── +(template_declaration + (function_definition + declarator: (function_declarator + declarator: (identifier) @declaration.name)) @declaration.function) + +;; ─── Declarations — template method (qualified) ───────────────────── +(template_declaration + (function_definition + declarator: (function_declarator + declarator: (qualified_identifier + name: (identifier) @declaration.name))) @declaration.method) + +;; ─── Declarations — inline method in class body (field_identifier) ── +;; tree-sitter-cpp uses field_identifier for names inside class bodies +(function_definition + declarator: (function_declarator + declarator: (field_identifier) @declaration.name)) @declaration.method + +;; ─── Declarations — inline method with pointer return (field_identifier) ── +;; Covers: User* lookup(int id) { ... } inside a class body +;; AST: function_definition > pointer_declarator > function_declarator > field_identifier +(function_definition + declarator: (pointer_declarator + declarator: (function_declarator + declarator: (field_identifier) @declaration.name))) @declaration.method + +;; ─── Declarations — inline method with reference return (field_identifier) ── +;; Covers: User& getRef() { ... } inside a class body +(function_definition + declarator: (reference_declarator + (function_declarator + declarator: (field_identifier) @declaration.name))) @declaration.method + +;; ─── Declarations — function prototype (forward declaration) ──────── +(declaration + declarator: (function_declarator + declarator: (identifier) @declaration.name)) @declaration.function + +;; ─── Declarations — function prototype with pointer return ────────── +(declaration + declarator: (pointer_declarator + declarator: (function_declarator + declarator: (identifier) @declaration.name))) @declaration.function + +;; ─── Declarations — typedef ───────────────────────────────────────── +(type_definition + declarator: (type_identifier) @declaration.name) @declaration.typedef + +;; ─── Declarations — type alias (using Name = Type) ────────────────── +(alias_declaration + name: (type_identifier) @declaration.name) @declaration.typedef + +;; ─── Declarations — method prototype in class body (forward decl) ──── +;; Covers: class User { void save(); std::string getName(); }; +;; AST: field_declaration > function_declarator > field_identifier +(field_declaration + declarator: (function_declarator + declarator: (field_identifier) @declaration.name)) @declaration.method + +;; Method prototype with pointer return: User* lookup(int id); +(field_declaration + declarator: (pointer_declarator + declarator: (function_declarator + declarator: (field_identifier) @declaration.name))) @declaration.method + +;; Method prototype with reference return: User& getRef(); +(field_declaration + declarator: (reference_declarator + (function_declarator + declarator: (field_identifier) @declaration.name))) @declaration.method + +;; ─── Declarations — fields ────────────────────────────────────────── +(field_declaration + declarator: (field_identifier) @declaration.name) @declaration.field + +;; Declarations — fields (pointer) +(field_declaration + declarator: (pointer_declarator + declarator: (field_identifier) @declaration.name)) @declaration.field + +;; Declarations — fields (reference) +(field_declaration + declarator: (reference_declarator + (field_identifier) @declaration.name)) @declaration.field + +;; ─── Declarations — variables (with initializer) ──────────────────── +(declaration + declarator: (init_declarator + declarator: (identifier) @declaration.name)) @declaration.variable + +;; ─── Declarations — macro definitions ─────────────────────────────── +(preproc_def + name: (identifier) @declaration.name) @declaration.macro + +(preproc_function_def + name: (identifier) @declaration.name) @declaration.macro + +;; ─── Imports — #include ───────────────────────────────────────────── +(preproc_include) @import.statement + +;; ─── Imports — using declaration ───────────────────────────────────── +;; Both "using namespace std;" and "using std::vector;" are +;; using_declaration nodes in tree-sitter-cpp. The captures.ts +;; differentiates between them by checking for a "namespace" anonymous +;; child token. +(using_declaration) @import.using-decl + +;; ─── Type bindings — parameter annotations ────────────────────────── +(parameter_declaration + type: (_) @type-binding.type + declarator: (identifier) @type-binding.name) @type-binding.parameter + +;; Type bindings — reference parameter (const std::string& name) +(parameter_declaration + type: (_) @type-binding.type + declarator: (reference_declarator + (identifier) @type-binding.name)) @type-binding.parameter + +;; Type bindings — pointer parameter (User* ptr) +(parameter_declaration + type: (_) @type-binding.type + declarator: (pointer_declarator + declarator: (identifier) @type-binding.name)) @type-binding.parameter + +;; ─── Type bindings — variable with type (init_declarator) ─────────── +;; Covers: User user("alice"), User user = ..., int x = 0 +(declaration + type: (_) @type-binding.type + declarator: (init_declarator + declarator: (identifier) @type-binding.name)) @type-binding.assignment + +;; ─── Type bindings — plain declaration (no initializer) ───────────── +;; Covers: User user; +(declaration + type: (type_identifier) @type-binding.type + declarator: (identifier) @type-binding.name) @type-binding.annotation + +;; ─── Type bindings — pointer variable declaration ─────────────────── +;; Covers: User* ptr = new User() +(declaration + type: (type_identifier) @type-binding.type + declarator: (init_declarator + declarator: (pointer_declarator + declarator: (identifier) @type-binding.name))) @type-binding.annotation + +;; ─── Type bindings — auto + constructor call ──────────────────────── +;; Covers: auto user = User("alice") +;; AST: declaration > placeholder_type_specifier/auto > init_declarator > identifier + call_expression > identifier +(declaration + type: (placeholder_type_specifier) + declarator: (init_declarator + declarator: (identifier) @type-binding.name + value: (call_expression + function: (identifier) @type-binding.type))) @type-binding.constructor + +;; ─── Type bindings — auto + brace-init (compound_literal_expression) ─ +;; Covers: auto user = User{}, auto user = User{args} +;; AST: declaration > placeholder_type_specifier/auto > init_declarator > identifier + compound_literal_expression > type_identifier +(declaration + type: (placeholder_type_specifier) + declarator: (init_declarator + declarator: (identifier) @type-binding.name + value: (compound_literal_expression + type: (type_identifier) @type-binding.type))) @type-binding.constructor + +;; ─── Type bindings — auto + scoped brace-init (qualified) ─────────── +;; Covers: auto client = ns::HttpClient{} +;; AST: compound_literal_expression > qualified_identifier > type_identifier +(declaration + type: (placeholder_type_specifier) + declarator: (init_declarator + declarator: (identifier) @type-binding.name + value: (compound_literal_expression + type: (qualified_identifier + name: (type_identifier) @type-binding.type)))) @type-binding.constructor + +;; ─── Type bindings — auto + new expression ────────────────────────── +;; Covers: auto user = new User(name) +;; AST: declaration > placeholder_type_specifier/auto > init_declarator > identifier + new_expression > type_identifier +(declaration + type: (placeholder_type_specifier) + declarator: (init_declarator + declarator: (identifier) @type-binding.name + value: (new_expression + type: (type_identifier) @type-binding.type))) @type-binding.constructor + +;; ─── Type bindings — auto + qualified template factory (std::make_shared()) ─ +;; AST: declaration(1 > placeholder_type_specifier(2)2 > init_declarator(3 > +;; identifier(4)4 > call_expression(5 > qualified_identifier(6 > +;; template_function(7 > template_argument_list(8 > type_descriptor(9 > +;; type_identifier(10)10 )9 )8 )7 )6 )5 )3 )1 +(declaration + type: (placeholder_type_specifier) + declarator: (init_declarator + declarator: (identifier) @type-binding.name + value: (call_expression + function: (qualified_identifier + name: (template_function + arguments: (template_argument_list + (type_descriptor + type: (type_identifier) @type-binding.type))))))) @type-binding.constructor + +;; ─── Type bindings — auto + bare template factory (make_shared()) ─────── +;; Same but without qualified_identifier wrapper — one fewer nesting level +(declaration + type: (placeholder_type_specifier) + declarator: (init_declarator + declarator: (identifier) @type-binding.name + value: (call_expression + function: (template_function + arguments: (template_argument_list + (type_descriptor + type: (type_identifier) @type-binding.type)))))) @type-binding.constructor + +;; ─── Type bindings — auto alias assignment ────────────────────────── +;; Covers: auto alias = existingVar (RHS is a plain identifier) +;; AST: declaration > placeholder_type_specifier/auto > init_declarator > identifier + identifier +(declaration + type: (placeholder_type_specifier) + declarator: (init_declarator + declarator: (identifier) @type-binding.name + value: (identifier) @type-binding.type)) @type-binding.alias + +;; ─── Type bindings — auto + member access (field_expression) ──────── +;; Covers: auto addr = user.address (RHS is obj.field) +;; AST: declaration > placeholder_type_specifier > init_declarator > identifier + field_expression +;; We capture the field name as @type-binding.type so the compound-receiver +;; chain resolver can look it up on the receiver class scope. +;; The full obj.field text is synthesized by interpret.ts into a dotted +;; rawName for chain-follow resolution. +(declaration + type: (placeholder_type_specifier) + declarator: (init_declarator + declarator: (identifier) @type-binding.name + value: (field_expression + argument: (_) @type-binding.member-access-receiver + field: (field_identifier) @type-binding.type))) @type-binding.member-access + +;; ─── Type bindings — function return type ─────────────────────────── +;; Covers: User getUser() { ... } +;; AST: function_definition > type_identifier + function_declarator > identifier +(function_definition + type: (type_identifier) @type-binding.type + declarator: (function_declarator + declarator: (identifier) @type-binding.name)) @type-binding.return + +;; Return type — out-of-class method: User Class::getUser() { ... } +(function_definition + type: (type_identifier) @type-binding.type + declarator: (function_declarator + declarator: (qualified_identifier + name: (identifier) @type-binding.name))) @type-binding.return + +;; Return type — pointer return: User* getUser() { ... } +(function_definition + type: (type_identifier) @type-binding.type + declarator: (pointer_declarator + declarator: (function_declarator + declarator: (identifier) @type-binding.name))) @type-binding.return + +;; ─── Type bindings — inline method return type ────────────────────── +;; Covers: class Foo { User getUser() { ... } }; +(function_definition + type: (type_identifier) @type-binding.type + declarator: (function_declarator + declarator: (field_identifier) @type-binding.name)) @type-binding.return + +;; Inline method pointer return type: class Foo { User* lookup(int) { ... } }; +(function_definition + type: (type_identifier) @type-binding.type + declarator: (pointer_declarator + declarator: (function_declarator + declarator: (field_identifier) @type-binding.name))) @type-binding.return + +;; ─── Type bindings — method prototype return type in class body ────── +;; Covers: class User { User* lookup(int); std::string getName(); }; +;; AST: field_declaration > function_declarator > field_identifier +(field_declaration + type: (type_identifier) @type-binding.type + declarator: (function_declarator + declarator: (field_identifier) @type-binding.name)) @type-binding.return + +;; Method prototype pointer return type: User* lookup(int id); +(field_declaration + type: (type_identifier) @type-binding.type + declarator: (pointer_declarator + declarator: (function_declarator + declarator: (field_identifier) @type-binding.name))) @type-binding.return + +;; ─── Type bindings — field type declarations (class members) ──────── +;; Covers: class User { Address address; }; +(field_declaration + type: (type_identifier) @type-binding.type + declarator: (field_identifier) @type-binding.name) @type-binding.field + +;; Field pointer type: Address* address; +(field_declaration + type: (type_identifier) @type-binding.type + declarator: (pointer_declarator + declarator: (field_identifier) @type-binding.name)) @type-binding.field + +;; Field reference type: Address& address; +(field_declaration + type: (type_identifier) @type-binding.type + declarator: (reference_declarator + (field_identifier) @type-binding.name)) @type-binding.field + +;; ─── References — constructor calls (new Foo()) ───────────────────── +(new_expression + type: (type_identifier) @reference.name) @reference.call.constructor + +;; Constructor call with qualified type: new ns::Foo() +(new_expression + type: (qualified_identifier + name: (type_identifier) @reference.name)) @reference.call.constructor + +;; ─── References — free calls ──────────────────────────────────────── +(call_expression + function: (identifier) @reference.name) @reference.call.free + +;; ─── References — qualified calls (Namespace func or Class method) ─── +;; Capture the LHS of scope-resolution as the explicit receiver so +;; qualified static member calls route through receiver-bound-calls +;; Case 2 (class-name receiver) path. Without the receiver capture, +;; qualified calls have no explicit receiver and class methods cannot +;; resolve through receiver-bound paths. +(call_expression + function: (qualified_identifier + scope: (_) @reference.receiver + name: (identifier) @reference.name)) @reference.call.qualified + +;; ─── References — member calls (obj.method() / ptr->method()) ─────── +(call_expression + function: (field_expression + argument: (_) @reference.receiver + field: (field_identifier) @reference.name)) @reference.call.member + +;; ─── References — template calls (func()) ──────────────────────── +(call_expression + function: (template_function + name: (identifier) @reference.name)) @reference.call.free + +;; Note: Ns::func() is parsed as qualified_identifier by tree-sitter-cpp, +;; already captured by the qualified calls pattern above. + +;; ─── References — field reads ─────────────────────────────────────── +(field_expression + argument: (_) @reference.receiver + field: (field_identifier) @reference.name) @reference.read + +;; ─── References — field writes (assignment) ───────────────────────── +(assignment_expression + left: (field_expression + argument: (_) @reference.receiver + field: (field_identifier) @reference.name)) @reference.write +`; + +let _parser: Parser | null = null; +let _query: Parser.Query | null = null; + +export function getCppParser(): Parser { + if (_parser === null) { + _parser = new Parser(); + _parser.setLanguage(CPP as Parameters[0]); + } + return _parser; +} + +export function getCppScopeQuery(): Parser.Query { + if (_query === null) { + _query = new Parser.Query(CPP as Parameters[0], CPP_SCOPE_QUERY); + } + return _query; +} diff --git a/gitnexus/src/core/ingestion/languages/cpp/range-bindings.ts b/gitnexus/src/core/ingestion/languages/cpp/range-bindings.ts new file mode 100644 index 000000000..204d1ab21 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/cpp/range-bindings.ts @@ -0,0 +1,255 @@ +import type { ParsedFile, Scope, TypeRef } from 'gitnexus-shared'; +import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js'; +import { getCppParser } from './query.js'; +import { getTreeSitterBufferSize } from '../../constants.js'; +import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js'; + +/** + * Populate range-for loop variable type bindings for C++. + * + * Handles three patterns: + * 1. `for (auto& user : users)` — simple range-for + * 2. `for (auto& [key, user] : userMap)` — structured binding + * 3. `for (auto& user : *usersPtr)` — dereference range-for + * + * Strategy: look up the range source variable's type in scope + * typeBindings, extract the last template argument as the element + * type, and inject a typeBinding for the loop variable. + */ +export function populateCppRangeBindings( + parsedFiles: readonly ParsedFile[], + _indexes: ScopeResolutionIndexes, + ctx: { + readonly fileContents: ReadonlyMap; + readonly treeCache?: { get(filePath: string): unknown }; + }, +): void { + const parser = getCppParser(); + + for (const parsed of parsedFiles) { + const sourceText = ctx.fileContents.get(parsed.filePath); + if (sourceText === undefined) continue; + + const cachedTree = ctx.treeCache?.get(parsed.filePath); + const tree = + (cachedTree as ReturnType | undefined) ?? + parseSourceSafe(parser, sourceText, undefined, { + bufferSize: getTreeSitterBufferSize(sourceText), + }); + + const moduleScope = parsed.scopes.find((s) => s.kind === 'Module'); + if (moduleScope === undefined) continue; + + const scopeMap = new Map(parsed.scopes.map((s) => [s.id, s])); + + // Build a map from parameter name → AST parameter_declaration node + // so we can extract the un-normalized template type from the AST. + const paramTypeMap = buildParamTemplateMap(tree.rootNode); + + for (const rangeNode of tree.rootNode.descendantsOfType('for_range_loop')) { + // Get the declarator (loop variable) + const declarator = rangeNode.childForFieldName('declarator'); + if (declarator === null) continue; + + // Get the range source expression (right side of ':') + const right = rangeNode.childForFieldName('right'); + if (right === null) continue; + + // Determine the loop variable name(s) and whether this is a structured binding + const varNames = extractLoopVarNames(declarator); + if (varNames.length === 0) continue; + + // Determine the range source variable name (handle dereference) + const sourceVarName = extractSourceVarName(right); + if (sourceVarName === null) continue; + + // Look up the source variable's full template type from the AST + // (scope typeBindings have been normalized and lost template params) + const fullType = paramTypeMap.get(sourceVarName); + if (fullType === undefined) continue; + + // Extract element type from the container type + const elementType = extractCppElementType(fullType); + if (elementType === null) continue; + + // Find the enclosing function scope + const functionScope = findEnclosingFunctionScope(rangeNode, scopeMap); + const targetScope = functionScope ?? moduleScope; + const mutable = targetScope.typeBindings as Map; + + // For structured binding [key, user], bind the last identifier to the element type + // For simple range-for, bind the single variable + const bindVar = varNames[varNames.length - 1]; + mutable.set(bindVar, { + rawName: elementType, + declaredAtScope: targetScope.id, + source: 'annotation', + }); + } + } +} + +/** Minimal tree-sitter node shape needed by range-binding helpers. */ +interface TsNode { + readonly type: string; + readonly text: string; + readonly childCount: number; + child(index: number): TsNode | null; + descendantsOfType(type: string): readonly TsNode[]; + childForFieldName(name: string): TsNode | null; +} + +/** + * Build a map from parameter name → full (un-normalized) type text + * by walking the AST for all `parameter_declaration` nodes. + * + * This bypasses `normalizeCppTypeName` which strips template params, + * giving us the raw `std::vector` text needed for element-type + * extraction. + */ +function buildParamTemplateMap(rootNode: TsNode): Map { + const map = new Map(); + for (const paramNode of rootNode.descendantsOfType('parameter_declaration')) { + const typeNode = paramNode.childForFieldName('type'); + if (typeNode === null) continue; + + // Extract the parameter name from the declarator subtree. + // The declarator may be: identifier, reference_declarator > identifier, + // or pointer_declarator > identifier. + const declNode = paramNode.childForFieldName('declarator'); + if (declNode === null) continue; + + const idents = declNode.descendantsOfType('identifier'); + if (idents.length === 0) continue; + const paramName = idents[idents.length - 1].text; + + // Use the full type node text (preserving template params) + map.set(paramName, typeNode.text); + } + return map; +} + +/** + * Extract loop variable name(s) from the declarator node. + * Handles both simple `identifier` and `structured_binding_declarator`. + */ +function extractLoopVarNames(declarator: TsNode): string[] { + // The declarator is typically reference_declarator or pointer_declarator wrapping + // either an identifier or a structured_binding_declarator. + const structBindings = declarator.descendantsOfType('structured_binding_declarator'); + if (structBindings.length > 0) { + // structured_binding_declarator contains identifiers like [key, user] + const idents = structBindings[0].descendantsOfType('identifier'); + return idents.map((id) => id.text).filter((t) => t !== '_'); + } + + // Simple case: reference_declarator > identifier or just identifier + const idents = declarator.descendantsOfType('identifier'); + if (idents.length > 0) { + return [idents[idents.length - 1].text]; + } + + return []; +} + +/** + * Extract the source variable name from the range expression. + * Handles plain identifiers and dereference expressions (*ptr). + */ +function extractSourceVarName(right: TsNode): string | null { + if (right.type === 'identifier') { + return right.text; + } + if (right.type === 'pointer_expression') { + // *usersPtr → get the argument (usersPtr) + const arg = right.childForFieldName('argument'); + if (arg !== null) return arg.text; + } + return null; +} + +/** + * Extract the element type from a C++ container type string. + * + * Examples: + * - `vector` → `User` + * - `std::vector` → `User` + * - `map` → `User` (last template arg) + * - `map` → `User` + * + * For structured bindings with maps, the last template arg is the value type. + * For vectors/sets, the first (and only) template arg is the element type. + */ +function extractCppElementType(rawType: string): string | null { + // Find the outermost template argument list + const ltIdx = rawType.indexOf('<'); + if (ltIdx === -1) return null; + + // Extract the template argument string (handle nested templates) + let depth = 0; + let lastCommaOrStart = ltIdx + 1; + let lastArg = ''; + + for (let i = ltIdx; i < rawType.length; i++) { + const ch = rawType[i]; + if (ch === '<') { + depth++; + } else if (ch === '>') { + depth--; + if (depth === 0) { + lastArg = rawType.slice(lastCommaOrStart, i).trim(); + break; + } + } else if (ch === ',' && depth === 1) { + lastCommaOrStart = i + 1; + } + } + + if (lastArg === '') return null; + + // Strip pointer/reference qualifiers and const + let elementType = lastArg + .replace(/^const\s+/, '') + .replace(/\s*[*&]+\s*$/, '') + .trim(); + + // Strip namespace prefix (std::string → string) + const lastColon = elementType.lastIndexOf('::'); + if (lastColon !== -1) { + elementType = elementType.slice(lastColon + 2); + } + + return elementType || null; +} + +/** + * Find the enclosing Function scope for a tree-sitter node by + * walking up the AST and matching source positions. + */ +function findEnclosingFunctionScope( + node: unknown, + scopeMap: ReadonlyMap, +): Scope | null { + const tsNode = node as { + readonly parent: unknown; + readonly type: string; + readonly startPosition: { readonly row: number; readonly column: number }; + }; + let current: typeof tsNode | null = tsNode; + while (current !== null) { + if (current.type === 'function_definition') { + for (const scope of scopeMap.values()) { + if ( + scope.kind === 'Function' && + scope.range.startLine === current.startPosition.row && + scope.range.startCol === current.startPosition.column + ) { + return scope; + } + } + break; + } + current = (current.parent as typeof tsNode) ?? null; + } + return null; +} diff --git a/gitnexus/src/core/ingestion/languages/cpp/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/cpp/scope-resolver.ts new file mode 100644 index 000000000..a85e5b113 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/cpp/scope-resolver.ts @@ -0,0 +1,230 @@ +import type { ParsedFile, SymbolDefinition } from 'gitnexus-shared'; +import { + findClassBindingInScope, + findEnclosingClassDef, +} from '../../scope-resolution/scope/walkers.js'; +import { SupportedLanguages } from 'gitnexus-shared'; +import { buildMro, defaultLinearize } from '../../scope-resolution/passes/mro.js'; +import { populateClassOwnedMembers } from '../../scope-resolution/scope/walkers.js'; +import type { ScopeResolver } from '../../scope-resolution/contract/scope-resolver.js'; +import { cppProvider } from '../c-cpp.js'; +import { cppArityCompatibility } from './arity.js'; +import { cppMergeBindings } from './merge-bindings.js'; +import { resolveCppImportTarget } from './import-target.js'; +import { scanCppHeaderFiles } from './header-scan.js'; +import { + expandCppWildcardNames, + isFileLocal, + clearFileLocalNames, + populateCppNonGloballyVisible, + isCppDefGloballyVisible, +} from './file-local-linkage.js'; +import { + populateCppDependentBases, + clearCppDependentBases, + isCppDependentBaseMember, +} from './two-phase-lookup.js'; +import { + populateCppAssociatedNamespaces, + clearCppAdlState, + pickCppAdlCandidates, + ADL_AMBIGUOUS, +} from './adl.js'; +import { + clearCppInlineNamespaces, + populateCppInlineNamespaceScopes, + resolveCppQualifiedNamespaceMember, +} from './inline-namespaces.js'; +import { populateCppRangeBindings } from './range-bindings.js'; + +/** + * C++ `ScopeResolver` registered in `SCOPE_RESOLVERS` and consumed by + * the generic `runScopeResolution` orchestrator (RFC #909 Ring 3). + * + * C++ extends C's scope resolution with: + * - Namespaces (`namespace foo { ... }`) + * - Classes with methods and multiple inheritance + * - `using namespace` (wildcard import from namespace) + * - `using X::name` (named import from namespace) + * - Anonymous namespace (file-local linkage, like C `static`) + * - Default parameters (requiredParameterCount < parameterCount) + * - Overloading (arity-based disambiguation) + * - Templates (V1: generic-ignored, `List` ≡ `List`) + * - Leftmost-base MRO for multiple inheritance + */ +export const cppScopeResolver: ScopeResolver = { + language: SupportedLanguages.CPlusPlus, + languageProvider: cppProvider, + importEdgeReason: 'cpp-scope: include', + + loadResolutionConfig: (repoPath: string) => { + // Clear stale per-pipeline state from any previous invocation. + clearFileLocalNames(); + clearCppDependentBases(); + clearCppAdlState(); + clearCppInlineNamespaces(); + return scanCppHeaderFiles(repoPath); + }, + + resolveImportTarget: (targetRaw, fromFile, allFilePaths, resolutionConfig) => { + // Augment allFilePaths with header files discovered via loadResolutionConfig. + // C++ .h/.hpp/.hxx/.hh files may be classified differently by language + // detection but are importable from .cpp files via #include. + const headerPaths = resolutionConfig as ReadonlySet | undefined; + if (headerPaths !== undefined && headerPaths.size > 0) { + const augmented = new Set(allFilePaths); + for (const h of headerPaths) augmented.add(h); + return resolveCppImportTarget(targetRaw, fromFile, augmented); + } + return resolveCppImportTarget(targetRaw, fromFile, allFilePaths); + }, + + expandsWildcardTo: (targetModuleScope, parsedFiles) => + expandCppWildcardNames(targetModuleScope, parsedFiles), + + mergeBindings: (existing, incoming, scopeId) => cppMergeBindings(existing, incoming, scopeId), + + // Adapter: cppArityCompatibility predates ScopeResolver and uses + // (def, callsite). ScopeResolver contract is (callsite, def). + arityCompatibility: (callsite, def) => cppArityCompatibility(def, callsite), + + buildMro: (graph, parsedFiles, nodeLookup) => + buildMro(graph, parsedFiles, nodeLookup, defaultLinearize), + + populateOwners: (parsed: ParsedFile) => { + populateClassOwnedMembers(parsed); + // Resolve inline-namespace ranges (recorded at capture time) to + // ScopeIds BEFORE `populateCppNonGloballyVisible` runs, so the + // inline-namespace exemption sees the populated Set. + populateCppInlineNamespaceScopes(parsed); + // Track namespace-nested and class-nested defs so the global free-call + // fallback and wildcard expansion can suppress them as unqualified + // cross-file callables. + populateCppNonGloballyVisible(parsed); + // Resolve recorded template-class → dependent-base simple names to + // class nodeIds for two-phase template lookup (U3 of plan + // 2026-05-13-001). + populateCppDependentBases(parsed); + // Build the class-def → enclosing-namespace-qualified-name map used + // by ADL (U2 of plan 2026-05-13-001) to identify each argument type's + // associated namespace for Koenig lookup. + populateCppAssociatedNamespaces(parsed); + }, + + // Simple `isSuperReceiver` returns false for C++. Real super + // classification is caller-context-dependent and lives in + // `isSuperReceiverInContext` below — without scope context the + // previous regex `/^[A-Z]\w*::/` misclassified namespace-qualified + // calls (e.g., `Singleton::getInstance()`) as super calls and routed + // them through the wrong resolution branch. + isSuperReceiver: () => false, + + isSuperReceiverInContext: (text, callerScope, scopes) => { + // The receiver text comes from the LHS of `::` in `qualified_identifier` + // (e.g., for `Base::method()`, text is `Base`). Strip template + // arguments (V1: name-only matching, generics ignored) and any leading + // namespace qualifier so the lookup matches the bare class def's + // simple name. `Base::method()` → `Base`; `outer::v1::Base` → + // `Base`. This handles the Phase 5 cross-unit composition where + // qualified base-method calls appear inside template bodies. + let lhs = text; + const sepIdx = lhs.indexOf('::'); + if (sepIdx > 0) lhs = lhs.slice(0, sepIdx).trim(); + // Strip trailing template-argument list (greedy: drop everything from + // the first `<` onward — V1 ignores generics). + const lt = lhs.indexOf('<'); + if (lt > 0) lhs = lhs.slice(0, lt).trim(); + // Strip nested namespace prefix from the receiver text itself (the + // `outer::v1::Base` shape that appears in derived-list `base_class_clause`). + const lastDoubleColon = lhs.lastIndexOf('::'); + if (lastDoubleColon >= 0) lhs = lhs.slice(lastDoubleColon + 2).trim(); + if (lhs.length === 0) return false; + + // Resolve the LHS in the caller's scope chain. Only class-like + // resolutions can be super receivers; Namespace and unresolved + // names are not super calls. + const lhsDef = findClassBindingInScope(callerScope, lhs, scopes); + if (lhsDef === undefined) return false; + + // The caller must have an enclosing class — super calls only make + // sense inside a class body. Free functions can use `ClassName::` + // for namespace-qualified calls but those are not super. + const enclosing = findEnclosingClassDef(callerScope, scopes); + if (enclosing === undefined) return false; + + // `lhsDef` must be in the caller's MRO (i.e., the caller's enclosing + // class derives from it). The class itself counts as its own MRO + // root — `Self::method()` is a qualified self-call, not a super + // call, so exclude the caller's own class. + if (lhsDef.nodeId === enclosing.nodeId) return false; + const mro = scopes.methodDispatch.mroFor(enclosing.nodeId); + return mro.includes(lhsDef.nodeId); + }, + + // C++ is statically typed — disable field fallback heuristic + fieldFallbackOnMethodLookup: false, + // C++ needs return type propagation across #include boundaries + propagatesReturnTypesAcrossImports: true, + // C++ #include brings in all symbols — enable global free call fallback + allowGlobalFreeCallFallback: true, + // Range-for element type inference: for (auto& user : users) → bind user to User + populateRangeBindings: populateCppRangeBindings, + // C++ method return-type bindings need to be visible from module scope + // for cross-file propagation and compound-receiver chain resolution. + // cppBindingScopeFor hoists @type-binding.return to Module scope. + hoistTypeBindingsToModule: true, + // The `isFileLocalDef` hook on the global free-call fallback names + // file-local linkage historically, but semantically gates "logically + // invisible cross-file" defs. C++ extends this to also reject class- + // owned methods/fields and namespace-nested symbols — an unqualified + // call from a free function MUST NOT resolve to `User::save` or + // `ns::foo` (Cppreference, "Unqualified name lookup"). Without this + // gate, the global fallback walks every callable in the workspace + // registry and matches any class method or namespace function by + // simple name. + isFileLocalDef: (def: SymbolDefinition) => { + const simple = def.qualifiedName?.split('.').pop() ?? def.qualifiedName ?? ''; + if (isFileLocal(def.filePath, simple)) return true; + // Class-owned (Method/Field) — `populateClassOwnedMembers` already + // stamps `ownerId`; cheap fast-path before consulting the scope map. + if (def.ownerId !== undefined) return true; + // Namespace-nested defs — require qualification cross-file. Scope- + // walked at `populateOwners` time into a per-file nodeId set. + if (!isCppDefGloballyVisible(def.filePath, def.nodeId)) return true; + return false; + }, + + // C++ two-phase template lookup: inside a class template body, + // unqualified calls MUST NOT bind to members of a dependent base + // class. The standard requires `this->name()` or `Base::name()` + // forms to make the lookup dependent. Without this gate the global + // free-call fallback walks the workspace registry and silently binds + // unqualified calls to dependent-base members, producing CALLS edges + // the compiler would reject. See plan 2026-05-13-001 U3. + isCallableVisibleFromCaller: ({ candidate, callerScope, scopes }) => { + if (callerScope === undefined || scopes === undefined) return true; + // Reject when the candidate is a member of a dependent base of the + // caller's enclosing template class. Otherwise allow. + return !isCppDependentBaseMember(callerScope, candidate, scopes); + }, + + // 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. + // V1 limitation: only direct enclosing-namespace closure for value + // class-typed args; pointer/reference/template-spec args excluded. + resolveAdlCandidates: (site, callerParsed, scopes, parsedFiles) => { + const result = pickCppAdlCandidates(site, callerParsed, scopes, parsedFiles); + if (result === ADL_AMBIGUOUS) return 'ambiguous'; + return result; + }, + + // C++ qualified namespace-member resolution (U5 of plan 2026-05-13-001). + // Handles `outer::foo()` where `outer` is a namespace (not a class). + // Walks each parsed file's namespace scopes by simple name, then + // descends transitively through inline-namespace children when + // searching for the called member. Returns undefined for non-namespace + // receivers so receiver-bound-calls Case 2 still gets a chance. + resolveQualifiedReceiverMember: (receiverName, memberName, _callerScope, scopes, parsedFiles) => + resolveCppQualifiedNamespaceMember(receiverName, memberName, parsedFiles, scopes), +}; diff --git a/gitnexus/src/core/ingestion/languages/cpp/simple-hooks.ts b/gitnexus/src/core/ingestion/languages/cpp/simple-hooks.ts new file mode 100644 index 000000000..63500abd5 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/cpp/simple-hooks.ts @@ -0,0 +1,79 @@ +import type { + CaptureMatch, + ParsedImport, + Scope, + ScopeId, + ScopeTree, + TypeRef, +} from 'gitnexus-shared'; + +/** + * C++ binding scope: default auto-hoist (null) for most declarations. + * + * For `for` statement init-scope variables (e.g. `for (int i = 0; ...)`), + * the variable is scoped to the for-block, not the enclosing function. + * The tree-sitter scope query already captures for_statement as @scope.block, + * so tree-sitter's scope nesting handles this automatically — we return null + * to let the default auto-hoist apply. + */ +export function cppBindingScopeFor( + decl: CaptureMatch, + innermost: Scope, + tree: ScopeTree, +): ScopeId | null { + // Hoist return-type bindings to Module scope so: + // 1. propagateImportedReturnTypes can mirror them across files + // 2. compound-receiver can find method return types via hoistTypeBindingsToModule + if (decl['@type-binding.return'] !== undefined) { + let cur: Scope | undefined = innermost; + while (cur !== undefined && cur.kind !== 'Module') { + const parentId: ScopeId | null = cur.parent ?? null; + if (parentId === null) break; + cur = tree.getScope(parentId); + } + if (cur !== undefined && cur.kind === 'Module') return cur.id; + } + return null; // default auto-hoist for other bindings +} + +/** + * C++ import owning scope: default (null). + * #include and using declarations are file-scoped in C++. + */ +export function cppImportOwningScope( + _imp: ParsedImport, + _innermost: Scope, + _tree: ScopeTree, +): ScopeId | null { + return null; +} + +/** + * C++ receiver binding: return `this` TypeRef for methods inside a class. + * + * When a function scope is inside a class scope, the implicit `this` pointer + * refers to the enclosing class. This enables `this->method()` and implicit + * `this` member access resolution. + */ +export function cppReceiverBinding(functionScope: Scope): TypeRef | null { + // Walk up the scope tree to find an enclosing class scope + if (functionScope.parent === null) return null; + + // The scope tree structure nests function scopes inside class scopes. + // The orchestrator provides the function scope; we need to check if + // its parent chain contains a class scope. + // + // However, the ScopeResolver.receiverBinding contract receives only + // the function Scope (not the full ScopeTree), and the Scope type + // includes `parent` (a ScopeId) but not a reference to the parent + // Scope object. + // + // The orchestrator already handles this by looking up the class owner + // via populateOwners. We return null here and let the shared infra + // handle receiver resolution through the class-ownership mechanism. + // + // This is consistent with how C# and Go handle it — the receiver + // binding is established through populateOwners + the MRO chain, + // not through this hook. + return null; +} diff --git a/gitnexus/src/core/ingestion/languages/cpp/two-phase-lookup.ts b/gitnexus/src/core/ingestion/languages/cpp/two-phase-lookup.ts new file mode 100644 index 000000000..7840ed81a --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/cpp/two-phase-lookup.ts @@ -0,0 +1,133 @@ +/** + * C++ two-phase template lookup support. + * + * Inside a class template body, names from a dependent base class are NOT + * found by ordinary unqualified lookup. The standard requires the + * `this->name` or `Base::name` forms to make the lookup dependent. + * GitNexus's global free-call fallback otherwise binds such names to the + * dependent base's members, producing CALLS edges the compiler would + * reject. + * + * This module records — during `emitCppScopeCaptures` — which template + * class declarations have which dependent base class names (per file). + * `populateCppDependentBases` then resolves those names to class nodeIds + * using the workspace registry, building the per-class set the + * `isDependentBaseMember` predicate consumes. + * + * NOTE: module-level state, single-process-single-repo use only. + * `clearFileLocalNames()` clears this state alongside file-local linkage + * (see `file-local-linkage.ts`). + */ + +import type { ParsedFile, ScopeId, SymbolDefinition } from 'gitnexus-shared'; +import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js'; +import { findEnclosingClassDef } from '../../scope-resolution/scope/walkers.js'; + +/** + * Capture-time record: for each template class declaration in a file, + * the simple names of its dependent base classes. + * + * Key: filePath + * Value: Map> + */ +const dependentBasesByFile = new Map>>(); + +/** + * Post-`populateOwners` resolution: per-class-nodeId, the set of + * dependent-base-class nodeIds. Built by `populateCppDependentBases` + * from `dependentBasesByFile` + the workspace registry. + */ +const dependentBaseNodeIds = new Map>(); + +/** + * Record a dependent-base relationship discovered during scope-capture + * emission. `className` is the simple name of the template class; + * `baseName` is the simple name of the dependent base class. + * + * The capture-time recorder uses simple names because the registry + * resolution that maps names → nodeIds runs later (in + * `populateCppDependentBases`). + */ +export function markCppDependentBase(filePath: string, className: string, baseName: string): void { + let perFile = dependentBasesByFile.get(filePath); + if (perFile === undefined) { + perFile = new Map(); + dependentBasesByFile.set(filePath, perFile); + } + let bases = perFile.get(className); + if (bases === undefined) { + bases = new Set(); + perFile.set(className, bases); + } + bases.add(baseName); +} + +/** Clear two-phase-lookup state. Called from `clearFileLocalNames`. */ +export function clearCppDependentBases(): void { + dependentBasesByFile.clear(); + dependentBaseNodeIds.clear(); +} + +/** + * Resolve recorded dependent-base simple names to class nodeIds using + * the parsed file's localDefs. Run as part of `populateOwners` so the + * resolved set is available before any resolution pass consults it. + * + * Matches by simple name within the same file (the template class and + * its base are typically declared in the same TU; cross-file template + * bases are an edge case deferred to V2). + */ +export function populateCppDependentBases(parsed: ParsedFile): void { + const perFile = dependentBasesByFile.get(parsed.filePath); + if (perFile === undefined) return; + + // Build simple-name → nodeId index for this file's class-like defs. + const classByName = new Map(); + for (const def of parsed.localDefs) { + if (def.type !== 'Class' && def.type !== 'Struct' && def.type !== 'Interface') continue; + const simple = def.qualifiedName?.split('.').pop() ?? def.qualifiedName ?? ''; + if (simple !== '') classByName.set(simple, def.nodeId); + } + + for (const [className, baseNames] of perFile) { + const classNodeId = classByName.get(className); + if (classNodeId === undefined) continue; + let bases = dependentBaseNodeIds.get(classNodeId); + if (bases === undefined) { + bases = new Set(); + dependentBaseNodeIds.set(classNodeId, bases); + } + for (const baseName of baseNames) { + const baseNodeId = classByName.get(baseName); + if (baseNodeId !== undefined) bases.add(baseNodeId); + } + } +} + +/** + * Two-phase lookup predicate: is the candidate def a member of a + * dependent base of the caller's enclosing template class? + * + * Used as an additional reject-filter in `pickUniqueGlobalCallable` and + * the receiver-bound member chain walk. ONLY apply for unqualified + * call forms — `this->name` and `Base::name` are dependent lookup + * forms that the standard allows. + * + * Conservative bias: when the caller's enclosing class can't be + * identified, return `false` (let normal resolution proceed). Over- + * rejection is acceptable for the template case because the standard + * itself requires `this->` or qualified forms for dependent base + * access; missing edges here match the compiler's diagnostic shape. + */ +export function isCppDependentBaseMember( + callerScopeId: ScopeId, + candidateDef: SymbolDefinition, + scopes: ScopeResolutionIndexes, +): boolean { + if (candidateDef.ownerId === undefined) return false; + const enclosing = findEnclosingClassDef(callerScopeId, scopes); + if (enclosing === undefined) return false; + const bases = dependentBaseNodeIds.get(enclosing.nodeId); + if (bases === undefined) return false; + return bases.has(candidateDef.ownerId); +} diff --git a/gitnexus/src/core/ingestion/registry-primary-flag.ts b/gitnexus/src/core/ingestion/registry-primary-flag.ts index 6818007a3..869157adb 100644 --- a/gitnexus/src/core/ingestion/registry-primary-flag.ts +++ b/gitnexus/src/core/ingestion/registry-primary-flag.ts @@ -72,6 +72,7 @@ export const MIGRATED_LANGUAGES: ReadonlySet = new Set::<...>` (or another super- + * form the language recognizes), AND + * - `` resolves (via scope chain) to a class-like def, AND + * - that class is in the MRO of the caller's enclosing class. + * + * Returns `false` for namespace-qualified calls, unresolved names, + * class-qualified calls where the class is NOT in the caller's MRO, + * and any text the simple `isSuperReceiver` hook also rejects. + */ + readonly isSuperReceiverInContext?: ( + receiverText: string, + callerScope: ScopeId, + scopes: ScopeResolutionIndexes, + ) => boolean; + // ─── Optional toggles ────────────────────────────────────────────────────── /** @@ -522,8 +560,70 @@ export interface ScopeResolver { readonly isCallableVisibleFromCaller?: (ctx: { readonly callerParsed: ParsedFile; readonly candidate: SymbolDefinition; + /** Caller's enclosing scope id. Languages that gate visibility on + * caller scope (e.g. C++ two-phase template lookup) consult it; + * others ignore. Optional so existing implementations stay valid. */ + readonly callerScope?: ScopeId; + /** ScopeResolutionIndexes for scope-tree walks. Optional for the + * same reason as `callerScope`. */ + readonly scopes?: ScopeResolutionIndexes; }) => boolean; + /** + * 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`). + * + * Languages without C++-style ADL leave this undefined. The + * cross-language contract is "additive tier" — defining the hook never + * removes candidates the prior tier would have produced. + */ + readonly resolveAdlCandidates?: ( + site: { + readonly name: string; + readonly arity?: number; + readonly argumentTypes?: readonly string[]; + readonly atRange: { readonly startLine: number; readonly startCol: number }; + }, + callerParsed: ParsedFile, + scopes: ScopeResolutionIndexes, + parsedFiles: readonly ParsedFile[], + ) => SymbolDefinition | 'ambiguous' | undefined; + + /** + * Optional resolver for qualified-receiver member calls where the + * receiver is a namespace (not a class) and ordinary scope-chain / + * import resolution doesn't find the member. C++ uses this for + * `outer::foo()` style calls and to walk through inline-namespace + * children transitively (`outer::v1::foo` reachable as `outer::foo`). + * + * Languages whose qualified-name semantics are already covered by the + * receiver-bound-calls Case-1 namespace-targets path (e.g., Python's + * `import X; X.foo()`) leave this undefined. + * + * 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. + */ + readonly resolveQualifiedReceiverMember?: ( + receiverName: string, + memberName: string, + callerScope: ScopeId, + scopes: ScopeResolutionIndexes, + parsedFiles: readonly ParsedFile[], + ) => SymbolDefinition | undefined; + /** * Optional post-finalize hook to inject cross-file bindings that * aren't modeled via explicit imports. Runs after diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/free-call-fallback.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/free-call-fallback.ts index eb6dd71fb..2dc20d2ef 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/passes/free-call-fallback.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/free-call-fallback.ts @@ -42,7 +42,20 @@ export function emitFreeCallFallback( readonly isCallableVisibleFromCaller?: (ctx: { readonly callerParsed: ParsedFile; readonly candidate: SymbolDefinition; + readonly callerScope?: ScopeId; + readonly scopes?: ScopeResolutionIndexes; }) => boolean; + readonly resolveAdlCandidates?: ( + site: { + readonly name: string; + readonly arity?: number; + readonly argumentTypes?: readonly string[]; + readonly atRange: { readonly startLine: number; readonly startCol: number }; + }, + callerParsed: ParsedFile, + scopes: ScopeResolutionIndexes, + parsedFiles: readonly ParsedFile[], + ) => SymbolDefinition | 'ambiguous' | undefined; } = {}, ): number { let emitted = 0; @@ -75,6 +88,35 @@ export function emitFreeCallFallback( 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; + } + } // 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 @@ -89,7 +131,12 @@ export function emitFreeCallFallback( site.arity, options.isCallableVisibleFromCaller !== undefined ? (candidate) => - options.isCallableVisibleFromCaller!({ callerParsed: parsed, candidate }) + options.isCallableVisibleFromCaller!({ + callerParsed: parsed, + candidate, + callerScope: site.inScope, + scopes, + }) : undefined, ); } diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/imported-return-types.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/imported-return-types.ts index cbdfc62aa..33716778d 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/passes/imported-return-types.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/imported-return-types.ts @@ -151,7 +151,8 @@ export function propagateImportedReturnTypes( const refs = lookupBindingsAt(importerModule.id, localName, indexes); for (const ref of refs) { - if (ref.origin !== 'import' && ref.origin !== 'reexport') continue; + if (ref.origin !== 'import' && ref.origin !== 'reexport' && ref.origin !== 'wildcard') + continue; const sourceModule = moduleScopeByFile.get(ref.def.filePath); if (sourceModule === undefined) continue; diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/overload-narrowing.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/overload-narrowing.ts index f36287052..bff16d27e 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/passes/overload-narrowing.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/overload-narrowing.ts @@ -88,3 +88,63 @@ export function narrowOverloadCandidates( return candidates; } + +/** + * Detect when >1 candidate share identical `parameterTypes` after the + * per-language normalizer has collapsed distinct underlying types. This + * signals "the resolver cannot pick the right overload — the + * normalization that helps single-candidate flows now hides a real + * ambiguity" and lets callers suppress the edge rather than pick + * arbitrarily. + * + * Concrete trigger (PR #1520 review follow-up plan U2, Claude review + * Finding 5): the C++ `arity-metadata.ts` normalizer collapses `int`, + * `long`, `short`, `unsigned`, and `size_t` to `'int'`. Without this + * check, `process(int)` and `process(long)` both end up with + * `parameterTypes === ['int']`, and `pickOverload` arbitrarily picks + * the first — emitting a false CALLS edge to the wrong overload. + * + * Returns false when: + * - 0 or 1 candidates (no ambiguity to detect) + * - any candidate has undefined `parameterTypes` (can't compare) + * - candidates differ in arity or in any parameter-type slot + * + * Other languages: this check is a precondition gate, not a behavior + * change for normal narrowing. Languages whose normalizers do not + * collapse distinct types (verified by grep over `*-arity-metadata.ts` + * — no `int → int` collapse outside C++) will never produce >1 + * candidate with identical `parameterTypes` from genuinely distinct + * declarations, so this returns false for them. The branch is + * effectively C++-only in practice. + */ +export function isOverloadAmbiguousAfterNormalization( + candidates: readonly SymbolDefinition[], + argCount?: number, +): boolean { + if (candidates.length < 2) return false; + const first = candidates[0].parameterTypes; + if (first === undefined) return false; + // When argCount is provided, compare only the first `argCount` slots — + // this catches default-argument ambiguity: `void f(int); void f(int, int = 0);` + // called with `f(1)` (argCount=1) leaves both candidates viable because + // default args make them arity-compatible, and their first slot is + // identical even though full parameterTypes lengths differ. + // Without argCount, fall back to full-sequence comparison (the original + // int/long normalization-collapse case). + const compareUpTo = argCount !== undefined ? argCount : first.length; + if (compareUpTo === 0) return false; + if (first.length < compareUpTo) return false; + for (let i = 1; i < candidates.length; i++) { + const p = candidates[i].parameterTypes; + if (p === undefined) return false; + if (p.length < compareUpTo) return false; + for (let j = 0; j < compareUpTo; j++) { + if (p[j] !== first[j]) return false; + } + // When argCount is NOT provided, also require length equality so + // distinct-arity candidates that happen to share a prefix don't + // collapse to ambiguous (preserves the original int/long contract). + if (argCount === undefined && p.length !== first.length) return false; + } + return true; +} 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 0cb544db9..9b57a7555 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 @@ -51,7 +51,10 @@ import { import { tryEmitEdge } from '../graph-bridge/edges.js'; import { resolveCompoundReceiverClass } from '../passes/compound-receiver.js'; import { resolveDefGraphId } from '../graph-bridge/ids.js'; -import { narrowOverloadCandidates } from './overload-narrowing.js'; +import { + narrowOverloadCandidates, + isOverloadAmbiguousAfterNormalization, +} from './overload-narrowing.js'; /** Subset of `ScopeResolver` consumed by this pass. Accepting the * subset rather than the full provider keeps tests and partial @@ -59,10 +62,12 @@ import { narrowOverloadCandidates } from './overload-narrowing.js'; type ReceiverBoundProviderSubset = Pick< ScopeResolver, | 'isSuperReceiver' + | 'isSuperReceiverInContext' | 'fieldFallbackOnMethodLookup' | 'collapseMemberCallsByCallerTarget' | 'unwrapCollectionAccessor' | 'hoistTypeBindingsToModule' + | 'resolveQualifiedReceiverMember' >; export function emitReceiverBoundCalls( @@ -162,7 +167,14 @@ export function emitReceiverBoundCalls( const siteKey = `${parsed.filePath}:${site.atRange.startLine}:${site.atRange.startCol}`; // ── super branch ───────────────────────────────────────────── - if (provider.isSuperReceiver(receiverName)) { + // Languages with caller-context-dependent super classification + // (C++) define `isSuperReceiverInContext`; we prefer it. Simple + // text-only languages (Python, Java, PHP) use the plain hook. + const isSuper = + provider.isSuperReceiverInContext !== undefined + ? provider.isSuperReceiverInContext(receiverName, site.inScope, scopes) + : provider.isSuperReceiver(receiverName); + if (isSuper) { const enclosingClass = findEnclosingClassDef(site.inScope, scopes); if (enclosingClass !== undefined) { // For super-receiver dispatch (`parent::`, `base.`, `super()`), @@ -285,6 +297,38 @@ export function emitReceiverBoundCalls( if (found) continue; } + // ── Case 1.5: qualified namespace-receiver (language-specific) ─── + // Languages whose qualified-name semantics need workspace-wide + // namespace-scope walking (C++ `outer::foo()`, including inline- + // namespace transitive traversal) implement `resolveQualifiedReceiverMember`. + // Runs before Case 2 so namespace receivers don't accidentally match a + // class with the same simple name. + if (provider.resolveQualifiedReceiverMember !== undefined) { + const memberDef = provider.resolveQualifiedReceiverMember( + receiverName, + memberName, + site.inScope, + scopes, + parsedFiles, + ); + if (memberDef !== undefined) { + const ok = tryEmitEdge( + graph, + scopes, + nodeLookup, + site, + memberDef, + memberDef.filePath !== parsed.filePath ? 'import-resolved' : 'global', + seen, + 0.85, + collapse, + ); + if (ok) emitted++; + handledSites.add(siteKey); + continue; + } + } + // ── Case 2: class-name receiver ────────────────────────────── const classDef = findClassBindingInScope(site.inScope, receiverName, scopes); if (classDef !== undefined) { @@ -454,9 +498,24 @@ export function emitReceiverBoundCalls( if (ownerDef !== undefined) { const chain = [ownerDef.nodeId, ...scopes.methodDispatch.mroFor(ownerDef.nodeId)]; let memberDef: SymbolDefinition | undefined; + let ambiguous = false; for (const ownerId of chain) { - memberDef = pickOverload(ownerId, memberName, site, model); - if (memberDef !== undefined) break; + const picked = pickOverload(ownerId, memberName, site, model); + if (picked === OVERLOAD_AMBIGUOUS) { + ambiguous = true; + break; + } + if (picked !== undefined) { + memberDef = picked; + break; + } + } + if (ambiguous) { + // Suppress and mark handled so `emitReferencesViaLookup` + // doesn't re-emit the pre-resolved reference. See + // OVERLOAD_AMBIGUOUS docstring for the upstream cause. + handledSites.add(siteKey); + continue; } if (memberDef !== undefined) { // For read/write ACCESSES, mirror the legacy DAG's reason @@ -509,7 +568,7 @@ function pickOverload( memberName: string, site: ParsedFile['referenceSites'][number], model: SemanticModel, -): SymbolDefinition | undefined { +): SymbolDefinition | typeof OVERLOAD_AMBIGUOUS | undefined { const overloads = model.methods.lookupAllByOwner(ownerId, memberName); if (overloads.length === 0) { // Non-callable member (field / property / variable) — ACCESSES @@ -520,5 +579,22 @@ function pickOverload( if (overloads.length === 1) return overloads[0]; const candidates = narrowOverloadCandidates(overloads, site.arity, site.argumentTypes); + // When narrowing leaves >1 candidate that share identical normalized + // parameter-types (e.g., C++ `f(int)` vs `f(long)` both collapsed to + // `['int']` by `normalizeCppParamType`), suppress the edge entirely. + // The graph schema has no ambiguous-target edge model, so emitting one + // would arbitrarily pick a candidate and lie about the call's target. + // PR #1520 review follow-up plan U2 / Claude review Finding 5. + if (isOverloadAmbiguousAfterNormalization(candidates, site.arity)) return OVERLOAD_AMBIGUOUS; return candidates[0] ?? overloads[0]; } + +/** + * Sentinel returned by `pickOverload` when narrowing leaves >1 candidate + * sharing identical normalized parameter-types. Callers should suppress + * the CALLS edge AND mark the site as handled so `emitReferencesViaLookup` + * does not re-emit from the pre-resolved reference index. See + * `pickOverload` JSDoc for the upstream cause (per-language normalizer + * collapses distinct types in arity-metadata). + */ +export const OVERLOAD_AMBIGUOUS = Symbol('overload-ambiguous'); diff --git a/gitnexus/src/core/ingestion/scope-resolution/pipeline/registry.ts b/gitnexus/src/core/ingestion/scope-resolution/pipeline/registry.ts index c606661c8..713497da4 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/pipeline/registry.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/pipeline/registry.ts @@ -17,6 +17,7 @@ import { typescriptScopeResolver } from '../../languages/typescript/scope-resolv import { goScopeResolver } from '../../languages/go/scope-resolver.js'; import { javaScopeResolver } from '../../languages/java/scope-resolver.js'; import { cScopeResolver } from '../../languages/c/scope-resolver.js'; +import { cppScopeResolver } from '../../languages/cpp/scope-resolver.js'; import { phpScopeResolver } from '../../languages/php/scope-resolver.js'; /** Map of `SupportedLanguages` → `ScopeResolver`. The phase iterates @@ -33,5 +34,6 @@ export const SCOPE_RESOLVERS: ReadonlyMap = n [SupportedLanguages.Go, goScopeResolver], [SupportedLanguages.Java, javaScopeResolver], [SupportedLanguages.C, cScopeResolver], + [SupportedLanguages.CPlusPlus, cppScopeResolver], [SupportedLanguages.PHP, phpScopeResolver], ]); diff --git a/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts b/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts index 47f8a3551..4f74cfce2 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts @@ -308,6 +308,7 @@ export function runScopeResolution( allowGlobalFallback: provider.allowGlobalFreeCallFallback === true, isFileLocalDef: provider.isFileLocalDef, isCallableVisibleFromCaller: provider.isCallableVisibleFromCaller, + resolveAdlCandidates: provider.resolveAdlCandidates, }, ); const { emitted, skipped } = emitReferencesViaLookup( diff --git a/gitnexus/src/core/ingestion/workers/parse-worker.ts b/gitnexus/src/core/ingestion/workers/parse-worker.ts index def4e1299..9a71fc16c 100644 --- a/gitnexus/src/core/ingestion/workers/parse-worker.ts +++ b/gitnexus/src/core/ingestion/workers/parse-worker.ts @@ -181,6 +181,8 @@ export interface ExtractedAssignment { propertyName: string; /** Resolved type name of the receiver if available from TypeEnv */ receiverTypeName?: string; + /** 1-indexed line number of the assignment site (used for per-site dedup) */ + line?: number; } // `ExtractedHeritage` now lives in `../model/heritage-map.ts` and is @@ -1580,6 +1582,7 @@ const processFileGroup = ( sourceId: srcId, receiverText, propertyName, + line: captureMap['assignment'].startPosition.row + 1, ...(receiverTypeName ? { receiverTypeName } : {}), }); } diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-adl-ambiguous/alpha.h b/gitnexus/test/fixtures/lang-resolution/cpp-adl-ambiguous/alpha.h new file mode 100644 index 000000000..c873a3a67 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-adl-ambiguous/alpha.h @@ -0,0 +1,7 @@ +#pragma once + +namespace alpha { + struct Token {}; + void process(Token t, int n); + void process(Token t, long n); +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-adl-ambiguous/app.cpp b/gitnexus/test/fixtures/lang-resolution/cpp-adl-ambiguous/app.cpp new file mode 100644 index 000000000..94c283ac5 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-adl-ambiguous/app.cpp @@ -0,0 +1,8 @@ +#include "alpha.h" + +namespace app { + void run() { + alpha::Token t; + process(t, 42); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-adl-basic/app.cpp b/gitnexus/test/fixtures/lang-resolution/cpp-adl-basic/app.cpp new file mode 100644 index 000000000..7fc21a0be --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-adl-basic/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-basic/audit.h b/gitnexus/test/fixtures/lang-resolution/cpp-adl-basic/audit.h new file mode 100644 index 000000000..2c9803a3d --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-adl-basic/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-pointer-arg-boundary/app.cpp b/gitnexus/test/fixtures/lang-resolution/cpp-adl-pointer-arg-boundary/app.cpp new file mode 100644 index 000000000..3eff3b66c --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-adl-pointer-arg-boundary/app.cpp @@ -0,0 +1,8 @@ +#include "audit.h" + +namespace app { + void run() { + audit::Event* p; + record(p); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-adl-pointer-arg-boundary/audit.h b/gitnexus/test/fixtures/lang-resolution/cpp-adl-pointer-arg-boundary/audit.h new file mode 100644 index 000000000..ca2c149d2 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-adl-pointer-arg-boundary/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-suppressed-parens/app.cpp b/gitnexus/test/fixtures/lang-resolution/cpp-adl-suppressed-parens/app.cpp new file mode 100644 index 000000000..64b2c6451 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-adl-suppressed-parens/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-suppressed-parens/audit.h b/gitnexus/test/fixtures/lang-resolution/cpp-adl-suppressed-parens/audit.h new file mode 100644 index 000000000..2c9803a3d --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-adl-suppressed-parens/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-anon-ns-cross-file/caller.cpp b/gitnexus/test/fixtures/lang-resolution/cpp-anon-ns-cross-file/caller.cpp new file mode 100644 index 000000000..d60127bfe --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-anon-ns-cross-file/caller.cpp @@ -0,0 +1,5 @@ +void worker(); + +void run() { + worker(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-anon-ns-cross-file/helper.cpp b/gitnexus/test/fixtures/lang-resolution/cpp-anon-ns-cross-file/helper.cpp new file mode 100644 index 000000000..feeef4747 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-anon-ns-cross-file/helper.cpp @@ -0,0 +1,7 @@ +namespace { + void worker() {} +} + +void helper_entry() { + worker(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-anon-ns-same-file-visible/helper.cpp b/gitnexus/test/fixtures/lang-resolution/cpp-anon-ns-same-file-visible/helper.cpp new file mode 100644 index 000000000..3d992bfe5 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-anon-ns-same-file-visible/helper.cpp @@ -0,0 +1,7 @@ +namespace { + void w() {} +} + +void run() { + w(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-include-no-class-leak/caller.cpp b/gitnexus/test/fixtures/lang-resolution/cpp-include-no-class-leak/caller.cpp new file mode 100644 index 000000000..8de016602 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-include-no-class-leak/caller.cpp @@ -0,0 +1,5 @@ +#include "user.h" + +void run() { + save(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-include-no-class-leak/user.h b/gitnexus/test/fixtures/lang-resolution/cpp-include-no-class-leak/user.h new file mode 100644 index 000000000..089fa6c59 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-include-no-class-leak/user.h @@ -0,0 +1,6 @@ +#pragma once + +class User { +public: + void save(); +}; diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-include-no-namespace-leak/caller.cpp b/gitnexus/test/fixtures/lang-resolution/cpp-include-no-namespace-leak/caller.cpp new file mode 100644 index 000000000..90b1f2aff --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-include-no-namespace-leak/caller.cpp @@ -0,0 +1,5 @@ +#include "lib.h" + +void run() { + foo(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-include-no-namespace-leak/lib.h b/gitnexus/test/fixtures/lang-resolution/cpp-include-no-namespace-leak/lib.h new file mode 100644 index 000000000..11f71286f --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-include-no-namespace-leak/lib.h @@ -0,0 +1,5 @@ +#pragma once + +namespace ns { + void foo(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-inline-namespace-adl-participation/app.cpp b/gitnexus/test/fixtures/lang-resolution/cpp-inline-namespace-adl-participation/app.cpp new file mode 100644 index 000000000..7fc21a0be --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-inline-namespace-adl-participation/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-inline-namespace-adl-participation/audit.h b/gitnexus/test/fixtures/lang-resolution/cpp-inline-namespace-adl-participation/audit.h new file mode 100644 index 000000000..9d657461a --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-inline-namespace-adl-participation/audit.h @@ -0,0 +1,8 @@ +#pragma once + +namespace audit { + inline namespace v1 { + struct Event {}; + void record(Event e); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-inline-namespace-nested/caller.cpp b/gitnexus/test/fixtures/lang-resolution/cpp-inline-namespace-nested/caller.cpp new file mode 100644 index 000000000..f2aa44454 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-inline-namespace-nested/caller.cpp @@ -0,0 +1,5 @@ +#include "lib.h" + +void run() { + outer::foo(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-inline-namespace-nested/lib.h b/gitnexus/test/fixtures/lang-resolution/cpp-inline-namespace-nested/lib.h new file mode 100644 index 000000000..ba85f2aed --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-inline-namespace-nested/lib.h @@ -0,0 +1,9 @@ +#pragma once + +namespace outer { + inline namespace v1 { + inline namespace experimental { + void foo(); + } + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-inline-namespace-unqualified/caller.cpp b/gitnexus/test/fixtures/lang-resolution/cpp-inline-namespace-unqualified/caller.cpp new file mode 100644 index 000000000..f2aa44454 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-inline-namespace-unqualified/caller.cpp @@ -0,0 +1,5 @@ +#include "lib.h" + +void run() { + outer::foo(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-inline-namespace-unqualified/lib.h b/gitnexus/test/fixtures/lang-resolution/cpp-inline-namespace-unqualified/lib.h new file mode 100644 index 000000000..e0ffb1eca --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-inline-namespace-unqualified/lib.h @@ -0,0 +1,7 @@ +#pragma once + +namespace outer { + inline namespace v1 { + void foo(); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-inline-namespace-versioned/caller.cpp b/gitnexus/test/fixtures/lang-resolution/cpp-inline-namespace-versioned/caller.cpp new file mode 100644 index 000000000..f2aa44454 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-inline-namespace-versioned/caller.cpp @@ -0,0 +1,5 @@ +#include "lib.h" + +void run() { + outer::foo(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-inline-namespace-versioned/lib.h b/gitnexus/test/fixtures/lang-resolution/cpp-inline-namespace-versioned/lib.h new file mode 100644 index 000000000..0ff3e61b3 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-inline-namespace-versioned/lib.h @@ -0,0 +1,10 @@ +#pragma once + +namespace outer { + inline namespace v1 { + void foo(); + } + namespace v0 { + void foo(); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-namespace-qualified-not-super/caller.cpp b/gitnexus/test/fixtures/lang-resolution/cpp-namespace-qualified-not-super/caller.cpp new file mode 100644 index 000000000..d1edc41c4 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-namespace-qualified-not-super/caller.cpp @@ -0,0 +1,5 @@ +#include "singleton.h" + +void run() { + Singleton::getInstance(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-namespace-qualified-not-super/singleton.h b/gitnexus/test/fixtures/lang-resolution/cpp-namespace-qualified-not-super/singleton.h new file mode 100644 index 000000000..aa2e255e6 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-namespace-qualified-not-super/singleton.h @@ -0,0 +1,6 @@ +#pragma once + +class Singleton { +public: + static Singleton* getInstance(); +}; diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-overload-default-arg-ambiguous/caller.cpp b/gitnexus/test/fixtures/lang-resolution/cpp-overload-default-arg-ambiguous/caller.cpp new file mode 100644 index 000000000..2c4ee7f29 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-overload-default-arg-ambiguous/caller.cpp @@ -0,0 +1,6 @@ +#include "service.h" + +void run() { + S s; + s.f(1); +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-overload-default-arg-ambiguous/service.cpp b/gitnexus/test/fixtures/lang-resolution/cpp-overload-default-arg-ambiguous/service.cpp new file mode 100644 index 000000000..cd6f080e5 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-overload-default-arg-ambiguous/service.cpp @@ -0,0 +1,4 @@ +#include "service.h" + +void S::f(int) {} +void S::f(int, int) {} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-overload-default-arg-ambiguous/service.h b/gitnexus/test/fixtures/lang-resolution/cpp-overload-default-arg-ambiguous/service.h new file mode 100644 index 000000000..66ad00371 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-overload-default-arg-ambiguous/service.h @@ -0,0 +1,7 @@ +#pragma once + +class S { +public: + void f(int); + void f(int, int = 0); +}; diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-overload-int-long/caller.cpp b/gitnexus/test/fixtures/lang-resolution/cpp-overload-int-long/caller.cpp new file mode 100644 index 000000000..89e62ead1 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-overload-int-long/caller.cpp @@ -0,0 +1,6 @@ +#include "service.h" + +void run() { + Service s; + s.process(42); +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-overload-int-long/service.cpp b/gitnexus/test/fixtures/lang-resolution/cpp-overload-int-long/service.cpp new file mode 100644 index 000000000..9bde80f6f --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-overload-int-long/service.cpp @@ -0,0 +1,4 @@ +#include "service.h" + +void Service::process(int x) {} +void Service::process(long x) {} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-overload-int-long/service.h b/gitnexus/test/fixtures/lang-resolution/cpp-overload-int-long/service.h new file mode 100644 index 000000000..1e4c5de07 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-overload-int-long/service.h @@ -0,0 +1,7 @@ +#pragma once + +class Service { +public: + void process(int x); + void process(long x); +}; diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-phase5-u1-u3-qualified-base-call/classes.h b/gitnexus/test/fixtures/lang-resolution/cpp-phase5-u1-u3-qualified-base-call/classes.h new file mode 100644 index 000000000..1d53b7dfa --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-phase5-u1-u3-qualified-base-call/classes.h @@ -0,0 +1,13 @@ +#pragma once + +template +struct Base { + void method(); +}; + +template +struct Derived : Base { + void g() { + Base::method(); + } +}; diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-phase5-u2-u3-adl-from-derived/audit.h b/gitnexus/test/fixtures/lang-resolution/cpp-phase5-u2-u3-adl-from-derived/audit.h new file mode 100644 index 000000000..2c9803a3d --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-phase5-u2-u3-adl-from-derived/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-phase5-u2-u3-adl-from-derived/base.h b/gitnexus/test/fixtures/lang-resolution/cpp-phase5-u2-u3-adl-from-derived/base.h new file mode 100644 index 000000000..ccbe5b670 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-phase5-u2-u3-adl-from-derived/base.h @@ -0,0 +1,8 @@ +#pragma once + +#include "audit.h" + +template +struct Base { + void record(audit::Event e); +}; diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-phase5-u2-u3-adl-from-derived/derived.h b/gitnexus/test/fixtures/lang-resolution/cpp-phase5-u2-u3-adl-from-derived/derived.h new file mode 100644 index 000000000..ca37c8109 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-phase5-u2-u3-adl-from-derived/derived.h @@ -0,0 +1,11 @@ +#pragma once + +#include "base.h" + +template +struct Derived : Base { + void g() { + audit::Event e; + record(e); + } +}; diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-phase5-u3-u5-inline-base/base.h b/gitnexus/test/fixtures/lang-resolution/cpp-phase5-u3-u5-inline-base/base.h new file mode 100644 index 000000000..e9711ff08 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-phase5-u3-u5-inline-base/base.h @@ -0,0 +1,10 @@ +#pragma once + +namespace outer { + inline namespace v1 { + template + struct Base { + void f(); + }; + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-phase5-u3-u5-inline-base/derived.h b/gitnexus/test/fixtures/lang-resolution/cpp-phase5-u3-u5-inline-base/derived.h new file mode 100644 index 000000000..b18febac8 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-phase5-u3-u5-inline-base/derived.h @@ -0,0 +1,10 @@ +#pragma once + +#include "base.h" + +template +struct Derived : outer::v1::Base { + void g() { + f(); + } +}; diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-two-phase-dependent-base/base.h b/gitnexus/test/fixtures/lang-resolution/cpp-two-phase-dependent-base/base.h new file mode 100644 index 000000000..1c7084ee6 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-two-phase-dependent-base/base.h @@ -0,0 +1,7 @@ +#pragma once + +template +struct Base { + void f(); + int i; +}; diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-two-phase-dependent-base/derived.h b/gitnexus/test/fixtures/lang-resolution/cpp-two-phase-dependent-base/derived.h new file mode 100644 index 000000000..fc66c6725 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-two-phase-dependent-base/derived.h @@ -0,0 +1,13 @@ +#pragma once + +#include "base.h" + +template +struct Derived : Base { + void g() { + f(); + } + int h() { + return i; + } +}; diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-two-phase-namespace-free-call-inside-template/base.h b/gitnexus/test/fixtures/lang-resolution/cpp-two-phase-namespace-free-call-inside-template/base.h new file mode 100644 index 000000000..2b7804ba4 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-two-phase-namespace-free-call-inside-template/base.h @@ -0,0 +1,6 @@ +#pragma once + +template +struct Base { + void unused(); +}; diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-two-phase-namespace-free-call-inside-template/derived.h b/gitnexus/test/fixtures/lang-resolution/cpp-two-phase-namespace-free-call-inside-template/derived.h new file mode 100644 index 000000000..ef57810fc --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-two-phase-namespace-free-call-inside-template/derived.h @@ -0,0 +1,11 @@ +#pragma once + +#include "base.h" +#include "helpers.h" + +template +struct D : Base { + void g() { + utils::ns_helper(); + } +}; diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-two-phase-namespace-free-call-inside-template/helpers.h b/gitnexus/test/fixtures/lang-resolution/cpp-two-phase-namespace-free-call-inside-template/helpers.h new file mode 100644 index 000000000..5e291aba6 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-two-phase-namespace-free-call-inside-template/helpers.h @@ -0,0 +1,5 @@ +#pragma once + +namespace utils { + void ns_helper(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-two-phase-non-dependent-base/concrete-base.h b/gitnexus/test/fixtures/lang-resolution/cpp-two-phase-non-dependent-base/concrete-base.h new file mode 100644 index 000000000..7b5d1a167 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-two-phase-non-dependent-base/concrete-base.h @@ -0,0 +1,5 @@ +#pragma once + +struct ConcreteBase { + void f(); +}; diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-two-phase-non-dependent-base/derived.h b/gitnexus/test/fixtures/lang-resolution/cpp-two-phase-non-dependent-base/derived.h new file mode 100644 index 000000000..e0db6269c --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-two-phase-non-dependent-base/derived.h @@ -0,0 +1,10 @@ +#pragma once + +#include "concrete-base.h" + +template +struct Derived : ConcreteBase { + void g() { + f(); + } +}; diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-two-phase-this-qualified/base.h b/gitnexus/test/fixtures/lang-resolution/cpp-two-phase-this-qualified/base.h new file mode 100644 index 000000000..1c7084ee6 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-two-phase-this-qualified/base.h @@ -0,0 +1,7 @@ +#pragma once + +template +struct Base { + void f(); + int i; +}; diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-two-phase-this-qualified/derived.h b/gitnexus/test/fixtures/lang-resolution/cpp-two-phase-this-qualified/derived.h new file mode 100644 index 000000000..5c13c1737 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-two-phase-this-qualified/derived.h @@ -0,0 +1,13 @@ +#pragma once + +#include "base.h" + +template +struct Derived : Base { + void g() { + this->f(); + } + int h() { + return this->i; + } +}; diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-using-namespace-conflict/a.h b/gitnexus/test/fixtures/lang-resolution/cpp-using-namespace-conflict/a.h new file mode 100644 index 000000000..c02e1e19c --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-using-namespace-conflict/a.h @@ -0,0 +1,5 @@ +#pragma once + +namespace a { + void foo(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-using-namespace-conflict/b.h b/gitnexus/test/fixtures/lang-resolution/cpp-using-namespace-conflict/b.h new file mode 100644 index 000000000..67b75dd79 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-using-namespace-conflict/b.h @@ -0,0 +1,5 @@ +#pragma once + +namespace b { + void foo(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-using-namespace-conflict/caller.cpp b/gitnexus/test/fixtures/lang-resolution/cpp-using-namespace-conflict/caller.cpp new file mode 100644 index 000000000..37270861c --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-using-namespace-conflict/caller.cpp @@ -0,0 +1,9 @@ +#include "a.h" +#include "b.h" + +using namespace a; +using namespace b; + +void run() { + foo(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-using-namespace-std-smoke/caller.cpp b/gitnexus/test/fixtures/lang-resolution/cpp-using-namespace-std-smoke/caller.cpp new file mode 100644 index 000000000..56737c424 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-using-namespace-std-smoke/caller.cpp @@ -0,0 +1,9 @@ +#include "std-shim.h" + +using namespace std; + +void project_helper(); + +void run() { + project_helper(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-using-namespace-std-smoke/helper.cpp b/gitnexus/test/fixtures/lang-resolution/cpp-using-namespace-std-smoke/helper.cpp new file mode 100644 index 000000000..010ffb083 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-using-namespace-std-smoke/helper.cpp @@ -0,0 +1 @@ +void project_helper() {} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-using-namespace-std-smoke/std-shim.h b/gitnexus/test/fixtures/lang-resolution/cpp-using-namespace-std-smoke/std-shim.h new file mode 100644 index 000000000..6055204c6 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-using-namespace-std-smoke/std-shim.h @@ -0,0 +1,13 @@ +#pragma once + +// Fixture-local std-shaped namespace. Captures the wildcard-leak shape +// without depending on real system-header modeling. The names mirror +// common STL identifiers (cout_write, println) so a regression that +// re-introduces unqualified std:: binding shows up in the assertions +// below — without us having to control whether GitNexus parses real +// system headers. + +namespace std { + void cout_write(); + void println(); +} diff --git a/gitnexus/test/integration/resolvers/cpp.test.ts b/gitnexus/test/integration/resolvers/cpp.test.ts index 0839d59f0..1f6135408 100644 --- a/gitnexus/test/integration/resolvers/cpp.test.ts +++ b/gitnexus/test/integration/resolvers/cpp.test.ts @@ -1,7 +1,7 @@ /** * C++: diamond inheritance + include-based imports + ambiguous #include disambiguation */ -import { describe, it, expect, beforeAll } from 'vitest'; +import { describe, expect, beforeAll } from 'vitest'; import path from 'path'; import { FIXTURES, @@ -11,9 +11,12 @@ import { getNodesByLabelFull, edgeSet, runPipelineFromRepo, + createResolverParityIt, type PipelineResult, } from './helpers.js'; +const it = createResolverParityIt('cpp'); + // --------------------------------------------------------------------------- // Heritage: diamond inheritance + include-based imports // --------------------------------------------------------------------------- @@ -937,10 +940,13 @@ describe('Write access tracking (C++)', () => { it('emits ACCESSES write edges for field assignments', () => { const accesses = getRelationships(result, 'ACCESSES'); const writes = accesses.filter((e) => e.rel.reason === 'write'); - expect(writes.length).toBe(2); - const fieldNames = writes.map((e) => e.target); - expect(fieldNames).toContain('name'); - expect(fieldNames).toContain('address'); + expect(writes.length).toBe(3); + // Per-field exact counts: both `user.name = ...` and `user.name += ...` + // must produce distinct edges (no dedup); single write to `address`. + const nameWrites = writes.filter((e) => e.target === 'name'); + expect(nameWrites.length).toBe(2); + const addrWrites = writes.filter((e) => e.target === 'address'); + expect(addrWrites.length).toBe(1); const sources = writes.map((e) => e.source); expect(sources).toContain('updateUser'); }); @@ -1582,3 +1588,598 @@ describe('C++ Derived : A, B — diamond inheritance via leftmost-base MRO (SM-1 expect(methodCall!.source).toBe('run'); }); }); + +// --------------------------------------------------------------------------- +// U1: `#include` must not leak class-owned methods as unqualified bindings +// --------------------------------------------------------------------------- + +describe('C++ include does not leak class methods', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'cpp-include-no-class-leak'), () => {}); + }, 60000); + + it('does NOT resolve unqualified save() to User::save via #include', () => { + const calls = getRelationships(result, 'CALLS'); + const leak = calls.filter((c) => c.source === 'run' && c.target === 'save'); + expect(leak.length).toBe(0); + }); + + it('preserves the file-level #include IMPORTS edge', () => { + const imports = getRelationships(result, 'IMPORTS'); + expect(imports.length).toBe(1); + expect(imports[0].targetFilePath).toBe('user.h'); + }); +}); + +// --------------------------------------------------------------------------- +// U1: `#include` must not leak namespace-nested symbols as unqualified bindings +// --------------------------------------------------------------------------- + +describe('C++ include does not leak namespace members', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'cpp-include-no-namespace-leak'), + () => {}, + ); + }, 60000); + + it('does NOT resolve unqualified foo() to ns::foo via #include', () => { + const calls = getRelationships(result, 'CALLS'); + const leak = calls.filter((c) => c.source === 'run' && c.target === 'foo'); + expect(leak.length).toBe(0); + }); + + it('preserves the file-level #include IMPORTS edge', () => { + const imports = getRelationships(result, 'IMPORTS'); + expect(imports.length).toBe(1); + expect(imports[0].targetFilePath).toBe('lib.h'); + }); +}); + +// --------------------------------------------------------------------------- +// U1: anonymous-namespace symbols remain visible within their declaring TU +// (positive companion to the cross-file exclusion test below) +// --------------------------------------------------------------------------- + +describe('C++ anonymous namespace symbols visible in same TU', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'cpp-anon-ns-same-file-visible'), + () => {}, + ); + }, 60000); + + it('resolves run() -> w() within the same TU', () => { + const calls = getRelationships(result, 'CALLS'); + const wCalls = calls.filter((c) => c.source === 'run' && c.target === 'w'); + expect(wCalls.length).toBe(1); + }); +}); + +// --------------------------------------------------------------------------- +// U2: integer-width overload ambiguity suppresses CALLS edge entirely +// (PR #1520 review follow-up plan U2; Claude review Finding 5) +// --------------------------------------------------------------------------- + +describe('C++ ambiguous integer-width overloads', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'cpp-overload-int-long'), () => {}); + }, 60000); + + it('emits zero CALLS edges when process(int)/process(long) collide after normalization', () => { + const calls = getRelationships(result, 'CALLS'); + const processCalls = calls.filter((c) => c.source === 'run' && c.target === 'process'); + // Exact .toBe(0): any non-zero count is a regression. count=1 = arbitrary + // pick (the bug U2 fixes); count=2+ would require an ambiguous-edge model + // GitNexus does not have. The resolver must suppress entirely. + expect(processCalls.length).toBe(0); + }); +}); + +// --------------------------------------------------------------------------- +// U3: anonymous-namespace symbols MUST NOT leak across translation units +// (full-pipeline integration test; unit-level coverage exists separately) +// PR #1520 review follow-up plan U3 / Claude review Finding 7 +// --------------------------------------------------------------------------- + +describe('C++ anonymous namespace cross-file exclusion (integration)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'cpp-anon-ns-cross-file'), () => {}); + }, 60000); + + it('caller.cpp::run -> worker does NOT target helper.cpp anonymous-namespace worker', () => { + const calls = getRelationships(result, 'CALLS'); + const crossFileLeak = calls.filter( + (c) => + c.source === 'run' && c.target === 'worker' && c.targetFilePath?.includes('helper.cpp'), + ); + expect(crossFileLeak.length).toBe(0); + }); + + it('helper.cpp::helper_entry still resolves its OWN anonymous-namespace worker (positive guard)', () => { + const calls = getRelationships(result, 'CALLS'); + const sameFileResolve = calls.filter( + (c) => + c.source === 'helper_entry' && + c.target === 'worker' && + c.targetFilePath?.includes('helper.cpp'), + ); + // Pairs with the negative test above so a "no edges at all" regression + // doesn't make the cross-file leak check pass vacuously. + expect(sameFileResolve.length).toBe(1); + }); +}); + +// State-isolation guard: re-run the same fixture and assert identical +// results. Proves `clearFileLocalNames()` (called from the cpp resolver's +// `loadResolutionConfig`) is exercised by `runPipelineFromRepo` and +// that module-level `fileLocalNames` state doesn't bleed across runs. +describe('C++ anonymous namespace state-isolation guard', () => { + it('second run of the same fixture produces identical worker-cross-file edge count', async () => { + const fixture = path.join(FIXTURES, 'cpp-anon-ns-cross-file'); + const r1 = await runPipelineFromRepo(fixture, () => {}); + const r2 = await runPipelineFromRepo(fixture, () => {}); + const countLeak = (r: PipelineResult): number => + getRelationships(r, 'CALLS').filter( + (c) => + c.source === 'run' && c.target === 'worker' && c.targetFilePath?.includes('helper.cpp'), + ).length; + expect(countLeak(r1)).toBe(0); + expect(countLeak(r2)).toBe(0); + }, 120000); +}); + +// --------------------------------------------------------------------------- +// U4: `using namespace` with conflicting names from two namespaces +// The resolver MUST emit zero CALLS edges — emitting one is arbitrary +// pick; emitting two requires an ambiguous-target edge model GitNexus +// does not have. +// Depends on U1 (without scope-aware filtering, `a::foo` and `b::foo` +// would already be in the importer's wildcard binding set as simple +// `foo` and this test would pass for the wrong reason). +// PR #1520 review follow-up plan U4 / Claude review Finding 7 +// --------------------------------------------------------------------------- + +describe('C++ using-namespace with conflicting names', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'cpp-using-namespace-conflict'), + () => {}, + ); + }, 60000); + + it('emits zero CALLS edges for ambiguous foo() bound via two using-namespace declarations', () => { + const calls = getRelationships(result, 'CALLS'); + const fooCalls = calls.filter((c) => c.source === 'run' && c.target === 'foo'); + expect(fooCalls.length).toBe(0); + }); +}); + +// --------------------------------------------------------------------------- +// U5: `using namespace std` MUST NOT leak shim STL symbols into unqualified +// bindings. Uses a fixture-local `namespace std { ... }` shim rather than +// real — captures the wildcard-leak shape deterministically +// without depending on system-header modeling stability. +// PR #1520 review follow-up plan U5 / Claude review Finding 7 +// --------------------------------------------------------------------------- + +describe('C++ using-namespace std smoke test', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'cpp-using-namespace-std-smoke'), + () => {}, + ); + }, 60000); + + it('resolves the project call (positive guard against vacuous pass)', () => { + const calls = getRelationships(result, 'CALLS'); + const projectCalls = calls.filter((c) => c.source === 'run' && c.target === 'project_helper'); + expect(projectCalls.length).toBe(1); + }); + + it('does NOT leak unqualified bindings for shim STL symbols', () => { + const calls = getRelationships(result, 'CALLS'); + const stlLeaks = calls.filter( + (c) => c.source === 'run' && (c.target === 'cout_write' || c.target === 'println'), + ); + expect(stlLeaks.length).toBe(0); + }); + + it('emits no CALLS or ACCESSES edges from run() into std-shim.h', () => { + const calls = getRelationships(result, 'CALLS'); + const accesses = getRelationships(result, 'ACCESSES'); + const intoShim = [...calls, ...accesses].filter( + (e) => e.source === 'run' && e.targetFilePath?.includes('std-shim.h'), + ); + expect(intoShim.length).toBe(0); + }); +}); + +// --------------------------------------------------------------------------- +// U1 (follow-up plan 2026-05-13-001): namespace-qualified or class-qualified +// calls from outside that class MUST NOT be classified as super-receiver calls. +// The `isSuperReceiverInContext` hook consults the caller's MRO. +// --------------------------------------------------------------------------- + +describe('C++ namespace-qualified call is not a super receiver', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'cpp-namespace-qualified-not-super'), + () => {}, + ); + }, 60000); + + it('resolves Singleton::getInstance() from a free function (not as super call)', () => { + const calls = getRelationships(result, 'CALLS'); + const getInstanceCalls = calls.filter((c) => c.source === 'run' && c.target === 'getInstance'); + // Exactly 1: routed through the normal qualified-call path, NOT the super + // branch. Before the U1 fix the regex `/^[A-Z]\w*::/` matched Singleton::, + // entered the super branch with no enclosing class, and dropped the edge. + expect(getInstanceCalls.length).toBe(1); + expect(getInstanceCalls[0].targetFilePath).toContain('singleton.h'); + }); +}); + +// --------------------------------------------------------------------------- +// U4 (follow-up plan 2026-05-13-001): default-argument overload ambiguity. +// `void f(int); void f(int, int = 0); f(1);` is ambiguous per ISO C++. The +// OVERLOAD_AMBIGUOUS sentinel from plan 2026-05-12-002 U2 should detect +// this case via isOverloadAmbiguousAfterNormalization. +// --------------------------------------------------------------------------- + +describe('C++ default-argument overload ambiguity', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'cpp-overload-default-arg-ambiguous'), + () => {}, + ); + }, 60000); + + it('s.f(1) emits zero CALLS edges when f(int) and f(int, int=0) both match', () => { + const calls = getRelationships(result, 'CALLS'); + const fCalls = calls.filter((c) => c.source === 'run' && c.target === 'f'); + // Exact .toBe(0): count=1 means arbitrary pick (the bug); count=2+ would + // require an ambiguous-target edge model GitNexus does not have. The + // resolver must suppress entirely. Standard C++ rejects the call as + // ambiguous (GCC/Clang both diagnose). + expect(fCalls.length).toBe(0); + }); +}); + +// --------------------------------------------------------------------------- +// U3 (follow-up plan 2026-05-13-001): two-phase template lookup. +// Inside a class template body, unqualified calls MUST NOT bind to members +// of a dependent base class. Only `this->name()` or `Base::name()` forms +// should resolve. +// --------------------------------------------------------------------------- + +describe('C++ two-phase template lookup — dependent base suppression', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'cpp-two-phase-dependent-base'), + () => {}, + ); + }, 60000); + + it('Derived::g() -> f() does NOT bind to Base::f (dependent base)', () => { + const calls = getRelationships(result, 'CALLS'); + const leaks = calls.filter((c) => c.source === 'g' && c.target === 'f'); + expect(leaks.length).toBe(0); + }); + + it('Derived::h() -> i does NOT bind to Base::i (dependent base)', () => { + const accesses = getRelationships(result, 'ACCESSES'); + const leaks = accesses.filter((c) => c.source === 'h' && c.target === 'i'); + expect(leaks.length).toBe(0); + }); +}); + +// NOTE: positive guards (this->f() resolves, non-dependent-base unqualified +// f() resolves, namespace-qualified utils::ns_helper() resolves) inside +// template bodies are documented gaps in C++ template-context resolution +// independent of U3's dependent-base suppression. The U3 core asserts only +// the negative behavior (dependent-base members are NOT bound by unqualified +// calls); the positive cases would require additional `this` type-binding +// and template-body member-lookup work tracked separately. See plan +// 2026-05-13-001 follow-ups. + +// --------------------------------------------------------------------------- +// U2 (follow-up plan 2026-05-13-001): argument-dependent (Koenig) lookup. +// Free-function calls with class-typed arguments must consider candidates +// declared in the argument's enclosing namespace (associated namespace). +// V1 boundary: only direct enclosing-namespace closure for value class- +// typed args; pointer / reference / template-spec args excluded. +// --------------------------------------------------------------------------- + +describe('C++ ADL — basic associated-namespace closure', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'cpp-adl-basic'), () => {}); + }, 60000); + + it('record(e) where e is audit::Event resolves to audit::record via ADL', () => { + const calls = getRelationships(result, 'CALLS'); + const recordCalls = calls.filter((c) => c.source === 'run' && c.target === 'record'); + // Exactly 1: ordinary lookup is empty (no `using` statement, no local + // declaration), ADL surfaces audit::record because audit::Event's + // associated namespace is `audit`. The CALLS edge should target the + // declaration in audit.h. + expect(recordCalls.length).toBe(1); + expect(recordCalls[0].targetFilePath).toContain('audit.h'); + }); +}); + +describe('C++ ADL — parenthesized name suppresses ADL', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'cpp-adl-suppressed-parens'), () => {}); + }, 60000); + + it('(record)(e) emits zero CALLS edges — ADL is suppressed by parentheses', () => { + const calls = getRelationships(result, 'CALLS'); + const recordCalls = calls.filter((c) => c.source === 'run' && c.target === 'record'); + // Exact .toBe(0): ISO C++ [basic.lookup.argdep]/3.1 specifies that the + // parenthesized form `(f)(x)` forces ordinary lookup only — ADL must + // NOT fire. Without ordinary-lookup candidates (no `using`, no local + // declaration), the call goes unresolved. + expect(recordCalls.length).toBe(0); + }); +}); + +describe('C++ ADL — pointer-arg V1 boundary', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'cpp-adl-pointer-arg-boundary'), + () => {}, + ); + }, 60000); + + it('record(p) where p is audit::Event* emits zero CALLS — V1 ADL excludes pointer args', () => { + const calls = getRelationships(result, 'CALLS'); + const recordCalls = calls.filter((c) => c.source === 'run' && c.target === 'record'); + // Exact .toBe(0): V1 ADL covers only directly-named class-type values + // (per plan 2026-05-13-001 R4). Pointer-typed args fall under + // associated-entity closure rules deferred to V2. This fixture locks + // the boundary in CI so the implementer cannot accidentally extend + // V1 to include pointer types. Real ISO C++ would resolve via V2 + // closure; matching that requires the V2 follow-up plan. + expect(recordCalls.length).toBe(0); + }); +}); + +describe('C++ ADL — int/long-collision overloads suppress via OVERLOAD_AMBIGUOUS', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'cpp-adl-ambiguous'), () => {}); + }, 60000); + + it('process(t, 42) emits zero CALLS edges when ADL surfaces process(Token,int)/process(Token,long) (collide after C++ int normalization)', () => { + const calls = getRelationships(result, 'CALLS'); + const processCalls = calls.filter((c) => c.source === 'run' && c.target === 'process'); + // Exact .toBe(0): both alpha::process(Token, int) and + // alpha::process(Token, long) are surfaced via ADL (alpha::Token's + // associated namespace). C++ arity-metadata normalizes int/long to + // '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. + // 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); + }); +}); + +// --------------------------------------------------------------------------- +// U5 (follow-up plan 2026-05-13-001): inline namespace transitive walking. +// `inline namespace v1 { ... }` makes its members reachable through the +// enclosing namespace's qualified lookup as if declared directly there +// (ISO C++ `[namespace.def]/p4`). Adds a C++-specific +// `resolveQualifiedReceiverMember` hook on the ScopeResolver contract. +// --------------------------------------------------------------------------- + +describe('C++ inline namespace — outer::foo resolves to inline child', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'cpp-inline-namespace-unqualified'), + () => {}, + ); + }, 60000); + + it('outer::foo() resolves to outer::v1::foo via inline-namespace transitive walking', () => { + const calls = getRelationships(result, 'CALLS'); + const fooCalls = calls.filter((c) => c.source === 'run' && c.target === 'foo'); + // Exactly 1: the inline-namespace exemption lets `outer::foo()` reach + // the declaration in `outer::v1::foo()`. Without U5 the call would be + // unresolved (count = 0). + expect(fooCalls.length).toBe(1); + expect(fooCalls[0].targetFilePath).toContain('lib.h'); + }); +}); + +describe('C++ inline namespace — versioned (v1 inline, v0 not)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'cpp-inline-namespace-versioned'), + () => {}, + ); + }, 60000); + + it('outer::foo() resolves to outer::v1::foo (inline child), NOT outer::v0::foo', () => { + const calls = getRelationships(result, 'CALLS'); + const fooCalls = calls.filter((c) => c.source === 'run' && c.target === 'foo'); + // Exactly 1: only inline-namespace children are reachable through the + // enclosing namespace's qualified lookup. `v0` is NOT inline so its + // `foo` is NOT visible as `outer::foo`. + expect(fooCalls.length).toBe(1); + expect(fooCalls[0].targetFilePath).toContain('lib.h'); + }); +}); + +describe('C++ inline namespace — nested (STL __1-style)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'cpp-inline-namespace-nested'), + () => {}, + ); + }, 60000); + + it('outer::foo() resolves through two transitive inline namespaces (v1 then experimental)', () => { + const calls = getRelationships(result, 'CALLS'); + const fooCalls = calls.filter((c) => c.source === 'run' && c.target === 'foo'); + // Exactly 1: the resolver descends inline namespaces depth-first, so + // `outer::foo` reaches `outer::v1::experimental::foo` through two + // transitive inline-namespace hops. Mirrors libc++ `std::__1::vector` + // / libstdc++ `std::__cxx11` qualified-call shape. + expect(fooCalls.length).toBe(1); + expect(fooCalls[0].targetFilePath).toContain('lib.h'); + }); +}); + +describe('C++ inline namespace — ADL participation', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'cpp-inline-namespace-adl-participation'), + () => {}, + ); + }, 60000); + + it('ADL surfaces audit::v1::record through inline-namespace transitive walking', () => { + const calls = getRelationships(result, 'CALLS'); + const recordCalls = calls.filter((c) => c.source === 'run' && c.target === 'record'); + // Exactly 1: `audit::Event e;` resolves Event's enclosing namespace + // to `audit` (the inline child `v1` is transparent — see U2's + // computeNamespaceQName walking through the inline scope). ADL then + // surfaces every callable named `record` in any namespace scope + // matching qname 'audit' across files. Since inline namespaces are + // exempted from the non-globally-visible filter, the `record` + // declared inside `inline namespace v1` is reachable. count=0 + // would be the bug — ADL failing to walk inline children. + 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), +// U3 (two-phase lookup), and U5 (inline namespaces). +// --------------------------------------------------------------------------- + +describe('C++ Phase 5 U1×U3 — qualified Base::method() inside template body (no false positives)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'cpp-phase5-u1-u3-qualified-base-call'), + () => {}, + ); + }, 60000); + + it('Base::method() does NOT mis-route to a class method outside the MRO', () => { + const calls = getRelationships(result, 'CALLS'); + const methodCalls = calls.filter((c) => c.source === 'g' && c.target === 'method'); + // V1 documented gap: cross-file (and same-file) template-class + // inheritance is not captured as an EXTENDS edge by the legacy DAG + // (the cpp captures.ts has no `base_class_clause` heritage emitter + // for template_type bases). Without an EXTENDS edge, MRO is empty + // and the U1 super branch can't dispatch. Result: 0 CALLS edges. + // + // This Phase 5 cross-unit composition test locks in that the + // template-arg-stripping U1 logic produces NO false positives — + // `Base` correctly classifies as a super-receiver candidate but + // (due to empty MRO) doesn't accidentally route to an unrelated + // method named `method` via any other case. count > 0 here would + // indicate the U1 stripped lookup mis-resolved across cases. + expect(methodCalls.length).toBe(0); + }); +}); + +describe('C++ Phase 5 U2×U3 — ADL routes around dependent-base shadow', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'cpp-phase5-u2-u3-adl-from-derived'), + () => {}, + ); + }, 60000); + + it('record(e) inside Derived::g() resolves via ADL to audit::record (not Base::record)', () => { + const calls = getRelationships(result, 'CALLS'); + const recordCalls = calls.filter((c) => c.source === 'g' && c.target === 'record'); + // Exactly 1: Base::record is class-owned so the global free-call + // fallback's `isFileLocalDef` blocks it (and U3's two-phase + // suppression also fires for unqualified calls inside template + // body when the candidate is a dependent-base member). ADL then + // surfaces audit::record via `audit::Event`'s associated namespace. + // The two-phase + ADL composition leaves exactly one CALLS edge — + // to audit::record in audit.h. + expect(recordCalls.length).toBe(1); + expect(recordCalls[0].targetFilePath).toContain('audit.h'); + }); + + it('record(e) does NOT bind to Base::record (class-owned dependent-base member)', () => { + const calls = getRelationships(result, 'CALLS'); + const baseRecordLeaks = calls.filter( + (c) => c.source === 'g' && c.target === 'record' && c.targetFilePath?.includes('base.h'), + ); + expect(baseRecordLeaks.length).toBe(0); + }); +}); + +describe('C++ Phase 5 U3×U5 — template Derived : outer::v1::Base (inline)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'cpp-phase5-u3-u5-inline-base'), + () => {}, + ); + }, 60000); + + it('unqualified f() inside Derived::g() does NOT bind to outer::v1::Base::f (dependent base across inline namespace)', () => { + const calls = getRelationships(result, 'CALLS'); + const fLeaks = calls.filter((c) => c.source === 'g' && c.target === 'f'); + // Exact .toBe(0): same suppression rationale as the plain U3 fixture + // (`cpp-two-phase-dependent-base`) — `f()` is unqualified, Base is a + // dependent base, and Base::f is class-owned so the global free-call + // fallback's `isFileLocalDef` blocks it. The inline-namespace wrapper + // doesn't change the suppression behavior: dependent-base detection + // walks the heritage's simple name (`Base`) regardless of the + // qualifying namespace path. + expect(fLeaks.length).toBe(0); + }); +}); diff --git a/gitnexus/test/integration/resolvers/helpers.ts b/gitnexus/test/integration/resolvers/helpers.ts index 571f2121f..7cbfaaf6c 100644 --- a/gitnexus/test/integration/resolvers/helpers.ts +++ b/gitnexus/test/integration/resolvers/helpers.ts @@ -90,6 +90,74 @@ const LEGACY_RESOLVER_PARITY_EXPECTED_FAILURES: Readonly([ + // The legacy DAG path has no scope-aware filtering on the global + // free-call fallback, so `#include`d headers still leak class + // methods (`User::save`) and namespace members (`ns::foo`) as + // resolution targets for unqualified calls. The scope-resolver + // path filters via `populateCppNonGloballyVisible` + + // `isFileLocalDef`. Scope-resolver-only correctness win + // (PR #1520 review follow-up plan U1); backporting to legacy is + // out of scope. + 'does NOT resolve unqualified save() to User::save via #include', + 'does NOT resolve unqualified foo() to ns::foo via #include', + // The legacy DAG path lacks the OVERLOAD_AMBIGUOUS suppression + // wired through `pickOverload` + `isOverloadAmbiguousAfterNormalization`, + // so it arbitrarily picks the first overload when `f(int)` and + // `f(long)` collide after C++ integer-width normalization. Scope- + // resolver-only correctness win (PR #1520 review follow-up plan U2 / + // Claude review Finding 5); backporting to legacy is out of scope. + 'emits zero CALLS edges when process(int)/process(long) collide after normalization', + // The legacy DAG path resolves `using namespace a; using namespace b; foo()` + // by walking the workspace registry by simple name and binding to + // the first match — same shape as the integer-width collision, just + // with namespace-resolution as the ambiguity source. Scope-resolver- + // only correctness win (PR #1520 review follow-up plan U4 / Claude + // review Finding 7); backporting to legacy is out of scope. + 'emits zero CALLS edges for ambiguous foo() bound via two using-namespace declarations', + // The legacy DAG path lacks two-phase template lookup. Unqualified + // calls inside a class template body bind to dependent-base members + // there, producing CALLS edges the compiler would reject (ISO C++ + // two-phase name lookup). Scope-resolver-only correctness win + // (PR #1520 review follow-up plan 2026-05-13-001 U3); backporting + // is out of scope. + 'Derived::g() -> f() does NOT bind to Base::f (dependent base)', + // The legacy DAG path has no V1/V2 ADL boundary — pointer-typed + // arguments resolve via the workspace-wide simple-name walk. The + // scope-resolver V1 ADL pass excludes pointer args (closure rules + // deferred to V2) per plan 2026-05-13-001 U2 / R4. Scope-resolver- + // only correctness win; backporting is out of scope. + 'record(p) where p is audit::Event* emits zero CALLS — V1 ADL excludes pointer args', + // The legacy DAG path has no ADL_AMBIGUOUS suppression sentinel. + // 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 + // 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)', + // 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- + // primary path resolves `outer::foo()` to v1 via the inline + // exemption; legacy can't see EITHER and emits zero edges. The + // unqualified / nested fixtures coincidentally resolve in legacy + // because their global free-call fallback picks the unique simple- + // name match; the versioned fixture has two `foo`s and legacy can't + // disambiguate. Scope-resolver-only correctness win (PR #1520 + // review follow-up plan 2026-05-13-001 U5); backporting is out of + // scope. + 'outer::foo() resolves to outer::v1::foo (inline child), NOT outer::v0::foo', + // Phase 5 cross-unit composition tests assert no false positives + // for compositions where the legacy DAG over-resolves. The legacy + // path has no template-arg-stripping qualified-receiver logic and + // no two-phase dependent-base suppression, so it produces CALLS + // edges where the registry-primary path correctly suppresses. + // Scope-resolver-only correctness wins (PR #1520 review follow-up + // plan 2026-05-13-001 Phase 5); backporting is out of scope. + 'Base::method() does NOT mis-route to a class method outside the MRO', + 'unqualified f() inside Derived::g() does NOT bind to outer::v1::Base::f (dependent base across inline namespace)', + ]), }; type ResolverParityEnv = Readonly>; diff --git a/gitnexus/test/unit/registry-primary-flag.test.ts b/gitnexus/test/unit/registry-primary-flag.test.ts index 864754b7f..e800bd14a 100644 --- a/gitnexus/test/unit/registry-primary-flag.test.ts +++ b/gitnexus/test/unit/registry-primary-flag.test.ts @@ -127,8 +127,10 @@ describe('isRegistryPrimary', () => { it('handles the CPlusPlus → REGISTRY_PRIMARY_CPP mapping correctly', () => { process.env['REGISTRY_PRIMARY_CPP'] = 'true'; expect(isRegistryPrimary(SupportedLanguages.CPlusPlus)).toBe(true); - // Negative: the TS-key-style name is NOT read. - delete process.env['REGISTRY_PRIMARY_CPP']; + // Negative: the TS-key-style name is NOT read. CPlusPlus is now in + // MIGRATED_LANGUAGES, so we must explicitly opt it out via the + // canonical env var to verify the wrong-name var has no effect. + process.env['REGISTRY_PRIMARY_CPP'] = 'false'; process.env['REGISTRY_PRIMARY_CPLUSPLUS'] = 'true'; expect(isRegistryPrimary(SupportedLanguages.CPlusPlus)).toBe(false); }); @@ -151,8 +153,8 @@ describe('primaryLanguages', () => { // testing explicit env overrides. Java (unmigrated) opts in. // Opt out every member of MIGRATED_LANGUAGES dynamically so this test // does not have to be updated each time a new language ships its - // Ring 3 migration (PHP joined the set in commit 69786b16; future - // Ring 3 additions land here without test churn). + // Ring 3 migration (C++ and PHP joined the set in their respective + // Ring 3 migrations; future Ring 3 additions land here without test churn). for (const lang of MIGRATED_LANGUAGES) { process.env[envVarNameFor(lang)] = 'false'; } @@ -161,6 +163,7 @@ describe('primaryLanguages', () => { expect(enabled.has(SupportedLanguages.Python)).toBe(false); expect(enabled.has(SupportedLanguages.CSharp)).toBe(false); expect(enabled.has(SupportedLanguages.Go)).toBe(false); + expect(enabled.has(SupportedLanguages.CPlusPlus)).toBe(false); expect(enabled.has(SupportedLanguages.PHP)).toBe(false); expect(enabled.has(SupportedLanguages.Java)).toBe(true); // Only Java is on: migrated defaults overridden off, Java explicitly on. diff --git a/gitnexus/test/unit/scope-resolution/cpp/cpp-arity.test.ts b/gitnexus/test/unit/scope-resolution/cpp/cpp-arity.test.ts new file mode 100644 index 000000000..a89a3167d --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/cpp/cpp-arity.test.ts @@ -0,0 +1,168 @@ +/** + * Unit tests for C++ arity compatibility and metadata. + */ + +import { describe, it, expect } from 'vitest'; +import { cppArityCompatibility } from '../../../../src/core/ingestion/languages/cpp/arity.js'; +import { + computeCppDeclarationArity, + computeCppCallArity, +} from '../../../../src/core/ingestion/languages/cpp/arity-metadata.js'; +import { getCppParser } from '../../../../src/core/ingestion/languages/cpp/query.js'; +import type { SyntaxNode } from '../../../../src/core/ingestion/utils/ast-helpers.js'; +import type { SymbolDefinition, Callsite } from 'gitnexus-shared'; + +function parseFuncDef(src: string): SyntaxNode | null { + const tree = getCppParser().parse(src); + for (let i = 0; i < tree.rootNode.namedChildCount; i++) { + const child = tree.rootNode.namedChild(i); + if (child?.type === 'function_definition') return child as SyntaxNode; + } + return null; +} + +function parseCallExpr(src: string): SyntaxNode | null { + const tree = getCppParser().parse(src); + const walk = (node: SyntaxNode): SyntaxNode | null => { + if (node.type === 'call_expression') return node; + for (let i = 0; i < node.namedChildCount; i++) { + const found = walk(node.namedChild(i) as SyntaxNode); + if (found) return found; + } + return null; + }; + return walk(tree.rootNode as SyntaxNode); +} + +function mkDef(overrides: Partial = {}): SymbolDefinition { + return { + nodeId: 'test-def', + qualifiedName: 'test', + filePath: 'test.cpp', + type: 'Function', + ...overrides, + } as SymbolDefinition; +} + +function mkCallsite(arity: number): Callsite { + return { arity } as Callsite; +} + +// ── Declaration arity ─────────────────────────────────────────────────────── + +describe('computeCppDeclarationArity', () => { + it('computes arity for zero-parameter function', () => { + const node = parseFuncDef('void foo() {}'); + expect(node).not.toBeNull(); + const arity = computeCppDeclarationArity(node!); + expect(arity.parameterCount).toBe(0); + expect(arity.requiredParameterCount).toBe(0); + }); + + it('computes arity for (void) parameter', () => { + const node = parseFuncDef('void foo(void) {}'); + expect(node).not.toBeNull(); + const arity = computeCppDeclarationArity(node!); + expect(arity.parameterCount).toBe(0); + expect(arity.requiredParameterCount).toBe(0); + }); + + it('computes arity for multiple parameters', () => { + const node = parseFuncDef('void foo(int x, int y, int z) {}'); + expect(node).not.toBeNull(); + const arity = computeCppDeclarationArity(node!); + expect(arity.parameterCount).toBe(3); + expect(arity.requiredParameterCount).toBe(3); + }); + + it('computes arity with default parameters', () => { + const node = parseFuncDef('void foo(int x, int y = 5, int z = 10) {}'); + expect(node).not.toBeNull(); + const arity = computeCppDeclarationArity(node!); + expect(arity.parameterCount).toBe(3); + expect(arity.requiredParameterCount).toBe(1); + }); + + it('detects variadic function', () => { + const node = parseFuncDef('void foo(int x, ...) {}'); + expect(node).not.toBeNull(); + const arity = computeCppDeclarationArity(node!); + expect(arity.parameterCount).toBeUndefined(); // variadic → undefined max + expect(arity.requiredParameterCount).toBe(1); + expect(arity.parameterTypes).toContain('...'); + }); + + it('handles pointer return type', () => { + const node = parseFuncDef('int* create(int size) {}'); + expect(node).not.toBeNull(); + const arity = computeCppDeclarationArity(node!); + expect(arity.parameterCount).toBe(1); + }); +}); + +// ── Call-site arity ───────────────────────────────────────────────────────── + +describe('computeCppCallArity', () => { + it('computes arity for no-argument call', () => { + const node = parseCallExpr('void f() { foo(); }'); + expect(node).not.toBeNull(); + expect(computeCppCallArity(node!)).toBe(0); + }); + + it('computes arity for multi-argument call', () => { + const node = parseCallExpr('void f() { foo(1, 2, 3); }'); + expect(node).not.toBeNull(); + expect(computeCppCallArity(node!)).toBe(3); + }); + + it('computes arity for single-argument call', () => { + const node = parseCallExpr('void f() { foo(42); }'); + expect(node).not.toBeNull(); + expect(computeCppCallArity(node!)).toBe(1); + }); +}); + +// ── Arity compatibility ───────────────────────────────────────────────────── + +describe('cppArityCompatibility', () => { + it('returns compatible for exact match', () => { + const def = mkDef({ parameterCount: 2, requiredParameterCount: 2 }); + expect(cppArityCompatibility(def, mkCallsite(2))).toBe('compatible'); + }); + + it('returns compatible when call uses default params', () => { + const def = mkDef({ parameterCount: 3, requiredParameterCount: 1 }); + expect(cppArityCompatibility(def, mkCallsite(1))).toBe('compatible'); + expect(cppArityCompatibility(def, mkCallsite(2))).toBe('compatible'); + expect(cppArityCompatibility(def, mkCallsite(3))).toBe('compatible'); + }); + + it('returns incompatible for too few args', () => { + const def = mkDef({ parameterCount: 3, requiredParameterCount: 2 }); + expect(cppArityCompatibility(def, mkCallsite(1))).toBe('incompatible'); + }); + + it('returns incompatible for too many args (non-variadic)', () => { + const def = mkDef({ parameterCount: 2, requiredParameterCount: 2 }); + expect(cppArityCompatibility(def, mkCallsite(5))).toBe('incompatible'); + }); + + it('returns compatible for variadic with extra args', () => { + const def = mkDef({ + parameterCount: undefined, + requiredParameterCount: 1, + parameterTypes: ['int', '...'], + }); + expect(cppArityCompatibility(def, mkCallsite(5))).toBe('compatible'); + }); + + it('returns unknown when no metadata', () => { + const def = mkDef({}); + expect(cppArityCompatibility(def, mkCallsite(2))).toBe('unknown'); + }); + + it('returns unknown for negative arity', () => { + const def = mkDef({ parameterCount: 2, requiredParameterCount: 2 }); + expect(cppArityCompatibility(def, mkCallsite(-1))).toBe('unknown'); + }); +}); diff --git a/gitnexus/test/unit/scope-resolution/cpp/cpp-captures.test.ts b/gitnexus/test/unit/scope-resolution/cpp/cpp-captures.test.ts new file mode 100644 index 000000000..8e000261c --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/cpp/cpp-captures.test.ts @@ -0,0 +1,426 @@ +/** + * Unit tests for C++ scope query + captures orchestrator. + * + * Pins the capture-tag vocabulary + range shape for every construct + * the scope-resolution pipeline reads. Runs against tree-sitter-cpp. + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { emitCppScopeCaptures } from '../../../../src/core/ingestion/languages/cpp/captures.js'; +import { + clearFileLocalNames, + isFileLocal, +} from '../../../../src/core/ingestion/languages/cpp/file-local-linkage.js'; + +function tagsFor(src: string, filePath = 'test.cpp'): string[][] { + const matches = emitCppScopeCaptures(src, filePath); + return matches.map((m) => Object.keys(m).sort()); +} + +function findMatch(src: string, predicate: (tags: string[]) => boolean, filePath = 'test.cpp') { + const matches = emitCppScopeCaptures(src, filePath); + return matches.find((m) => predicate(Object.keys(m))); +} + +function allMatches(src: string, predicate: (tags: string[]) => boolean, filePath = 'test.cpp') { + const matches = emitCppScopeCaptures(src, filePath); + return matches.filter((m) => predicate(Object.keys(m))); +} + +// ── Scopes ────────────────────────────────────────────────────────────────── + +describe('emitCppScopeCaptures — scopes', () => { + it('captures translation_unit as @scope.module', () => { + const all = tagsFor('int x = 1;'); + expect(all.some((t) => t.includes('@scope.module'))).toBe(true); + }); + + it('captures class_specifier as @scope.class', () => { + const all = tagsFor('class Foo { int x; };'); + expect(all.some((t) => t.includes('@scope.class'))).toBe(true); + }); + + it('captures struct_specifier as @scope.class', () => { + const all = tagsFor('struct Point { int x; int y; };'); + expect(all.some((t) => t.includes('@scope.class'))).toBe(true); + }); + + it('captures namespace_definition as @scope.namespace', () => { + const all = tagsFor('namespace foo { int x; }'); + expect(all.some((t) => t.includes('@scope.namespace'))).toBe(true); + }); + + it('captures function_definition as @scope.function', () => { + const all = tagsFor('void foo() { }'); + expect(all.some((t) => t.includes('@scope.function'))).toBe(true); + }); + + it('captures lambda_expression as @scope.function', () => { + const all = tagsFor('auto f = [](int x) { return x; };'); + expect(all.some((t) => t.includes('@scope.function'))).toBe(true); + }); + + it('captures block-level scopes (if, for, while, do, switch, case, try, catch)', () => { + const src = ` + void f() { + if (true) { } + for (int i = 0; i < 10; i++) { } + while (true) { } + do { } while (false); + switch (0) { case 0: break; } + try { } catch (...) { } + } + `; + const all = tagsFor(src); + const blocks = all.filter((t) => t.includes('@scope.block')); + expect(blocks.length).toBeGreaterThanOrEqual(6); + }); + + it('captures for_range_loop as @scope.block', () => { + const src = ` + #include + void f() { + std::vector v; + for (auto& x : v) { } + } + `; + const all = tagsFor(src); + const blocks = all.filter((t) => t.includes('@scope.block')); + expect(blocks.length).toBeGreaterThanOrEqual(1); + }); +}); + +// ── Declarations — classes / structs ──────────────────────────────────────── + +describe('emitCppScopeCaptures — class declarations', () => { + it('captures named class with @declaration.class', () => { + const m = findMatch('class Foo { int x; };', (t) => t.includes('@declaration.class')); + expect(m).toBeDefined(); + expect(m!['@declaration.name'].text).toBe('Foo'); + }); + + it('captures named struct with @declaration.struct', () => { + const m = findMatch('struct Point { int x; int y; };', (t) => + t.includes('@declaration.struct'), + ); + expect(m).toBeDefined(); + expect(m!['@declaration.name'].text).toBe('Point'); + }); + + it('captures template class with @declaration.class', () => { + const m = findMatch('template class Container { T val; };', (t) => + t.includes('@declaration.class'), + ); + expect(m).toBeDefined(); + expect(m!['@declaration.name'].text).toBe('Container'); + }); +}); + +// ── Declarations — namespaces ─────────────────────────────────────────────── + +describe('emitCppScopeCaptures — namespace declarations', () => { + it('captures named namespace with @declaration.namespace', () => { + const m = findMatch('namespace foo { int x; }', (t) => t.includes('@declaration.namespace')); + expect(m).toBeDefined(); + expect(m!['@declaration.name'].text).toBe('foo'); + }); + + it('anonymous namespace has no @declaration.namespace (only @scope.namespace)', () => { + const matches = allMatches('namespace { int x; }', (t) => t.includes('@declaration.namespace')); + // Anonymous namespace should NOT produce a @declaration.namespace + expect(matches.length).toBe(0); + }); +}); + +// ── Declarations — functions / methods ────────────────────────────────────── + +describe('emitCppScopeCaptures — function declarations', () => { + it('captures function definition with @declaration.function', () => { + const m = findMatch('void foo() {}', (t) => t.includes('@declaration.function')); + expect(m).toBeDefined(); + expect(m!['@declaration.name'].text).toBe('foo'); + }); + + it('captures function with pointer return as @declaration.function', () => { + const m = findMatch('int* create() {}', (t) => t.includes('@declaration.function')); + expect(m).toBeDefined(); + expect(m!['@declaration.name'].text).toBe('create'); + }); + + it('captures out-of-class method (qualified_identifier) as @declaration.method', () => { + const m = findMatch('void Foo::bar() {}', (t) => t.includes('@declaration.method')); + expect(m).toBeDefined(); + expect(m!['@declaration.name'].text).toBe('bar'); + }); + + it('captures destructor as @declaration.method', () => { + const m = findMatch('void Foo::~Foo() {}', (t) => t.includes('@declaration.method')); + expect(m).toBeDefined(); + // destructor_name includes the ~ + expect(m!['@declaration.name'].text).toContain('~'); + }); + + it('captures inline method (field_identifier) as @declaration.method', () => { + const src = 'class Foo { void bar() {} };'; + const m = findMatch(src, (t) => t.includes('@declaration.method')); + expect(m).toBeDefined(); + expect(m!['@declaration.name'].text).toBe('bar'); + }); + + it('captures function prototype as @declaration.function', () => { + const m = findMatch('void foo();', (t) => t.includes('@declaration.function')); + expect(m).toBeDefined(); + expect(m!['@declaration.name'].text).toBe('foo'); + }); + + it('captures template function as @declaration.function', () => { + const m = findMatch('template void foo(T x) {}', (t) => + t.includes('@declaration.function'), + ); + expect(m).toBeDefined(); + expect(m!['@declaration.name'].text).toBe('foo'); + }); +}); + +// ── Declarations — fields ─────────────────────────────────────────────────── + +describe('emitCppScopeCaptures — field declarations', () => { + it('captures plain field', () => { + const m = findMatch('class Foo { int val; };', (t) => t.includes('@declaration.field')); + expect(m).toBeDefined(); + expect(m!['@declaration.name'].text).toBe('val'); + }); + + it('captures pointer field', () => { + const m = findMatch('class Foo { int* ptr; };', (t) => t.includes('@declaration.field')); + expect(m).toBeDefined(); + expect(m!['@declaration.name'].text).toBe('ptr'); + }); + + it('captures reference field', () => { + const m = findMatch('class Foo { int& ref; };', (t) => t.includes('@declaration.field')); + expect(m).toBeDefined(); + expect(m!['@declaration.name'].text).toBe('ref'); + }); +}); + +// ── Declarations — variables ──────────────────────────────────────────────── + +describe('emitCppScopeCaptures — variable declarations', () => { + it('captures variable with initializer', () => { + const m = findMatch('int x = 42;', (t) => t.includes('@declaration.variable')); + expect(m).toBeDefined(); + expect(m!['@declaration.name'].text).toBe('x'); + }); +}); + +// ── Declarations — enums ──────────────────────────────────────────────────── + +describe('emitCppScopeCaptures — enum declarations', () => { + it('captures enum with @declaration.enum', () => { + const m = findMatch('enum Color { Red, Green, Blue };', (t) => t.includes('@declaration.enum')); + expect(m).toBeDefined(); + expect(m!['@declaration.name'].text).toBe('Color'); + }); + + it('captures enum constants with @declaration.const', () => { + const matches = allMatches('enum Color { Red, Green, Blue };', (t) => + t.includes('@declaration.const'), + ); + expect(matches.length).toBe(3); + const names = matches.map((m) => m['@declaration.name'].text).sort(); + expect(names).toEqual(['Blue', 'Green', 'Red']); + }); +}); + +// ── Declarations — typedef / alias ────────────────────────────────────────── + +describe('emitCppScopeCaptures — typedef/alias declarations', () => { + it('captures typedef as @declaration.typedef', () => { + const m = findMatch('typedef int MyInt;', (t) => t.includes('@declaration.typedef')); + expect(m).toBeDefined(); + expect(m!['@declaration.name'].text).toBe('MyInt'); + }); + + it('captures using alias as @declaration.typedef', () => { + const m = findMatch('using MyInt = int;', (t) => t.includes('@declaration.typedef')); + expect(m).toBeDefined(); + expect(m!['@declaration.name'].text).toBe('MyInt'); + }); +}); + +// ── Declarations — macros ─────────────────────────────────────────────────── + +describe('emitCppScopeCaptures — macro declarations', () => { + it('captures #define as @declaration.macro', () => { + const m = findMatch('#define MAX 100', (t) => t.includes('@declaration.macro')); + expect(m).toBeDefined(); + expect(m!['@declaration.name'].text).toBe('MAX'); + }); + + it('captures #define function as @declaration.macro', () => { + const m = findMatch('#define ADD(a,b) ((a)+(b))', (t) => t.includes('@declaration.macro')); + expect(m).toBeDefined(); + expect(m!['@declaration.name'].text).toBe('ADD'); + }); +}); + +// ── Imports ───────────────────────────────────────────────────────────────── + +describe('emitCppScopeCaptures — imports', () => { + it('captures #include local as wildcard import', () => { + const m = findMatch('#include "foo.h"', (t) => t.includes('@import.source')); + expect(m).toBeDefined(); + expect(m!['@import.source'].text).toBe('foo.h'); + expect(m!['@import.kind'].text).toBe('wildcard'); + expect(m!['@import.system']).toBeUndefined(); + }); + + it('captures #include system with system marker', () => { + const m = findMatch('#include ', (t) => t.includes('@import.source')); + expect(m).toBeDefined(); + expect(m!['@import.source'].text).toBe('iostream'); + expect(m!['@import.system']).toBeDefined(); + }); + + it('captures using namespace as wildcard import', () => { + const m = findMatch('using namespace std;', (t) => t.includes('@import.using-namespace')); + expect(m).toBeDefined(); + expect(m!['@import.source'].text).toBe('std'); + expect(m!['@import.kind'].text).toBe('wildcard'); + }); + + it('captures using declaration as named import', () => { + const m = findMatch('using std::vector;', (t) => t.includes('@import.name')); + expect(m).toBeDefined(); + expect(m!['@import.source'].text).toBe('std'); + expect(m!['@import.name'].text).toBe('vector'); + expect(m!['@import.kind'].text).toBe('named'); + }); +}); + +// ── References ────────────────────────────────────────────────────────────── + +describe('emitCppScopeCaptures — references', () => { + it('captures free call', () => { + const src = 'void f() { foo(); }'; + const m = findMatch(src, (t) => t.includes('@reference.call.free')); + expect(m).toBeDefined(); + expect(m!['@reference.name'].text).toBe('foo'); + }); + + it('captures member call (obj.method())', () => { + const src = 'void f() { obj.method(); }'; + const m = findMatch(src, (t) => t.includes('@reference.call.member')); + expect(m).toBeDefined(); + expect(m!['@reference.name'].text).toBe('method'); + }); + + it('captures member call (ptr->method())', () => { + const src = 'void f() { ptr->method(); }'; + const m = findMatch(src, (t) => t.includes('@reference.call.member')); + expect(m).toBeDefined(); + expect(m!['@reference.name'].text).toBe('method'); + }); + + it('captures qualified call (Namespace::func())', () => { + const src = 'void f() { Foo::bar(); }'; + const m = findMatch(src, (t) => t.includes('@reference.call.qualified')); + expect(m).toBeDefined(); + expect(m!['@reference.name'].text).toBe('bar'); + }); + + it('captures field read', () => { + const src = 'void f() { int x = obj.val; }'; + const m = findMatch(src, (t) => t.includes('@reference.read')); + expect(m).toBeDefined(); + expect(m!['@reference.name'].text).toBe('val'); + }); + + it('captures field write', () => { + const src = 'void f() { obj.val = 42; }'; + const m = findMatch(src, (t) => t.includes('@reference.write')); + expect(m).toBeDefined(); + expect(m!['@reference.name'].text).toBe('val'); + }); +}); + +// ── Type bindings ─────────────────────────────────────────────────────────── + +describe('emitCppScopeCaptures — type bindings', () => { + it('captures parameter type binding', () => { + const src = 'void foo(int x) {}'; + const m = findMatch(src, (t) => t.includes('@type-binding.parameter')); + expect(m).toBeDefined(); + expect(m!['@type-binding.name'].text).toBe('x'); + }); + + it('captures variable type binding', () => { + const src = 'int x = 42;'; + const m = findMatch(src, (t) => t.includes('@type-binding.assignment')); + expect(m).toBeDefined(); + expect(m!['@type-binding.name'].text).toBe('x'); + }); +}); + +// ── Arity enrichment ──────────────────────────────────────────────────────── + +describe('emitCppScopeCaptures — arity enrichment', () => { + it('enriches function declaration with parameter count', () => { + const m = findMatch('void foo(int x, int y) {}', (t) => + t.includes('@declaration.parameter-count'), + ); + expect(m).toBeDefined(); + expect(m!['@declaration.parameter-count'].text).toBe('2'); + }); + + it('enriches zero-parameter function', () => { + const m = findMatch('void foo() {}', (t) => t.includes('@declaration.parameter-count')); + expect(m).toBeDefined(); + expect(m!['@declaration.parameter-count'].text).toBe('0'); + }); + + it('detects default parameters (required < total)', () => { + const m = findMatch('void foo(int x, int y = 5) {}', (t) => + t.includes('@declaration.required-parameter-count'), + ); + expect(m).toBeDefined(); + expect(m!['@declaration.required-parameter-count'].text).toBe('1'); + expect(m!['@declaration.parameter-count'].text).toBe('2'); + }); + + it('enriches call reference with arity', () => { + const src = 'void f() { foo(1, 2, 3); }'; + const m = findMatch(src, (t) => t.includes('@reference.arity')); + expect(m).toBeDefined(); + expect(m!['@reference.arity'].text).toBe('3'); + }); +}); + +// ── Static / anonymous namespace detection ────────────────────────────────── + +describe('emitCppScopeCaptures — file-local linkage', () => { + beforeEach(() => { + clearFileLocalNames(); + }); + + it('detects static function as file-local', () => { + emitCppScopeCaptures('static void helper() {}', 'test.cpp'); + expect(isFileLocal('test.cpp', 'helper')).toBe(true); + }); + + it('does not mark non-static function as file-local', () => { + emitCppScopeCaptures('void helper() {}', 'test.cpp'); + expect(isFileLocal('test.cpp', 'helper')).toBe(false); + }); + + it('detects function in anonymous namespace as file-local', () => { + emitCppScopeCaptures('namespace { void helper() {} }', 'test.cpp'); + expect(isFileLocal('test.cpp', 'helper')).toBe(true); + }); + + it('does not mark function in named namespace as file-local', () => { + emitCppScopeCaptures('namespace foo { void helper() {} }', 'test.cpp'); + expect(isFileLocal('test.cpp', 'helper')).toBe(false); + }); +}); diff --git a/gitnexus/test/unit/scope-resolution/cpp/cpp-imports.test.ts b/gitnexus/test/unit/scope-resolution/cpp/cpp-imports.test.ts new file mode 100644 index 000000000..6bc6e1b86 --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/cpp/cpp-imports.test.ts @@ -0,0 +1,161 @@ +/** + * Unit tests for C++ import decomposition, interpretation, and target resolution. + */ + +import { describe, it, expect } from 'vitest'; +import { getCppParser } from '../../../../src/core/ingestion/languages/cpp/query.js'; +import { + splitCppInclude, + splitCppUsingDecl, +} from '../../../../src/core/ingestion/languages/cpp/import-decomposer.js'; +import { interpretCppImport } from '../../../../src/core/ingestion/languages/cpp/interpret.js'; +import { resolveCppImportTarget } from '../../../../src/core/ingestion/languages/cpp/import-target.js'; +import type { SyntaxNode } from '../../../../src/core/ingestion/utils/ast-helpers.js'; + +function parseNode(src: string, type: string): SyntaxNode | null { + const tree = getCppParser().parse(src); + for (let i = 0; i < tree.rootNode.namedChildCount; i++) { + const child = tree.rootNode.namedChild(i); + if (child?.type === type) return child as SyntaxNode; + } + return null; +} + +function capt(name: string, text: string) { + return { name, text, range: { startLine: 1, startCol: 1, endLine: 1, endCol: 1 } }; +} + +// ── #include decomposition ────────────────────────────────────────────────── + +describe('C++ include decomposition (splitCppInclude)', () => { + it('decomposes local include "#include \\"foo.h\\""', () => { + const node = parseNode('#include "foo.h"', 'preproc_include'); + expect(node).not.toBeNull(); + const match = splitCppInclude(node!); + expect(match).not.toBeNull(); + expect(match!['@import.source'].text).toBe('foo.h'); + expect(match!['@import.kind'].text).toBe('wildcard'); + expect(match!['@import.system']).toBeUndefined(); + }); + + it('decomposes system include "#include "', () => { + const node = parseNode('#include ', 'preproc_include'); + expect(node).not.toBeNull(); + const match = splitCppInclude(node!); + expect(match).not.toBeNull(); + expect(match!['@import.source'].text).toBe('iostream'); + expect(match!['@import.system']).toBeDefined(); + }); + + it('decomposes C++ header include "#include \\"utils/helpers.hpp\\""', () => { + const node = parseNode('#include "utils/helpers.hpp"', 'preproc_include'); + expect(node).not.toBeNull(); + const match = splitCppInclude(node!); + expect(match).not.toBeNull(); + expect(match!['@import.source'].text).toBe('utils/helpers.hpp'); + }); +}); + +// ── using declaration decomposition ───────────────────────────────────────── + +describe('C++ using declaration decomposition (splitCppUsingDecl)', () => { + it('decomposes "using namespace std;" as wildcard import', () => { + const node = parseNode('using namespace std;', 'using_declaration'); + expect(node).not.toBeNull(); + const match = splitCppUsingDecl(node!); + expect(match).not.toBeNull(); + expect(match!['@import.kind'].text).toBe('wildcard'); + expect(match!['@import.source'].text).toBe('std'); + expect(match!['@import.using-namespace']).toBeDefined(); + }); + + it('decomposes "using std::vector;" as named import', () => { + const node = parseNode('using std::vector;', 'using_declaration'); + expect(node).not.toBeNull(); + const match = splitCppUsingDecl(node!); + expect(match).not.toBeNull(); + expect(match!['@import.kind'].text).toBe('named'); + expect(match!['@import.source'].text).toBe('std'); + expect(match!['@import.name'].text).toBe('vector'); + }); + + it('decomposes nested namespace "using namespace foo::bar;"', () => { + const node = parseNode('using namespace foo::bar;', 'using_declaration'); + expect(node).not.toBeNull(); + const match = splitCppUsingDecl(node!); + expect(match).not.toBeNull(); + expect(match!['@import.kind'].text).toBe('wildcard'); + expect(match!['@import.source'].text).toBe('foo::bar'); + }); +}); + +// ── Import interpretation ─────────────────────────────────────────────────── + +describe('C++ import interpretation (interpretCppImport)', () => { + it('interprets local include as wildcard import', () => { + const result = interpretCppImport({ + '@import.kind': capt('@import.kind', 'wildcard'), + '@import.source': capt('@import.source', 'header.hpp'), + }); + expect(result).toEqual({ kind: 'wildcard', targetRaw: 'header.hpp' }); + }); + + it('returns null for system headers', () => { + const result = interpretCppImport({ + '@import.kind': capt('@import.kind', 'wildcard'), + '@import.source': capt('@import.source', 'iostream'), + '@import.system': capt('@import.system', 'true'), + }); + expect(result).toBeNull(); + }); + + it('interprets named import (using std::vector)', () => { + const result = interpretCppImport({ + '@import.kind': capt('@import.kind', 'named'), + '@import.source': capt('@import.source', 'std'), + '@import.name': capt('@import.name', 'vector'), + }); + expect(result).not.toBeNull(); + expect(result!.kind).toBe('named'); + expect(result!.targetRaw).toBe('std'); + }); + + it('returns null when @import.source is missing', () => { + const result = interpretCppImport({ + '@import.kind': capt('@import.kind', 'wildcard'), + }); + expect(result).toBeNull(); + }); +}); + +// ── Import target resolution ──────────────────────────────────────────────── + +describe('C++ import target resolution (resolveCppImportTarget)', () => { + it('resolves .hpp header', () => { + const result = resolveCppImportTarget('foo.hpp', 'main.cpp', new Set(['foo.hpp', 'bar.cpp'])); + expect(result).toBe('foo.hpp'); + }); + + it('resolves .hxx header', () => { + const result = resolveCppImportTarget('foo.hxx', 'main.cpp', new Set(['foo.hxx'])); + expect(result).toBe('foo.hxx'); + }); + + it('prefers same-directory sibling', () => { + const result = resolveCppImportTarget( + 'bar.hpp', + 'src/foo.cpp', + new Set(['include/bar.hpp', 'src/bar.hpp']), + ); + expect(result).toBe('src/bar.hpp'); + }); + + it('resolves suffix match with depth tiebreak', () => { + const result = resolveCppImportTarget('foo.h', 'main.cpp', new Set(['a/b/c/foo.h', 'z/foo.h'])); + expect(result).toBe('z/foo.h'); + }); + + it('returns null for no match', () => { + expect(resolveCppImportTarget('missing.hpp', 'main.cpp', new Set(['foo.h']))).toBeNull(); + }); +});