diff --git a/gitnexus-shared/src/scope-resolution/symbol-definition.ts b/gitnexus-shared/src/scope-resolution/symbol-definition.ts index d07dbf38b..7f9840f5c 100644 --- a/gitnexus-shared/src/scope-resolution/symbol-definition.ts +++ b/gitnexus-shared/src/scope-resolution/symbol-definition.ts @@ -30,6 +30,8 @@ export interface SymbolDefinition { returnType?: string; /** Declared type for non-callable symbols — fields/properties (e.g. 'Address', 'List') */ declaredType?: string; + /** Generic/template specialization arguments for class-like symbols (e.g. ['User'], ['T*']). */ + templateArguments?: string[]; /** Links Method/Constructor/Property to owning Class/Struct/Trait nodeId */ ownerId?: string; } diff --git a/gitnexus/src/core/ingestion/class-extractors/configs/c-cpp.ts b/gitnexus/src/core/ingestion/class-extractors/configs/c-cpp.ts index fcc1a22bf..fb5df99c3 100644 --- a/gitnexus/src/core/ingestion/class-extractors/configs/c-cpp.ts +++ b/gitnexus/src/core/ingestion/class-extractors/configs/c-cpp.ts @@ -2,6 +2,40 @@ import { SupportedLanguages } from 'gitnexus-shared'; import type { ClassExtractionConfig } from '../../class-types.js'; +import { + extractTemplateArguments, + stripTemplateArguments, +} from '../../utils/template-arguments.js'; + +function shouldSkipCppTemplateDuplicateCapture( + captureMap: Record, + definitionName: string | undefined, + capturedName: string | undefined, +): boolean { + if (captureMap['template-arguments'] !== undefined) return false; + if (!definitionName) return false; + const argsFromDefinitionName = extractTemplateArguments(definitionName); + if (argsFromDefinitionName === undefined) return false; + const argsFromCaptureName = capturedName ? extractTemplateArguments(capturedName) : undefined; + // Generic class capture emits only `List`, while the specialization-aware + // capture emits `List` + `@declaration.template-arguments`. Skip the former + // when the declaration name itself is templated to avoid duplicate class defs. + return argsFromCaptureName === undefined; +} + +function extractCppTemplateArgumentsWithFallback( + captureMap: Record, + definitionName: string | undefined, + capturedName: string | undefined, +): string[] | undefined { + return ( + (captureMap['template-arguments'] + ? extractTemplateArguments(captureMap['template-arguments'].text) + : undefined) ?? + (definitionName ? extractTemplateArguments(definitionName) : undefined) ?? + (capturedName ? extractTemplateArguments(capturedName) : undefined) + ); +} export const cClassConfig: ClassExtractionConfig = { language: SupportedLanguages.C, @@ -12,4 +46,27 @@ export const cppClassConfig: ClassExtractionConfig = { language: SupportedLanguages.CPlusPlus, typeDeclarationNodes: ['class_specifier', 'struct_specifier', 'enum_specifier'], ancestorScopeNodeTypes: ['namespace_definition', 'class_specifier', 'struct_specifier'], + extractName: (node) => { + const nameNode = node.childForFieldName?.('name'); + if (!nameNode) return undefined; + if (nameNode.type !== 'template_type') return undefined; + return stripTemplateArguments(nameNode.text); + }, + extractTemplateArguments: (node) => { + const nameNode = node.childForFieldName?.('name'); + if (!nameNode || nameNode.type !== 'template_type') return undefined; + return extractTemplateArguments(nameNode.text); + }, + shouldSkipClassCapture: ({ captureMap, definitionNode, nameNode }) => + shouldSkipCppTemplateDuplicateCapture( + captureMap, + definitionNode?.childForFieldName?.('name')?.text, + nameNode?.text, + ), + extractTemplateArgumentsFromCapture: ({ captureMap, definitionNode, nameNode }) => + extractCppTemplateArgumentsWithFallback( + captureMap, + definitionNode?.childForFieldName?.('name')?.text, + nameNode?.text, + ), }; diff --git a/gitnexus/src/core/ingestion/class-extractors/generic.ts b/gitnexus/src/core/ingestion/class-extractors/generic.ts index 303eb80c0..5f20d1dc2 100644 --- a/gitnexus/src/core/ingestion/class-extractors/generic.ts +++ b/gitnexus/src/core/ingestion/class-extractors/generic.ts @@ -154,10 +154,12 @@ export function createClassExtractor(config: ClassExtractionConfig): ClassExtrac if (!name || !type) return null; + const templateArguments = config.extractTemplateArguments?.(node); return { name, type, qualifiedName: buildQualifiedName(node, name) || name, + ...(templateArguments !== undefined ? { templateArguments } : {}), }; }; @@ -173,5 +175,13 @@ export function createClassExtractor(config: ClassExtractionConfig): ClassExtrac extractQualifiedName(node: SyntaxNode, simpleName: string): string | null { return extract(node, { name: simpleName })?.qualifiedName ?? null; }, + + shouldSkipClassCapture(context): boolean { + return config.shouldSkipClassCapture?.(context) ?? false; + }, + + extractTemplateArgumentsFromCapture(context): string[] | undefined { + return config.extractTemplateArgumentsFromCapture?.(context); + }, }; } diff --git a/gitnexus/src/core/ingestion/class-types.ts b/gitnexus/src/core/ingestion/class-types.ts index 858d4c2eb..9407d41fa 100644 --- a/gitnexus/src/core/ingestion/class-types.ts +++ b/gitnexus/src/core/ingestion/class-types.ts @@ -10,6 +10,13 @@ export interface ExtractedClassSymbol { name: string; type: ClassLikeNodeLabel; qualifiedName: string; + templateArguments?: string[]; +} + +export interface ClassCaptureContext { + captureMap: Record; + definitionNode: SyntaxNode | null; + nameNode: SyntaxNode | undefined; } /** @@ -30,6 +37,10 @@ export interface ClassExtractor { }, ): ExtractedClassSymbol | null; extractQualifiedName(node: SyntaxNode, simpleName: string): string | null; + shouldSkipClassCapture?( + context: ClassCaptureContext & { nodeLabel: ClassLikeNodeLabel }, + ): boolean; + extractTemplateArgumentsFromCapture?(context: ClassCaptureContext): string[] | undefined; } export interface ClassExtractionConfig { @@ -41,4 +52,9 @@ export interface ClassExtractionConfig { extractName?: (node: SyntaxNode) => string | undefined; extractType?: (node: SyntaxNode) => ClassLikeNodeLabel | undefined; extractScopeSegments?: (node: SyntaxNode) => string[] | null | undefined; + extractTemplateArguments?: (node: SyntaxNode) => string[] | undefined; + shouldSkipClassCapture?( + context: ClassCaptureContext & { nodeLabel: ClassLikeNodeLabel }, + ): boolean; + extractTemplateArgumentsFromCapture?(context: ClassCaptureContext): string[] | undefined; } diff --git a/gitnexus/src/core/ingestion/languages/cpp/interpret.ts b/gitnexus/src/core/ingestion/languages/cpp/interpret.ts index a5c1692a8..b330a3fc0 100644 --- a/gitnexus/src/core/ingestion/languages/cpp/interpret.ts +++ b/gitnexus/src/core/ingestion/languages/cpp/interpret.ts @@ -82,7 +82,12 @@ export function interpretCppTypeBinding(captures: CaptureMatch): ParsedTypeBindi /** * Normalize a C++ type name: strip pointer/array/reference syntax, - * qualifiers, and template parameters (V1: generic-ignored). + * qualifiers, while preserving template arguments for specialization-aware + * receiver binding (`List` vs `List`). + * + * Keeping template arguments here allows receiver-bound fallback to match + * specialization-specific class defs first; non-template behavior is preserved + * by base-name fallback in resolveClassBindingForName. */ export function normalizeCppTypeName(text: string): string { let t = text.trim(); @@ -90,13 +95,6 @@ export function normalizeCppTypeName(text: string): string { 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(); diff --git a/gitnexus/src/core/ingestion/languages/cpp/query.ts b/gitnexus/src/core/ingestion/languages/cpp/query.ts index 0e6a0a7ae..70d544e3d 100644 --- a/gitnexus/src/core/ingestion/languages/cpp/query.ts +++ b/gitnexus/src/core/ingestion/languages/cpp/query.ts @@ -32,21 +32,47 @@ const CPP_SCOPE_QUERY = ` name: (type_identifier) @declaration.name body: (field_declaration_list)) @declaration.class +(class_specifier + name: (template_type + (type_identifier) @declaration.name + (template_argument_list) @declaration.template-arguments) + body: (field_declaration_list)) @declaration.class + (struct_specifier name: (type_identifier) @declaration.name body: (field_declaration_list)) @declaration.struct +(struct_specifier + name: (template_type + (type_identifier) @declaration.name + (template_argument_list) @declaration.template-arguments) + 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 + (class_specifier + name: (template_type + (type_identifier) @declaration.name + (template_argument_list) @declaration.template-arguments) + body: (field_declaration_list)) @declaration.class) + (template_declaration (struct_specifier name: (type_identifier) @declaration.name body: (field_declaration_list)) @declaration.struct) +(template_declaration + (struct_specifier + name: (template_type + (type_identifier) @declaration.name + (template_argument_list) @declaration.template-arguments) + body: (field_declaration_list)) @declaration.struct) + ;; ─── Declarations — enum ───────────────────────────────────────────── (enum_specifier name: (type_identifier) @declaration.name) @declaration.enum @@ -223,6 +249,11 @@ const CPP_SCOPE_QUERY = ` type: (type_identifier) @type-binding.type declarator: (identifier) @type-binding.name) @type-binding.annotation +;; Covers: List users; +(declaration + type: (template_type) @type-binding.type + declarator: (identifier) @type-binding.name) @type-binding.annotation + ;; ─── Type bindings — pointer variable declaration ─────────────────── ;; Covers: User* ptr = new User() (declaration diff --git a/gitnexus/src/core/ingestion/model/symbol-table.ts b/gitnexus/src/core/ingestion/model/symbol-table.ts index c22f63acb..a730c66eb 100644 --- a/gitnexus/src/core/ingestion/model/symbol-table.ts +++ b/gitnexus/src/core/ingestion/model/symbol-table.ts @@ -128,6 +128,7 @@ export interface AddMetadata { parameterTypes?: string[]; returnType?: string; declaredType?: string; + templateArguments?: string[]; ownerId?: string; qualifiedName?: string; } @@ -277,6 +278,9 @@ export const createSymbolTable = (): InternalSymbolTable => { : {}), ...(metadata?.returnType !== undefined ? { returnType: metadata.returnType } : {}), ...(metadata?.declaredType !== undefined ? { declaredType: metadata.declaredType } : {}), + ...(metadata?.templateArguments !== undefined + ? { templateArguments: metadata.templateArguments } + : {}), ...(metadata?.ownerId !== undefined ? { ownerId: metadata.ownerId } : {}), }; diff --git a/gitnexus/src/core/ingestion/parsing-processor.ts b/gitnexus/src/core/ingestion/parsing-processor.ts index 04a17db4f..f88e78ed9 100644 --- a/gitnexus/src/core/ingestion/parsing-processor.ts +++ b/gitnexus/src/core/ingestion/parsing-processor.ts @@ -30,6 +30,7 @@ import { constTagForId, buildCollisionGroups, } from './utils/method-props.js'; +import { extractTemplateArguments, templateArgumentsIdTag } from './utils/template-arguments.js'; import type { LanguageProvider } from './language-provider.js'; import type { ParsedFile } from 'gitnexus-shared'; import { WorkerPool } from './workers/worker-pool.js'; @@ -129,6 +130,7 @@ export const mergeChunkResults = ( parameterTypes: sym.parameterTypes, returnType: sym.returnType, declaredType: sym.declaredType, + templateArguments: sym.templateArguments, ownerId: sym.ownerId, qualifiedName: sym.qualifiedName, }); @@ -483,6 +485,23 @@ const processParsingSequential = async ( }) : null; const nodeLabel = extractedClassSymbol?.type ?? defaultNodeLabel; + const isClassLikeLabel = + nodeLabel === 'Class' || + nodeLabel === 'Struct' || + nodeLabel === 'Interface' || + nodeLabel === 'Enum' || + nodeLabel === 'Record'; + if ( + isClassLikeLabel && + provider.classExtractor?.shouldSkipClassCapture?.({ + captureMap, + definitionNode, + nameNode, + nodeLabel, + }) === true + ) { + return; + } // Synthesize name for constructors without explicit @name capture (e.g. Swift init) if (!nameNode && nodeLabel !== 'Constructor' && !extractedClassSymbol) return; const nodeName = extractedClassSymbol?.name ?? (nameNode ? nameNode.text : 'init'); @@ -610,7 +629,31 @@ const processParsingSequential = async ( cached.groups, ); } - const nodeId = generateId(nodeLabel, `${file.path}:${qualifiedName}${arityTag}`); + const classTemplateArguments = + extractedClassSymbol?.templateArguments ?? + provider.classExtractor?.extractTemplateArgumentsFromCapture?.({ + captureMap, + definitionNode, + nameNode, + }) ?? + (captureMap['template-arguments'] + ? extractTemplateArguments(captureMap['template-arguments'].text) + : undefined) ?? + (nameNode && nameNode.text ? extractTemplateArguments(nameNode.text) : undefined); + const classTemplateTag = + (nodeLabel === 'Class' || + nodeLabel === 'Struct' || + nodeLabel === 'Interface' || + nodeLabel === 'Enum' || + nodeLabel === 'Record') && + classTemplateArguments !== undefined && + classTemplateArguments.length > 0 + ? templateArgumentsIdTag(classTemplateArguments) + : ''; + const nodeId = generateId( + nodeLabel, + `${file.path}:${qualifiedName}${classTemplateTag}${arityTag}`, + ); const classNodeForSymbol = definitionNodeForRange || definitionNode || nameNode; const qualifiedTypeName = extractedClassSymbol?.qualifiedName ?? @@ -643,6 +686,9 @@ const processParsingSequential = async ( nodeName, ), ...(qualifiedTypeName !== undefined ? { qualifiedName: qualifiedTypeName } : {}), + ...(classTemplateArguments !== undefined && classTemplateArguments.length > 0 + ? { templateArguments: classTemplateArguments } + : {}), ...(frameworkHint ? { astFrameworkMultiplier: frameworkHint.entryPointMultiplier, @@ -700,6 +746,7 @@ const processParsingSequential = async ( parameterTypes: methodProps.parameterTypes as string[] | undefined, returnType: methodProps.returnType as string | undefined, declaredType, + templateArguments: classTemplateArguments, ownerId: enclosingClassId ?? undefined, qualifiedName: qualifiedTypeName, }); diff --git a/gitnexus/src/core/ingestion/scope-extractor.ts b/gitnexus/src/core/ingestion/scope-extractor.ts index f13cb2c73..44088b49f 100644 --- a/gitnexus/src/core/ingestion/scope-extractor.ts +++ b/gitnexus/src/core/ingestion/scope-extractor.ts @@ -76,6 +76,7 @@ import type { } from 'gitnexus-shared'; import { buildPositionIndex, buildScopeTree, canParentScope, makeScopeId } from 'gitnexus-shared'; import type { LanguageProvider } from './language-provider.js'; +import { extractTemplateArguments } from './utils/template-arguments.js'; // ─── Narrow hook surface the extractor actually uses ─────────────────────── @@ -533,6 +534,9 @@ function buildDefFromDeclarationMatch( const qualifiedCap = match['@declaration.qualified_name']; const qualifiedName = qualifiedCap?.text; + const templateArguments = + extractTemplateArguments(match['@declaration.template-arguments']?.text ?? '') ?? + extractTemplateArguments(qualifiedName ?? nameCap.text); // Optional arity metadata — producers (e.g. Python emit-captures) // synthesize these on function/method declarations. Their absence is @@ -554,6 +558,7 @@ function buildDefFromDeclarationMatch( ...(parameterTypes !== undefined ? { parameterTypes } : {}), ...(declaredType !== undefined ? { declaredType } : {}), ...(returnType !== undefined ? { returnType } : {}), + ...(templateArguments !== undefined ? { templateArguments } : {}), }; } diff --git a/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/ids.ts b/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/ids.ts index ad59f4f10..adc32bbd3 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/ids.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/ids.ts @@ -71,7 +71,12 @@ function isCallerAnchorLabel(label: NodeLabel): boolean { */ export function resolveDefGraphId( filePath: string, - def: { qualifiedName?: string; type?: NodeLabel; parameterTypes?: readonly string[] }, + def: { + qualifiedName?: string; + type?: NodeLabel; + parameterTypes?: readonly string[]; + templateArguments?: readonly string[]; + }, nodeLookup: GraphNodeLookup, ): string | undefined { const qn = def.qualifiedName; @@ -89,6 +94,19 @@ export function resolveDefGraphId( const pHit = nodeLookup.get(pKey); if (pHit !== undefined) return pHit; } + if ( + (def.type === 'Class' || + def.type === 'Struct' || + def.type === 'Interface' || + def.type === 'Enum' || + def.type === 'Record') && + def.templateArguments !== undefined && + def.templateArguments.length > 0 + ) { + const tKey = qualifiedKey(filePath, def.type, `${qn}~${def.templateArguments.join(',')}`); + const tHit = nodeLookup.get(tKey); + if (tHit !== undefined) return tHit; + } const qualifiedHit = nodeLookup.get(qualifiedKey(filePath, def.type, qn)); if (qualifiedHit !== undefined) return qualifiedHit; } diff --git a/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/node-lookup.ts b/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/node-lookup.ts index c3b53c6f7..d712c29e3 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/node-lookup.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/node-lookup.ts @@ -67,6 +67,7 @@ export function buildGraphNodeLookup(graph: KnowledgeGraph): GraphNodeLookup { filePath?: string; name?: string; qualifiedName?: string; + templateArguments?: readonly string[]; }; if (props.filePath === undefined || props.name === undefined) continue; if (!isLinkableLabel(node.label)) continue; @@ -96,6 +97,22 @@ export function buildGraphNodeLookup(graph: KnowledgeGraph): GraphNodeLookup { // Each overload is unique — set unconditionally. lookup.set(pKey, node.id); } + if ( + (node.label === 'Class' || + node.label === 'Struct' || + node.label === 'Interface' || + node.label === 'Enum' || + node.label === 'Record') && + props.templateArguments !== undefined && + props.templateArguments.length > 0 + ) { + const tKey = qualifiedKey( + props.filePath, + node.label, + `${qualified}~${props.templateArguments.join(',')}`, + ); + if (!lookup.has(tKey)) lookup.set(tKey, node.id); + } } // Fallback key: simple name. First-wins within a file — used when 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 9b57a7555..80ff3a200 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 @@ -55,6 +55,10 @@ import { narrowOverloadCandidates, isOverloadAmbiguousAfterNormalization, } from './overload-narrowing.js'; +import { + extractTemplateArguments, + stripTemplateArguments, +} from '../../utils/template-arguments.js'; /** Subset of `ScopeResolver` consumed by this pass. Accepting the * subset rather than the full provider keeps tests and partial @@ -70,6 +74,53 @@ type ReceiverBoundProviderSubset = Pick< | 'resolveQualifiedReceiverMember' >; +function normalizeTemplateArgToken(value: string): string { + return value.replace(/\s+/g, ''); +} + +function resolveClassBindingForName( + scopeId: string, + rawClassName: string, + scopes: ScopeResolutionIndexes, +): SymbolDefinition | undefined { + const direct = findClassBindingInScope(scopeId, rawClassName, scopes); + if (direct !== undefined) return direct; + + if (!rawClassName.includes('<')) return undefined; + const baseName = stripTemplateArguments(rawClassName).replace(/\s+/g, ''); + if (baseName.length === 0) return undefined; + + const wantedArgs = extractTemplateArguments(rawClassName)?.map(normalizeTemplateArgToken); + if (wantedArgs !== undefined && wantedArgs.length > 0) { + // qualifiedNames is a Map and may not contain the stripped base name at all + // (e.g., unresolved type binding or only template-qualified entries), so + // default to [] before checking `.length`. + const qnameIds = scopes.qualifiedNames.get(baseName) ?? []; + if (qnameIds.length === 0) { + return findClassBindingInScope(scopeId, baseName, scopes); + } + const matches: SymbolDefinition[] = []; + for (const id of qnameIds) { + const def = scopes.defs.get(id); + if (def === undefined || !isClassLike(def.type)) continue; + const defArgs = def.templateArguments?.map(normalizeTemplateArgToken); + if ( + defArgs !== undefined && + defArgs.length === wantedArgs.length && + defArgs.every((value, i) => value === wantedArgs[i]) + ) { + matches.push(def); + } + } + if (matches.length === 1) return matches[0]; + // Scope extractor only records class definitions with bodies in C++, so + // forward declarations are not expected here. Keep fallback behavior for + // safety in non-ODR or mixed-language edge cases. + } + + return findClassBindingInScope(scopeId, baseName, scopes); +} + export function emitReceiverBoundCalls( graph: KnowledgeGraph, scopes: ScopeResolutionIndexes, @@ -470,7 +521,7 @@ export function emitReceiverBoundCalls( // ── Case 4: simple typeBinding (`u: U`) ────────────────────── if (typeRef !== undefined && !typeRef.rawName.includes('.')) { - let ownerDef = findClassBindingInScope(site.inScope, typeRef.rawName, scopes); + let ownerDef = resolveClassBindingForName(site.inScope, typeRef.rawName, scopes); // `findClassBindingInScope(..., typeRef.rawName)` only works when // rawName is itself a class symbol reachable through scope bindings. // For languages with namespace-style imports (Go), imported types diff --git a/gitnexus/src/core/ingestion/tree-sitter-queries.ts b/gitnexus/src/core/ingestion/tree-sitter-queries.ts index d65229808..f02ae2cb2 100644 --- a/gitnexus/src/core/ingestion/tree-sitter-queries.ts +++ b/gitnexus/src/core/ingestion/tree-sitter-queries.ts @@ -680,7 +680,15 @@ export const GO_QUERIES = ` export const CPP_QUERIES = ` ; Classes, Structs, Namespaces (class_specifier name: (type_identifier) @name) @definition.class +(class_specifier + name: (template_type + (type_identifier) @name + (template_argument_list) @template-arguments)) @definition.class (struct_specifier name: (type_identifier) @name) @definition.struct +(struct_specifier + name: (template_type + (type_identifier) @name + (template_argument_list) @template-arguments)) @definition.struct (namespace_definition name: (namespace_identifier) @name) @definition.namespace (enum_specifier name: (type_identifier) @name) @definition.enum @@ -762,6 +770,11 @@ export const CPP_QUERIES = ` ; Templates (template_declaration (class_specifier name: (type_identifier) @name)) @definition.template +(template_declaration + (class_specifier + name: (template_type + (type_identifier) @name + (template_argument_list) @template-arguments))) @definition.template (template_declaration (function_definition declarator: (function_declarator declarator: (identifier) @name))) @definition.template ; Includes diff --git a/gitnexus/src/core/ingestion/utils/ast-helpers.ts b/gitnexus/src/core/ingestion/utils/ast-helpers.ts index ec76cd1db..351cfdedb 100644 --- a/gitnexus/src/core/ingestion/utils/ast-helpers.ts +++ b/gitnexus/src/core/ingestion/utils/ast-helpers.ts @@ -2,6 +2,11 @@ import type Parser from 'tree-sitter'; import type { Capture, NodeLabel, Range } from 'gitnexus-shared'; import type { LanguageProvider } from '../language-provider.js'; import { generateId } from '../../../lib/utils.js'; +import { + extractTemplateArguments, + stripTemplateArguments, + templateArgumentsIdTag, +} from './template-arguments.js'; /** Tree-sitter AST node. Re-exported for use across ingestion modules. */ export type SyntaxNode = Parser.SyntaxNode; @@ -390,8 +395,13 @@ export const findEnclosingClassInfo = ( ) { label = 'Interface'; } + const templateArguments = extractTemplateArguments(nameNode.text); + const classIdName = + templateArguments !== undefined + ? `${stripTemplateArguments(nameNode.text)}${templateArgumentsIdTag(templateArguments)}` + : nameNode.text; return { - classId: generateId(label, `${filePath}:${nameNode.text}`), + classId: generateId(label, `${filePath}:${classIdName}`), className: nameNode.text, }; } diff --git a/gitnexus/src/core/ingestion/utils/template-arguments.ts b/gitnexus/src/core/ingestion/utils/template-arguments.ts new file mode 100644 index 000000000..e1c6e3463 --- /dev/null +++ b/gitnexus/src/core/ingestion/utils/template-arguments.ts @@ -0,0 +1,57 @@ +/** + * Parse top-level generic/template arguments from a type-like string. + * + * Examples: + * - `List` -> ['int'] + * - `Map>` -> ['string', 'vector'] + * - `List` -> ['T*'] + */ +export function extractTemplateArguments(text: string): string[] | undefined { + const start = text.indexOf('<'); + if (start === -1) return undefined; + let depth = 0; + let end = -1; + for (let i = start; i < text.length; i += 1) { + const ch = text[i]; + if (ch === '<') depth += 1; + else if (ch === '>') { + depth -= 1; + if (depth === 0) { + end = i; + break; + } + if (depth < 0) return undefined; + } + } + if (end === -1) return undefined; + const inner = text.slice(start + 1, end); + if (inner.trim().length === 0) return undefined; + + const args: string[] = []; + let tokenStart = 0; + let nested = 0; + for (let i = 0; i < inner.length; i += 1) { + const ch = inner[i]; + if (ch === '<') nested += 1; + else if (ch === '>') nested -= 1; + else if (ch === ',' && nested === 0) { + const token = inner.slice(tokenStart, i).replace(/\s+/g, ''); + if (token.length > 0) args.push(token); + tokenStart = i + 1; + } + } + const last = inner.slice(tokenStart).replace(/\s+/g, ''); + if (last.length > 0) args.push(last); + return args.length > 0 ? args : undefined; +} + +export function stripTemplateArguments(text: string): string { + const start = text.indexOf('<'); + if (start === -1) return text; + return text.slice(0, start); +} + +export function templateArgumentsIdTag(templateArguments?: readonly string[]): string { + if (templateArguments === undefined || templateArguments.length === 0) return ''; + return `~${templateArguments.join(',')}`; +} diff --git a/gitnexus/src/core/ingestion/workers/parse-worker.ts b/gitnexus/src/core/ingestion/workers/parse-worker.ts index 9a71fc16c..e22b927ed 100644 --- a/gitnexus/src/core/ingestion/workers/parse-worker.ts +++ b/gitnexus/src/core/ingestion/workers/parse-worker.ts @@ -82,6 +82,7 @@ import { constTagForId, buildCollisionGroups, } from '../utils/method-props.js'; +import { extractTemplateArguments, templateArgumentsIdTag } from '../utils/template-arguments.js'; import type { LanguageProvider } from '../language-provider.js'; import type { ParsedFile } from 'gitnexus-shared'; import { extractParsedFile } from '../scope-extractor-bridge.js'; @@ -129,6 +130,7 @@ interface ParsedSymbol { parameterTypes?: string[]; returnType?: string; declaredType?: string; + templateArguments?: string[]; ownerId?: string; visibility?: string; isStatic?: boolean; @@ -2001,6 +2003,23 @@ const processFileGroup = ( }) : null; const nodeLabel = extractedClassSymbol?.type ?? defaultNodeLabel; + const isClassLikeLabel = + nodeLabel === 'Class' || + nodeLabel === 'Struct' || + nodeLabel === 'Interface' || + nodeLabel === 'Enum' || + nodeLabel === 'Record'; + if ( + isClassLikeLabel && + provider.classExtractor?.shouldSkipClassCapture?.({ + captureMap, + definitionNode, + nameNode, + nodeLabel, + }) === true + ) { + continue; + } // Dedup: variable captures (Const/Static/Variable) may overlap with higher-priority // captures (e.g. `const fn = () => {}` matches both @definition.function and @definition.const). @@ -2114,7 +2133,31 @@ const processFileGroup = ( ); arityTag += constTagForId(defMethodMap, nodeName, arityForId, defMethodInfo, groups); } - const nodeId = generateId(nodeLabel, `${file.path}:${qualifiedName}${arityTag}`); + const classTemplateArguments = + extractedClassSymbol?.templateArguments ?? + provider.classExtractor?.extractTemplateArgumentsFromCapture?.({ + captureMap, + definitionNode, + nameNode, + }) ?? + (captureMap['template-arguments'] + ? extractTemplateArguments(captureMap['template-arguments'].text) + : undefined) ?? + (nameNode && nameNode.text ? extractTemplateArguments(nameNode.text) : undefined); + const classTemplateTag = + (nodeLabel === 'Class' || + nodeLabel === 'Struct' || + nodeLabel === 'Interface' || + nodeLabel === 'Enum' || + nodeLabel === 'Record') && + classTemplateArguments !== undefined && + classTemplateArguments.length > 0 + ? templateArgumentsIdTag(classTemplateArguments) + : ''; + const nodeId = generateId( + nodeLabel, + `${file.path}:${qualifiedName}${classTemplateTag}${arityTag}`, + ); const classNodeForSymbol = definitionNode || nameNode; const qualifiedTypeName = extractedClassSymbol?.qualifiedName ?? @@ -2237,6 +2280,9 @@ const processFileGroup = ( ? isVueSetupTopLevel(nameNode || definitionNode) : cachedExportCheck(provider.exportChecker, nameNode || definitionNode, nodeName), ...(qualifiedTypeName !== undefined ? { qualifiedName: qualifiedTypeName } : {}), + ...(classTemplateArguments !== undefined && classTemplateArguments.length > 0 + ? { templateArguments: classTemplateArguments } + : {}), ...(frameworkHint ? { astFrameworkMultiplier: frameworkHint.entryPointMultiplier, @@ -2262,6 +2308,9 @@ const processFileGroup = ( parameterTypes: methodProps.parameterTypes as string[] | undefined, returnType: methodProps.returnType as string | undefined, ...(declaredType !== undefined ? { declaredType } : {}), + ...(classTemplateArguments !== undefined && classTemplateArguments.length > 0 + ? { templateArguments: classTemplateArguments } + : {}), ...(enclosingClassId ? { ownerId: enclosingClassId } : {}), visibility: methodProps.visibility as string | undefined, isStatic: methodProps.isStatic as boolean | undefined, diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-template-specialization-disambiguation/app.cpp b/gitnexus/test/fixtures/lang-resolution/cpp-template-specialization-disambiguation/app.cpp new file mode 100644 index 000000000..237ce1cae --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-template-specialization-disambiguation/app.cpp @@ -0,0 +1,7 @@ +#include "list_user.h" +#include "list_order.h" + +void callUserSave() { + List list; + list.save(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-template-specialization-disambiguation/list_order.h b/gitnexus/test/fixtures/lang-resolution/cpp-template-specialization-disambiguation/list_order.h new file mode 100644 index 000000000..015a16b5b --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-template-specialization-disambiguation/list_order.h @@ -0,0 +1,14 @@ +#pragma once + +struct Order {}; + +template +class List; + +template <> +class List { +public: + void callSave() { save(); } + void save() { persistOrder(); } + void persistOrder() {} +}; diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-template-specialization-disambiguation/list_user.h b/gitnexus/test/fixtures/lang-resolution/cpp-template-specialization-disambiguation/list_user.h new file mode 100644 index 000000000..d9ba7fb24 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-template-specialization-disambiguation/list_user.h @@ -0,0 +1,14 @@ +#pragma once + +struct User {}; + +template +class List; + +template <> +class List { +public: + void callSave() { save(); } + void save() { persistUser(); } + void persistUser() {} +}; diff --git a/gitnexus/test/integration/resolvers/cpp.test.ts b/gitnexus/test/integration/resolvers/cpp.test.ts index 26fb02d36..25a7f9de7 100644 --- a/gitnexus/test/integration/resolvers/cpp.test.ts +++ b/gitnexus/test/integration/resolvers/cpp.test.ts @@ -1464,6 +1464,84 @@ describe('C++ template overload cross-file and chain resolution', () => { }); }); +describe('C++ template specialization disambiguation across files', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'cpp-template-specialization-disambiguation'), + () => {}, + ); + }, 60000); + + it('emits distinct Class nodes for List and List', () => { + const classes = getNodesByLabelFull(result, 'Class').filter( + (c) => c.name === 'List' && Array.isArray(c.properties.templateArguments), + ); + expect(classes.length).toBe(2); + const fingerprints = new Set(classes.map((c) => c.properties.templateArguments.join(','))); + expect(fingerprints).toEqual(new Set(['User', 'Order'])); + }); + + it('callSave() in each specialization resolves to its own save()', () => { + const calls = getRelationships(result, 'CALLS'); + const saveEdges = calls.filter((c) => c.source === 'callSave' && c.target === 'save'); + expect(saveEdges.length).toBe(2); + + const hasMethod = getRelationships(result, 'HAS_METHOD'); + const ownerFingerprints = new Set(); + for (const edge of saveEdges) { + const sourceOwnerEdge = hasMethod.find((e) => e.rel.targetId === edge.rel.sourceId); + const targetOwnerEdge = hasMethod.find((e) => e.rel.targetId === edge.rel.targetId); + expect(sourceOwnerEdge).toBeDefined(); + expect(targetOwnerEdge).toBeDefined(); + expect(sourceOwnerEdge!.rel.sourceId).toBe(targetOwnerEdge!.rel.sourceId); + const ownerNode = result.graph.getNode(sourceOwnerEdge!.rel.sourceId); + const fp = ownerNode?.properties.templateArguments?.join(','); + if (fp) ownerFingerprints.add(fp); + } + expect(ownerFingerprints).toEqual(new Set(['User', 'Order'])); + }); + + it('save specialization bodies route to their own sibling method', () => { + const calls = getRelationships(result, 'CALLS'); + + const persistUserCalls = calls.filter((c) => c.target === 'persistUser'); + expect(persistUserCalls.length).toBe(1); + const userSaveOwner = getRelationships(result, 'HAS_METHOD').find( + (e) => e.rel.targetId === persistUserCalls[0].rel.sourceId, + ); + expect(userSaveOwner).toBeDefined(); + const userOwnerNode = result.graph.getNode(userSaveOwner!.rel.sourceId); + expect(userOwnerNode?.properties.templateArguments).toEqual(['User']); + + const persistOrderCalls = calls.filter((c) => c.target === 'persistOrder'); + expect(persistOrderCalls.length).toBe(1); + const orderSaveOwner = getRelationships(result, 'HAS_METHOD').find( + (e) => e.rel.targetId === persistOrderCalls[0].rel.sourceId, + ); + expect(orderSaveOwner).toBeDefined(); + const orderOwnerNode = result.graph.getNode(orderSaveOwner!.rel.sourceId); + expect(orderOwnerNode?.properties.templateArguments).toEqual(['Order']); + }); + + it('resolves external List receiver call to List::save', () => { + const calls = getRelationships(result, 'CALLS'); + const edge = calls.find( + (c) => + c.source === 'callUserSave' && c.target === 'save' && c.targetFilePath === 'list_user.h', + ); + expect(edge).toBeDefined(); + + const ownerEdge = getRelationships(result, 'HAS_METHOD').find( + (e) => e.rel.targetId === edge!.rel.targetId, + ); + expect(ownerEdge).toBeDefined(); + const ownerNode = result.graph.getNode(ownerEdge!.rel.sourceId); + expect(ownerNode?.properties.templateArguments).toEqual(['User']); + }); +}); + // ── Phase P: C++ out-of-class method definition + overload disambiguation ─ describe('C++ out-of-class method definition with overloaded declarations', () => { diff --git a/gitnexus/test/integration/resolvers/helpers.ts b/gitnexus/test/integration/resolvers/helpers.ts index 418bbc0fb..5149e3e69 100644 --- a/gitnexus/test/integration/resolvers/helpers.ts +++ b/gitnexus/test/integration/resolvers/helpers.ts @@ -162,6 +162,12 @@ const LEGACY_RESOLVER_PARITY_EXPECTED_FAILURES: Readonly', 'outer::v1::Base::f() resolves to Base::f inside template body', 'outer::v1::free_fn() resolves as a namespace free function, not a super-receiver method', + // Template specialization owner identity currently relies on + // class-template fingerprints in the registry-primary graph bridge. + // Legacy DAG collapses specializations to the simple class name. + 'emits distinct Class nodes for List and List', + 'callSave() in each specialization resolves to its own save()', + 'save specialization bodies route to their own sibling method', ]), };