feat(cpp): disambiguate template specializations in class graph IDs and receiver routing (#1587)

* Initial plan

* fix(cpp): disambiguate template specializations in class graph IDs

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/c929edb2-f2e9-41c6-a2e9-2092b967f603

* fix(cpp): guard template-specialization class lookup fallback

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/c929edb2-f2e9-41c6-a2e9-2092b967f603

* fix(cpp): address github-actions inline review findings

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/68d8fbac-4ff4-47f7-b732-eaf2c2f94043

* fix(cpp): cover template-type receiver binding for specialization routing

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/9505dfcd-3fb6-4bc2-a134-f60fe0dc8cd9

* chore(cpp): clarify specialization-binding fallback assumptions

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/9505dfcd-3fb6-4bc2-a134-f60fe0dc8cd9

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
This commit is contained in:
Copilot 2026-05-14 15:00:29 +01:00 committed by GitHub
parent c901ee4666
commit 586dbf7aa1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 517 additions and 13 deletions

View file

@ -30,6 +30,8 @@ export interface SymbolDefinition {
returnType?: string;
/** Declared type for non-callable symbols — fields/properties (e.g. 'Address', 'List<User>') */
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;
}

View file

@ -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<string, { text: string } | undefined>,
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<string, { text: string } | undefined>,
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,
),
};

View file

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

View file

@ -10,6 +10,13 @@ export interface ExtractedClassSymbol {
name: string;
type: ClassLikeNodeLabel;
qualifiedName: string;
templateArguments?: string[];
}
export interface ClassCaptureContext {
captureMap: Record<string, SyntaxNode>;
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;
}

View file

@ -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<User>` vs `List<Order>`).
*
* 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<List<int>> → 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();

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,57 @@
/**
* Parse top-level generic/template arguments from a type-like string.
*
* Examples:
* - `List<int>` -> ['int']
* - `Map<string, vector<int>>` -> ['string', 'vector<int>']
* - `List<T*>` -> ['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(',')}`;
}

View file

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

View file

@ -0,0 +1,7 @@
#include "list_user.h"
#include "list_order.h"
void callUserSave() {
List<User> list;
list.save();
}

View file

@ -0,0 +1,14 @@
#pragma once
struct Order {};
template <typename T>
class List;
template <>
class List<Order> {
public:
void callSave() { save(); }
void save() { persistOrder(); }
void persistOrder() {}
};

View file

@ -0,0 +1,14 @@
#pragma once
struct User {};
template <typename T>
class List;
template <>
class List<User> {
public:
void callSave() { save(); }
void save() { persistUser(); }
void persistUser() {}
};

View file

@ -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<User> and List<Order>', () => {
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<string>();
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<User> receiver call to List<User>::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', () => {

View file

@ -162,6 +162,12 @@ const LEGACY_RESOLVER_PARITY_EXPECTED_FAILURES: Readonly<Record<string, Readonly
'emits EXTENDS edge: Derived → Base for qualified template base outer::v1::Base<T>',
'outer::v1::Base<T>::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<User> and List<Order>',
'callSave() in each specialization resolves to its own save()',
'save specialization bodies route to their own sibling method',
]),
};