From 0004aeeac89cff659cb637d6ca1599f80e19566b Mon Sep 17 00:00:00 2001 From: Gergo Magyar Date: Fri, 20 Mar 2026 07:23:14 +0000 Subject: [PATCH] feat: Implement virtual dispatch and overload disambiguation enhancements - Updated AGENTS.md and CLAUDE.md to reflect new indexing metrics. - Enhanced call-processor.ts to support cross-file inheritance tracking and improved virtual dispatch resolution. - Added support for TypeScript overload signatures in tree-sitter queries. - Improved type extraction for C++, C#, and Kotlin to handle smart pointers and constructor types. - Introduced inferLiteralType for overload disambiguation across multiple languages. - Added tests for C++ smart pointer dispatch and Kotlin virtual dispatch scenarios. - Updated type-resolution-roadmap.md to reflect completion of phases P.1 to P.3 and outline future work on covariant return types. --- AGENTS.md | 2 +- CLAUDE.md | 2 +- gitnexus/src/core/ingestion/call-processor.ts | 82 +++++++++++++---- gitnexus/src/core/ingestion/symbol-table.ts | 3 +- .../src/core/ingestion/tree-sitter-queries.ts | 4 + gitnexus/src/core/ingestion/type-env.ts | 48 +++++++--- .../core/ingestion/type-extractors/c-cpp.ts | 87 ++++++++++++++++++- .../core/ingestion/type-extractors/csharp.ts | 4 +- .../src/core/ingestion/type-extractors/jvm.ts | 41 ++++++--- .../core/ingestion/type-extractors/types.ts | 25 ++++-- .../ingestion/type-extractors/typescript.ts | 25 +++++- .../cpp-smart-ptr-dispatch/src/app.cpp | 17 ++++ .../kotlin-virtual-dispatch/models/Animal.kt | 5 ++ .../kotlin-virtual-dispatch/models/Dog.kt | 5 ++ .../kotlin-virtual-dispatch/services/App.kt | 9 ++ .../ts-overload-disambiguation/src/app.ts | 10 +++ .../test/integration/resolvers/cpp.test.ts | 26 ++++++ .../test/integration/resolvers/kotlin.test.ts | 26 ++++++ .../integration/resolvers/typescript.test.ts | 34 +++++++- type-resolution-roadmap.md | 12 ++- 20 files changed, 405 insertions(+), 62 deletions(-) create mode 100644 gitnexus/test/fixtures/lang-resolution/cpp-smart-ptr-dispatch/src/app.cpp create mode 100644 gitnexus/test/fixtures/lang-resolution/kotlin-virtual-dispatch/models/Animal.kt create mode 100644 gitnexus/test/fixtures/lang-resolution/kotlin-virtual-dispatch/models/Dog.kt create mode 100644 gitnexus/test/fixtures/lang-resolution/kotlin-virtual-dispatch/services/App.kt create mode 100644 gitnexus/test/fixtures/lang-resolution/ts-overload-disambiguation/src/app.ts diff --git a/AGENTS.md b/AGENTS.md index 43c71b854..c93ee1b23 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,7 +1,7 @@ # GitNexus — Code Intelligence -This project is indexed by GitNexus as **GitNexus** (2174 symbols, 5218 relationships, 166 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. +This project is indexed by GitNexus as **GitNexus** (2184 symbols, 5245 relationships, 167 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. > If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first. diff --git a/CLAUDE.md b/CLAUDE.md index 43c71b854..c93ee1b23 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,7 +1,7 @@ # GitNexus — Code Intelligence -This project is indexed by GitNexus as **GitNexus** (2174 symbols, 5218 relationships, 166 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. +This project is indexed by GitNexus as **GitNexus** (2184 symbols, 5245 relationships, 167 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. > If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first. diff --git a/gitnexus/src/core/ingestion/call-processor.ts b/gitnexus/src/core/ingestion/call-processor.ts index 71a3ef8c4..c433f8ffb 100644 --- a/gitnexus/src/core/ingestion/call-processor.ts +++ b/gitnexus/src/core/ingestion/call-processor.ts @@ -30,6 +30,7 @@ import type { ExtractedCall, ExtractedAssignment, ExtractedHeritage, ExtractedRo import { callRouters } from './call-routing.js'; import { extractReturnTypeName, stripNullable } from './type-extractors/shared.js'; import { typeConfigs } from './type-extractors/index.js'; +import type { LiteralTypeInferrer } from './type-extractors/types.js'; import type { SyntaxNode } from './utils.js'; // Stdlib methods that preserve the receiver's type identity. When TypeEnv already @@ -141,6 +142,12 @@ export const processCalls = async ( const parser = await loadParser(); const collectedHeritage: ExtractedHeritage[] = []; const pendingWrites: { receiverTypeName: string; propertyName: string; filePath: string; srcId: string }[] = []; + // Phase P cross-file: accumulate heritage across files for cross-file isSubclassOf. + // Used as a secondary check when per-file parentMap lacks the relationship — helps + // when the heritage-declaring file is processed before the call site file. + // For remaining cases (reverse file order), the SymbolTable class-type fallback applies. + const globalParentMap = new Map(); + const globalParentSeen = new Map>(); const logSkipped = isVerboseIngestionEnabled(); const skippedByLang = logSkipped ? new Map() : null; @@ -204,6 +211,17 @@ export const processCalls = async ( } } const parentMap: ReadonlyMap = fileParentMap; + // Merge per-file heritage into globalParentMap for cross-file isSubclassOf lookups. + // Uses a parallel Set (globalParentSeen) for O(1) deduplication instead of O(n) includes(). + for (const [cls, parents] of fileParentMap) { + let global = globalParentMap.get(cls); + let seen = globalParentSeen.get(cls); + if (!global) { global = []; globalParentMap.set(cls, global); } + if (!seen) { seen = new Set(); globalParentSeen.set(cls, seen); } + for (const p of parents) { + if (!seen.has(p)) { seen.add(p); global.push(p); } + } + } const typeEnv = lang ? buildTypeEnv(tree, lang, { symbolTable: ctx.symbols, parentMap }) : null; const callRouter = callRouters[language]; @@ -329,22 +347,35 @@ export const processCalls = async ( let receiverTypeName = receiverName && typeEnv ? typeEnv.lookup(receiverName, callNode) : undefined; // Phase P: virtual dispatch override — when the declared type is a base class but // the constructor created a known subclass, prefer the more specific type. - // Same-file only (parentMap is per-file from heritage pre-pass). - // Scans constructorTypeMap for entries matching `*\0receiverName` since - // reconstructing the exact scope key from call-processor context is fragile. + // Checks per-file parentMap first, then falls back to globalParentMap for + // cross-file heritage (e.g. Dog extends Animal declared in a different file). + // Reconstructs the exact scope key (funcName@startIndex\0varName) from the + // enclosing function AST node for a correct, O(1) map lookup. if (receiverTypeName && receiverName && typeEnv && typeEnv.constructorTypeMap.size > 0) { - // Quick scan for matching receiver name in constructorTypeMap - let ctorType: string | undefined; - for (const [key, val] of typeEnv.constructorTypeMap) { - const nul = key.indexOf('\0'); - if (nul >= 0 && key.slice(nul + 1) === receiverName) { - ctorType = val; - break; + // Reconstruct scope key to match constructorTypeMap's scope\0varName format + let scope = ''; + let p = callNode.parent; + while (p) { + if (FUNCTION_NODE_TYPES.has(p.type)) { + const { funcName } = extractFunctionName(p); + if (funcName) { scope = `${funcName}@${p.startIndex}`; break; } } + p = p.parent; } + const ctorType = typeEnv.constructorTypeMap.get(`${scope}\0${receiverName}`); if (ctorType && ctorType !== receiverTypeName) { - // Verify subclass relationship via same-file parentMap - if (isSubclassOf(ctorType, receiverTypeName, parentMap)) { + // Verify subclass relationship: per-file parentMap first, then cross-file + // globalParentMap, then fall back to SymbolTable class verification. + // The SymbolTable fallback handles cross-file cases where heritage is declared + // in a file not yet processed (e.g. Dog extends Animal in models/Dog.kt when + // processing services/App.kt). Since constructorTypeMap only records entries + // when a type annotation AND constructor are both present (val x: Base = Sub()), + // confirming both are class-like types is sufficient — the original code would + // not compile if Sub didn't extend Base. + if (isSubclassOf(ctorType, receiverTypeName, parentMap) + || isSubclassOf(ctorType, receiverTypeName, globalParentMap) + || (ctx.symbols.lookupFuzzy(ctorType).some(d => d.type === 'Class' || d.type === 'Struct') + && ctx.symbols.lookupFuzzy(receiverTypeName).some(d => d.type === 'Class' || d.type === 'Struct' || d.type === 'Interface'))) { receiverTypeName = ctorType; } } @@ -525,7 +556,7 @@ const toResolveResult = ( * Only available on the sequential path (has AST); worker path passes undefined. */ interface OverloadHints { callNode: SyntaxNode; - inferLiteralType: (node: SyntaxNode) => string | undefined; + inferLiteralType: LiteralTypeInferrer; } /** @@ -582,10 +613,12 @@ const tryOverloadDisambiguation = ( for (const arg of argList.namedChildren) { if (arg.type === 'comment') continue; // Unwrap argument wrapper nodes before passing to inferLiteralType: - // - Kotlin value_argument: literal is the first (and only) named child - // - C# argument: no named fields — the literal is the only named child + // - Kotlin value_argument: has 'value' field containing the literal + // - C# argument: has 'expression' field (handles named args like `name: "alice"` + // where firstNamedChild would return name_colon instead of the value) // - Java/others: arg IS the literal directly (no unwrapping needed) const valueNode = arg.childForFieldName?.('value') + ?? arg.childForFieldName?.('expression') ?? (arg.type === 'argument' || arg.type === 'value_argument' ? arg.firstNamedChild ?? arg : arg); @@ -596,7 +629,10 @@ const tryOverloadDisambiguation = ( if (argTypes.every(t => t === undefined)) return null; const matched = candidates.filter(c => { - if (!c.parameterTypes) return true; // Keep candidates without type info + // Keep candidates without type info — conservative: partially-annotated codebases + // (e.g. C++ with some missing declarations) may have mixed typed/untyped overloads. + // If one typed and one untyped both survive, matched.length > 1 → returns null (no edge). + if (!c.parameterTypes) return true; return c.parameterTypes.every((pType, i) => { if (i >= argTypes.length || !argTypes[i]) return true; // Normalise Kotlin boxed type names (Int→int, Boolean→boolean, etc.) so @@ -605,7 +641,15 @@ const tryOverloadDisambiguation = ( }); }); - return matched.length === 1 ? matched[0] : null; + if (matched.length === 1) return matched[0]; + // Multiple survivors may share the same nodeId (e.g. TypeScript overload signatures + + // implementation body all collide via generateId). Deduplicate by nodeId — if all + // matched candidates resolve to the same graph node, disambiguation succeeded. + if (matched.length > 1) { + const uniqueIds = new Set(matched.map(c => c.nodeId)); + if (uniqueIds.size === 1) return matched[0]; + } + return null; }; /** @@ -665,8 +709,8 @@ const resolveCallTarget = ( } // E. Try overload disambiguation on the narrowed pool if ((fileFiltered.length > 1 || ownerFiltered.length > 1) && overloadHints) { - const pool = ownerFiltered.length > 1 ? ownerFiltered : fileFiltered; - const disambiguated = tryOverloadDisambiguation(pool, overloadHints); + const overloadPool = ownerFiltered.length > 1 ? ownerFiltered : fileFiltered; + const disambiguated = tryOverloadDisambiguation(overloadPool, overloadHints); if (disambiguated) return toResolveResult(disambiguated, tiered.tier); } if (fileFiltered.length > 1 || ownerFiltered.length > 1) return null; diff --git a/gitnexus/src/core/ingestion/symbol-table.ts b/gitnexus/src/core/ingestion/symbol-table.ts index ea8d0f9b3..3ba629826 100644 --- a/gitnexus/src/core/ingestion/symbol-table.ts +++ b/gitnexus/src/core/ingestion/symbol-table.ts @@ -6,7 +6,8 @@ export interface SymbolDefinition { type: NodeLabel; parameterCount?: number; /** Per-parameter type names for overload disambiguation (e.g. ['int', 'String']). - * Only populated for overloading languages (Java, Kotlin, C#, C++). */ + * Populated when parameter types are resolvable from AST (any typed language). + * Used for disambiguation in overloading languages (Java, Kotlin, C#, C++). */ parameterTypes?: string[]; /** Raw return type text extracted from AST (e.g. 'User', 'Promise') */ returnType?: string; diff --git a/gitnexus/src/core/ingestion/tree-sitter-queries.ts b/gitnexus/src/core/ingestion/tree-sitter-queries.ts index f2b7c9c6e..70d26116c 100644 --- a/gitnexus/src/core/ingestion/tree-sitter-queries.ts +++ b/gitnexus/src/core/ingestion/tree-sitter-queries.ts @@ -19,6 +19,10 @@ export const TYPESCRIPT_QUERIES = ` (function_declaration name: (identifier) @name) @definition.function +; TypeScript overload signatures (function_signature is a separate node type from function_declaration) +(function_signature + name: (identifier) @name) @definition.function + (method_definition name: (property_identifier) @name) @definition.method diff --git a/gitnexus/src/core/ingestion/type-env.ts b/gitnexus/src/core/ingestion/type-env.ts index 1fab53daf..f370e20c1 100644 --- a/gitnexus/src/core/ingestion/type-env.ts +++ b/gitnexus/src/core/ingestion/type-env.ts @@ -1,5 +1,5 @@ import type { SyntaxNode } from './utils.js'; -import { FUNCTION_NODE_TYPES, extractFunctionName, CLASS_CONTAINER_TYPES, isBuiltInOrNoise } from './utils.js'; +import { FUNCTION_NODE_TYPES, extractFunctionName, CLASS_CONTAINER_TYPES, CALL_EXPRESSION_TYPES, isBuiltInOrNoise } from './utils.js'; import { SupportedLanguages } from '../../config/supported-languages.js'; import { typeConfigs, TYPED_PARAMETER_TYPES } from './type-extractors/index.js'; import type { ClassNameLookup, ReturnTypeLookup, ForLoopExtractorContext, PendingAssignment } from './type-extractors/types.js'; @@ -409,9 +409,12 @@ const createClassDefCache = (symbolTable?: SymbolTable) => { }; }; -/** AST node types representing constructor expressions across languages. */ +/** AST node types representing constructor expressions across languages. + * Note: C# also has `implicit_object_creation_expression` (`new()` with type + * inference) which is NOT captured — the type is inferred, not explicit. + * Kotlin constructors use `call_expression` (no `new` keyword) — not detected. */ const CONSTRUCTOR_EXPR_TYPES = new Set([ - 'new_expression', // TS/JS: new Dog() + 'new_expression', // TS/JS/C++: new Dog() 'object_creation_expression', // Java/C#: new Dog() ]); @@ -434,14 +437,19 @@ const extractConstructorTypeName = (node: SyntaxNode, depth = 0): string | undef for (let i = 0; i < node.namedChildCount; i++) { const child = node.namedChild(i); if (!child) continue; - // Don't descend into nested functions/classes - if (FUNCTION_NODE_TYPES.has(child.type) || CLASS_CONTAINER_TYPES.has(child.type)) continue; + // Don't descend into nested functions/classes or call expressions (prevents + // finding constructor args inside method calls, e.g. processAll(new Dog())) + if (FUNCTION_NODE_TYPES.has(child.type) || CLASS_CONTAINER_TYPES.has(child.type) + || CALL_EXPRESSION_TYPES.has(child.type)) continue; const result = extractConstructorTypeName(child, depth + 1); if (result) return result; } return undefined; }; +/** Max depth for MRO parent chain walking. Real-world inheritance rarely exceeds 3-4 levels. */ +const MAX_MRO_DEPTH = 5; + /** Check if `child` is a subclass of `parent` using the parentMap. * BFS up from child, depth-limited (5), cycle-safe. */ export const isSubclassOf = ( @@ -466,9 +474,6 @@ export const isSubclassOf = ( return false; }; -/** Max depth for MRO parent chain walking. Real-world inheritance rarely exceeds 3-4 levels. */ -const MAX_MRO_DEPTH = 5; - /** Walk up the parent class chain to find a field or method on an ancestor. * BFS-like traversal with depth limit and cycle detection. First match wins. * Used by resolveFieldType and resolveMethodReturnType when direct lookup fails. */ @@ -795,7 +800,20 @@ export const buildTypeEnv = ( if (c?.type === 'variable_declaration') { wrapped = c; break; } } } - if (wrapped) typeNode = wrapped.childForFieldName('type'); + if (wrapped) { + typeNode = wrapped.childForFieldName('type'); + // Kotlin: variable_declaration stores the type as user_type / nullable_type + // child rather than a named 'type' field. + if (!typeNode) { + for (let i = 0; i < wrapped.namedChildCount; i++) { + const c = wrapped.namedChild(i); + if (c && (c.type === 'user_type' || c.type === 'nullable_type')) { + typeNode = c; + break; + } + } + } + } } if (typeNode) { const nameNode = node.childForFieldName('name') @@ -837,8 +855,16 @@ export const buildTypeEnv = ( if (keysBefore.has(varName)) continue; const declaredType = scopeEnv.get(varName); if (!declaredType) continue; - const ctorType = extractConstructorTypeName(node); - if (ctorType && ctorType !== declaredType) { + const ctorType = extractConstructorTypeName(node) + ?? config.detectConstructorType?.(node, classNames); + if (!ctorType || ctorType === declaredType) continue; + // Unwrap wrapper types (e.g., C++ shared_ptr → Animal) for an + // accurate isSubclassOf comparison. Language-specific via config hook. + const declTypeNode = declarationTypeNodes.get(`${scope}\0${varName}`); + const effectiveDeclaredType = (declTypeNode && config.unwrapDeclaredType) + ? (config.unwrapDeclaredType(declaredType, declTypeNode) ?? declaredType) + : declaredType; + if (ctorType !== effectiveDeclaredType) { constructorTypeMap.set(`${scope}\0${varName}`, ctorType); } } diff --git a/gitnexus/src/core/ingestion/type-extractors/c-cpp.ts b/gitnexus/src/core/ingestion/type-extractors/c-cpp.ts index 818a2c73c..8b4da3731 100644 --- a/gitnexus/src/core/ingestion/type-extractors/c-cpp.ts +++ b/gitnexus/src/core/ingestion/type-extractors/c-cpp.ts @@ -1,11 +1,34 @@ import type { SyntaxNode } from '../utils.js'; -import type { LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor, InitializerExtractor, ClassNameLookup, ConstructorBindingScanner, PendingAssignmentExtractor, ForLoopExtractor } from './types.js'; +import type { LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor, InitializerExtractor, ClassNameLookup, ConstructorBindingScanner, PendingAssignmentExtractor, ForLoopExtractor, LiteralTypeInferrer, ConstructorTypeDetector, DeclaredTypeUnwrapper } from './types.js'; import { extractSimpleTypeName, extractVarName, resolveIterableElementType, methodToTypeArgPosition, type TypeArgPosition } from './shared.js'; const DECLARATION_NODE_TYPES: ReadonlySet = new Set([ 'declaration', ]); +/** Smart pointer factory function names that create a typed object. */ +const SMART_PTR_FACTORIES = new Set([ + 'make_shared', 'make_unique', 'make_shared_for_overwrite', +]); + +/** Smart pointer wrapper type names. When the declared type is a smart pointer, + * the inner template type is extracted for virtual dispatch comparison. */ +const SMART_PTR_WRAPPERS = new Set(['shared_ptr', 'unique_ptr', 'weak_ptr']); + +/** Extract the first type name from a template_argument_list child. + * Unwraps type_descriptor wrappers common in tree-sitter-cpp ASTs. + * Returns undefined if no template arguments or no type found. */ +export const extractFirstTemplateTypeArg = (parentNode: SyntaxNode): string | undefined => { + const templateArgs = parentNode.children.find((c: any) => c.type === 'template_argument_list'); + if (!templateArgs?.firstNamedChild) return undefined; + let argNode: any = templateArgs.firstNamedChild; + if (argNode.type === 'type_descriptor') { + const inner = argNode.childForFieldName('type'); + if (inner) argNode = inner; + } + return extractSimpleTypeName(argNode) ?? undefined; +}; + /** C++: Type x = ...; Type* x; Type& x; */ const extractDeclaration: TypeBindingExtractor = (node: SyntaxNode, env: Map): void => { const typeNode = node.childForFieldName('type'); @@ -88,6 +111,27 @@ const extractInitializer: InitializerExtractor = (node: SyntaxNode, env: Map() — smart pointer factory via template_function. + // AST: call_expression > function: qualified_identifier > template_function + // or: call_expression > function: template_function (unqualified) + const templateFunc = func.type === 'template_function' + ? func + : (func.type === 'qualified_identifier' || func.type === 'scoped_identifier') + ? func.namedChildren.find((c: any) => c.type === 'template_function') ?? null + : null; + if (templateFunc) { + const nameNode = templateFunc.firstNamedChild; + if (nameNode) { + const funcName = (nameNode.type === 'qualified_identifier' || nameNode.type === 'scoped_identifier') + ? nameNode.lastNamedChild?.text ?? '' + : nameNode.text; + if (SMART_PTR_FACTORIES.has(funcName)) { + const typeName = extractFirstTemplateTypeArg(templateFunc); + if (typeName) env.set(varName, typeName); + } + } + } } return; } @@ -372,7 +416,7 @@ const extractForLoopBinding: ForLoopExtractor = (node, { scopeEnv, declarationTy }; /** Infer the type of a literal AST node for C++ overload disambiguation. */ -const inferLiteralType = (node: SyntaxNode): string | undefined => { +const inferLiteralType: LiteralTypeInferrer = (node) => { switch (node.type) { case 'number_literal': { const t = node.text; @@ -400,6 +444,43 @@ const inferLiteralType = (node: SyntaxNode): string | undefined => { } }; +/** C++: detect constructor type from smart pointer factory calls (make_shared()). + * Extracts the template type argument as the constructor type for virtual dispatch. */ +const detectCppConstructorType: ConstructorTypeDetector = (node, classNames) => { + // Navigate to the initializer value in the declaration + const declarator = node.childForFieldName('declarator'); + const initDecl = declarator?.type === 'init_declarator' ? declarator : undefined; + if (!initDecl) return undefined; + const value = initDecl.childForFieldName('value'); + if (!value || value.type !== 'call_expression') return undefined; + + // Check for template_function pattern: make_shared() + const func = value.childForFieldName('function'); + if (!func || func.type !== 'template_function') return undefined; + + // Extract function name (possibly qualified: std::make_shared) + const nameNode = func.firstNamedChild; + if (!nameNode) return undefined; + let funcName: string; + if (nameNode.type === 'qualified_identifier' || nameNode.type === 'scoped_identifier') { + funcName = nameNode.lastNamedChild?.text ?? ''; + } else { + funcName = nameNode.text; + } + if (!SMART_PTR_FACTORIES.has(funcName)) return undefined; + + // Extract template type argument + return extractFirstTemplateTypeArg(func); +}; + +/** Unwrap a C++ smart pointer declared type to its inner template type. + * E.g., shared_ptr → Animal. Returns the original name if not a smart pointer. */ +const unwrapCppDeclaredType: DeclaredTypeUnwrapper = (declaredType, typeNode) => { + if (!SMART_PTR_WRAPPERS.has(declaredType)) return declaredType; + if (typeNode.type !== 'template_type') return declaredType; + return extractFirstTemplateTypeArg(typeNode) ?? declaredType; +}; + export const typeConfig: LanguageTypeConfig = { declarationNodeTypes: DECLARATION_NODE_TYPES, forLoopNodeTypes: FOR_LOOP_NODE_TYPES, @@ -410,4 +491,6 @@ export const typeConfig: LanguageTypeConfig = { extractForLoopBinding, extractPendingAssignment, inferLiteralType, + detectConstructorType: detectCppConstructorType, + unwrapDeclaredType: unwrapCppDeclaredType, }; diff --git a/gitnexus/src/core/ingestion/type-extractors/csharp.ts b/gitnexus/src/core/ingestion/type-extractors/csharp.ts index 96d430fa9..64468b1e5 100644 --- a/gitnexus/src/core/ingestion/type-extractors/csharp.ts +++ b/gitnexus/src/core/ingestion/type-extractors/csharp.ts @@ -1,5 +1,5 @@ import type { SyntaxNode } from '../utils.js'; -import type { ConstructorBindingScanner, ForLoopExtractor, LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor, PendingAssignmentExtractor, PatternBindingExtractor } from './types.js'; +import type { ConstructorBindingScanner, ForLoopExtractor, LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor, PendingAssignmentExtractor, PatternBindingExtractor, LiteralTypeInferrer } from './types.js'; import { extractSimpleTypeName, extractVarName, findChildByType, unwrapAwait, extractGenericTypeArgs, resolveIterableElementType, methodToTypeArgPosition, extractElementTypeFromString, type TypeArgPosition } from './shared.js'; /** Known container property accessors that operate on the container itself (e.g., dict.Keys, dict.Values) */ @@ -464,7 +464,7 @@ const extractPendingAssignment: PendingAssignmentExtractor = (node, scopeEnv) => }; /** Infer the type of a literal AST node for C# overload disambiguation. */ -const inferLiteralType = (node: SyntaxNode): string | undefined => { +const inferLiteralType: LiteralTypeInferrer = (node) => { switch (node.type) { case 'integer_literal': if (node.text.endsWith('L') || node.text.endsWith('l')) return 'long'; diff --git a/gitnexus/src/core/ingestion/type-extractors/jvm.ts b/gitnexus/src/core/ingestion/type-extractors/jvm.ts index 852ada5e1..528afa080 100644 --- a/gitnexus/src/core/ingestion/type-extractors/jvm.ts +++ b/gitnexus/src/core/ingestion/type-extractors/jvm.ts @@ -1,5 +1,5 @@ import type { SyntaxNode } from '../utils.js'; -import type { LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor, InitializerExtractor, ClassNameLookup, ConstructorBindingScanner, ForLoopExtractor, PendingAssignmentExtractor, PatternBindingExtractor } from './types.js'; +import type { LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor, InitializerExtractor, ClassNameLookup, ConstructorBindingScanner, ForLoopExtractor, PendingAssignmentExtractor, PatternBindingExtractor, LiteralTypeInferrer, ConstructorTypeDetector } from './types.js'; import { extractSimpleTypeName, extractVarName, findChildByType, extractGenericTypeArgs, resolveIterableElementType, methodToTypeArgPosition, extractElementTypeFromString, type TypeArgPosition } from './shared.js'; // ── Java ────────────────────────────────────────────────────────────────── @@ -269,7 +269,7 @@ const extractJavaPatternBinding: PatternBindingExtractor = (node) => { }; /** Infer the type of a literal AST node for Java/Kotlin overload disambiguation. */ -const inferJvmLiteralType = (node: SyntaxNode): string | undefined => { +const inferJvmLiteralType: LiteralTypeInferrer = (node) => { switch (node.type) { case 'decimal_integer_literal': case 'integer_literal': @@ -384,26 +384,30 @@ const extractKotlinParameter: ParameterExtractor = (node: SyntaxNode, env: Map { + if (node.type !== 'property_declaration') return undefined; + const value = node.childForFieldName('value') + ?? findChildByType(node, 'call_expression'); + if (!value || value.type !== 'call_expression') return undefined; + const callee = value.firstNamedChild; + if (!callee || callee.type !== 'simple_identifier') return undefined; + const calleeName = callee.text; + if (!calleeName || !classNames.has(calleeName)) return undefined; + return calleeName; +}; + /** Kotlin: val user = User() — infer type from call_expression when callee is a known class. * Kotlin constructors are syntactically identical to function calls, so we verify * against classNames (which may include cross-file SymbolTable lookups). */ const extractKotlinInitializer: InitializerExtractor = (node: SyntaxNode, env: Map, classNames: ClassNameLookup): void => { - if (node.type !== 'property_declaration') return; // Skip if there's an explicit type annotation — Tier 0 already handled it const varDecl = findChildByType(node, 'variable_declaration'); if (varDecl && findChildByType(varDecl, 'user_type')) return; - // Get the initializer value — the call_expression after '=' - const value = node.childForFieldName('value') - ?? findChildByType(node, 'call_expression'); - if (!value || value.type !== 'call_expression') return; - - // The callee is the first child of call_expression (simple_identifier for direct calls) - const callee = value.firstNamedChild; - if (!callee || callee.type !== 'simple_identifier') return; - - const calleeName = callee.text; - if (!calleeName || !classNames.has(calleeName)) return; + const calleeName = findKotlinConstructorCallee(node, classNames); + if (!calleeName) return; // Extract the variable name from the variable_declaration inside property_declaration const nameNode = varDecl @@ -415,6 +419,14 @@ const extractKotlinInitializer: InitializerExtractor = (node: SyntaxNode, env: M if (varName) env.set(varName, calleeName); }; +/** Kotlin: detect constructor type from call_expression in typed declarations. + * Unlike extractKotlinInitializer (which SKIPS typed declarations), this detects + * the constructor type EVEN when a type annotation exists, enabling virtual dispatch + * for patterns like `val a: Animal = Dog()`. */ +const detectKotlinConstructorType: ConstructorTypeDetector = (node, classNames) => { + return findKotlinConstructorCallee(node, classNames); +}; + /** Kotlin: val x = User(...) — constructor binding for property_declaration with call_expression */ const scanKotlinConstructorBinding: ConstructorBindingScanner = (node) => { if (node.type !== 'property_declaration') return undefined; @@ -773,4 +785,5 @@ export const kotlinTypeConfig: LanguageTypeConfig = { extractPendingAssignment: extractKotlinPendingAssignment, extractPatternBinding: extractKotlinPatternBinding, inferLiteralType: inferJvmLiteralType, + detectConstructorType: detectKotlinConstructorType, }; diff --git a/gitnexus/src/core/ingestion/type-extractors/types.ts b/gitnexus/src/core/ingestion/type-extractors/types.ts index 66a742759..e6c4c227b 100644 --- a/gitnexus/src/core/ingestion/type-extractors/types.ts +++ b/gitnexus/src/core/ingestion/type-extractors/types.ts @@ -24,6 +24,23 @@ export type ConstructorBindingScanner = (node: SyntaxNode) => { varName: string; * rather than in AST fields. Returns undefined if no return type can be determined. */ export type ReturnTypeExtractor = (node: SyntaxNode) => string | undefined; +/** Infer the type name of a literal AST node for overload disambiguation. + * Returns the canonical type name (e.g. 'int', 'String', 'boolean') or undefined + * for non-literal nodes. Only used when resolveCallTarget has multiple candidates + * with parameterTypes — ~1-3% of call sites. */ +export type LiteralTypeInferrer = (node: SyntaxNode) => string | undefined; + +/** Detect constructor-style call expressions that don't use `new` keyword. + * Returns the constructor class name if the node's initializer is a constructor call, + * or undefined otherwise. Used for virtual dispatch in languages like Kotlin + * where constructors are syntactically identical to function calls, and C++ + * where smart pointer factory functions (make_shared/make_unique) wrap constructors. */ +export type ConstructorTypeDetector = (node: SyntaxNode, classNames: ClassNameLookup) => string | undefined; + +/** Unwrap a declared type name to its inner type for virtual dispatch comparison. + * E.g., C++ shared_ptr → Animal. Returns undefined if no unwrapping applies. */ +export type DeclaredTypeUnwrapper = (declaredType: string, typeNode: SyntaxNode) => string | undefined; + /** Narrow lookup interface for resolving a callee name → return type name. * Backed by SymbolTable.lookupFuzzyCallable; passed via ForLoopExtractorContext. * Conservative: returns undefined when the callee is ambiguous (0 or 2+ matches). */ @@ -149,9 +166,7 @@ export interface LanguageTypeConfig { * The extractor receives the current scope's resolved bindings (read-only) to look up the * source variable's type. Returns undefined for non-matching nodes or unknown source types. */ extractPatternBinding?: PatternBindingExtractor; - /** Infer the type name of a literal AST node for overload disambiguation. - * Returns the canonical type name (e.g. 'int', 'String', 'boolean') or undefined - * for non-literal nodes. Only used when resolveCallTarget has multiple candidates - * with parameterTypes — ~1-3% of call sites. */ - inferLiteralType?: (node: SyntaxNode) => string | undefined; + inferLiteralType?: LiteralTypeInferrer; + detectConstructorType?: ConstructorTypeDetector; + unwrapDeclaredType?: DeclaredTypeUnwrapper; } diff --git a/gitnexus/src/core/ingestion/type-extractors/typescript.ts b/gitnexus/src/core/ingestion/type-extractors/typescript.ts index d193e8399..eba798cc0 100644 --- a/gitnexus/src/core/ingestion/type-extractors/typescript.ts +++ b/gitnexus/src/core/ingestion/type-extractors/typescript.ts @@ -1,5 +1,5 @@ import type { SyntaxNode } from '../utils.js'; -import type { LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor, InitializerExtractor, ClassNameLookup, ConstructorBindingScanner, ReturnTypeExtractor, PendingAssignmentExtractor, PendingAssignment, ForLoopExtractor, PatternBindingExtractor } from './types.js'; +import type { LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor, InitializerExtractor, ClassNameLookup, ConstructorBindingScanner, ReturnTypeExtractor, PendingAssignmentExtractor, PendingAssignment, ForLoopExtractor, PatternBindingExtractor, LiteralTypeInferrer } from './types.js'; import { extractSimpleTypeName, extractVarName, hasTypeAnnotation, unwrapAwait, extractCalleeName, extractElementTypeFromString, extractGenericTypeArgs, resolveIterableElementType, methodToTypeArgPosition, type TypeArgPosition } from './shared.js'; const DECLARATION_NODE_TYPES: ReadonlySet = new Set([ @@ -597,6 +597,28 @@ const extractPatternBinding: PatternBindingExtractor = (node, scopeEnv, declarat }; }; +/** Infer the type of a literal AST node for TypeScript overload disambiguation. */ +const inferTsLiteralType: LiteralTypeInferrer = (node) => { + switch (node.type) { + case 'number': + return 'number'; + case 'string': + case 'template_string': + return 'string'; + case 'true': + case 'false': + return 'boolean'; + case 'null': + return 'null'; + case 'undefined': + return 'undefined'; + case 'regex': + return 'RegExp'; + default: + return undefined; + } +}; + export const typeConfig: LanguageTypeConfig = { declarationNodeTypes: DECLARATION_NODE_TYPES, forLoopNodeTypes: FOR_LOOP_NODE_TYPES, @@ -609,4 +631,5 @@ export const typeConfig: LanguageTypeConfig = { extractForLoopBinding, extractPendingAssignment, extractPatternBinding, + inferLiteralType: inferTsLiteralType, }; diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-smart-ptr-dispatch/src/app.cpp b/gitnexus/test/fixtures/lang-resolution/cpp-smart-ptr-dispatch/src/app.cpp new file mode 100644 index 000000000..ff3b38c85 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-smart-ptr-dispatch/src/app.cpp @@ -0,0 +1,17 @@ +#include +#include + +class Animal { +public: + virtual std::string speak() { return "..."; } +}; + +class Dog : public Animal { +public: + std::string speak() override { return "woof"; } +}; + +void process() { + auto dog = std::make_shared(); + dog->speak(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/kotlin-virtual-dispatch/models/Animal.kt b/gitnexus/test/fixtures/lang-resolution/kotlin-virtual-dispatch/models/Animal.kt new file mode 100644 index 000000000..036ef3e8d --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/kotlin-virtual-dispatch/models/Animal.kt @@ -0,0 +1,5 @@ +package models + +open class Animal { + open fun speak(): String = "..." +} diff --git a/gitnexus/test/fixtures/lang-resolution/kotlin-virtual-dispatch/models/Dog.kt b/gitnexus/test/fixtures/lang-resolution/kotlin-virtual-dispatch/models/Dog.kt new file mode 100644 index 000000000..ae7e76a47 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/kotlin-virtual-dispatch/models/Dog.kt @@ -0,0 +1,5 @@ +package models + +class Dog : Animal() { + override fun speak(): String = "woof" +} diff --git a/gitnexus/test/fixtures/lang-resolution/kotlin-virtual-dispatch/services/App.kt b/gitnexus/test/fixtures/lang-resolution/kotlin-virtual-dispatch/services/App.kt new file mode 100644 index 000000000..8dc94d6dd --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/kotlin-virtual-dispatch/services/App.kt @@ -0,0 +1,9 @@ +package services + +import models.Animal +import models.Dog + +fun process() { + val animal: Animal = Dog() + animal.speak() +} diff --git a/gitnexus/test/fixtures/lang-resolution/ts-overload-disambiguation/src/app.ts b/gitnexus/test/fixtures/lang-resolution/ts-overload-disambiguation/src/app.ts new file mode 100644 index 000000000..76e4efc85 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/ts-overload-disambiguation/src/app.ts @@ -0,0 +1,10 @@ +function lookup(id: number): string; +function lookup(name: string): string; +function lookup(key: number | string): string { + return String(key); +} + +function process() { + lookup(42); + lookup("alice"); +} diff --git a/gitnexus/test/integration/resolvers/cpp.test.ts b/gitnexus/test/integration/resolvers/cpp.test.ts index 4f4e99510..2ef03aa70 100644 --- a/gitnexus/test/integration/resolvers/cpp.test.ts +++ b/gitnexus/test/integration/resolvers/cpp.test.ts @@ -1088,3 +1088,29 @@ describe('C++ overload disambiguation by parameter types', () => { expect(lookupCalls.length).toBe(1); }); }); + +// --------------------------------------------------------------------------- +// C++ smart pointer virtual dispatch via std::make_shared() +// --------------------------------------------------------------------------- + +describe('C++ smart pointer virtual dispatch via make_shared', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'cpp-smart-ptr-dispatch'), + () => {}, + ); + }, 60000); + + it('detects Dog and Animal classes', () => { + expect(getNodesByLabel(result, 'Class')).toContain('Animal'); + expect(getNodesByLabel(result, 'Class')).toContain('Dog'); + }); + + it('emits CALLS edge from process → speak', () => { + const calls = getRelationships(result, 'CALLS'); + const speakCall = calls.find(c => c.source === 'process' && c.target === 'speak'); + expect(speakCall).toBeDefined(); + }); +}); diff --git a/gitnexus/test/integration/resolvers/kotlin.test.ts b/gitnexus/test/integration/resolvers/kotlin.test.ts index b57d9ce89..04ee162dd 100644 --- a/gitnexus/test/integration/resolvers/kotlin.test.ts +++ b/gitnexus/test/integration/resolvers/kotlin.test.ts @@ -1559,3 +1559,29 @@ describe('Kotlin overload disambiguation by parameter types', () => { expect(lookupCalls.length).toBe(1); }); }); + +// ── Phase P: Virtual Dispatch via Constructor Type (cross-file) ────────── + +describe('Kotlin virtual dispatch via constructor type (cross-file)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'kotlin-virtual-dispatch'), + () => {}, + ); + }, 60000); + + it('detects Dog class', () => { + const classes = getNodesByLabel(result, 'Class'); + expect(classes).toContain('Dog'); + }); + + it('resolves animal.speak() to models/Dog.kt via constructor type override', () => { + const calls = getRelationships(result, 'CALLS'); + const speakCall = calls.find(c => + c.source === 'process' && c.target === 'speak' && c.targetFilePath === 'models/Dog.kt', + ); + expect(speakCall).toBeDefined(); + }); +}); diff --git a/gitnexus/test/integration/resolvers/typescript.test.ts b/gitnexus/test/integration/resolvers/typescript.test.ts index 3bb205874..48823b590 100644 --- a/gitnexus/test/integration/resolvers/typescript.test.ts +++ b/gitnexus/test/integration/resolvers/typescript.test.ts @@ -4,7 +4,7 @@ import { describe, it, expect, beforeAll } from 'vitest'; import path from 'path'; import { - FIXTURES, getRelationships, getNodesByLabel, edgeSet, + FIXTURES, getRelationships, getNodesByLabel, getNodesByLabelFull, edgeSet, runPipelineFromRepo, type PipelineResult, } from './helpers.js'; @@ -2258,3 +2258,35 @@ describe('TypeScript virtual dispatch via constructor type (same-file)', () => { expect(fetchCalls.length).toBe(1); }); }); + +// ── Phase P: Overload Disambiguation via inferLiteralType ──────────────── + +describe('TypeScript overload disambiguation via inferLiteralType', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'ts-overload-disambiguation'), + () => {}, + ); + }, 60000); + + it('detects lookup function with parameterTypes on graph node', () => { + const functions = getNodesByLabelFull(result, 'Function'); + const lookupNodes = functions.filter(f => f.name === 'lookup'); + // generateId collision → 1 graph node, first overload's parameterTypes wins + expect(lookupNodes.length).toBeGreaterThanOrEqual(1); + // At least one lookup node has parameterTypes set + const withParamTypes = lookupNodes.filter(n => n.properties.parameterTypes); + expect(withParamTypes.length).toBeGreaterThanOrEqual(1); + }); + + it('emits CALLS edges from process() → lookup() via overload disambiguation', () => { + const calls = getRelationships(result, 'CALLS'); + const lookupCalls = calls.filter(c => c.source === 'process' && c.target === 'lookup'); + // Phase 0 (fileIndex stores both overloads) + Phase 2 (literal type matching) + // enables resolution where previously 2 same-arity candidates → null. + // Both calls resolve to same nodeId (ID collision) → 1 CALLS edge after dedup. + expect(lookupCalls.length).toBe(1); + }); +}); diff --git a/type-resolution-roadmap.md b/type-resolution-roadmap.md index 3837e0b18..fbe623996 100644 --- a/type-resolution-roadmap.md +++ b/type-resolution-roadmap.md @@ -101,14 +101,14 @@ The goal is not to build a compiler. The goal is to support high-value static an **Plan:** `docs/plans/2026-03-19-feat-polymorphism-overloading-type-resolution-plan.md` Four incremental phases: -1. **Parameter type metadata** — extend `SymbolDefinition` with `parameterTypes: string[]` extracted during parsing -2. **Overload disambiguation** — filter overloaded methods by argument literal types at call sites -3. **Constructor-visible virtual dispatch** — `Base b = new Derived(); b.method()` resolves to `Derived#method` when constructor type is a known subclass +1. **Parameter type metadata** — extend `SymbolDefinition` with `parameterTypes: string[]` extracted during parsing — **DELIVERED** +2. **Overload disambiguation** — filter overloaded methods by argument literal types at call sites — **DELIVERED** +3. **Constructor-visible virtual dispatch** — `Base b = new Derived(); b.method()` resolves to `Derived#method` when constructor type is a known subclass — **DELIVERED** 4. **Covariant return type awareness** — prefer child's return type over inherited definition Languages benefiting: Java, Kotlin, C#, C++, TypeScript (overloading). All OOP languages (virtual dispatch). -**Impact: High | Effort: High** +**Impact: High | Effort: High** (P.1–P.3 delivered; P.4 remains open) --- @@ -165,6 +165,7 @@ Phase P (polymorphism) ───────────┤ │ Phase S (Swift parity) ───────────┘ +Phase P.1–P.3 are delivered. P.4 (covariant return types) remains open. Phase P and Phase S are independent of each other and Phase 14. Phase 14 benefits from Phase P (better per-file resolution = fewer cross-file gaps). ``` @@ -178,6 +179,9 @@ Phase 14 benefits from Phase P (better per-file resolution = fewer cross-file ga - Assignment chains (copy, callResult, fieldAccess, methodCallResult) → Phase S - `guard let` narrowing → Phase S +### Kotlin +- Virtual dispatch: `Dog()` uses `call_expression` (no `new` keyword), not detected by `CONSTRUCTOR_EXPR_TYPES` + ### All languages - Cross-file binding propagation → Phase 14