mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-08-28 05:25:25 +00:00
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:
parent
c901ee4666
commit
586dbf7aa1
21 changed files with 517 additions and 13 deletions
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 } : {}),
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
57
gitnexus/src/core/ingestion/utils/template-arguments.ts
Normal file
57
gitnexus/src/core/ingestion/utils/template-arguments.ts
Normal 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(',')}`;
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
7
gitnexus/test/fixtures/lang-resolution/cpp-template-specialization-disambiguation/app.cpp
vendored
Normal file
7
gitnexus/test/fixtures/lang-resolution/cpp-template-specialization-disambiguation/app.cpp
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
#include "list_user.h"
|
||||
#include "list_order.h"
|
||||
|
||||
void callUserSave() {
|
||||
List<User> list;
|
||||
list.save();
|
||||
}
|
||||
14
gitnexus/test/fixtures/lang-resolution/cpp-template-specialization-disambiguation/list_order.h
vendored
Normal file
14
gitnexus/test/fixtures/lang-resolution/cpp-template-specialization-disambiguation/list_order.h
vendored
Normal 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() {}
|
||||
};
|
||||
14
gitnexus/test/fixtures/lang-resolution/cpp-template-specialization-disambiguation/list_user.h
vendored
Normal file
14
gitnexus/test/fixtures/lang-resolution/cpp-template-specialization-disambiguation/list_user.h
vendored
Normal 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() {}
|
||||
};
|
||||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
]),
|
||||
};
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue