From 1c8ae5eb46651a103d62e603bb7143a69aeb52bc Mon Sep 17 00:00:00 2001 From: "Tushar Dhawas (Kyo)" <90651381+tushardhawas@users.noreply.github.com> Date: Tue, 7 Apr 2026 16:26:03 +0530 Subject: [PATCH 01/11] refactor: extract CLASS_LIKE_TYPES constant (#693) * refactor: extract CLASS_LIKE_TYPES constant * chore: apply prettier formatting --- gitnexus/src/core/ingestion/call-processor.ts | 47 +++---------------- 1 file changed, 7 insertions(+), 40 deletions(-) diff --git a/gitnexus/src/core/ingestion/call-processor.ts b/gitnexus/src/core/ingestion/call-processor.ts index dc615be1c..bb7af0bb9 100644 --- a/gitnexus/src/core/ingestion/call-processor.ts +++ b/gitnexus/src/core/ingestion/call-processor.ts @@ -51,6 +51,9 @@ import { extractParsedCallSite } from './call-sites/extract-language-call-site.j * Populated during call processing, consumed by Phase 14 re-resolution pass. */ export type ExportedTypeMap = Map>; +/** Types that represent class-like declarations (used for receiver/owner resolution). */ +const CLASS_LIKE_TYPES = new Set(['Class', 'Struct', 'Interface', 'Enum', 'Record', 'Impl']); + const MAX_EXPORTS_PER_FILE = 500; const MAX_TYPE_NAME_LENGTH = 256; @@ -784,17 +787,7 @@ export const processCalls = async ( } if (!receiverTypeName && receiverText) { const resolved = ctx.resolve(receiverText, file.path); - if ( - resolved?.candidates.some( - (d) => - d.type === 'Class' || - d.type === 'Struct' || - d.type === 'Interface' || - d.type === 'Enum' || - d.type === 'Record' || - d.type === 'Impl', - ) - ) { + if (resolved?.candidates.some((d) => CLASS_LIKE_TYPES.has(d.type))) { receiverTypeName = receiverText; } } @@ -1656,15 +1649,7 @@ const resolveFieldOwnership = ( ): { nodeId: string; declaredType?: string } | undefined => { const typeResolved = ctx.resolve(receiverName, filePath); if (!typeResolved) return undefined; - const classDef = typeResolved.candidates.find( - (d) => - d.type === 'Class' || - d.type === 'Struct' || - d.type === 'Interface' || - d.type === 'Enum' || - d.type === 'Record' || - d.type === 'Impl', - ); + const classDef = typeResolved.candidates.find((d) => CLASS_LIKE_TYPES.has(d.type)); if (!classDef) return undefined; return ctx.symbols.lookupFieldByOwner(classDef.nodeId, fieldName) ?? undefined; @@ -1683,15 +1668,7 @@ const resolveMethodByOwner = ( ): SymbolDefinition | undefined => { const typeResolved = ctx.resolve(receiverTypeName, filePath); if (!typeResolved) return undefined; - const classDef = typeResolved.candidates.find( - (d) => - d.type === 'Class' || - d.type === 'Struct' || - d.type === 'Interface' || - d.type === 'Enum' || - d.type === 'Record' || - d.type === 'Impl', - ); + const classDef = typeResolved.candidates.find((d) => CLASS_LIKE_TYPES.has(d.type)); if (!classDef) return undefined; return ctx.symbols.lookupMethodByOwner(classDef.nodeId, methodName); @@ -2036,17 +2013,7 @@ export const processAssignmentsFromExtracted = ( // Tier 3: static class-as-receiver fallback if (!receiverTypeName) { const resolved = ctx.resolve(asn.receiverText, asn.filePath); - if ( - resolved?.candidates.some( - (d) => - d.type === 'Class' || - d.type === 'Struct' || - d.type === 'Interface' || - d.type === 'Enum' || - d.type === 'Record' || - d.type === 'Impl', - ) - ) { + if (resolved?.candidates.some((d) => CLASS_LIKE_TYPES.has(d.type))) { receiverTypeName = asn.receiverText; } } From b73233d2321ec5bb659e62c9c0e7b95b8078eefb Mon Sep 17 00:00:00 2001 From: Deepak Chauhan <91007260+ideepakchauhan7@users.noreply.github.com> Date: Tue, 7 Apr 2026 17:59:50 +0530 Subject: [PATCH 02/11] feat(symbol-table): add class name lookup index (#707) --- gitnexus/src/core/ingestion/symbol-table.ts | 29 +++++ gitnexus/test/unit/symbol-table.test.ts | 131 +++++++++++++++++++- 2 files changed, 159 insertions(+), 1 deletion(-) diff --git a/gitnexus/src/core/ingestion/symbol-table.ts b/gitnexus/src/core/ingestion/symbol-table.ts index b007948c3..e1d1efe88 100644 --- a/gitnexus/src/core/ingestion/symbol-table.ts +++ b/gitnexus/src/core/ingestion/symbol-table.ts @@ -88,6 +88,14 @@ export interface SymbolTable { */ lookupMethodByOwner: (ownerNodeId: string, methodName: string) => SymbolDefinition | undefined; + /** + * Look up class-like definitions (Class, Struct, Interface, Enum, Record) by name. + * O(1) via dedicated eagerly-populated index keyed by symbol name. + * Returns all matching definitions across files (e.g. partial classes). + * Used by Phase 1 semantic-model tasks to replace filtered lookupFuzzy calls. + */ + lookupClassByName: (name: string) => SymbolDefinition[]; + /** * Debugging: See how many symbols are tracked */ @@ -122,7 +130,12 @@ export const createSymbolTable = (): SymbolTable => { // Method symbols with ownerId are indexed. Supports overloads (array values). const methodByOwner = new Map(); + // 6. Eagerly-populated Class-type Index — keyed by symbol name. + // Only Class, Struct, Interface, Enum, Record symbols are indexed. + const classByName = new Map(); + const CALLABLE_TYPES = new Set(['Function', 'Method', 'Constructor']); + const CLASS_TYPES = new Set(['Class', 'Struct', 'Interface', 'Enum', 'Record']); const add = ( filePath: string, @@ -194,6 +207,16 @@ export const createSymbolTable = (): SymbolTable => { } } + // C3. Class-like types go to classByName index (in addition to globalIndex). + if (CLASS_TYPES.has(type)) { + const existing = classByName.get(name); + if (existing) { + existing.push(def); + } else { + classByName.set(name, [def]); + } + } + // D. Invalidate the lazy callable index only when adding callable types if (CALLABLE_TYPES.has(type)) { callableIndex = null; @@ -254,6 +277,10 @@ export const createSymbolTable = (): SymbolTable => { return defs[0]; }; + const lookupClassByName = (name: string): SymbolDefinition[] => { + return classByName.get(name) ?? []; + }; + const getStats = () => ({ fileCount: fileIndex.size, globalSymbolCount: globalIndex.size, @@ -265,6 +292,7 @@ export const createSymbolTable = (): SymbolTable => { callableIndex = null; fieldByOwner.clear(); methodByOwner.clear(); + classByName.clear(); }; return { @@ -276,6 +304,7 @@ export const createSymbolTable = (): SymbolTable => { lookupFuzzyCallable, lookupFieldByOwner, lookupMethodByOwner, + lookupClassByName, getStats, clear, }; diff --git a/gitnexus/test/unit/symbol-table.test.ts b/gitnexus/test/unit/symbol-table.test.ts index e01548277..9bd2603f8 100644 --- a/gitnexus/test/unit/symbol-table.test.ts +++ b/gitnexus/test/unit/symbol-table.test.ts @@ -465,7 +465,7 @@ describe('SymbolTable', () => { }); describe('clear', () => { - it('resets all state including fieldByOwner and methodByOwner', () => { + it('resets all state including fieldByOwner, methodByOwner, and classByName', () => { table.add('src/a.ts', 'foo', 'func:foo', 'Function'); table.add('src/b.ts', 'bar', 'func:bar', 'Function'); table.add('src/models.ts', 'address', 'prop:address', 'Property', { @@ -476,6 +476,7 @@ describe('SymbolTable', () => { returnType: 'void', ownerId: 'class:User', }); + table.add('src/models.ts', 'User', 'class:User', 'Class'); table.clear(); expect(table.getStats()).toEqual({ fileCount: 0, globalSymbolCount: 0 }); expect(table.lookupExact('src/a.ts', 'foo')).toBeUndefined(); @@ -483,6 +484,7 @@ describe('SymbolTable', () => { expect(table.lookupFieldByOwner('class:User', 'address')).toBeUndefined(); expect(table.lookupMethodByOwner('class:User', 'save')).toBeUndefined(); expect(table.lookupFuzzyCallable('foo')).toEqual([]); + expect(table.lookupClassByName('User')).toEqual([]); }); it('allows re-adding after clear', () => { @@ -713,4 +715,131 @@ describe('SymbolTable', () => { expect(table.lookupFieldByOwner('class:A\0id', '')).toBeUndefined(); }); }); + + describe('lookupClassByName', () => { + it('returns Class definitions by name', () => { + table.add('src/models.ts', 'User', 'class:User', 'Class'); + const results = table.lookupClassByName('User'); + expect(results).toHaveLength(1); + expect(results[0]).toEqual({ + nodeId: 'class:User', + filePath: 'src/models.ts', + type: 'Class', + }); + }); + + it('returns Struct definitions by name', () => { + table.add('src/models.rs', 'Point', 'struct:Point', 'Struct'); + const results = table.lookupClassByName('Point'); + expect(results).toHaveLength(1); + expect(results[0].type).toBe('Struct'); + }); + + it('returns Interface definitions by name', () => { + table.add('src/types.ts', 'Serializable', 'iface:Serializable', 'Interface'); + const results = table.lookupClassByName('Serializable'); + expect(results).toHaveLength(1); + expect(results[0].type).toBe('Interface'); + }); + + it('returns Enum definitions by name', () => { + table.add('src/types.ts', 'Color', 'enum:Color', 'Enum'); + const results = table.lookupClassByName('Color'); + expect(results).toHaveLength(1); + expect(results[0].type).toBe('Enum'); + }); + + it('returns Record definitions by name', () => { + table.add('src/models.java', 'Config', 'record:Config', 'Record'); + const results = table.lookupClassByName('Config'); + expect(results).toHaveLength(1); + expect(results[0].type).toBe('Record'); + }); + + it('does NOT include Function with the same name', () => { + table.add('src/models.ts', 'User', 'class:User', 'Class'); + table.add('src/utils.ts', 'User', 'func:User', 'Function'); + const results = table.lookupClassByName('User'); + expect(results).toHaveLength(1); + expect(results[0].type).toBe('Class'); + expect(results[0].nodeId).toBe('class:User'); + }); + + it('does NOT include Method, Variable, Property, or Constructor', () => { + table.add('src/a.ts', 'Foo', 'method:Foo', 'Method'); + table.add('src/a.ts', 'Bar', 'var:Bar', 'Variable'); + table.add('src/a.ts', 'Baz', 'prop:Baz', 'Property'); + table.add('src/a.ts', 'Qux', 'ctor:Qux', 'Constructor'); + expect(table.lookupClassByName('Foo')).toEqual([]); + expect(table.lookupClassByName('Bar')).toEqual([]); + expect(table.lookupClassByName('Baz')).toEqual([]); + expect(table.lookupClassByName('Qux')).toEqual([]); + }); + + it('does NOT include other type-like labels outside the allowed class set', () => { + table.add('src/a.rs', 'User', 'trait:User', 'Trait'); + table.add('src/a.ts', 'User', 'type:User', 'Type'); + expect(table.lookupClassByName('User')).toEqual([]); + }); + + it('returns multiple classes with the same name from different files', () => { + table.add('src/models/user.ts', 'User', 'class:user:User', 'Class'); + table.add('src/dto/user.ts', 'User', 'class:dto:User', 'Class'); + const results = table.lookupClassByName('User'); + expect(results).toHaveLength(2); + expect(results[0].filePath).toBe('src/models/user.ts'); + expect(results[1].filePath).toBe('src/dto/user.ts'); + }); + + it('returns empty array for unknown name', () => { + table.add('src/models.ts', 'User', 'class:User', 'Class'); + expect(table.lookupClassByName('NonExistent')).toEqual([]); + }); + + it('returns empty array for empty table', () => { + expect(table.lookupClassByName('User')).toEqual([]); + }); + + it('after clear(), returns empty array', () => { + table.add('src/models.ts', 'User', 'class:User', 'Class'); + expect(table.lookupClassByName('User')).toHaveLength(1); + table.clear(); + expect(table.lookupClassByName('User')).toEqual([]); + }); + + it('returns mixed class-like types with the same name', () => { + // e.g. a Class and an Interface both named 'Comparable' in different files + table.add('src/base.ts', 'Comparable', 'class:Comparable', 'Class'); + table.add('src/types.ts', 'Comparable', 'iface:Comparable', 'Interface'); + const results = table.lookupClassByName('Comparable'); + expect(results).toHaveLength(2); + expect(results.map((r) => r.type)).toEqual(['Class', 'Interface']); + }); + + it('preserves metadata on indexed class definitions', () => { + table.add('src/models.ts', 'User', 'class:User', 'Class', { + returnType: 'User', + ownerId: 'module:models', + }); + const results = table.lookupClassByName('User'); + expect(results).toHaveLength(1); + expect(results[0].ownerId).toBe('module:models'); + }); + + it('class-like symbols are still available via lookupFuzzy', () => { + table.add('src/models.ts', 'User', 'class:User', 'Class'); + // classByName is an additional index, not a replacement for globalIndex + expect(table.lookupFuzzy('User')).toHaveLength(1); + expect(table.lookupClassByName('User')).toHaveLength(1); + }); + + it('allows re-adding after clear and returns correct results', () => { + table.add('src/models.ts', 'User', 'class:User:v1', 'Class'); + table.clear(); + table.add('src/models.ts', 'User', 'class:User:v2', 'Class'); + const results = table.lookupClassByName('User'); + expect(results).toHaveLength(1); + expect(results[0].nodeId).toBe('class:User:v2'); + }); + }); }); From be2401061e2502c0a588a9c0f38b3a9c3ef630b6 Mon Sep 17 00:00:00 2001 From: Deepak Chauhan <91007260+ideepakchauhan7@users.noreply.github.com> Date: Wed, 8 Apr 2026 03:27:18 +0530 Subject: [PATCH 03/11] [cli] Add qualified class lookups to SymbolTable (#716) --- .../ingestion/class-extractors/generic.ts | 177 ++++++++++++++++++ gitnexus/src/core/ingestion/class-types.ts | 44 +++++ .../src/core/ingestion/language-provider.ts | 5 + .../src/core/ingestion/languages/c-cpp.ts | 14 ++ .../src/core/ingestion/languages/csharp.ts | 20 ++ gitnexus/src/core/ingestion/languages/dart.ts | 6 + gitnexus/src/core/ingestion/languages/go.ts | 17 ++ gitnexus/src/core/ingestion/languages/java.ts | 17 ++ .../src/core/ingestion/languages/kotlin.ts | 11 ++ gitnexus/src/core/ingestion/languages/php.ts | 6 + .../src/core/ingestion/languages/python.ts | 6 + gitnexus/src/core/ingestion/languages/ruby.ts | 6 + gitnexus/src/core/ingestion/languages/rust.ts | 6 + .../src/core/ingestion/languages/swift.ts | 13 ++ .../core/ingestion/languages/typescript.ts | 23 +++ gitnexus/src/core/ingestion/languages/vue.ts | 18 ++ .../src/core/ingestion/parsing-processor.ts | 31 ++- gitnexus/src/core/ingestion/symbol-table.ts | 36 +++- .../core/ingestion/workers/parse-worker.ts | 29 ++- .../csharp-qualified-types/Data/User.cs | 7 + .../csharp-qualified-types/Services/User.cs | 6 + .../com/example/admin/User.java | 5 + .../com/example/models/User.java | 5 + .../ruby-qualified-types/lib/admin/user.rb | 7 + .../lib/services/auth/user.rb | 9 + .../qualified-class-lookups.test.ts | 81 ++++++++ .../test/integration/resolvers/csharp.test.ts | 17 ++ .../test/integration/resolvers/java.test.ts | 17 ++ .../test/integration/resolvers/ruby.test.ts | 17 ++ gitnexus/test/unit/symbol-table.test.ts | 53 ++++++ 30 files changed, 695 insertions(+), 14 deletions(-) create mode 100644 gitnexus/src/core/ingestion/class-extractors/generic.ts create mode 100644 gitnexus/src/core/ingestion/class-types.ts create mode 100644 gitnexus/test/fixtures/lang-resolution/csharp-qualified-types/Data/User.cs create mode 100644 gitnexus/test/fixtures/lang-resolution/csharp-qualified-types/Services/User.cs create mode 100644 gitnexus/test/fixtures/lang-resolution/java-qualified-types/com/example/admin/User.java create mode 100644 gitnexus/test/fixtures/lang-resolution/java-qualified-types/com/example/models/User.java create mode 100644 gitnexus/test/fixtures/lang-resolution/ruby-qualified-types/lib/admin/user.rb create mode 100644 gitnexus/test/fixtures/lang-resolution/ruby-qualified-types/lib/services/auth/user.rb create mode 100644 gitnexus/test/integration/qualified-class-lookups.test.ts diff --git a/gitnexus/src/core/ingestion/class-extractors/generic.ts b/gitnexus/src/core/ingestion/class-extractors/generic.ts new file mode 100644 index 000000000..303eb80c0 --- /dev/null +++ b/gitnexus/src/core/ingestion/class-extractors/generic.ts @@ -0,0 +1,177 @@ +import type { SyntaxNode } from '../utils/ast-helpers.js'; +import type { NodeLabel } from 'gitnexus-shared'; +import type { + ClassExtractionConfig, + ClassExtractor, + ClassLikeNodeLabel, + ExtractedClassSymbol, +} from '../class-types.js'; + +const DEFAULT_SCOPE_NAME_NODE_TYPES = new Set([ + 'nested_namespace_specifier', + 'scoped_identifier', + 'scoped_type_identifier', + 'qualified_name', + 'namespace_name', + 'namespace_identifier', + 'package_identifier', + 'type_identifier', + 'identifier', + 'name', + 'constant', +]); + +const DEFAULT_TYPE_NAME_NODE_TYPES = new Set([ + 'type_identifier', + 'identifier', + 'simple_identifier', + 'namespace_identifier', + 'constant', + 'name', +]); + +const DEFAULT_LABEL_BY_NODE_TYPE: Record = { + class_declaration: 'Class', + abstract_class_declaration: 'Class', + interface_declaration: 'Interface', + struct_declaration: 'Struct', + record_declaration: 'Record', + enum_declaration: 'Enum', + class_definition: 'Class', + struct_specifier: 'Struct', + class_specifier: 'Class', + enum_specifier: 'Enum', + struct_item: 'Struct', + enum_item: 'Enum', + class: 'Class', + object_declaration: 'Class', + companion_object: 'Class', + protocol_declaration: 'Interface', + extension_declaration: 'Class', +}; + +const CLASS_LIKE_LABELS = new Set([ + 'Class', + 'Struct', + 'Interface', + 'Enum', + 'Record', +]); + +const normalizeQualifiedName = (value: string): string => + value + .replace(/\s+/g, '') + .replace(/^::/, '') + .replace(/::/g, '.') + .replace(/\\/g, '.') + .replace(/\.+/g, '.') + .replace(/^\.+|\.+$/g, ''); + +const splitQualifiedName = (value: string): string[] => { + const normalized = normalizeQualifiedName(value); + return normalized ? normalized.split('.').filter(Boolean) : []; +}; + +const extractScopeSegmentsFromNode = ( + scopeNode: SyntaxNode, + scopeNameNodeTypes: ReadonlySet, +): string[] => { + const nameNode = + scopeNode.childForFieldName?.('name') ?? + scopeNode.namedChildren?.find((child) => scopeNameNodeTypes.has(child.type)); + return nameNode ? splitQualifiedName(nameNode.text) : []; +}; + +const extractTypeNameFromNode = (node: SyntaxNode): string | undefined => { + const nameField = node.childForFieldName?.('name'); + if (nameField) return nameField.text; + const nameChild = node.namedChildren?.find((child) => + DEFAULT_TYPE_NAME_NODE_TYPES.has(child.type), + ); + return nameChild?.text; +}; + +const isClassLikeLabel = (label: NodeLabel | null | undefined): label is ClassLikeNodeLabel => + label !== undefined && label !== null && CLASS_LIKE_LABELS.has(label as ClassLikeNodeLabel); + +export function createClassExtractor(config: ClassExtractionConfig): ClassExtractor { + const typeDeclarationSet = new Set(config.typeDeclarationNodes); + const fileScopeSet = new Set(config.fileScopeNodeTypes ?? []); + const ancestorScopeSet = new Set(config.ancestorScopeNodeTypes ?? []); + const scopeNameNodeTypes = new Set([ + ...DEFAULT_SCOPE_NAME_NODE_TYPES, + ...(config.scopeNameNodeTypes ?? []), + ]); + + const buildQualifiedName = (node: SyntaxNode, simpleName: string): string => { + let root = node; + while (root.parent) root = root.parent; + + const readScopeSegments = (scopeNode: SyntaxNode): string[] => + config.extractScopeSegments?.(scopeNode) ?? + extractScopeSegmentsFromNode(scopeNode, scopeNameNodeTypes); + + const fileScopeSegments: string[] = []; + for (const child of root.namedChildren ?? []) { + if (fileScopeSet.has(child.type)) { + fileScopeSegments.push(...readScopeSegments(child)); + } + } + + const ancestorScopes: string[][] = []; + let current = node.parent; + while (current) { + if (ancestorScopeSet.has(current.type)) { + const segments = readScopeSegments(current); + if (segments.length > 0) ancestorScopes.push(segments); + } + current = current.parent; + } + + return [ + ...fileScopeSegments, + ...ancestorScopes.reverse().flat(), + ...splitQualifiedName(simpleName), + ] + .filter(Boolean) + .join('.'); + }; + + const extract = ( + node: SyntaxNode, + fallback?: { + name?: string; + type?: NodeLabel | null; + }, + ): ExtractedClassSymbol | null => { + if (!typeDeclarationSet.has(node.type)) return null; + + const name = config.extractName?.(node) ?? extractTypeNameFromNode(node) ?? fallback?.name; + const type = + config.extractType?.(node) ?? + DEFAULT_LABEL_BY_NODE_TYPE[node.type] ?? + (isClassLikeLabel(fallback?.type) ? fallback.type : undefined); + + if (!name || !type) return null; + + return { + name, + type, + qualifiedName: buildQualifiedName(node, name) || name, + }; + }; + + return { + language: config.language, + + isTypeDeclaration(node: SyntaxNode): boolean { + return typeDeclarationSet.has(node.type); + }, + + extract, + + extractQualifiedName(node: SyntaxNode, simpleName: string): string | null { + return extract(node, { name: simpleName })?.qualifiedName ?? null; + }, + }; +} diff --git a/gitnexus/src/core/ingestion/class-types.ts b/gitnexus/src/core/ingestion/class-types.ts new file mode 100644 index 000000000..858d4c2eb --- /dev/null +++ b/gitnexus/src/core/ingestion/class-types.ts @@ -0,0 +1,44 @@ +import type { NodeLabel, SupportedLanguages } from 'gitnexus-shared'; +import type { SyntaxNode } from './utils/ast-helpers.js'; + +export type ClassLikeNodeLabel = Extract< + NodeLabel, + 'Class' | 'Struct' | 'Interface' | 'Enum' | 'Record' +>; + +export interface ExtractedClassSymbol { + name: string; + type: ClassLikeNodeLabel; + qualifiedName: string; +} + +/** + * Cross-language qualified type names are normalized to dot-separated scope + * segments: + * - file/package scope contributes leading segments when the language has one + * - lexical namespace/module/type scope contributes enclosing segments + * - the simple type name is always the trailing segment + */ +export interface ClassExtractor { + language: SupportedLanguages; + isTypeDeclaration(node: SyntaxNode): boolean; + extract( + node: SyntaxNode, + fallback?: { + name?: string; + type?: NodeLabel | null; + }, + ): ExtractedClassSymbol | null; + extractQualifiedName(node: SyntaxNode, simpleName: string): string | null; +} + +export interface ClassExtractionConfig { + language: SupportedLanguages; + typeDeclarationNodes: string[]; + fileScopeNodeTypes?: string[]; + ancestorScopeNodeTypes?: string[]; + scopeNameNodeTypes?: string[]; + extractName?: (node: SyntaxNode) => string | undefined; + extractType?: (node: SyntaxNode) => ClassLikeNodeLabel | undefined; + extractScopeSegments?: (node: SyntaxNode) => string[] | null | undefined; +} diff --git a/gitnexus/src/core/ingestion/language-provider.ts b/gitnexus/src/core/ingestion/language-provider.ts index 070a4acb0..38997cb0d 100644 --- a/gitnexus/src/core/ingestion/language-provider.ts +++ b/gitnexus/src/core/ingestion/language-provider.ts @@ -12,6 +12,7 @@ import type { SupportedLanguages } from 'gitnexus-shared'; import type { LanguageTypeConfig } from './type-extractors/types.js'; import type { CallRouter } from './call-routing.js'; +import type { ClassExtractor } from './class-types.js'; import type { ExportChecker } from './export-detection.js'; import type { FieldExtractor } from './field-extractor.js'; import type { MethodExtractor } from './method-types.js'; @@ -131,6 +132,10 @@ interface LanguageProviderConfig { * declarations. Produces MethodInfo[] with name, parameters, visibility, isAbstract, * isFinal, annotations metadata. Default: undefined (no method extraction). */ readonly methodExtractor?: MethodExtractor; + /** Class/type extractor for deriving canonical qualified names for class-like symbols. + * Uses the same provider-driven strategy pattern as method/field extraction so + * namespace/package/module rules stay language-specific. */ + readonly classExtractor?: ClassExtractor; /** Extract a semantic description for a definition node (e.g., PHP Eloquent * property arrays, relation method descriptions). * Default: undefined (no description extraction). */ diff --git a/gitnexus/src/core/ingestion/languages/c-cpp.ts b/gitnexus/src/core/ingestion/languages/c-cpp.ts index 8edd1c2b8..a0508ff5b 100644 --- a/gitnexus/src/core/ingestion/languages/c-cpp.ts +++ b/gitnexus/src/core/ingestion/languages/c-cpp.ts @@ -9,6 +9,7 @@ */ import { SupportedLanguages } from 'gitnexus-shared'; +import { createClassExtractor } from '../class-extractors/generic.js'; import { defineLanguage } from '../language-provider.js'; import { typeConfig as cCppConfig } from '../type-extractors/c-cpp.js'; import { cCppExportChecker } from '../export-detection.js'; @@ -144,6 +145,17 @@ const C_BUILT_INS: ReadonlySet = new Set([ 'put', ]); +const cClassExtractor = createClassExtractor({ + language: SupportedLanguages.C, + typeDeclarationNodes: ['struct_specifier', 'enum_specifier'], +}); + +const cppClassExtractor = createClassExtractor({ + language: SupportedLanguages.CPlusPlus, + typeDeclarationNodes: ['class_specifier', 'struct_specifier', 'enum_specifier'], + ancestorScopeNodeTypes: ['namespace_definition', 'class_specifier', 'struct_specifier'], +}); + /** * C/C++ function name extraction — unwraps pointer_declarator / reference_declarator / * function_declarator / qualified_identifier chains to find the actual function name. @@ -315,6 +327,7 @@ export const cProvider = defineLanguage({ ...cMethodConfig, extractFunctionName: cCppExtractFunctionName, }), + classExtractor: cClassExtractor, labelOverride: cppLabelOverride, builtInNames: C_BUILT_INS, }); @@ -333,6 +346,7 @@ export const cppProvider = defineLanguage({ ...cppMethodConfig, extractFunctionName: cCppExtractFunctionName, }), + classExtractor: cppClassExtractor, labelOverride: cppLabelOverride, builtInNames: C_BUILT_INS, }); diff --git a/gitnexus/src/core/ingestion/languages/csharp.ts b/gitnexus/src/core/ingestion/languages/csharp.ts index 08bba42c2..6ec3a6a8b 100644 --- a/gitnexus/src/core/ingestion/languages/csharp.ts +++ b/gitnexus/src/core/ingestion/languages/csharp.ts @@ -7,6 +7,7 @@ */ import { SupportedLanguages } from 'gitnexus-shared'; +import { createClassExtractor } from '../class-extractors/generic.js'; import { defineLanguage } from '../language-provider.js'; import { typeConfig as csharpConfig } from '../type-extractors/csharp.js'; import { csharpExportChecker } from '../export-detection.js'; @@ -125,5 +126,24 @@ export const csharpProvider = defineLanguage({ mroStrategy: 'implements-split', fieldExtractor: createFieldExtractor(csharpFieldConfig), methodExtractor: createMethodExtractor(csharpMethodConfig), + classExtractor: createClassExtractor({ + language: SupportedLanguages.CSharp, + typeDeclarationNodes: [ + 'class_declaration', + 'interface_declaration', + 'struct_declaration', + 'enum_declaration', + 'record_declaration', + ], + fileScopeNodeTypes: ['file_scoped_namespace_declaration'], + ancestorScopeNodeTypes: [ + 'namespace_declaration', + 'class_declaration', + 'interface_declaration', + 'struct_declaration', + 'enum_declaration', + 'record_declaration', + ], + }), builtInNames: BUILT_INS, }); diff --git a/gitnexus/src/core/ingestion/languages/dart.ts b/gitnexus/src/core/ingestion/languages/dart.ts index cdd53c8d8..591519895 100644 --- a/gitnexus/src/core/ingestion/languages/dart.ts +++ b/gitnexus/src/core/ingestion/languages/dart.ts @@ -14,6 +14,7 @@ import type { SyntaxNode } from '../utils/ast-helpers.js'; import type { NodeLabel } from 'gitnexus-shared'; import { FUNCTION_NODE_TYPES } from '../utils/ast-helpers.js'; import { SupportedLanguages } from 'gitnexus-shared'; +import { createClassExtractor } from '../class-extractors/generic.js'; import { defineLanguage } from '../language-provider.js'; import { typeConfig as dartConfig } from '../type-extractors/dart.js'; import { dartExportChecker } from '../export-detection.js'; @@ -92,6 +93,11 @@ export const dartProvider = defineLanguage({ importSemantics: 'wildcard', fieldExtractor: createFieldExtractor(dartFieldConfig), methodExtractor: createMethodExtractor(dartMethodConfig), + classExtractor: createClassExtractor({ + language: SupportedLanguages.Dart, + typeDeclarationNodes: ['class_definition', 'extension_declaration', 'enum_declaration'], + ancestorScopeNodeTypes: ['class_definition', 'extension_declaration', 'enum_declaration'], + }), enclosingFunctionFinder: dartEnclosingFunctionFinder, builtInNames: BUILT_INS, }); diff --git a/gitnexus/src/core/ingestion/languages/go.ts b/gitnexus/src/core/ingestion/languages/go.ts index a70749018..803e70bbb 100644 --- a/gitnexus/src/core/ingestion/languages/go.ts +++ b/gitnexus/src/core/ingestion/languages/go.ts @@ -10,6 +10,7 @@ */ import { SupportedLanguages } from 'gitnexus-shared'; +import { createClassExtractor } from '../class-extractors/generic.js'; import { defineLanguage } from '../language-provider.js'; import { typeConfig as goConfig } from '../type-extractors/go.js'; import { goExportChecker } from '../export-detection.js'; @@ -30,4 +31,20 @@ export const goProvider = defineLanguage({ importSemantics: 'wildcard', fieldExtractor: createFieldExtractor(goFieldConfig), methodExtractor: createMethodExtractor(goMethodConfig), + classExtractor: createClassExtractor({ + language: SupportedLanguages.Go, + typeDeclarationNodes: ['type_declaration'], + fileScopeNodeTypes: ['package_clause'], + extractName(node) { + const typeSpec = node.namedChildren.find((child) => child.type === 'type_spec'); + return typeSpec?.childForFieldName('name')?.text; + }, + extractType(node) { + const typeSpec = node.namedChildren.find((child) => child.type === 'type_spec'); + const typeNode = typeSpec?.childForFieldName('type'); + if (typeNode?.type === 'struct_type') return 'Struct'; + if (typeNode?.type === 'interface_type') return 'Interface'; + return undefined; + }, + }), }); diff --git a/gitnexus/src/core/ingestion/languages/java.ts b/gitnexus/src/core/ingestion/languages/java.ts index 8f18056d3..b9fab77f1 100644 --- a/gitnexus/src/core/ingestion/languages/java.ts +++ b/gitnexus/src/core/ingestion/languages/java.ts @@ -8,6 +8,7 @@ */ import { SupportedLanguages } from 'gitnexus-shared'; +import { createClassExtractor } from '../class-extractors/generic.js'; import { defineLanguage } from '../language-provider.js'; import { javaTypeConfig } from '../type-extractors/jvm.js'; import { javaExportChecker } from '../export-detection.js'; @@ -31,4 +32,20 @@ export const javaProvider = defineLanguage({ mroStrategy: 'implements-split', fieldExtractor: createFieldExtractor(javaConfig), methodExtractor: createMethodExtractor(javaMethodConfig), + classExtractor: createClassExtractor({ + language: SupportedLanguages.Java, + typeDeclarationNodes: [ + 'class_declaration', + 'interface_declaration', + 'enum_declaration', + 'record_declaration', + ], + fileScopeNodeTypes: ['package_declaration'], + ancestorScopeNodeTypes: [ + 'class_declaration', + 'interface_declaration', + 'enum_declaration', + 'record_declaration', + ], + }), }); diff --git a/gitnexus/src/core/ingestion/languages/kotlin.ts b/gitnexus/src/core/ingestion/languages/kotlin.ts index 639713c1a..94c47e7ab 100644 --- a/gitnexus/src/core/ingestion/languages/kotlin.ts +++ b/gitnexus/src/core/ingestion/languages/kotlin.ts @@ -8,6 +8,7 @@ */ import { SupportedLanguages } from 'gitnexus-shared'; +import { createClassExtractor } from '../class-extractors/generic.js'; import { defineLanguage } from '../language-provider.js'; import { kotlinTypeConfig } from '../type-extractors/jvm.js'; import { kotlinExportChecker } from '../export-detection.js'; @@ -106,6 +107,16 @@ export const kotlinProvider = defineLanguage({ mroStrategy: 'implements-split', fieldExtractor: createFieldExtractor(kotlinConfig), methodExtractor: createMethodExtractor(kotlinMethodConfig), + classExtractor: createClassExtractor({ + language: SupportedLanguages.Kotlin, + typeDeclarationNodes: ['class_declaration', 'object_declaration', 'companion_object'], + fileScopeNodeTypes: ['package_header'], + ancestorScopeNodeTypes: ['class_declaration', 'object_declaration', 'companion_object'], + extractType(node) { + if (node.type !== 'class_declaration') return undefined; + return node.children.some((child) => child?.text === 'interface') ? 'Interface' : 'Class'; + }, + }), builtInNames: BUILT_INS, labelOverride: (functionNode, defaultLabel) => { if (defaultLabel !== 'Function') return defaultLabel; diff --git a/gitnexus/src/core/ingestion/languages/php.ts b/gitnexus/src/core/ingestion/languages/php.ts index 06783fa3f..e93010009 100644 --- a/gitnexus/src/core/ingestion/languages/php.ts +++ b/gitnexus/src/core/ingestion/languages/php.ts @@ -7,6 +7,7 @@ */ import { SupportedLanguages } from 'gitnexus-shared'; +import { createClassExtractor } from '../class-extractors/generic.js'; import { defineLanguage } from '../language-provider.js'; import { typeConfig as phpConfig } from '../type-extractors/php.js'; import { phpExportChecker } from '../export-detection.js'; @@ -234,6 +235,11 @@ export const phpProvider = defineLanguage({ namedBindingExtractor: extractPhpNamedBindings, fieldExtractor: createFieldExtractor(phpFieldConfig), methodExtractor: createMethodExtractor(phpMethodConfig), + classExtractor: createClassExtractor({ + language: SupportedLanguages.PHP, + typeDeclarationNodes: ['class_declaration', 'interface_declaration', 'enum_declaration'], + ancestorScopeNodeTypes: ['namespace_definition'], + }), descriptionExtractor: phpDescriptionExtractor, isRouteFile: isPhpRouteFile, builtInNames: BUILT_INS, diff --git a/gitnexus/src/core/ingestion/languages/python.ts b/gitnexus/src/core/ingestion/languages/python.ts index 7fc079102..8c776d4a0 100644 --- a/gitnexus/src/core/ingestion/languages/python.ts +++ b/gitnexus/src/core/ingestion/languages/python.ts @@ -11,6 +11,7 @@ */ import { SupportedLanguages } from 'gitnexus-shared'; +import { createClassExtractor } from '../class-extractors/generic.js'; import { defineLanguage } from '../language-provider.js'; import { typeConfig as pythonConfig } from '../type-extractors/python.js'; import { pythonExportChecker } from '../export-detection.js'; @@ -64,5 +65,10 @@ export const pythonProvider = defineLanguage({ mroStrategy: 'c3', fieldExtractor: createFieldExtractor(pythonFieldConfig), methodExtractor: createMethodExtractor(pythonMethodConfig), + classExtractor: createClassExtractor({ + language: SupportedLanguages.Python, + typeDeclarationNodes: ['class_definition'], + ancestorScopeNodeTypes: ['class_definition'], + }), builtInNames: BUILT_INS, }); diff --git a/gitnexus/src/core/ingestion/languages/ruby.ts b/gitnexus/src/core/ingestion/languages/ruby.ts index 8b488fafb..edd85ea6c 100644 --- a/gitnexus/src/core/ingestion/languages/ruby.ts +++ b/gitnexus/src/core/ingestion/languages/ruby.ts @@ -9,6 +9,7 @@ import { SupportedLanguages } from 'gitnexus-shared'; import type { NodeLabel } from 'gitnexus-shared'; +import { createClassExtractor } from '../class-extractors/generic.js'; import { defineLanguage } from '../language-provider.js'; import type { SyntaxNode } from '../utils/ast-helpers.js'; import { typeConfig as rubyConfig } from '../type-extractors/ruby.js'; @@ -112,5 +113,10 @@ export const rubyProvider = defineLanguage({ ...rubyMethodConfig, extractFunctionName: rubyExtractFunctionName, }), + classExtractor: createClassExtractor({ + language: SupportedLanguages.Ruby, + typeDeclarationNodes: ['class'], + ancestorScopeNodeTypes: ['module', 'class'], + }), builtInNames: BUILT_INS, }); diff --git a/gitnexus/src/core/ingestion/languages/rust.ts b/gitnexus/src/core/ingestion/languages/rust.ts index db8e2ad5b..5e664ef8f 100644 --- a/gitnexus/src/core/ingestion/languages/rust.ts +++ b/gitnexus/src/core/ingestion/languages/rust.ts @@ -12,6 +12,7 @@ import { SupportedLanguages } from 'gitnexus-shared'; import type { NodeLabel } from 'gitnexus-shared'; +import { createClassExtractor } from '../class-extractors/generic.js'; import { defineLanguage } from '../language-provider.js'; import type { SyntaxNode } from '../utils/ast-helpers.js'; import { typeConfig as rustConfig } from '../type-extractors/rust.js'; @@ -124,5 +125,10 @@ export const rustProvider = defineLanguage({ ...rustMethodConfig, extractFunctionName: rustExtractFunctionName, }), + classExtractor: createClassExtractor({ + language: SupportedLanguages.Rust, + typeDeclarationNodes: ['struct_item', 'enum_item'], + ancestorScopeNodeTypes: ['mod_item', 'struct_item', 'enum_item'], + }), builtInNames: BUILT_INS, }); diff --git a/gitnexus/src/core/ingestion/languages/swift.ts b/gitnexus/src/core/ingestion/languages/swift.ts index f6753c397..7d01b4912 100644 --- a/gitnexus/src/core/ingestion/languages/swift.ts +++ b/gitnexus/src/core/ingestion/languages/swift.ts @@ -12,6 +12,7 @@ import { SupportedLanguages } from 'gitnexus-shared'; import type { NodeLabel } from 'gitnexus-shared'; +import { createClassExtractor } from '../class-extractors/generic.js'; import { defineLanguage } from '../language-provider.js'; import { typeConfig as swiftConfig } from '../type-extractors/swift.js'; import { swiftExportChecker } from '../export-detection.js'; @@ -244,6 +245,18 @@ export const swiftProvider = defineLanguage({ ...swiftMethodConfig, extractFunctionName: swiftExtractFunctionName, }), + classExtractor: createClassExtractor({ + language: SupportedLanguages.Swift, + typeDeclarationNodes: ['class_declaration', 'protocol_declaration'], + ancestorScopeNodeTypes: ['class_declaration', 'protocol_declaration'], + extractType(node) { + if (node.type === 'protocol_declaration') return 'Interface'; + if (node.type !== 'class_declaration') return undefined; + if (node.children.some((child) => child?.text === 'struct')) return 'Struct'; + if (node.children.some((child) => child?.text === 'enum')) return 'Enum'; + return 'Class'; + }, + }), implicitImportWirer: wireSwiftImplicitImports, builtInNames: BUILT_INS, }); diff --git a/gitnexus/src/core/ingestion/languages/typescript.ts b/gitnexus/src/core/ingestion/languages/typescript.ts index 7563704e3..b680c5aa1 100644 --- a/gitnexus/src/core/ingestion/languages/typescript.ts +++ b/gitnexus/src/core/ingestion/languages/typescript.ts @@ -10,6 +10,8 @@ import { SupportedLanguages } from 'gitnexus-shared'; import type { NodeLabel } from 'gitnexus-shared'; import { defineLanguage } from '../language-provider.js'; +import { createClassExtractor } from '../class-extractors/generic.js'; +import type { ClassExtractionConfig } from '../class-types.js'; import type { SyntaxNode } from '../utils/ast-helpers.js'; import { typeConfig as typescriptConfig } from '../type-extractors/typescript.js'; import { tsExportChecker } from '../export-detection.js'; @@ -147,6 +149,22 @@ export const BUILT_INS: ReadonlySet = new Set([ 'valueOf', ]); +const tsJsClassConfig: ClassExtractionConfig = { + language: SupportedLanguages.TypeScript, + typeDeclarationNodes: [ + 'class_declaration', + 'abstract_class_declaration', + 'interface_declaration', + 'enum_declaration', + ], + ancestorScopeNodeTypes: [ + 'class_declaration', + 'abstract_class_declaration', + 'interface_declaration', + 'enum_declaration', + ], +}; + export const typescriptProvider = defineLanguage({ id: SupportedLanguages.TypeScript, extensions: ['.ts', '.tsx'], @@ -160,6 +178,7 @@ export const typescriptProvider = defineLanguage({ ...typescriptMethodConfig, extractFunctionName: tsExtractFunctionName, }), + classExtractor: createClassExtractor(tsJsClassConfig), builtInNames: BUILT_INS, }); @@ -176,5 +195,9 @@ export const javascriptProvider = defineLanguage({ ...javascriptMethodConfig, extractFunctionName: tsExtractFunctionName, }), + classExtractor: createClassExtractor({ + ...tsJsClassConfig, + language: SupportedLanguages.JavaScript, + }), builtInNames: BUILT_INS, }); diff --git a/gitnexus/src/core/ingestion/languages/vue.ts b/gitnexus/src/core/ingestion/languages/vue.ts index ccf1517ec..20ccdfa18 100644 --- a/gitnexus/src/core/ingestion/languages/vue.ts +++ b/gitnexus/src/core/ingestion/languages/vue.ts @@ -12,6 +12,7 @@ */ import { SupportedLanguages } from 'gitnexus-shared'; +import { createClassExtractor } from '../class-extractors/generic.js'; import { defineLanguage } from '../language-provider.js'; import { typeConfig as typescriptConfig } from '../type-extractors/typescript.js'; import { tsExportChecker } from '../export-detection.js'; @@ -55,6 +56,22 @@ const VUE_SPECIFIC_BUILT_INS = [ const VUE_BUILT_INS: ReadonlySet = new Set([...TS_BUILT_INS, ...VUE_SPECIFIC_BUILT_INS]); +const vueClassExtractor = createClassExtractor({ + language: SupportedLanguages.Vue, + typeDeclarationNodes: [ + 'class_declaration', + 'abstract_class_declaration', + 'interface_declaration', + 'enum_declaration', + ], + ancestorScopeNodeTypes: [ + 'class_declaration', + 'abstract_class_declaration', + 'interface_declaration', + 'enum_declaration', + ], +}); + export const vueProvider = defineLanguage({ id: SupportedLanguages.Vue, extensions: ['.vue'], @@ -64,5 +81,6 @@ export const vueProvider = defineLanguage({ importResolver: resolveVueImport, namedBindingExtractor: extractTsNamedBindings, fieldExtractor: typescriptFieldExtractor, + classExtractor: vueClassExtractor, builtInNames: VUE_BUILT_INS, }); diff --git a/gitnexus/src/core/ingestion/parsing-processor.ts b/gitnexus/src/core/ingestion/parsing-processor.ts index d9913e671..b55b70747 100644 --- a/gitnexus/src/core/ingestion/parsing-processor.ts +++ b/gitnexus/src/core/ingestion/parsing-processor.ts @@ -4,7 +4,7 @@ import Parser from 'tree-sitter'; import { loadParser, loadLanguage, isLanguageAvailable } from '../tree-sitter/parser-loader.js'; import { getProvider } from './languages/index.js'; import { generateId } from '../../lib/utils.js'; -import { SymbolTable } from './symbol-table.js'; +import type { SymbolTable } from './symbol-table.js'; import { ASTCache } from './ast-cache.js'; import { getLanguageFromFilename, SupportedLanguages } from 'gitnexus-shared'; import { extractVueScript, isVueSetupTopLevel } from './vue-sfc-extractor.js'; @@ -140,6 +140,7 @@ const processParsingWithWorkers = async ( returnType: sym.returnType, declaredType: sym.declaredType, ownerId: sym.ownerId, + qualifiedName: sym.qualifiedName, }); } @@ -368,21 +369,29 @@ const processParsingSequential = async ( captureMap[c.name] = c.node; }); - const nodeLabel = getLabelFromCaptures(captureMap, provider); - if (!nodeLabel) return; + const definitionNodeForRange = getDefinitionNodeFromCaptures(captureMap); + const definitionNode = getDefinitionNodeFromCaptures(captureMap); + const defaultNodeLabel = getLabelFromCaptures(captureMap, provider); + if (!defaultNodeLabel) return; const nameNode = captureMap['name']; + const extractedClassSymbol = + definitionNode && provider.classExtractor?.isTypeDeclaration(definitionNode) + ? provider.classExtractor.extract(definitionNode, { + name: nameNode?.text, + type: defaultNodeLabel, + }) + : null; + const nodeLabel = extractedClassSymbol?.type ?? defaultNodeLabel; // Synthesize name for constructors without explicit @name capture (e.g. Swift init) - if (!nameNode && nodeLabel !== 'Constructor') return; - const nodeName = nameNode ? nameNode.text : 'init'; + if (!nameNode && nodeLabel !== 'Constructor' && !extractedClassSymbol) return; + const nodeName = extractedClassSymbol?.name ?? (nameNode ? nameNode.text : 'init'); - const definitionNodeForRange = getDefinitionNodeFromCaptures(captureMap); const startLine = definitionNodeForRange ? definitionNodeForRange.startPosition.row + lineOffset : nameNode ? nameNode.startPosition.row + lineOffset : lineOffset; - const definitionNode = getDefinitionNodeFromCaptures(captureMap); // Compute enclosing class BEFORE node ID — needed to qualify method IDs const needsOwner = @@ -493,6 +502,12 @@ const processParsingSequential = async ( ); } const nodeId = generateId(nodeLabel, `${file.path}:${qualifiedName}${arityTag}`); + const classNodeForSymbol = definitionNodeForRange || definitionNode || nameNode; + const qualifiedTypeName = + extractedClassSymbol?.qualifiedName ?? + (classNodeForSymbol && provider.classExtractor?.isTypeDeclaration(classNodeForSymbol) + ? (provider.classExtractor.extractQualifiedName(classNodeForSymbol, nodeName) ?? nodeName) + : undefined); const frameworkHint = definitionNode ? detectFrameworkFromAST(language, (definitionNode.text || '').slice(0, 300)) : null; @@ -518,6 +533,7 @@ const processParsingSequential = async ( nameNode || definitionNodeForRange, nodeName, ), + ...(qualifiedTypeName !== undefined ? { qualifiedName: qualifiedTypeName } : {}), ...(frameworkHint ? { astFrameworkMultiplier: frameworkHint.entryPointMultiplier, @@ -573,6 +589,7 @@ const processParsingSequential = async ( returnType: methodProps.returnType as string | undefined, declaredType, ownerId: enclosingClassId ?? undefined, + qualifiedName: qualifiedTypeName, }); const fileId = generateId('File', file.path); diff --git a/gitnexus/src/core/ingestion/symbol-table.ts b/gitnexus/src/core/ingestion/symbol-table.ts index e1d1efe88..2135c7db4 100644 --- a/gitnexus/src/core/ingestion/symbol-table.ts +++ b/gitnexus/src/core/ingestion/symbol-table.ts @@ -1,9 +1,15 @@ import type { NodeLabel } from 'gitnexus-shared'; +export const CLASS_TYPES = new Set(['Class', 'Struct', 'Interface', 'Enum', 'Record']); + export interface SymbolDefinition { nodeId: string; filePath: string; type: NodeLabel; + /** Canonical dot-separated qualified type name for class-like symbols + * (e.g. `App.Models.User`). Falls back to the simple symbol name when no + * package/namespace/module scope exists or no explicit qualified metadata is provided. */ + qualifiedName?: string; parameterCount?: number; /** Number of required (non-optional, non-default) parameters. * Enables range-based arity filtering: argCount >= requiredParameterCount && argCount <= parameterCount. */ @@ -36,6 +42,7 @@ export interface SymbolTable { returnType?: string; declaredType?: string; ownerId?: string; + qualifiedName?: string; }, ) => void; @@ -96,6 +103,14 @@ export interface SymbolTable { */ lookupClassByName: (name: string) => SymbolDefinition[]; + /** + * Look up class-like definitions by canonical qualified name. + * Qualified names are normalized to dot-separated scope segments across languages, + * e.g. `App.Models.User`, `com.example.User`, or `Admin.User`. + * Top-level class-like symbols with no explicit scope are indexed under their simple name. + */ + lookupClassByQualifiedName: (qualifiedName: string) => SymbolDefinition[]; + /** * Debugging: See how many symbols are tracked */ @@ -133,9 +148,9 @@ export const createSymbolTable = (): SymbolTable => { // 6. Eagerly-populated Class-type Index — keyed by symbol name. // Only Class, Struct, Interface, Enum, Record symbols are indexed. const classByName = new Map(); + const classByQualifiedName = new Map(); const CALLABLE_TYPES = new Set(['Function', 'Method', 'Constructor']); - const CLASS_TYPES = new Set(['Class', 'Struct', 'Interface', 'Enum', 'Record']); const add = ( filePath: string, @@ -149,12 +164,17 @@ export const createSymbolTable = (): SymbolTable => { returnType?: string; declaredType?: string; ownerId?: string; + qualifiedName?: string; }, ) => { + const qualifiedName = CLASS_TYPES.has(type) + ? (metadata?.qualifiedName ?? name) + : metadata?.qualifiedName; const def: SymbolDefinition = { nodeId, filePath, type, + ...(qualifiedName !== undefined ? { qualifiedName } : {}), ...(metadata?.parameterCount !== undefined ? { parameterCount: metadata.parameterCount } : {}), @@ -215,6 +235,14 @@ export const createSymbolTable = (): SymbolTable => { } else { classByName.set(name, [def]); } + + const qualifiedKey = qualifiedName ?? name; + const qualifiedMatches = classByQualifiedName.get(qualifiedKey); + if (qualifiedMatches) { + qualifiedMatches.push(def); + } else { + classByQualifiedName.set(qualifiedKey, [def]); + } } // D. Invalidate the lazy callable index only when adding callable types @@ -281,6 +309,10 @@ export const createSymbolTable = (): SymbolTable => { return classByName.get(name) ?? []; }; + const lookupClassByQualifiedName = (qualifiedName: string): SymbolDefinition[] => { + return classByQualifiedName.get(qualifiedName) ?? []; + }; + const getStats = () => ({ fileCount: fileIndex.size, globalSymbolCount: globalIndex.size, @@ -293,6 +325,7 @@ export const createSymbolTable = (): SymbolTable => { fieldByOwner.clear(); methodByOwner.clear(); classByName.clear(); + classByQualifiedName.clear(); }; return { @@ -305,6 +338,7 @@ export const createSymbolTable = (): SymbolTable => { lookupFieldByOwner, lookupMethodByOwner, lookupClassByName, + lookupClassByQualifiedName, getStats, clear, }; diff --git a/gitnexus/src/core/ingestion/workers/parse-worker.ts b/gitnexus/src/core/ingestion/workers/parse-worker.ts index 82ead976c..59e6cc03b 100644 --- a/gitnexus/src/core/ingestion/workers/parse-worker.ts +++ b/gitnexus/src/core/ingestion/workers/parse-worker.ts @@ -15,7 +15,7 @@ import { createRequire } from 'node:module'; import { SupportedLanguages } from 'gitnexus-shared'; import { getProvider } from '../languages/index.js'; import { getTreeSitterBufferSize, TREE_SITTER_MAX_BUFFER } from '../constants.js'; -import { SymbolTable } from '../symbol-table.js'; +import type { SymbolTable } from '../symbol-table.js'; /** Language grammar type accepted by Parser.setLanguage(). */ type TreeSitterLanguage = Parameters[0]; @@ -121,6 +121,7 @@ interface ParsedSymbol { name: string; nodeId: string; type: NodeLabel; + qualifiedName?: string; parameterCount?: number; requiredParameterCount?: number; parameterTypes?: string[]; @@ -1809,14 +1810,22 @@ const processFileGroup = ( } } - const nodeLabel = getLabelFromCaptures(captureMap, provider); - if (!nodeLabel) continue; + const definitionNode = getDefinitionNodeFromCaptures(captureMap); + const defaultNodeLabel = getLabelFromCaptures(captureMap, provider); + if (!defaultNodeLabel) continue; const nameNode = captureMap['name']; + const extractedClassSymbol = + definitionNode && provider.classExtractor?.isTypeDeclaration(definitionNode) + ? provider.classExtractor.extract(definitionNode, { + name: nameNode?.text, + type: defaultNodeLabel, + }) + : null; + const nodeLabel = extractedClassSymbol?.type ?? defaultNodeLabel; // Synthesize name for constructors without explicit @name capture (e.g. Swift init) - if (!nameNode && nodeLabel !== 'Constructor') continue; - const nodeName = nameNode ? nameNode.text : 'init'; - const definitionNode = getDefinitionNodeFromCaptures(captureMap); + if (!nameNode && nodeLabel !== 'Constructor' && !extractedClassSymbol) continue; + const nodeName = extractedClassSymbol?.name ?? (nameNode ? nameNode.text : 'init'); const startLine = definitionNode ? definitionNode.startPosition.row + lineOffset : nameNode @@ -1906,6 +1915,12 @@ const processFileGroup = ( arityTag += constTagForId(defMethodMap, nodeName, arityForId, defMethodInfo, groups); } const nodeId = generateId(nodeLabel, `${file.path}:${qualifiedName}${arityTag}`); + const classNodeForSymbol = definitionNode || nameNode; + const qualifiedTypeName = + extractedClassSymbol?.qualifiedName ?? + (classNodeForSymbol && provider.classExtractor?.isTypeDeclaration(classNodeForSymbol) + ? (provider.classExtractor.extractQualifiedName(classNodeForSymbol, nodeName) ?? nodeName) + : undefined); const description = provider.descriptionExtractor?.(nodeLabel, nodeName, captureMap); @@ -1983,6 +1998,7 @@ const processFileGroup = ( language === SupportedLanguages.Vue && isVueSetup ? isVueSetupTopLevel(nameNode || definitionNode) : cachedExportCheck(provider.exportChecker, nameNode || definitionNode, nodeName), + ...(qualifiedTypeName !== undefined ? { qualifiedName: qualifiedTypeName } : {}), ...(frameworkHint ? { astFrameworkMultiplier: frameworkHint.entryPointMultiplier, @@ -2002,6 +2018,7 @@ const processFileGroup = ( name: nodeName, nodeId, type: nodeLabel, + ...(qualifiedTypeName !== undefined ? { qualifiedName: qualifiedTypeName } : {}), parameterCount: methodProps.parameterCount as number | undefined, requiredParameterCount: methodProps.requiredParameterCount as number | undefined, parameterTypes: methodProps.parameterTypes as string[] | undefined, diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-qualified-types/Data/User.cs b/gitnexus/test/fixtures/lang-resolution/csharp-qualified-types/Data/User.cs new file mode 100644 index 000000000..853cbb898 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-qualified-types/Data/User.cs @@ -0,0 +1,7 @@ +namespace Data.Auth +{ + public class User + { + public void Save() {} + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-qualified-types/Services/User.cs b/gitnexus/test/fixtures/lang-resolution/csharp-qualified-types/Services/User.cs new file mode 100644 index 000000000..43fc4d059 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-qualified-types/Services/User.cs @@ -0,0 +1,6 @@ +namespace Services.Auth; + +public class User +{ + public void Save() {} +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-qualified-types/com/example/admin/User.java b/gitnexus/test/fixtures/lang-resolution/java-qualified-types/com/example/admin/User.java new file mode 100644 index 000000000..bd7ca440b --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-qualified-types/com/example/admin/User.java @@ -0,0 +1,5 @@ +package com.example.admin; + +public class User { + public void save() {} +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-qualified-types/com/example/models/User.java b/gitnexus/test/fixtures/lang-resolution/java-qualified-types/com/example/models/User.java new file mode 100644 index 000000000..a68ce81a8 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-qualified-types/com/example/models/User.java @@ -0,0 +1,5 @@ +package com.example.models; + +public class User { + public void save() {} +} diff --git a/gitnexus/test/fixtures/lang-resolution/ruby-qualified-types/lib/admin/user.rb b/gitnexus/test/fixtures/lang-resolution/ruby-qualified-types/lib/admin/user.rb new file mode 100644 index 000000000..29bf5adc1 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/ruby-qualified-types/lib/admin/user.rb @@ -0,0 +1,7 @@ +module Admin + class User + def save + true + end + end +end diff --git a/gitnexus/test/fixtures/lang-resolution/ruby-qualified-types/lib/services/auth/user.rb b/gitnexus/test/fixtures/lang-resolution/ruby-qualified-types/lib/services/auth/user.rb new file mode 100644 index 000000000..9b5615e4d --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/ruby-qualified-types/lib/services/auth/user.rb @@ -0,0 +1,9 @@ +module Services + module Auth + class User + def save + true + end + end + end +end diff --git a/gitnexus/test/integration/qualified-class-lookups.test.ts b/gitnexus/test/integration/qualified-class-lookups.test.ts new file mode 100644 index 000000000..1d6fa4fc6 --- /dev/null +++ b/gitnexus/test/integration/qualified-class-lookups.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from 'vitest'; +import { createASTCache } from '../../src/core/ingestion/ast-cache.js'; +import { processParsing } from '../../src/core/ingestion/parsing-processor.js'; +import { createSymbolTable } from '../../src/core/ingestion/symbol-table.js'; +import { createKnowledgeGraph } from '../../src/core/graph/graph.js'; + +describe('qualified class lookups', () => { + it('derives canonical dot-separated names from namespaces, packages, and modules', async () => { + const graph = createKnowledgeGraph(); + const symbolTable = createSymbolTable(); + const astCache = createASTCache(); + + await processParsing( + graph, + [ + { + path: 'src/Services/User.cs', + content: 'namespace Services.Auth;\npublic class User {}\n', + }, + { + path: 'src/Data/User.cs', + content: 'namespace Data.Auth;\npublic class User {}\n', + }, + { + path: 'src/models/Config.java', + content: 'package com.example.models;\nclass Config {}\n', + }, + { + path: 'lib/admin/user.rb', + content: 'module Admin\n class User\n end\nend\n', + }, + ], + symbolTable, + astCache, + ); + + const userMatches = symbolTable.lookupClassByName('User'); + expect(userMatches).toHaveLength(3); + expect(userMatches.map((match) => match.qualifiedName).sort()).toEqual( + ['Admin.User', 'Data.Auth.User', 'Services.Auth.User'].sort(), + ); + + const servicesUser = symbolTable.lookupClassByQualifiedName('Services.Auth.User'); + expect(servicesUser).toHaveLength(1); + expect(servicesUser[0].filePath).toBe('src/Services/User.cs'); + expect(servicesUser[0].qualifiedName).toBe('Services.Auth.User'); + + const dataUser = symbolTable.lookupClassByQualifiedName('Data.Auth.User'); + expect(dataUser).toHaveLength(1); + expect(dataUser[0].filePath).toBe('src/Data/User.cs'); + + const javaConfig = symbolTable.lookupClassByQualifiedName('com.example.models.Config'); + expect(javaConfig).toHaveLength(1); + expect(javaConfig[0].qualifiedName).toBe('com.example.models.Config'); + + const rubyUser = symbolTable.lookupClassByQualifiedName('Admin.User'); + expect(rubyUser).toHaveLength(1); + expect(rubyUser[0].qualifiedName).toBe('Admin.User'); + }); + + it('falls back to the simple name for top-level class-like symbols', async () => { + const graph = createKnowledgeGraph(); + const symbolTable = createSymbolTable(); + const astCache = createASTCache(); + + await processParsing( + graph, + [{ path: 'src/plain-user.ts', content: 'export class User {}\n' }], + symbolTable, + astCache, + ); + + const simpleMatches = symbolTable.lookupClassByName('User'); + expect(simpleMatches).toHaveLength(1); + expect(simpleMatches[0].qualifiedName).toBe('User'); + + const matches = symbolTable.lookupClassByQualifiedName('User'); + expect(matches).toHaveLength(1); + expect(matches[0].qualifiedName).toBe('User'); + }); +}); diff --git a/gitnexus/test/integration/resolvers/csharp.test.ts b/gitnexus/test/integration/resolvers/csharp.test.ts index 9620117e1..07cf4a186 100644 --- a/gitnexus/test/integration/resolvers/csharp.test.ts +++ b/gitnexus/test/integration/resolvers/csharp.test.ts @@ -132,6 +132,23 @@ describe('C# ambiguous symbol resolution', () => { }); }); +describe('C# qualified class names', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'csharp-qualified-types'), () => {}); + }, 60000); + + it('stores distinct qualified names for same-named classes across namespaces', () => { + const users = getNodesByLabelFull(result, 'Class').filter((node) => node.name === 'User'); + expect(users).toHaveLength(2); + expect(users.map((node) => node.properties.qualifiedName).sort()).toEqual([ + 'Data.Auth.User', + 'Services.Auth.User', + ]); + }); +}); + describe('C# call resolution with arity filtering', () => { let result: PipelineResult; diff --git a/gitnexus/test/integration/resolvers/java.test.ts b/gitnexus/test/integration/resolvers/java.test.ts index d03dd1bbd..7ce8453bf 100644 --- a/gitnexus/test/integration/resolvers/java.test.ts +++ b/gitnexus/test/integration/resolvers/java.test.ts @@ -137,6 +137,23 @@ describe('Java ambiguous symbol resolution', () => { }); }); +describe('Java qualified class names', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'java-qualified-types'), () => {}); + }, 60000); + + it('stores distinct qualified names for same-named classes across packages', () => { + const users = getNodesByLabelFull(result, 'Class').filter((node) => node.name === 'User'); + expect(users).toHaveLength(2); + expect(users.map((node) => node.properties.qualifiedName).sort()).toEqual([ + 'com.example.admin.User', + 'com.example.models.User', + ]); + }); +}); + describe('Java call resolution with arity filtering', () => { let result: PipelineResult; diff --git a/gitnexus/test/integration/resolvers/ruby.test.ts b/gitnexus/test/integration/resolvers/ruby.test.ts index 84bb137eb..865c41c4a 100644 --- a/gitnexus/test/integration/resolvers/ruby.test.ts +++ b/gitnexus/test/integration/resolvers/ruby.test.ts @@ -255,6 +255,23 @@ describe('Ruby member-call resolution', () => { }); }); +describe('Ruby qualified class names', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'ruby-qualified-types'), () => {}); + }, 60000); + + it('stores distinct qualified names for same-named classes across modules', () => { + const users = getNodesByLabelFull(result, 'Class').filter((node) => node.name === 'User'); + expect(users).toHaveLength(2); + expect(users.map((node) => node.properties.qualifiedName).sort()).toEqual([ + 'Admin.User', + 'Services.Auth.User', + ]); + }); +}); + // --------------------------------------------------------------------------- // Ambiguous: Handler in two dirs, require_relative disambiguates // --------------------------------------------------------------------------- diff --git a/gitnexus/test/unit/symbol-table.test.ts b/gitnexus/test/unit/symbol-table.test.ts index 9bd2603f8..a7d6a9ea1 100644 --- a/gitnexus/test/unit/symbol-table.test.ts +++ b/gitnexus/test/unit/symbol-table.test.ts @@ -725,6 +725,7 @@ describe('SymbolTable', () => { nodeId: 'class:User', filePath: 'src/models.ts', type: 'Class', + qualifiedName: 'User', }); }); @@ -842,4 +843,56 @@ describe('SymbolTable', () => { expect(results[0].nodeId).toBe('class:User:v2'); }); }); + + describe('lookupClassByQualifiedName', () => { + it('indexes class-like definitions by qualified name without replacing simple-name lookup', () => { + table.add('src/services/user.cs', 'User', 'class:services:User', 'Class', { + qualifiedName: 'Services.User', + }); + table.add('src/data/user.cs', 'User', 'class:data:User', 'Class', { + qualifiedName: 'Data.User', + }); + + expect(table.lookupClassByName('User')).toHaveLength(2); + expect(table.lookupClassByQualifiedName('Services.User')).toEqual([ + { + nodeId: 'class:services:User', + filePath: 'src/services/user.cs', + type: 'Class', + qualifiedName: 'Services.User', + }, + ]); + const dataUserMatches = table.lookupClassByQualifiedName('Data.User'); + expect(dataUserMatches).toHaveLength(1); + expect(dataUserMatches[0].qualifiedName).toBe('Data.User'); + }); + + it('falls back to the simple name when no qualified metadata is provided', () => { + table.add('src/models.ts', 'User', 'class:User', 'Class'); + expect(table.lookupClassByQualifiedName('User')).toEqual([ + { + nodeId: 'class:User', + filePath: 'src/models.ts', + type: 'Class', + qualifiedName: 'User', + }, + ]); + }); + + it('returns empty array for non-class-like types even when qualified metadata is present', () => { + table.add('src/utils.ts', 'User', 'func:User', 'Function', { + qualifiedName: 'Services.User', + }); + expect(table.lookupClassByQualifiedName('Services.User')).toEqual([]); + }); + + it('after clear(), returns empty array', () => { + table.add('src/services/user.cs', 'User', 'class:User', 'Class', { + qualifiedName: 'Services.User', + }); + expect(table.lookupClassByQualifiedName('Services.User')).toHaveLength(1); + table.clear(); + expect(table.lookupClassByQualifiedName('Services.User')).toEqual([]); + }); + }); }); From fe87ff8f7435c74d80ce2f11ace9d3ad44fa5346 Mon Sep 17 00:00:00 2001 From: Roshan Warrier Date: Wed, 8 Apr 2026 11:02:02 +0530 Subject: [PATCH 04/11] fix(symbol-table): index constructors in methodByOwner (#694) --- gitnexus/src/core/ingestion/symbol-table.ts | 5 +++-- gitnexus/test/unit/symbol-table.test.ts | 10 ++++++++-- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/gitnexus/src/core/ingestion/symbol-table.ts b/gitnexus/src/core/ingestion/symbol-table.ts index 2135c7db4..96d0948db 100644 --- a/gitnexus/src/core/ingestion/symbol-table.ts +++ b/gitnexus/src/core/ingestion/symbol-table.ts @@ -216,8 +216,9 @@ export const createSymbolTable = (): SymbolTable => { } globalIndex.get(name)!.push(def); - // C2. Methods with ownerId go to methodByOwner index (in addition to globalIndex). - if (type === 'Method' && metadata?.ownerId) { + // C2. Methods and constructors with ownerId go to methodByOwner index + // (in addition to globalIndex). + if ((type === 'Method' || type === 'Constructor') && metadata?.ownerId) { const key = `${metadata.ownerId}\0${name}`; const existing = methodByOwner.get(key); if (existing) { diff --git a/gitnexus/test/unit/symbol-table.test.ts b/gitnexus/test/unit/symbol-table.test.ts index a7d6a9ea1..95f79cfb8 100644 --- a/gitnexus/test/unit/symbol-table.test.ts +++ b/gitnexus/test/unit/symbol-table.test.ts @@ -383,12 +383,18 @@ describe('SymbolTable', () => { expect(table.lookupMethodByOwner('class:Handler', 'process')).toBeUndefined(); }); - it('does NOT index Constructor in methodByOwner', () => { + it('indexes Constructor in methodByOwner', () => { table.add('src/models.ts', 'User', 'ctor:User', 'Constructor', { parameterCount: 0, ownerId: 'class:User', }); - expect(table.lookupMethodByOwner('class:User', 'User')).toBeUndefined(); + expect(table.lookupMethodByOwner('class:User', 'User')).toEqual({ + nodeId: 'ctor:User', + filePath: 'src/models.ts', + type: 'Constructor', + parameterCount: 0, + ownerId: 'class:User', + }); // But it should be in lookupFuzzyCallable expect(table.lookupFuzzyCallable('User')).toHaveLength(1); }); From 0f431905433a454a41789db5e398b4a6c55315e6 Mon Sep 17 00:00:00 2001 From: Kunal Hemnani <143599188+kunalhemnani1@users.noreply.github.com> Date: Wed, 8 Apr 2026 12:23:30 +0530 Subject: [PATCH 05/11] feat(symbol-table): add fuzzy lookup counters (#708) --- gitnexus/src/core/ingestion/pipeline.ts | 3 +++ .../src/core/ingestion/resolution-context.ts | 2 ++ gitnexus/src/core/ingestion/symbol-table.ts | 17 ++++++++++++++- gitnexus/test/unit/symbol-table.test.ts | 21 ++++++++++++++++--- 4 files changed, 39 insertions(+), 4 deletions(-) diff --git a/gitnexus/src/core/ingestion/pipeline.ts b/gitnexus/src/core/ingestion/pipeline.ts index e840e4025..623fd51c9 100644 --- a/gitnexus/src/core/ingestion/pipeline.ts +++ b/gitnexus/src/core/ingestion/pipeline.ts @@ -1041,6 +1041,9 @@ async function runChunkedParseAndResolve( console.log( `🔍 Resolution cache: ${rcStats.cacheHits} hits, ${rcStats.cacheMisses} misses (${hitRate}% hit rate)`, ); + console.log( + `🔍 Fuzzy Lookups: ${rcStats.fuzzyCallCount} total, ${rcStats.fuzzyCallableCallCount} callable`, + ); } // ── Worker path quality enrichment: merge TypeEnv file-scope bindings into ExportedTypeMap ── diff --git a/gitnexus/src/core/ingestion/resolution-context.ts b/gitnexus/src/core/ingestion/resolution-context.ts index 0371c527d..da7908638 100644 --- a/gitnexus/src/core/ingestion/resolution-context.ts +++ b/gitnexus/src/core/ingestion/resolution-context.ts @@ -70,6 +70,8 @@ export interface ResolutionContext { getStats(): { fileCount: number; globalSymbolCount: number; + fuzzyCallCount: number; + fuzzyCallableCallCount: number; cacheHits: number; cacheMisses: number; }; diff --git a/gitnexus/src/core/ingestion/symbol-table.ts b/gitnexus/src/core/ingestion/symbol-table.ts index 96d0948db..086148f2f 100644 --- a/gitnexus/src/core/ingestion/symbol-table.ts +++ b/gitnexus/src/core/ingestion/symbol-table.ts @@ -114,7 +114,12 @@ export interface SymbolTable { /** * Debugging: See how many symbols are tracked */ - getStats: () => { fileCount: number; globalSymbolCount: number }; + getStats: () => { + fileCount: number; + globalSymbolCount: number; + fuzzyCallCount: number; + fuzzyCallableCallCount: number; + }; /** * Cleanup memory @@ -150,6 +155,10 @@ export const createSymbolTable = (): SymbolTable => { const classByName = new Map(); const classByQualifiedName = new Map(); + let fuzzyCallCount = 0; + + let fuzzyCallableCallCount = 0; + const CALLABLE_TYPES = new Set(['Function', 'Method', 'Constructor']); const add = ( @@ -267,10 +276,12 @@ export const createSymbolTable = (): SymbolTable => { }; const lookupFuzzy = (name: string): SymbolDefinition[] => { + fuzzyCallCount++; return globalIndex.get(name) || []; }; const lookupFuzzyCallable = (name: string): SymbolDefinition[] => { + fuzzyCallableCallCount++; if (!callableIndex) { // Build the callable index lazily on first use callableIndex = new Map(); @@ -317,6 +328,8 @@ export const createSymbolTable = (): SymbolTable => { const getStats = () => ({ fileCount: fileIndex.size, globalSymbolCount: globalIndex.size, + fuzzyCallableCallCount: fuzzyCallableCallCount, + fuzzyCallCount: fuzzyCallCount, }); const clear = () => { @@ -327,6 +340,8 @@ export const createSymbolTable = (): SymbolTable => { methodByOwner.clear(); classByName.clear(); classByQualifiedName.clear(); + fuzzyCallCount = 0; + fuzzyCallableCallCount = 0; }; return { diff --git a/gitnexus/test/unit/symbol-table.test.ts b/gitnexus/test/unit/symbol-table.test.ts index 95f79cfb8..bcb3df7e5 100644 --- a/gitnexus/test/unit/symbol-table.test.ts +++ b/gitnexus/test/unit/symbol-table.test.ts @@ -88,7 +88,12 @@ describe('SymbolTable', () => { describe('getStats', () => { it('returns zero counts for empty table', () => { - expect(table.getStats()).toEqual({ fileCount: 0, globalSymbolCount: 0 }); + expect(table.getStats()).toEqual({ + fileCount: 0, + globalSymbolCount: 0, + fuzzyCallCount: 0, + fuzzyCallableCallCount: 0, + }); }); it('tracks unique file count correctly', () => { @@ -484,7 +489,12 @@ describe('SymbolTable', () => { }); table.add('src/models.ts', 'User', 'class:User', 'Class'); table.clear(); - expect(table.getStats()).toEqual({ fileCount: 0, globalSymbolCount: 0 }); + expect(table.getStats()).toEqual({ + fileCount: 0, + globalSymbolCount: 0, + fuzzyCallCount: 0, + fuzzyCallableCallCount: 0, + }); expect(table.lookupExact('src/a.ts', 'foo')).toBeUndefined(); expect(table.lookupFuzzy('foo')).toEqual([]); expect(table.lookupFieldByOwner('class:User', 'address')).toBeUndefined(); @@ -497,7 +507,12 @@ describe('SymbolTable', () => { table.add('src/a.ts', 'foo', 'func:foo', 'Function'); table.clear(); table.add('src/b.ts', 'bar', 'func:bar', 'Function'); - expect(table.getStats()).toEqual({ fileCount: 1, globalSymbolCount: 1 }); + expect(table.getStats()).toEqual({ + fileCount: 1, + globalSymbolCount: 1, + fuzzyCallCount: 0, + fuzzyCallableCallCount: 0, + }); }); it('resets callableIndex so first lookup after clear rebuilds from scratch', () => { From d784f591b2cdb84892150b637be94a1f9635d852 Mon Sep 17 00:00:00 2001 From: MyShining <249674729@qq.com> Date: Wed, 8 Apr 2026 21:12:01 +0800 Subject: [PATCH 06/11] [cli] Replace class-type fuzzy lookups in type-env.ts (#733) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(type-env): use class lookup index for type resolution * test(type-env): add lookupClassByName regression coverage * test(type-env): expand class lookup regression coverage --------- Co-authored-by: 许恩宁 --- gitnexus/src/core/ingestion/type-env.ts | 27 +- gitnexus/test/unit/type-env.test.ts | 374 +++++++++++++++++++++++- 2 files changed, 386 insertions(+), 15 deletions(-) diff --git a/gitnexus/src/core/ingestion/type-env.ts b/gitnexus/src/core/ingestion/type-env.ts index 6bd469bee..5458d437e 100644 --- a/gitnexus/src/core/ingestion/type-env.ts +++ b/gitnexus/src/core/ingestion/type-env.ts @@ -395,7 +395,7 @@ const findEnclosingScopeKey = ( * using cross-file type information when available. * * Only `.has()` is exposed — the SymbolTable doesn't support iteration. - * Results are memoized to avoid redundant lookupFuzzy scans across declarations. + * Results are memoized to avoid redundant class-index scans across declarations. */ const createClassNameLookup = ( localNames: Set, @@ -410,7 +410,7 @@ const createClassNameLookup = ( const cached = memo.get(name); if (cached !== undefined) return cached; const result = symbolTable - .lookupFuzzy(name) + .lookupClassByName(name) .some((def) => def.type === 'Class' || def.type === 'Enum' || def.type === 'Struct'); memo.set(name, result); return result; @@ -460,6 +460,13 @@ const SKIP_SUBTREE_TYPES = new Set([ const CLASS_LIKE_TYPES = new Set(['Class', 'Struct', 'Interface']); +const lookupClassDefsByName = ( + symbolTable: SymbolTable, + name: string, + allowedTypes: ReadonlySet = CLASS_LIKE_TYPES, +): Array<{ nodeId: string; type: string }> => + symbolTable.lookupClassByName(name).filter((d) => allowedTypes.has(d.type)); + /** Memoize class definition lookups during fixpoint iteration. * SymbolTable is immutable during type resolution, so results never change. * Eliminates redundant array allocations + filter scans across iterations. */ @@ -468,9 +475,7 @@ const createClassDefCache = (symbolTable?: SymbolTable) => { return (typeName: string) => { let result = cache.get(typeName); if (result === undefined) { - result = symbolTable - ? symbolTable.lookupFuzzy(typeName).filter((d) => CLASS_LIKE_TYPES.has(d.type)) - : []; + result = symbolTable ? lookupClassDefsByName(symbolTable, typeName) : []; cache.set(typeName, result); } return result; @@ -598,9 +603,7 @@ const resolveFieldType = ( if (!symbolTable) return undefined; const receiverType = scopeEnv.get(receiver); if (!receiverType) return undefined; - const lookup = - getClassDefs ?? - ((name: string) => symbolTable.lookupFuzzy(name).filter((d) => CLASS_LIKE_TYPES.has(d.type))); + const lookup = getClassDefs ?? ((name: string) => lookupClassDefsByName(symbolTable, name)); const classDefs = lookup(receiverType); if (classDefs.length !== 1) return undefined; // Direct lookup first @@ -631,15 +634,11 @@ const resolveMethodReturnType = ( // When substituteThisReceiver replaced $this/self with the enclosing class name, // the receiver IS the type — look it up directly as a class name. if (!receiverType) { - const lookup = - getClassDefs ?? - ((name: string) => symbolTable.lookupFuzzy(name).filter((d) => CLASS_LIKE_TYPES.has(d.type))); + const lookup = getClassDefs ?? ((name: string) => lookupClassDefsByName(symbolTable, name)); if (lookup(receiver).length > 0) receiverType = receiver; } if (!receiverType) return undefined; - const lookup = - getClassDefs ?? - ((name: string) => symbolTable.lookupFuzzy(name).filter((d) => CLASS_LIKE_TYPES.has(d.type))); + const lookup = getClassDefs ?? ((name: string) => lookupClassDefsByName(symbolTable, name)); const classDefs = lookup(receiverType); if (classDefs.length === 0) return undefined; // Direct lookup first diff --git a/gitnexus/test/unit/type-env.test.ts b/gitnexus/test/unit/type-env.test.ts index 3396c425b..47942c7e3 100644 --- a/gitnexus/test/unit/type-env.test.ts +++ b/gitnexus/test/unit/type-env.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from 'vitest'; import { buildTypeEnv, type TypeEnvironment } from '../../src/core/ingestion/type-env.js'; +import type { SymbolDefinition, SymbolTable } from '../../src/core/ingestion/symbol-table.js'; import { stripNullable, extractSimpleTypeName, @@ -16,6 +17,24 @@ import Kotlin from 'tree-sitter-kotlin'; import PHP from 'tree-sitter-php'; import Ruby from 'tree-sitter-ruby'; +let Dart: unknown; +try { + Dart = require('tree-sitter-dart'); + const testParser = new Parser(); + testParser.setLanguage(Dart as Parser.Language); +} catch { + Dart = null; +} + +let Swift: unknown; +try { + Swift = require('tree-sitter-swift'); + const testParser = new Parser(); + testParser.setLanguage(Swift as Parser.Language); +} catch { + Swift = null; +} + const parser = new Parser(); const parse = (code: string, lang: any) => { @@ -23,6 +42,21 @@ const parse = (code: string, lang: any) => { return parser.parse(code); }; +const parseDart = (code: string) => { + if (!Dart) throw new Error('tree-sitter-dart not available'); + parser.setLanguage(Dart as Parser.Language); + return parser.parse(code); +}; + +const parseSwift = (code: string) => { + if (!Swift) throw new Error('tree-sitter-swift not available'); + parser.setLanguage(Swift as Parser.Language); + return parser.parse(code); +}; + +const describeDart = Dart ? describe : describe.skip; +const describeSwift = Swift ? describe : describe.skip; + /** Flatten a scoped TypeEnvironment into a simple name→type map (for simple test assertions). */ function flatGet(typeEnv: TypeEnvironment, varName: string): string | undefined { for (const [, scopeMap] of typeEnv.allScopes()) { @@ -39,6 +73,37 @@ function flatSize(typeEnv: TypeEnvironment): number { return count; } +const createMockSymbolTable = (overrides: Partial = {}): SymbolTable => ({ + add: () => {}, + lookupExact: () => undefined, + lookupExactFull: () => undefined, + lookupExactAll: () => [], + lookupFuzzy: () => [], + lookupFuzzyCallable: () => [], + lookupFieldByOwner: () => undefined, + lookupMethodByOwner: () => undefined, + lookupClassByName: () => [], + lookupClassByQualifiedName: () => [], + getStats: () => ({ + fileCount: 0, + globalSymbolCount: 0, + fuzzyCallCount: 0, + fuzzyCallableCallCount: 0, + }), + clear: () => {}, + ...overrides, +}); + +const createClassDef = ( + name: string, + type: SymbolDefinition['type'] = 'Class', + filePath = `${name}.ts`, +): SymbolDefinition => ({ + nodeId: `${type.toLowerCase()}:${name}`, + filePath, + type, +}); + describe('buildTypeEnv', () => { describe('TypeScript', () => { it('extracts type from const declaration', () => { @@ -1134,6 +1199,7 @@ class RepoService { type: 'Function' as const, returnType: c.returnType, })), + lookupClassByName: () => [], lookupFuzzy: () => [], lookupExact: () => undefined, lookupExactFull: () => undefined, @@ -1979,7 +2045,7 @@ class RepoService { ); // User is NOT defined in this file, but SymbolTable knows it's a Class const mockSymbolTable = { - lookupFuzzy: (name: string) => + lookupClassByName: (name: string) => name === 'User' ? [{ nodeId: 'n1', filePath: 'models.kt', type: 'Class' }] : [], lookupExact: () => undefined, lookupExactFull: () => undefined, @@ -2001,6 +2067,7 @@ class RepoService { Kotlin, ); const mockSymbolTable = { + lookupClassByName: () => [], lookupFuzzy: (name: string) => name === 'doStuff' ? [{ nodeId: 'n1', filePath: 'utils.kt', type: 'Function' }] : [], lookupFuzzyCallable: () => [], @@ -2076,6 +2143,310 @@ def main(): }); }); + describe('lookupClassByName regression coverage', () => { + const makeClassLookupTable = (classDefs: Record) => + createMockSymbolTable({ + lookupClassByName: (name: string) => classDefs[name] ?? [], + }); + + it('Python cross-file constructor inference uses lookupClassByName', () => { + const tree = parse( + ` +def main(): + user = User("alice") +`, + Python, + ); + const typeEnv = buildTypeEnv(tree, 'python', { + symbolTable: makeClassLookupTable({ + User: [createClassDef('User', 'Class', 'models.py')], + }), + }); + expect(flatGet(typeEnv, 'user')).toBe('User'); + }); + + it('Python cross-file constructor inference does not bind plain functions', () => { + const tree = parse( + ` +def main(): + result = get_user() +`, + Python, + ); + const typeEnv = buildTypeEnv(tree, 'python', { + symbolTable: makeClassLookupTable({}), + }); + expect(flatGet(typeEnv, 'result')).toBeUndefined(); + }); + + it('Python qualified cross-file constructor inference uses lookupClassByName', () => { + const tree = parse( + ` +def main(): + user = models.User("alice") +`, + Python, + ); + const typeEnv = buildTypeEnv(tree, 'python', { + symbolTable: makeClassLookupTable({ + User: [createClassDef('User', 'Class', 'models.py')], + }), + }); + expect(flatGet(typeEnv, 'user')).toBe('User'); + }); + + it('C++ cross-file constructor inference uses lookupClassByName', () => { + const tree = parse( + ` +void run() { + auto user = User(); +} +`, + CPP, + ); + const typeEnv = buildTypeEnv(tree, 'cpp', { + symbolTable: makeClassLookupTable({ + User: [createClassDef('User', 'Class', 'models.h')], + }), + }); + expect(flatGet(typeEnv, 'user')).toBe('User'); + }); + + it('C++ cross-file constructor inference does not bind plain functions', () => { + const tree = parse( + ` +void run() { + auto result = getUser(); +} +`, + CPP, + ); + const typeEnv = buildTypeEnv(tree, 'cpp', { + symbolTable: makeClassLookupTable({}), + }); + expect(flatGet(typeEnv, 'result')).toBeUndefined(); + }); + + it('Ruby cross-file constructor inference uses lookupClassByName', () => { + const tree = parse( + ` +def run + user = User.new +end +`, + Ruby, + ); + const typeEnv = buildTypeEnv(tree, 'ruby', { + symbolTable: makeClassLookupTable({ + User: [createClassDef('User', 'Class', 'models/user.rb')], + }), + }); + expect(flatGet(typeEnv, 'user')).toBe('User'); + }); + + it('Ruby namespaced constructor inference uses lookupClassByName', () => { + const tree = parse( + ` +def run + service = Models::UserService.new +end +`, + Ruby, + ); + const typeEnv = buildTypeEnv(tree, 'ruby', { + symbolTable: makeClassLookupTable({ + UserService: [createClassDef('UserService', 'Class', 'models/user_service.rb')], + }), + }); + expect(flatGet(typeEnv, 'service')).toBe('UserService'); + }); + + it('Ruby cross-file constructor inference does not bind plain functions', () => { + const tree = parse( + ` +def run + result = get_user() +end +`, + Ruby, + ); + const typeEnv = buildTypeEnv(tree, 'ruby', { + symbolTable: makeClassLookupTable({}), + }); + expect(flatGet(typeEnv, 'result')).toBeUndefined(); + }); + + describeDart('Dart lookupClassByName regression coverage', () => { + it('Dart cross-file constructor inference uses lookupClassByName', () => { + const tree = parseDart( + ` +void run() { + final user = User(); +} +`, + ); + const typeEnv = buildTypeEnv(tree, 'dart', { + symbolTable: makeClassLookupTable({ + User: [createClassDef('User', 'Class', 'models.dart')], + }), + }); + expect(flatGet(typeEnv, 'user')).toBe('User'); + }); + + it('Dart named constructor inference uses lookupClassByName', () => { + const tree = parseDart( + ` +void run() { + final user = User.named(); +} +`, + ); + const typeEnv = buildTypeEnv(tree, 'dart', { + symbolTable: makeClassLookupTable({ + User: [createClassDef('User', 'Class', 'models.dart')], + }), + }); + expect(flatGet(typeEnv, 'user')).toBe('User'); + }); + + it('Dart cross-file constructor inference does not bind plain functions', () => { + const tree = parseDart( + ` +void run() { + final result = getUser(); +} +`, + ); + const typeEnv = buildTypeEnv(tree, 'dart', { + symbolTable: makeClassLookupTable({}), + }); + expect(flatGet(typeEnv, 'result')).toBeUndefined(); + }); + }); + + it('Rust unit-struct inference uses lookupClassByName', () => { + const tree = parse( + ` +fn run() { + let service = UserService; +} +`, + Rust, + ); + const typeEnv = buildTypeEnv(tree, 'rust', { + symbolTable: makeClassLookupTable({ + UserService: [createClassDef('UserService', 'Struct', 'models.rs')], + }), + }); + expect(flatGet(typeEnv, 'service')).toBe('UserService'); + }); + + it('Rust unit-struct inference stays unresolved when lookupClassByName misses', () => { + const tree = parse( + ` +fn run() { + let value = helper; +} +`, + Rust, + ); + const typeEnv = buildTypeEnv(tree, 'rust', { + symbolTable: makeClassLookupTable({}), + }); + expect(flatGet(typeEnv, 'value')).toBeUndefined(); + }); + + describeSwift('Swift lookupClassByName regression coverage', () => { + it('Swift cross-file constructor inference uses lookupClassByName', () => { + const tree = parseSwift( + ` +func run() { + let user = User(name: "alice") +} +`, + ); + const typeEnv = buildTypeEnv(tree, 'swift', { + symbolTable: makeClassLookupTable({ + User: [createClassDef('User', 'Class', 'Models/User.swift')], + }), + }); + expect(flatGet(typeEnv, 'user')).toBe('User'); + }); + + it('Swift explicit init inference uses lookupClassByName', () => { + const tree = parseSwift( + ` +func run() { + let user = User.init(name: "alice") +} +`, + ); + const typeEnv = buildTypeEnv(tree, 'swift', { + symbolTable: makeClassLookupTable({ + User: [createClassDef('User', 'Class', 'Models/User.swift')], + }), + }); + expect(flatGet(typeEnv, 'user')).toBe('User'); + }); + + it('Swift cross-file constructor inference does not bind plain functions', () => { + const tree = parseSwift( + ` +func run() { + let result = getUser() +} +`, + ); + const typeEnv = buildTypeEnv(tree, 'swift', { + symbolTable: makeClassLookupTable({}), + }); + expect(flatGet(typeEnv, 'result')).toBeUndefined(); + }); + }); + + it('field type resolution uses lookupClassByName-backed class defs', () => { + const tree = parse( + ` +function process(user: User) { + const addr = user.address; +} +`, + TypeScript.typescript, + ); + const symbolTable = createMockSymbolTable({ + lookupClassByName: (name: string) => + name === 'User' ? [createClassDef('User', 'Class', 'models.ts')] : [], + lookupFieldByOwner: (ownerNodeId: string, fieldName: string) => + ownerNodeId === 'class:User' && fieldName === 'address' + ? { + nodeId: 'prop:User:address', + filePath: 'models.ts', + type: 'Property' as const, + declaredType: 'Address', + } + : undefined, + }); + const typeEnv = buildTypeEnv(tree, 'typescript', { symbolTable }); + expect(flatGet(typeEnv, 'addr')).toBe('Address'); + }); + + it('field type resolution stays unresolved when lookupClassByName finds no class', () => { + const tree = parse( + ` +function process(user: User) { + const addr = user.address; +} +`, + TypeScript.typescript, + ); + const symbolTable = createMockSymbolTable({ + lookupClassByName: () => [], + }); + const typeEnv = buildTypeEnv(tree, 'typescript', { symbolTable }); + expect(flatGet(typeEnv, 'addr')).toBeUndefined(); + }); + }); + describe('Python walrus operator type inference', () => { it('infers type from walrus operator with constructor call', () => { const tree = parse( @@ -4992,6 +5363,7 @@ function process() { type: 'Function' as const, returnType: c.returnType, })), + lookupClassByName: () => [], lookupFuzzy: () => [], lookupExact: () => undefined, lookupExactFull: () => undefined, From 3388ae16d799a093d4a0180bf75f673604f47de3 Mon Sep 17 00:00:00 2001 From: MyShining <249674729@qq.com> Date: Wed, 8 Apr 2026 21:48:54 +0800 Subject: [PATCH 07/11] [cli] Replace Phase P class checks with class lookup index (#734) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(call-processor): use class lookup index in phase p * test(call-processor): cover class lookup fallback --------- Co-authored-by: 许恩宁 --- gitnexus/src/core/ingestion/call-processor.ts | 8 +- gitnexus/test/unit/call-processor.test.ts | 109 ++++++++++++++++++ 2 files changed, 111 insertions(+), 6 deletions(-) diff --git a/gitnexus/src/core/ingestion/call-processor.ts b/gitnexus/src/core/ingestion/call-processor.ts index bb7af0bb9..27d18d495 100644 --- a/gitnexus/src/core/ingestion/call-processor.ts +++ b/gitnexus/src/core/ingestion/call-processor.ts @@ -993,12 +993,8 @@ export const processCalls = async ( 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')) + (ctx.symbols.lookupClassByName(ctorType).length > 0 && + ctx.symbols.lookupClassByName(receiverTypeName).length > 0) ) { receiverTypeName = ctorType; } diff --git a/gitnexus/test/unit/call-processor.test.ts b/gitnexus/test/unit/call-processor.test.ts index 6f1390b45..8b9bd5b8d 100644 --- a/gitnexus/test/unit/call-processor.test.ts +++ b/gitnexus/test/unit/call-processor.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { + processCalls, processCallsFromExtracted, seedCrossFileReceiverTypes, extractConsumerAccessedKeys, @@ -7,6 +8,7 @@ import { buildImplementorMap, mergeImplementorMaps, } from '../../src/core/ingestion/call-processor.js'; +import { createASTCache } from '../../src/core/ingestion/ast-cache.js'; import { extractReturnTypeName } from '../../src/core/ingestion/type-extractors/shared.js'; import { createResolutionContext, @@ -754,6 +756,113 @@ describe('processCallsFromExtracted', () => { }); }); +describe('processCalls — Phase P class lookup fallback', () => { + let graph: ReturnType; + let ctx: ResolutionContext; + + beforeEach(() => { + graph = createKnowledgeGraph(); + ctx = createResolutionContext(); + }); + + it('uses lookupClassByName to override interface receiver types for cross-file virtual dispatch', async () => { + const appFile = 'services/App.java'; + const contractFile = 'models/Pet.java'; + const dogFile = 'models/Dog.java'; + const petId = 'Interface:models/Pet.java:Pet'; + const dogId = 'Class:models/Dog.java:Dog'; + const fetchBallId = 'Method:models/Dog.java:fetchBall'; + + ctx.symbols.add(contractFile, 'Pet', petId, 'Interface'); + ctx.symbols.add(dogFile, 'Dog', dogId, 'Class'); + ctx.symbols.add(dogFile, 'fetchBall', fetchBallId, 'Method', { ownerId: dogId }); + ctx.importMap.set(appFile, new Set([contractFile, dogFile])); + + const classLookupSpy = vi.spyOn(ctx.symbols, 'lookupClassByName'); + + await processCalls( + graph, + [ + { + path: appFile, + content: ` +package services; + +import models.Pet; +import models.Dog; + +class App { + void run() { + Pet pet = new Dog(); + pet.fetchBall(); + } +} +`, + }, + ], + createASTCache(), + ctx, + ); + + const fetchBallCalls = graph.relationships.filter( + (r) => r.type === 'CALLS' && r.targetId === fetchBallId, + ); + expect(fetchBallCalls).toHaveLength(1); + expect(classLookupSpy).toHaveBeenCalledWith('Dog'); + expect(classLookupSpy).toHaveBeenCalledWith('Pet'); + }); + + it('does not override when the constructor type is not indexed as class-like', async () => { + const appFile = 'services/App.java'; + const contractFile = 'models/Pet.java'; + const dogFile = 'models/Dog.java'; + const otherDogFile = 'models/OtherDog.java'; + const petId = 'Interface:models/Pet.java:Pet'; + + ctx.symbols.add(contractFile, 'Pet', petId, 'Interface'); + ctx.symbols.add(dogFile, 'fetchBall', 'Method:models/Dog.java:fetchBall', 'Method', { + ownerId: 'Class:models/Dog.java:Dog', + }); + ctx.symbols.add(otherDogFile, 'fetchBall', 'Method:models/OtherDog.java:fetchBall', 'Method', { + ownerId: 'Class:models/OtherDog.java:OtherDog', + }); + ctx.importMap.set(appFile, new Set([contractFile, dogFile, otherDogFile])); + + const classLookupSpy = vi.spyOn(ctx.symbols, 'lookupClassByName'); + + await processCalls( + graph, + [ + { + path: appFile, + content: ` +package services; + +import models.Pet; +import models.Dog; + +class App { + void run() { + Pet pet = new Dog(); + pet.fetchBall(); + } +} +`, + }, + ], + createASTCache(), + ctx, + ); + + const fetchBallCalls = graph.relationships.filter( + (r) => r.type === 'CALLS' && r.targetId === 'Method:models/Dog.java:fetchBall', + ); + expect(fetchBallCalls).toHaveLength(0); + expect(classLookupSpy).toHaveBeenCalledWith('Dog'); + expect(classLookupSpy).not.toHaveBeenCalledWith('Pet'); + }); +}); + describe('extractReturnTypeName', () => { it('extracts simple type name', () => { expect(extractReturnTypeName('User')).toBe('User'); From 83b5bec29353b520362865a929517497c4da7c8a Mon Sep 17 00:00:00 2001 From: MyShining <249674729@qq.com> Date: Thu, 9 Apr 2026 00:41:09 +0800 Subject: [PATCH 08/11] [cli] Replace owner-filtered method lookups in type-env (#736) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(type-env): use owner method lookup * test(type-env): cover owner lookup edge cases * test(type-env): cover inherited overload ambiguity --------- Co-authored-by: 许恩宁 --- gitnexus/src/core/ingestion/type-env.ts | 40 ++- gitnexus/test/unit/type-env.test.ts | 396 +++++++++++++++++++++++- 2 files changed, 418 insertions(+), 18 deletions(-) diff --git a/gitnexus/src/core/ingestion/type-env.ts b/gitnexus/src/core/ingestion/type-env.ts index 5458d437e..e5fbb8cd2 100644 --- a/gitnexus/src/core/ingestion/type-env.ts +++ b/gitnexus/src/core/ingestion/type-env.ts @@ -459,19 +459,19 @@ const SKIP_SUBTREE_TYPES = new Set([ ]); const CLASS_LIKE_TYPES = new Set(['Class', 'Struct', 'Interface']); +type ClassDefRef = { nodeId: string; type: string; filePath: string }; const lookupClassDefsByName = ( symbolTable: SymbolTable, name: string, allowedTypes: ReadonlySet = CLASS_LIKE_TYPES, -): Array<{ nodeId: string; type: string }> => - symbolTable.lookupClassByName(name).filter((d) => allowedTypes.has(d.type)); +): ClassDefRef[] => symbolTable.lookupClassByName(name).filter((d) => allowedTypes.has(d.type)); /** Memoize class definition lookups during fixpoint iteration. * SymbolTable is immutable during type resolution, so results never change. * Eliminates redundant array allocations + filter scans across iterations. */ const createClassDefCache = (symbolTable?: SymbolTable) => { - const cache = new Map>(); + const cache = new Map(); return (typeName: string) => { let result = cache.get(typeName); if (result === undefined) { @@ -561,7 +561,7 @@ export const isSubclassOf = ( const walkParentChain = ( typeName: string, parentMap: ReadonlyMap | undefined, - getClassDefs: (name: string) => Array<{ nodeId: string; type: string }>, + getClassDefs: (name: string) => ClassDefRef[], lookupOnClass: (nodeId: string) => T | undefined, ): T | undefined => { if (!parentMap) return undefined; @@ -597,7 +597,7 @@ const resolveFieldType = ( field: string, scopeEnv: ReadonlyMap, symbolTable?: SymbolTable, - getClassDefs?: (typeName: string) => Array<{ nodeId: string; type: string }>, + getClassDefs?: (typeName: string) => ClassDefRef[], parentMap?: ReadonlyMap, ): string | undefined => { if (!symbolTable) return undefined; @@ -619,14 +619,14 @@ const resolveFieldType = ( /** Resolve a method's return type given a receiver variable and method name. * Uses SymbolTable to find class nodeIds for the receiver's type, then - * looks up the method via lookupFuzzyCallable filtered by ownerId. + * looks up the method via owner-scoped lookupMethodByOwner. * Falls back to MRO parent chain walking if direct lookup fails (Phase 11A). */ const resolveMethodReturnType = ( receiver: string, method: string, scopeEnv: ReadonlyMap, symbolTable?: SymbolTable, - getClassDefs?: (typeName: string) => Array<{ nodeId: string; type: string }>, + getClassDefs?: (typeName: string) => ClassDefRef[], parentMap?: ReadonlyMap, ): string | undefined => { if (!symbolTable) return undefined; @@ -642,21 +642,29 @@ const resolveMethodReturnType = ( const classDefs = lookup(receiverType); if (classDefs.length === 0) return undefined; // Direct lookup first - const classNodeIds = new Set(classDefs.map((d) => d.nodeId)); - const methods = symbolTable - .lookupFuzzyCallable(method) - .filter((d) => d.ownerId && classNodeIds.has(d.ownerId)); + const directMethodLookups = classDefs.map((d) => ({ + classDef: d, + methodDef: symbolTable.lookupMethodByOwner(d.nodeId, method), + })); + const hasAmbiguousDirectLookup = directMethodLookups.some(({ classDef, methodDef }) => { + if (methodDef) return false; + return symbolTable + .lookupExactAll(classDef.filePath, method) + .some((d) => d.ownerId === classDef.nodeId); + }); + if (hasAmbiguousDirectLookup) return undefined; + const methods = directMethodLookups + .map(({ methodDef }) => methodDef) + .filter((d): d is NonNullable => d !== undefined); if (methods.length === 1 && methods[0].returnType) { return extractReturnTypeName(methods[0].returnType); } // MRO parent chain walking on miss if (methods.length === 0) { const inherited = walkParentChain(receiverType, parentMap, lookup, (nodeId) => { - const parentMethods = symbolTable - .lookupFuzzyCallable(method) - .filter((d) => d.ownerId === nodeId); - if (parentMethods.length !== 1 || !parentMethods[0].returnType) return undefined; - return extractReturnTypeName(parentMethods[0].returnType); + const parentMethod = symbolTable.lookupMethodByOwner(nodeId, method); + if (!parentMethod?.returnType) return undefined; + return extractReturnTypeName(parentMethod.returnType); }); return inherited; } diff --git a/gitnexus/test/unit/type-env.test.ts b/gitnexus/test/unit/type-env.test.ts index 47942c7e3..db46bb31b 100644 --- a/gitnexus/test/unit/type-env.test.ts +++ b/gitnexus/test/unit/type-env.test.ts @@ -1,6 +1,10 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi } from 'vitest'; import { buildTypeEnv, type TypeEnvironment } from '../../src/core/ingestion/type-env.js'; -import type { SymbolDefinition, SymbolTable } from '../../src/core/ingestion/symbol-table.js'; +import { + createSymbolTable, + type SymbolDefinition, + type SymbolTable, +} from '../../src/core/ingestion/symbol-table.js'; import { stripNullable, extractSimpleTypeName, @@ -2445,6 +2449,394 @@ function process(user: User) { const typeEnv = buildTypeEnv(tree, 'typescript', { symbolTable }); expect(flatGet(typeEnv, 'addr')).toBeUndefined(); }); + + it('method return type resolution uses lookupMethodByOwner-backed class defs', () => { + const tree = parse( + ` +function process(repo: Repo) { + const profile = repo.getProfile(); +} +`, + TypeScript.typescript, + ); + const lookupFuzzyCallable = vi.fn(() => []); + const symbolTable = createMockSymbolTable({ + lookupClassByName: (name: string) => + name === 'Repo' ? [createClassDef('Repo', 'Class', 'models.ts')] : [], + lookupMethodByOwner: (ownerNodeId: string, methodName: string) => + ownerNodeId === 'class:Repo' && methodName === 'getProfile' + ? { + nodeId: 'method:Repo:getProfile', + filePath: 'models.ts', + type: 'Method', + ownerId: 'class:Repo', + returnType: 'Profile', + } + : undefined, + lookupFuzzyCallable, + }); + const typeEnv = buildTypeEnv(tree, 'typescript', { symbolTable }); + expect(flatGet(typeEnv, 'profile')).toBe('Profile'); + expect(lookupFuzzyCallable).not.toHaveBeenCalledWith('getProfile'); + }); + + it('inherited method return type resolution uses lookupMethodByOwner on parent owners', () => { + const tree = parse( + ` +function process(repo: Repo) { + const profile = repo.getProfile(); +} +`, + TypeScript.typescript, + ); + const lookupFuzzyCallable = vi.fn(() => []); + const symbolTable = createMockSymbolTable({ + lookupClassByName: (name: string) => { + if (name === 'Repo') return [createClassDef('Repo', 'Class', 'models.ts')]; + if (name === 'BaseRepo') return [createClassDef('BaseRepo', 'Class', 'base.ts')]; + return []; + }, + lookupMethodByOwner: (ownerNodeId: string, methodName: string) => + ownerNodeId === 'class:BaseRepo' && methodName === 'getProfile' + ? { + nodeId: 'method:BaseRepo:getProfile', + filePath: 'base.ts', + type: 'Method', + ownerId: 'class:BaseRepo', + returnType: 'Profile', + } + : undefined, + lookupFuzzyCallable, + }); + const typeEnv = buildTypeEnv(tree, 'typescript', { + symbolTable, + parentMap: new Map([['Repo', ['BaseRepo']]]), + }); + expect(flatGet(typeEnv, 'profile')).toBe('Profile'); + expect(lookupFuzzyCallable).not.toHaveBeenCalledWith('getProfile'); + }); + + it('method return type resolution handles multiple class defs when only one owner has the method', () => { + const tree = parse( + ` +function process(repo: Repo) { + const profile = repo.getProfile(); +} +`, + TypeScript.typescript, + ); + const lookupFuzzyCallable = vi.fn(() => []); + const symbolTable = createMockSymbolTable({ + lookupClassByName: (name: string) => + name === 'Repo' + ? [ + createClassDef('Repo', 'Class', 'models-a.ts'), + { + ...createClassDef('Repo', 'Class', 'models-b.ts'), + nodeId: 'class:Repo:partial', + }, + ] + : [], + lookupMethodByOwner: (ownerNodeId: string, methodName: string) => + ownerNodeId === 'class:Repo:partial' && methodName === 'getProfile' + ? { + nodeId: 'method:Repo:getProfile', + filePath: 'models-b.ts', + type: 'Method', + ownerId: 'class:Repo:partial', + returnType: 'Profile', + } + : undefined, + lookupExactAll: () => [], + lookupFuzzyCallable, + }); + const typeEnv = buildTypeEnv(tree, 'typescript', { symbolTable }); + expect(flatGet(typeEnv, 'profile')).toBe('Profile'); + expect(lookupFuzzyCallable).not.toHaveBeenCalledWith('getProfile'); + }); + + it('method return type resolution with multiple class defs falls back to MRO when direct owners miss', () => { + const tree = parse( + ` +function process(repo: Repo) { + const profile = repo.getProfile(); +} +`, + TypeScript.typescript, + ); + const lookupFuzzyCallable = vi.fn(() => []); + const symbolTable = createMockSymbolTable({ + lookupClassByName: (name: string) => { + if (name === 'Repo') { + return [ + createClassDef('Repo', 'Class', 'models-a.ts'), + { ...createClassDef('Repo', 'Class', 'models-b.ts'), nodeId: 'class:Repo:partial' }, + ]; + } + if (name === 'BaseRepo') return [createClassDef('BaseRepo', 'Class', 'base.ts')]; + return []; + }, + lookupMethodByOwner: (ownerNodeId: string, methodName: string) => + ownerNodeId === 'class:BaseRepo' && methodName === 'getProfile' + ? { + nodeId: 'method:BaseRepo:getProfile', + filePath: 'base.ts', + type: 'Method', + ownerId: 'class:BaseRepo', + returnType: 'Profile', + } + : undefined, + lookupExactAll: () => [], + lookupFuzzyCallable, + }); + const typeEnv = buildTypeEnv(tree, 'typescript', { + symbolTable, + parentMap: new Map([['Repo', ['BaseRepo']]]), + }); + expect(flatGet(typeEnv, 'profile')).toBe('Profile'); + expect(lookupFuzzyCallable).not.toHaveBeenCalledWith('getProfile'); + }); + + it('method return type resolution stays unresolved when multiple class defs each define the method', () => { + const tree = parse( + ` +function process(repo: Repo) { + const profile = repo.getProfile(); +} +`, + TypeScript.typescript, + ); + const lookupFuzzyCallable = vi.fn(() => []); + const symbolTable = createMockSymbolTable({ + lookupClassByName: (name: string) => + name === 'Repo' + ? [ + createClassDef('Repo', 'Class', 'models-a.ts'), + { + ...createClassDef('Repo', 'Class', 'models-b.ts'), + nodeId: 'class:Repo:partial', + }, + ] + : [], + lookupMethodByOwner: (ownerNodeId: string, methodName: string) => { + if (methodName !== 'getProfile') return undefined; + if (ownerNodeId === 'class:Repo') { + return { + nodeId: 'method:Repo:getProfile#a', + filePath: 'models-a.ts', + type: 'Method', + ownerId: 'class:Repo', + returnType: 'Profile', + }; + } + if (ownerNodeId === 'class:Repo:partial') { + return { + nodeId: 'method:Repo:getProfile#b', + filePath: 'models-b.ts', + type: 'Method', + ownerId: 'class:Repo:partial', + returnType: 'Profile', + }; + } + return undefined; + }, + lookupExactAll: () => [], + lookupFuzzyCallable, + }); + const typeEnv = buildTypeEnv(tree, 'typescript', { symbolTable }); + expect(flatGet(typeEnv, 'profile')).toBeUndefined(); + expect(lookupFuzzyCallable).not.toHaveBeenCalledWith('getProfile'); + }); + + it('method return type resolution preserves same-return overload success', () => { + const tree = parse( + ` +function process(repo: Repo) { + const profile = repo.getProfile(); +} +`, + TypeScript.typescript, + ); + const lookupFuzzyCallable = vi.fn(() => []); + const symbolTable = createMockSymbolTable({ + lookupClassByName: (name: string) => + name === 'Repo' ? [createClassDef('Repo', 'Class', 'models.ts')] : [], + lookupMethodByOwner: (ownerNodeId: string, methodName: string) => + ownerNodeId === 'class:Repo' && methodName === 'getProfile' + ? { + nodeId: 'method:Repo:getProfile#1', + filePath: 'models.ts', + type: 'Method', + ownerId: 'class:Repo', + returnType: 'Profile', + } + : undefined, + lookupExactAll: (filePath: string, name: string) => + filePath === 'models.ts' && name === 'getProfile' + ? [ + { + nodeId: 'method:Repo:getProfile#1', + filePath: 'models.ts', + type: 'Method', + ownerId: 'class:Repo', + returnType: 'Profile', + }, + { + nodeId: 'method:Repo:getProfile#2', + filePath: 'models.ts', + type: 'Method', + ownerId: 'class:Repo', + returnType: 'Profile', + }, + ] + : [], + lookupFuzzyCallable, + }); + const typeEnv = buildTypeEnv(tree, 'typescript', { symbolTable }); + expect(flatGet(typeEnv, 'profile')).toBe('Profile'); + expect(lookupFuzzyCallable).not.toHaveBeenCalledWith('getProfile'); + }); + + it('method return type resolution stays unresolved for ambiguous overloads with differing returns', () => { + const tree = parse( + ` +function process(repo: Repo) { + const profile = repo.getProfile(); +} +`, + TypeScript.typescript, + ); + const lookupFuzzyCallable = vi.fn(() => []); + const symbolTable = createMockSymbolTable({ + lookupClassByName: (name: string) => { + if (name === 'Repo') return [createClassDef('Repo', 'Class', 'models.ts')]; + if (name === 'BaseRepo') return [createClassDef('BaseRepo', 'Class', 'base.ts')]; + return []; + }, + lookupMethodByOwner: (ownerNodeId: string, methodName: string) => + ownerNodeId === 'class:BaseRepo' && methodName === 'getProfile' + ? { + nodeId: 'method:BaseRepo:getProfile', + filePath: 'base.ts', + type: 'Method', + ownerId: 'class:BaseRepo', + returnType: 'Profile', + } + : undefined, + lookupExactAll: (filePath: string, name: string) => + filePath === 'models.ts' && name === 'getProfile' + ? [ + { + nodeId: 'method:Repo:getProfile#1', + filePath: 'models.ts', + type: 'Method', + ownerId: 'class:Repo', + returnType: 'User', + }, + { + nodeId: 'method:Repo:getProfile#2', + filePath: 'models.ts', + type: 'Method', + ownerId: 'class:Repo', + returnType: 'Admin', + }, + ] + : [], + lookupFuzzyCallable, + }); + const typeEnv = buildTypeEnv(tree, 'typescript', { + symbolTable, + parentMap: new Map([['Repo', ['BaseRepo']]]), + }); + expect(flatGet(typeEnv, 'profile')).toBeUndefined(); + expect(lookupFuzzyCallable).not.toHaveBeenCalledWith('getProfile'); + }); + + it('inherited method return type resolution preserves same-return overload success on parent owners', () => { + const tree = parse( + ` +function process(repo: Repo) { + const profile = repo.getProfile(); +} +`, + TypeScript.typescript, + ); + const lookupFuzzyCallable = vi.fn(() => []); + const symbolTable = createMockSymbolTable({ + lookupClassByName: (name: string) => { + if (name === 'Repo') return [createClassDef('Repo', 'Class', 'models.ts')]; + if (name === 'BaseRepo') return [createClassDef('BaseRepo', 'Class', 'base.ts')]; + return []; + }, + lookupMethodByOwner: (ownerNodeId: string, methodName: string) => + ownerNodeId === 'class:BaseRepo' && methodName === 'getProfile' + ? { + nodeId: 'method:BaseRepo:getProfile#1', + filePath: 'base.ts', + type: 'Method', + ownerId: 'class:BaseRepo', + returnType: 'Profile', + } + : undefined, + lookupExactAll: (filePath: string, name: string) => + filePath === 'base.ts' && name === 'getProfile' + ? [ + { + nodeId: 'method:BaseRepo:getProfile#1', + filePath: 'base.ts', + type: 'Method', + ownerId: 'class:BaseRepo', + returnType: 'Profile', + }, + { + nodeId: 'method:BaseRepo:getProfile#2', + filePath: 'base.ts', + type: 'Method', + ownerId: 'class:BaseRepo', + returnType: 'Profile', + }, + ] + : [], + lookupFuzzyCallable, + }); + const typeEnv = buildTypeEnv(tree, 'typescript', { + symbolTable, + parentMap: new Map([['Repo', ['BaseRepo']]]), + }); + expect(flatGet(typeEnv, 'profile')).toBe('Profile'); + expect(lookupFuzzyCallable).not.toHaveBeenCalledWith('getProfile'); + }); + + it('inherited method return type resolution stays unresolved for ambiguous overloads on parent owners', () => { + const tree = parse( + ` +function process(repo: Repo) { + const profile = repo.getProfile(); +} +`, + TypeScript.typescript, + ); + const symbolTable = createSymbolTable(); + symbolTable.add('models.ts', 'Repo', 'class:Repo', 'Class'); + symbolTable.add('base.ts', 'BaseRepo', 'class:BaseRepo', 'Class'); + symbolTable.add('base.ts', 'getProfile', 'method:BaseRepo:getProfile#1', 'Method', { + ownerId: 'class:BaseRepo', + parameterCount: 1, + returnType: 'User', + }); + symbolTable.add('base.ts', 'getProfile', 'method:BaseRepo:getProfile#2', 'Method', { + ownerId: 'class:BaseRepo', + parameterCount: 2, + returnType: 'Admin', + }); + const lookupFuzzyCallable = vi.spyOn(symbolTable, 'lookupFuzzyCallable'); + const typeEnv = buildTypeEnv(tree, 'typescript', { + symbolTable, + parentMap: new Map([['Repo', ['BaseRepo']]]), + }); + expect(flatGet(typeEnv, 'profile')).toBeUndefined(); + expect(lookupFuzzyCallable).not.toHaveBeenCalled(); + }); }); describe('Python walrus operator type inference', () => { From b75e76d44a8d9d7c1963c882d54cd88ee7ef8dc3 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Wed, 8 Apr 2026 19:00:08 +0100 Subject: [PATCH 09/11] feat(SM-8): Build HeritageMap from accumulated ExtractedHeritage[] (#739) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Initial plan * feat(SM-8): add HeritageMap with MRO-aware parent/ancestor lookup - New heritage-map.ts: HeritageMap interface with getParents() and getAncestors() - buildHeritageMap() consumes ExtractedHeritage[], resolves names via lookupClassByName - Cycle protection and bounded depth (MAX_ANCESTOR_DEPTH=32) in getAncestors - Worker path: HeritageMap built from deferredWorkerHeritage, threaded into processCallsFromExtracted - Sequential path: Heritage accumulated across chunks, HeritageMap built after all chunks, passed to processCalls - 18 unit tests covering parent lookup, multi-level, diamond, cycles, missing parent, bounded depth Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/c413e0a3-5d63-4ddb-8ece-02fe6ed99efd Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * test: rename cycle test for clarity per code review Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/c413e0a3-5d63-4ddb-8ece-02fe6ed99efd Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * refactor(SM-8): merge implementor map into heritage map - Add `getImplementorFiles(interfaceName)` to HeritageMap interface - Build implementor index (interface name → file paths) alongside parent lookup in `buildHeritageMap`, using same `resolveExtendsType` logic - Remove `ImplementorMap` type, `buildImplementorMap`, `mergeImplementorMaps` from call-processor.ts - Update `findInterfaceDispatchTargets`, `processCalls`, and `processCallsFromExtracted` to use HeritageMap for both parent lookup and implementor dispatch - Pipeline: single `buildHeritageMap` call replaces separate buildImplementorMap + buildHeritageMap for both worker and sequential paths - Migrate implementor tests from call-processor.test.ts to heritage-map.test.ts (4 new getImplementorFiles tests) - Update interface dispatch test to use buildHeritageMap instead of hand-constructed ImplementorMap Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/085dffb4-b31e-4aa5-9aa3-4314bc0010e7 Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * test: rename implementor test for clarity per code review Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/085dffb4-b31e-4aa5-9aa3-4314bc0010e7 Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * fix(SM-8): address PR #739 review comments - pipeline.ts: cache chunk file contents from Pass 1 to eliminate double-read of sequential chunks in Pass 2. Peak memory drains incrementally as Pass 2 processes each chunk. - heritage-map.ts: document Rust trait-impl omission from implementor index and the interface-name collision limitation. - heritage-map.test.ts: add six tests covering the extends->IMPLEMENTS path across C# (interfaceNamePattern), Swift (heritageDefaultEdge), Java (symbol-table Interface lookup), Kotlin, PHP, and the Rust trait-impl omission. - pipeline.ts: comment why the heritage accumulation uses a manual push loop instead of spread (ref #650). * test(SM-8): address second PR #739 review pass - Add TypeScript implements test to getImplementorFiles (closes the .ts coverage gap flagged by the bot reviewer). - Tighten deep-chain boundary assertion from toBeLessThanOrEqual(32) to toBe(32) so a future regression returning fewer ancestors fails loudly. Added an ancestors[31] === 'class:Level32' check to pin the upper boundary. --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> Co-authored-by: Gergo Magyar --- gitnexus/src/core/ingestion/call-processor.ts | 83 +-- gitnexus/src/core/ingestion/heritage-map.ts | 167 ++++++ .../src/core/ingestion/heritage-processor.ts | 2 +- gitnexus/src/core/ingestion/pipeline.ts | 48 +- gitnexus/test/unit/call-processor.test.ts | 66 +-- gitnexus/test/unit/heritage-map.test.ts | 491 ++++++++++++++++++ 6 files changed, 716 insertions(+), 141 deletions(-) create mode 100644 gitnexus/src/core/ingestion/heritage-map.ts create mode 100644 gitnexus/test/unit/heritage-map.test.ts diff --git a/gitnexus/src/core/ingestion/call-processor.ts b/gitnexus/src/core/ingestion/call-processor.ts index 27d18d495..e3bacbf7b 100644 --- a/gitnexus/src/core/ingestion/call-processor.ts +++ b/gitnexus/src/core/ingestion/call-processor.ts @@ -30,7 +30,7 @@ import { } from './utils/call-analysis.js'; import { buildTypeEnv, isSubclassOf } from './type-env.js'; import type { ConstructorBinding, TypeEnvironment } from './type-env.js'; -import { resolveExtendsType } from './heritage-processor.js'; +import type { HeritageMap } from './heritage-map.js'; import { getTreeSitterBufferSize } from './constants.js'; import type { ExtractedCall, @@ -515,65 +515,6 @@ interface ResolveResult { returnType?: string; } -/** Maps interface/abstract-class name → set of file paths of direct implementors. */ -export type ImplementorMap = ReadonlyMap>; - -/** - * Build an ImplementorMap from extracted heritage data. - * Only direct `implements` relationships are tracked (transitive not needed for - * the common Java/Kotlin/C# interface dispatch pattern). - * `extends` is ignored — dispatch keyed on abstract class bases is not modeled here. - */ -/** - * Maps interface name → file paths of classes that implement it (direct only). - * When `ctx` is set, `kind: 'extends'` rows are classified like heritage-processor - * (C#/Java base_list: class vs interface parents share one capture name). - */ -export const buildImplementorMap = ( - heritage: readonly ExtractedHeritage[], - ctx?: ResolutionContext, -): Map> => { - const map = new Map>(); - for (const h of heritage) { - let record = false; - if (h.kind === 'implements') { - record = true; - } else if (h.kind === 'extends' && ctx) { - const lang = getLanguageFromFilename(h.filePath); - if (lang) { - const { type } = resolveExtendsType(h.parentName, h.filePath, ctx, lang); - record = type === 'IMPLEMENTS'; - } - } - if (record) { - let files = map.get(h.parentName); - if (!files) { - files = new Set(); - map.set(h.parentName, files); - } - files.add(h.filePath); - } - } - return map; -}; - -/** - * Merge a chunk's implementor map into the global accumulator. - */ -export const mergeImplementorMaps = ( - target: Map>, - source: ReadonlyMap>, -): void => { - for (const [name, files] of source) { - let existing = target.get(name); - if (!existing) { - existing = new Set(); - target.set(name, existing); - } - for (const f of files) existing.add(f); - } -}; - /** * After resolving a call to an interface method, find additional targets * in classes implementing that interface. Returns implementation method @@ -584,11 +525,11 @@ function findInterfaceDispatchTargets( receiverTypeName: string, currentFile: string, ctx: ResolutionContext, - implementorMap: ImplementorMap, + heritageMap: HeritageMap, primaryNodeId: string, ): ResolveResult[] { - const implFiles = implementorMap.get(receiverTypeName); - if (!implFiles || implFiles.size === 0) return []; + const implFiles = heritageMap.getImplementorFiles(receiverTypeName); + if (implFiles.size === 0) return []; const typeResolved = ctx.resolve(receiverTypeName, currentFile); if (!typeResolved) return []; @@ -624,7 +565,7 @@ export const processCalls = async ( importedReturnTypesMap?: ReadonlyMap>, /** Phase 14 E3: cross-file RAW return types for for-loop element extraction. Keyed by filePath → Map. */ importedRawReturnTypesMap?: ReadonlyMap>, - implementorMap?: ImplementorMap, + heritageMap?: HeritageMap, ): Promise => { const parser = await loadParser(); const collectedHeritage: ExtractedHeritage[] = []; @@ -857,13 +798,13 @@ export const processCalls = async ( reason: resolved.reason, }); - if (implementorMap && languageSeed.callForm === 'member' && receiverTypeName) { + if (heritageMap && languageSeed.callForm === 'member' && receiverTypeName) { const implTargets = findInterfaceDispatchTargets( languageSeed.calledName, receiverTypeName, file.path, ctx, - implementorMap, + heritageMap, resolved.nodeId, ); for (const impl of implTargets) { @@ -1104,13 +1045,13 @@ export const processCalls = async ( reason: resolved.reason, }); - if (implementorMap && callForm === 'member' && receiverTypeName) { + if (heritageMap && callForm === 'member' && receiverTypeName) { const implTargets = findInterfaceDispatchTargets( calledName, receiverTypeName, file.path, ctx, - implementorMap, + heritageMap, resolved.nodeId, ); for (const impl of implTargets) { @@ -1779,7 +1720,7 @@ export const processCallsFromExtracted = async ( ctx: ResolutionContext, onProgress?: (current: number, total: number) => void, constructorBindings?: FileConstructorBindings[], - implementorMap?: ImplementorMap, + heritageMap?: HeritageMap, ) => { // Scope-aware receiver types: keyed by filePath → "funcName\0varName" → typeName. // The scope dimension prevents collisions when two functions in the same file @@ -1942,13 +1883,13 @@ export const processCallsFromExtracted = async ( reason: resolved.reason, }); - if (implementorMap && effectiveCall.callForm === 'member' && effectiveCall.receiverTypeName) { + if (heritageMap && effectiveCall.callForm === 'member' && effectiveCall.receiverTypeName) { const implTargets = findInterfaceDispatchTargets( effectiveCall.calledName, effectiveCall.receiverTypeName, effectiveCall.filePath, ctx, - implementorMap, + heritageMap, resolved.nodeId, ); for (const impl of implTargets) { diff --git a/gitnexus/src/core/ingestion/heritage-map.ts b/gitnexus/src/core/ingestion/heritage-map.ts new file mode 100644 index 000000000..46d0c2120 --- /dev/null +++ b/gitnexus/src/core/ingestion/heritage-map.ts @@ -0,0 +1,167 @@ +/** + * Heritage Map + * + * Unified inheritance data structure built from accumulated + * {@link ExtractedHeritage} records **after all chunks complete** (between + * chunk processing and call resolution). Consumes `ExtractedHeritage[]` and + * resolves type names to nodeIds via `lookupClassByName`, NOT graph-edge + * queries. + * + * Combines two previously separate concerns: + * 1. **Parent/ancestor lookup** (MRO-aware method resolution) + * 2. **Implementor lookup** (interface dispatch — which files contain + * classes implementing a given interface) + */ + +import type { ExtractedHeritage } from './workers/parse-worker.js'; +import type { ResolutionContext } from './resolution-context.js'; +import { getLanguageFromFilename } from 'gitnexus-shared'; +import { resolveExtendsType } from './heritage-processor.js'; + +// --------------------------------------------------------------------------- +// Public types +// --------------------------------------------------------------------------- + +/** Maximum ancestor chain depth to prevent runaway traversal. */ +const MAX_ANCESTOR_DEPTH = 32; + +export interface HeritageMap { + /** Direct parents of `childNodeId` (extends + implements + trait-impl). */ + getParents(childNodeId: string): string[]; + /** Full ancestor chain (BFS, bounded depth, cycle-safe). */ + getAncestors(childNodeId: string): string[]; + /** + * File paths of classes that directly implement or extend-as-interface the + * given interface/abstract-class **name**. Replaces the standalone + * `ImplementorMap` — used by interface-dispatch in call resolution. + */ + getImplementorFiles(interfaceName: string): ReadonlySet; +} + +/** Shared empty set returned when no implementors are found. */ +const EMPTY_SET: ReadonlySet = new Set(); + +// --------------------------------------------------------------------------- +// Builder +// --------------------------------------------------------------------------- + +/** + * Build a HeritageMap from accumulated ExtractedHeritage records. + * + * Resolves class/interface/struct/trait names to nodeIds via + * `ctx.symbols.lookupClassByName`. When a name resolves to multiple + * candidates, all are recorded (partial-class / cross-file scenario). + * Unresolvable names are silently skipped — a missing parent is better + * than a wrong edge. + * + * Also builds the implementor index (interface name → implementing file + * paths) that was previously maintained by `buildImplementorMap` in + * call-processor.ts. + */ +export const buildHeritageMap = ( + heritage: readonly ExtractedHeritage[], + ctx: ResolutionContext, +): HeritageMap => { + // childNodeId → Set (Set to deduplicate cross-chunk duplicates) + const directParents = new Map>(); + + // interfaceName → Set (implementor lookup for interface dispatch) + const implementorFiles = new Map>(); + + for (const h of heritage) { + // ── Parent lookup (nodeId-based) ──────────────────────────────── + const childDefs = ctx.symbols.lookupClassByName(h.className); + const parentDefs = ctx.symbols.lookupClassByName(h.parentName); + + if (childDefs.length > 0 && parentDefs.length > 0) { + for (const child of childDefs) { + for (const parent of parentDefs) { + // Skip self-references + if (child.nodeId === parent.nodeId) continue; + + let parents = directParents.get(child.nodeId); + if (!parents) { + parents = new Set(); + directParents.set(child.nodeId, parents); + } + parents.add(parent.nodeId); + } + } + } + + // ── Implementor index (name-based) ────────────────────────────── + // + // Known limitation: Rust `kind: 'trait-impl'` entries are intentionally NOT + // added to the implementor index. Interface dispatch resolution currently + // does not traverse Rust trait objects, so recording them here would + // inflate the index without a consumer. Revisit if/when trait-object + // dispatch is added. + // + // Known limitation: `getImplementorFiles` is keyed by interface **name** + // (string), so two interfaces with the same unqualified name in different + // packages (e.g. `pkgA.IRepository` vs `pkgB.IRepository`) collide. This + // matches the behavior of the prior standalone `ImplementorMap` and is + // not a regression introduced by this consolidation. + let isImpl = false; + if (h.kind === 'implements') { + isImpl = true; + } else if (h.kind === 'extends') { + const lang = getLanguageFromFilename(h.filePath); + if (lang) { + const { type } = resolveExtendsType(h.parentName, h.filePath, ctx, lang); + isImpl = type === 'IMPLEMENTS'; + } + } + if (isImpl) { + let files = implementorFiles.get(h.parentName); + if (!files) { + files = new Set(); + implementorFiles.set(h.parentName, files); + } + files.add(h.filePath); + } + } + + // --- Public API --------------------------------------------------- + + const getParents = (childNodeId: string): string[] => { + const parents = directParents.get(childNodeId); + return parents ? [...parents] : []; + }; + + const getAncestors = (childNodeId: string): string[] => { + const result: string[] = []; + const visited = new Set(); + visited.add(childNodeId); // prevent cycles through the start node + + // BFS with bounded depth + let frontier = getParents(childNodeId); + let depth = 0; + + while (frontier.length > 0 && depth < MAX_ANCESTOR_DEPTH) { + const nextFrontier: string[] = []; + for (const parentId of frontier) { + if (visited.has(parentId)) continue; + visited.add(parentId); + result.push(parentId); + // Expand parent's own parents for next level + const grandparents = directParents.get(parentId); + if (grandparents) { + for (const gp of grandparents) { + if (!visited.has(gp)) nextFrontier.push(gp); + } + } + } + frontier = nextFrontier; + depth++; + } + + return result; + }; + + const getImplementorFiles = (interfaceName: string): ReadonlySet => { + return implementorFiles.get(interfaceName) ?? EMPTY_SET; + }; + + return { getParents, getAncestors, getImplementorFiles }; +}; diff --git a/gitnexus/src/core/ingestion/heritage-processor.ts b/gitnexus/src/core/ingestion/heritage-processor.ts index 25085ddd8..37c3653a8 100644 --- a/gitnexus/src/core/ingestion/heritage-processor.ts +++ b/gitnexus/src/core/ingestion/heritage-processor.ts @@ -372,7 +372,7 @@ export const processHeritageFromExtracted = async ( /** * Walk source files with the same heritage captures as parse-worker, producing * {@link ExtractedHeritage} rows without mutating the graph. Used on the - * sequential pipeline path so `buildImplementorMap(..., ctx)` can run before + * sequential pipeline path so `buildHeritageMap(..., ctx)` can run before * `processCalls` (worker path defers calls until heritage from all chunks exists). */ export async function extractExtractedHeritageFromFiles( diff --git a/gitnexus/src/core/ingestion/pipeline.ts b/gitnexus/src/core/ingestion/pipeline.ts index 623fd51c9..e73efd86e 100644 --- a/gitnexus/src/core/ingestion/pipeline.ts +++ b/gitnexus/src/core/ingestion/pipeline.ts @@ -21,9 +21,8 @@ import { buildImportedRawReturnTypes, type ExportedTypeMap, buildExportedTypeMapFromGraph, - buildImplementorMap, - mergeImplementorMaps, } from './call-processor.js'; +import { buildHeritageMap } from './heritage-map.js'; import { nextjsFileToRouteURL, normalizeFetchURL } from './route-extractors/nextjs.js'; import { expoFileToRouteURL } from './route-extractors/expo.js'; import { phpFileToRouteURL } from './route-extractors/php.js'; @@ -949,11 +948,9 @@ async function runChunkedParseAndResolve( // chunkContents + chunkFiles + chunkWorkerData go out of scope → GC reclaims } - // Complete implementor map from all worker heritage, then resolve CALLS once (interface dispatch). - const fullWorkerImplementorMap = - deferredWorkerHeritage.length > 0 - ? buildImplementorMap(deferredWorkerHeritage, ctx) - : new Map>(); + // Build unified HeritageMap (parent lookup + implementor index) after all chunks. + const fullWorkerHeritageMap = + deferredWorkerHeritage.length > 0 ? buildHeritageMap(deferredWorkerHeritage, ctx) : undefined; if (deferredWorkerCalls.length > 0) { await processCallsFromExtracted( @@ -974,7 +971,7 @@ async function runChunkedParseAndResolve( }); }, deferredConstructorBindings.length > 0 ? deferredConstructorBindings : undefined, - fullWorkerImplementorMap, + fullWorkerHeritageMap, ); } @@ -994,17 +991,38 @@ async function runChunkedParseAndResolve( // Synthesize wildcard import bindings once after ALL imports are processed, // before any call resolution — same rationale as the worker-path inline synthesis. if (sequentialChunkPaths.length > 0) synthesizeWildcardImportBindings(graph, ctx); - // Merge implementor-map deltas per chunk (O(heritage per chunk)), not O(|edges|) graph scans - // per chunk — mirrors worker-path deferred heritage without re-iterating all relationships. - const sequentialImplementorMap = new Map>(); + // Pass 1: Extract heritage from all sequential chunks. + // Heritage must be fully accumulated BEFORE call resolution so the HeritageMap + // has the complete ancestor chain and implementor index (same constraint as + // the worker path). + // + // File contents are read once here and cached for Pass 2 to avoid a 2× I/O + // cost on the sequential path (ASTs are intentionally NOT cached — rebuilding + // them in Pass 2 keeps peak memory bounded to one chunk at a time). + const allSequentialHeritage: ExtractedHeritage[] = []; + const cachedSequentialChunkFiles: Array> = []; for (const chunkPaths of sequentialChunkPaths) { const chunkContents = await readFileContents(repoPath, chunkPaths); const chunkFiles = chunkPaths .filter((p) => chunkContents.has(p)) .map((p) => ({ path: p, content: chunkContents.get(p)! })); + cachedSequentialChunkFiles.push(chunkFiles); astCache = createASTCache(chunkFiles.length); const sequentialHeritage = await extractExtractedHeritageFromFiles(chunkFiles, astCache); - mergeImplementorMaps(sequentialImplementorMap, buildImplementorMap(sequentialHeritage, ctx)); + // Manual loop (not spread) — `push(...arr)` blows the stack on very large + // arrays, see #650. Pay the explicit iteration cost for safety. + for (const h of sequentialHeritage) allSequentialHeritage.push(h); + astCache.clear(); + } + // Build unified HeritageMap from all sequential heritage (parent lookup + implementor index). + const sequentialHeritageMap = + allSequentialHeritage.length > 0 ? buildHeritageMap(allSequentialHeritage, ctx) : undefined; + + // Pass 2: Process calls, heritage edges, fetch calls, and ORM queries per chunk. + // Reuse the file contents cached in Pass 1 instead of re-reading from disk. + for (let chunkIdx = 0; chunkIdx < sequentialChunkPaths.length; chunkIdx++) { + const chunkFiles = cachedSequentialChunkFiles[chunkIdx]; + astCache = createASTCache(chunkFiles.length); const rubyHeritage = await processCalls( graph, chunkFiles, @@ -1015,7 +1033,7 @@ async function runChunkedParseAndResolve( undefined, undefined, undefined, - sequentialImplementorMap, + sequentialHeritageMap, ); await processHeritage(graph, chunkFiles, astCache, ctx); if (rubyHeritage.length > 0) { @@ -1031,6 +1049,10 @@ async function runChunkedParseAndResolve( extractORMQueriesInline(f.path, f.content, allORMQueries); } astCache.clear(); + // Release cached chunk content as soon as Pass 2 finishes with it so the + // Pass-1 content map drains incrementally rather than being held for the + // full duration of Pass 2. + cachedSequentialChunkFiles[chunkIdx] = []; } // Log resolution cache stats diff --git a/gitnexus/test/unit/call-processor.test.ts b/gitnexus/test/unit/call-processor.test.ts index 8b9bd5b8d..bb4590d5a 100644 --- a/gitnexus/test/unit/call-processor.test.ts +++ b/gitnexus/test/unit/call-processor.test.ts @@ -5,9 +5,8 @@ import { seedCrossFileReceiverTypes, extractConsumerAccessedKeys, processNextjsFetchRoutes, - buildImplementorMap, - mergeImplementorMaps, } from '../../src/core/ingestion/call-processor.js'; +import { buildHeritageMap } from '../../src/core/ingestion/heritage-map.js'; import { createASTCache } from '../../src/core/ingestion/ast-cache.js'; import { extractReturnTypeName } from '../../src/core/ingestion/type-extractors/shared.js'; import { @@ -1506,56 +1505,6 @@ describe('processNextjsFetchRoutes', () => { }); }); -describe('buildImplementorMap / mergeImplementorMaps', () => { - it('records direct implements edges per interface name', () => { - const heritage: ExtractedHeritage[] = [ - { filePath: 'a.java', className: 'C', parentName: 'Runnable', kind: 'implements' }, - { filePath: 'b.java', className: 'D', parentName: 'Runnable', kind: 'implements' }, - ]; - const map = buildImplementorMap(heritage); - expect(map.get('Runnable')).toEqual(new Set(['a.java', 'b.java'])); - }); - - it('ignores extends and other heritage kinds', () => { - const heritage: ExtractedHeritage[] = [ - { filePath: 'a.java', className: 'C', parentName: 'Base', kind: 'extends' }, - { filePath: 'a.java', className: 'C', parentName: 'I', kind: 'implements' }, - ]; - const map = buildImplementorMap(heritage); - expect(map.has('Base')).toBe(false); - expect(map.get('I')).toEqual(new Set(['a.java'])); - }); - - it('mergeImplementorMaps unions files per interface and adds new keys', () => { - const acc = new Map>(); - mergeImplementorMaps(acc, new Map([['I', new Set(['a.java'])]])); - mergeImplementorMaps( - acc, - new Map([ - ['I', new Set(['b.java'])], - ['J', new Set(['c.java'])], - ]), - ); - expect(acc.get('I')).toEqual(new Set(['a.java', 'b.java'])); - expect(acc.get('J')).toEqual(new Set(['c.java'])); - }); - - it('heritage merged across disjoint lists matches single buildImplementorMap (chunk-order invariant)', () => { - const chunk1: ExtractedHeritage[] = [ - { filePath: 'a.java', className: 'A', parentName: 'Iface', kind: 'implements' }, - ]; - const chunk2: ExtractedHeritage[] = [ - { filePath: 'b.java', className: 'B', parentName: 'Iface', kind: 'implements' }, - ]; - const oneShot = buildImplementorMap([...chunk1, ...chunk2]); - const acc = new Map>(); - mergeImplementorMaps(acc, buildImplementorMap(chunk1)); - mergeImplementorMaps(acc, buildImplementorMap(chunk2)); - expect(oneShot.get('Iface')).toEqual(acc.get('Iface')); - expect(oneShot.get('Iface')).toEqual(new Set(['a.java', 'b.java'])); - }); -}); - describe('processCallsFromExtracted — interface dispatch', () => { let graph: ReturnType; let ctx: ResolutionContext; @@ -1606,9 +1555,14 @@ describe('processCallsFromExtracted — interface dispatch', () => { }); it('adds CALLS to interface method plus lower-confidence edges to implementing methods', async () => { - const implementorMap = new Map>([ - ['Action', new Set(['impl/A.java', 'impl/B.java'])], - ]); + const heritage: ExtractedHeritage[] = [ + { filePath: 'impl/A.java', className: 'A', parentName: 'Action', kind: 'implements' }, + { filePath: 'impl/B.java', className: 'B', parentName: 'Action', kind: 'implements' }, + ]; + // Need class symbols for heritage map to resolve implementors + ctx.symbols.add('impl/A.java', 'A', 'Class:impl/A.java:A', 'Class'); + ctx.symbols.add('impl/B.java', 'B', 'Class:impl/B.java:B', 'Class'); + const heritageMap = buildHeritageMap(heritage, ctx); const calls: ExtractedCall[] = [ { @@ -1621,7 +1575,7 @@ describe('processCallsFromExtracted — interface dispatch', () => { }, ]; - await processCallsFromExtracted(graph, calls, ctx, undefined, undefined, implementorMap); + await processCallsFromExtracted(graph, calls, ctx, undefined, undefined, heritageMap); const rels = graph.relationships.filter((r) => r.type === 'CALLS'); expect(rels).toHaveLength(3); diff --git a/gitnexus/test/unit/heritage-map.test.ts b/gitnexus/test/unit/heritage-map.test.ts new file mode 100644 index 000000000..b4a6b1c4f --- /dev/null +++ b/gitnexus/test/unit/heritage-map.test.ts @@ -0,0 +1,491 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { buildHeritageMap } from '../../src/core/ingestion/heritage-map.js'; +import { + createResolutionContext, + type ResolutionContext, +} from '../../src/core/ingestion/resolution-context.js'; +import type { ExtractedHeritage } from '../../src/core/ingestion/workers/parse-worker.js'; + +describe('buildHeritageMap', () => { + let ctx: ResolutionContext; + + beforeEach(() => { + ctx = createResolutionContext(); + }); + + // ── getParents ────────────────────────────────────────────────────── + + describe('getParents', () => { + it('returns direct parents for a single extends relationship', () => { + ctx.symbols.add('src/child.ts', 'Child', 'class:Child', 'Class'); + ctx.symbols.add('src/parent.ts', 'Parent', 'class:Parent', 'Class'); + + const heritage: ExtractedHeritage[] = [ + { filePath: 'src/child.ts', className: 'Child', parentName: 'Parent', kind: 'extends' }, + ]; + + const map = buildHeritageMap(heritage, ctx); + expect(map.getParents('class:Child')).toEqual(['class:Parent']); + }); + + it('returns direct parents for implements relationship', () => { + ctx.symbols.add('src/service.ts', 'Service', 'class:Service', 'Class'); + ctx.symbols.add('src/iface.ts', 'IService', 'iface:IService', 'Interface'); + + const heritage: ExtractedHeritage[] = [ + { + filePath: 'src/service.ts', + className: 'Service', + parentName: 'IService', + kind: 'implements', + }, + ]; + + const map = buildHeritageMap(heritage, ctx); + expect(map.getParents('class:Service')).toEqual(['iface:IService']); + }); + + it('returns direct parents for trait-impl relationship', () => { + ctx.symbols.add('src/point.rs', 'Point', 'struct:Point', 'Struct'); + ctx.symbols.add('src/display.rs', 'Display', 'trait:Display', 'Interface'); + + const heritage: ExtractedHeritage[] = [ + { + filePath: 'src/point.rs', + className: 'Point', + parentName: 'Display', + kind: 'trait-impl', + }, + ]; + + const map = buildHeritageMap(heritage, ctx); + expect(map.getParents('struct:Point')).toEqual(['trait:Display']); + }); + + it('returns multiple parents when class extends and implements', () => { + ctx.symbols.add('src/admin.ts', 'Admin', 'class:Admin', 'Class'); + ctx.symbols.add('src/user.ts', 'User', 'class:User', 'Class'); + ctx.symbols.add('src/serializable.ts', 'Serializable', 'iface:Serializable', 'Interface'); + + const heritage: ExtractedHeritage[] = [ + { filePath: 'src/admin.ts', className: 'Admin', parentName: 'User', kind: 'extends' }, + { + filePath: 'src/admin.ts', + className: 'Admin', + parentName: 'Serializable', + kind: 'implements', + }, + ]; + + const map = buildHeritageMap(heritage, ctx); + const parents = map.getParents('class:Admin'); + expect(parents).toHaveLength(2); + expect(parents).toContain('class:User'); + expect(parents).toContain('iface:Serializable'); + }); + + it('returns empty array for unknown nodeId', () => { + const map = buildHeritageMap([], ctx); + expect(map.getParents('class:NonExistent')).toEqual([]); + }); + + it('skips heritage records where child class is not in symbol table', () => { + ctx.symbols.add('src/parent.ts', 'Parent', 'class:Parent', 'Class'); + + const heritage: ExtractedHeritage[] = [ + { + filePath: 'src/child.ts', + className: 'Unknown', + parentName: 'Parent', + kind: 'extends', + }, + ]; + + const map = buildHeritageMap(heritage, ctx); + // No child resolved, so no entries + expect(map.getParents('class:Parent')).toEqual([]); + }); + + it('skips heritage records where parent class is not in symbol table', () => { + ctx.symbols.add('src/child.ts', 'Child', 'class:Child', 'Class'); + + const heritage: ExtractedHeritage[] = [ + { + filePath: 'src/child.ts', + className: 'Child', + parentName: 'Unknown', + kind: 'extends', + }, + ]; + + const map = buildHeritageMap(heritage, ctx); + expect(map.getParents('class:Child')).toEqual([]); + }); + + it('skips self-references', () => { + ctx.symbols.add('src/a.ts', 'A', 'class:A', 'Class'); + + const heritage: ExtractedHeritage[] = [ + { filePath: 'src/a.ts', className: 'A', parentName: 'A', kind: 'extends' }, + ]; + + const map = buildHeritageMap(heritage, ctx); + expect(map.getParents('class:A')).toEqual([]); + }); + + it('deduplicates cross-chunk duplicates', () => { + ctx.symbols.add('src/child.ts', 'Child', 'class:Child', 'Class'); + ctx.symbols.add('src/parent.ts', 'Parent', 'class:Parent', 'Class'); + + const heritage: ExtractedHeritage[] = [ + { filePath: 'src/child.ts', className: 'Child', parentName: 'Parent', kind: 'extends' }, + { filePath: 'src/child.ts', className: 'Child', parentName: 'Parent', kind: 'extends' }, + ]; + + const map = buildHeritageMap(heritage, ctx); + expect(map.getParents('class:Child')).toEqual(['class:Parent']); + }); + }); + + // ── getAncestors ──────────────────────────────────────────────────── + + describe('getAncestors', () => { + it('returns full ancestor chain for multi-level inheritance', () => { + ctx.symbols.add('src/c.ts', 'C', 'class:C', 'Class'); + ctx.symbols.add('src/b.ts', 'B', 'class:B', 'Class'); + ctx.symbols.add('src/a.ts', 'A', 'class:A', 'Class'); + + const heritage: ExtractedHeritage[] = [ + { filePath: 'src/c.ts', className: 'C', parentName: 'B', kind: 'extends' }, + { filePath: 'src/b.ts', className: 'B', parentName: 'A', kind: 'extends' }, + ]; + + const map = buildHeritageMap(heritage, ctx); + const ancestors = map.getAncestors('class:C'); + expect(ancestors).toHaveLength(2); + expect(ancestors).toContain('class:B'); + expect(ancestors).toContain('class:A'); + }); + + it('handles diamond inheritance without duplicates', () => { + // A + // / \ + // B C + // \ / + // D + ctx.symbols.add('src/a.ts', 'A', 'class:A', 'Class'); + ctx.symbols.add('src/b.ts', 'B', 'class:B', 'Class'); + ctx.symbols.add('src/c.ts', 'C', 'class:C', 'Class'); + ctx.symbols.add('src/d.ts', 'D', 'class:D', 'Class'); + + const heritage: ExtractedHeritage[] = [ + { filePath: 'src/d.ts', className: 'D', parentName: 'B', kind: 'extends' }, + { filePath: 'src/d.ts', className: 'D', parentName: 'C', kind: 'implements' }, + { filePath: 'src/b.ts', className: 'B', parentName: 'A', kind: 'extends' }, + { filePath: 'src/c.ts', className: 'C', parentName: 'A', kind: 'extends' }, + ]; + + const map = buildHeritageMap(heritage, ctx); + const ancestors = map.getAncestors('class:D'); + expect(ancestors).toHaveLength(3); // B, C, A — no duplicates + expect(ancestors).toContain('class:B'); + expect(ancestors).toContain('class:C'); + expect(ancestors).toContain('class:A'); + }); + + it('protects against cycles', () => { + ctx.symbols.add('src/a.ts', 'A', 'class:A', 'Class'); + ctx.symbols.add('src/b.ts', 'B', 'class:B', 'Class'); + + const heritage: ExtractedHeritage[] = [ + { filePath: 'src/a.ts', className: 'A', parentName: 'B', kind: 'extends' }, + { filePath: 'src/b.ts', className: 'B', parentName: 'A', kind: 'extends' }, + ]; + + const map = buildHeritageMap(heritage, ctx); + // Should not infinite-loop; each visited once + const ancestorsA = map.getAncestors('class:A'); + expect(ancestorsA).toEqual(['class:B']); + + const ancestorsB = map.getAncestors('class:B'); + expect(ancestorsB).toEqual(['class:A']); + }); + + it('protects against multi-node cycles (A→B→C→A)', () => { + ctx.symbols.add('src/a.ts', 'A', 'class:A', 'Class'); + ctx.symbols.add('src/b.ts', 'B', 'class:B', 'Class'); + ctx.symbols.add('src/c.ts', 'C', 'class:C', 'Class'); + + // A → B → C → A (3-node cycle) + const heritage: ExtractedHeritage[] = [ + { filePath: 'src/a.ts', className: 'A', parentName: 'B', kind: 'extends' }, + { filePath: 'src/b.ts', className: 'B', parentName: 'C', kind: 'extends' }, + { filePath: 'src/c.ts', className: 'C', parentName: 'A', kind: 'extends' }, + ]; + + const map = buildHeritageMap(heritage, ctx); + const ancestors = map.getAncestors('class:A'); + // Should visit B and C but not loop back to A + expect(ancestors).toHaveLength(2); + expect(ancestors).toContain('class:B'); + expect(ancestors).toContain('class:C'); + }); + + it('returns empty array for node with no parents', () => { + ctx.symbols.add('src/a.ts', 'A', 'class:A', 'Class'); + + const map = buildHeritageMap([], ctx); + expect(map.getAncestors('class:A')).toEqual([]); + }); + + it('returns empty array for unknown nodeId', () => { + const map = buildHeritageMap([], ctx); + expect(map.getAncestors('class:NonExistent')).toEqual([]); + }); + + it('handles deep inheritance chain (bounded depth)', () => { + // Build a chain of 40 levels — should be bounded by MAX_ANCESTOR_DEPTH (32) + const heritage: ExtractedHeritage[] = []; + for (let i = 0; i < 40; i++) { + const childName = `Level${i}`; + const parentName = `Level${i + 1}`; + ctx.symbols.add(`src/${childName}.ts`, childName, `class:${childName}`, 'Class'); + if (i === 39) { + ctx.symbols.add(`src/${parentName}.ts`, parentName, `class:${parentName}`, 'Class'); + } + heritage.push({ + filePath: `src/${childName}.ts`, + className: childName, + parentName: parentName, + kind: 'extends', + }); + } + + const map = buildHeritageMap(heritage, ctx); + const ancestors = map.getAncestors('class:Level0'); + // Strictly linear chain of depth > MAX_ANCESTOR_DEPTH must terminate + // at exactly 32 BFS iterations. The tight `toBe(32)` guards against a + // future regression that silently returns fewer ancestors. + expect(ancestors.length).toBe(32); + // First ancestor should be the direct parent + expect(ancestors[0]).toBe('class:Level1'); + // Last ancestor should be the 32nd level — beyond that is cut off + expect(ancestors[31]).toBe('class:Level32'); + }); + }); + + // ── empty heritage ────────────────────────────────────────────────── + + describe('empty heritage', () => { + it('returns empty results for empty heritage array', () => { + const map = buildHeritageMap([], ctx); + expect(map.getParents('any')).toEqual([]); + expect(map.getAncestors('any')).toEqual([]); + expect(map.getImplementorFiles('any').size).toBe(0); + }); + }); + + // ── getImplementorFiles ───────────────────────────────────────────── + + describe('getImplementorFiles', () => { + it('records direct implements edges per interface name', () => { + ctx.symbols.add('a.java', 'C', 'class:C', 'Class'); + ctx.symbols.add('b.java', 'D', 'class:D', 'Class'); + ctx.symbols.add('iface.java', 'Runnable', 'iface:Runnable', 'Interface'); + + const heritage: ExtractedHeritage[] = [ + { filePath: 'a.java', className: 'C', parentName: 'Runnable', kind: 'implements' }, + { filePath: 'b.java', className: 'D', parentName: 'Runnable', kind: 'implements' }, + ]; + const map = buildHeritageMap(heritage, ctx); + expect(map.getImplementorFiles('Runnable')).toEqual(new Set(['a.java', 'b.java'])); + }); + + it('only records implementors for interface parents, not class parents', () => { + ctx.symbols.add('a.java', 'C', 'class:C', 'Class'); + ctx.symbols.add('base.java', 'Base', 'class:Base', 'Class'); + ctx.symbols.add('iface.java', 'I', 'iface:I', 'Interface'); + + const heritage: ExtractedHeritage[] = [ + { filePath: 'a.java', className: 'C', parentName: 'Base', kind: 'extends' }, + { filePath: 'a.java', className: 'C', parentName: 'I', kind: 'implements' }, + ]; + const map = buildHeritageMap(heritage, ctx); + expect(map.getImplementorFiles('Base').size).toBe(0); + expect(map.getImplementorFiles('I')).toEqual(new Set(['a.java'])); + }); + + it('returns empty set for unknown interface name', () => { + const map = buildHeritageMap([], ctx); + const result = map.getImplementorFiles('NonExistent'); + expect(result.size).toBe(0); + }); + + it('records C# extends→IMPLEMENTS via interfaceNamePattern when parent is unresolved', () => { + // C# provider has interfaceNamePattern: /^I[A-Z]/. + // Only the child class is registered; the parent interface has no symbol. + // resolveExtendsType must fall through to the provider heuristic and + // classify `IDisposable` as IMPLEMENTS. + ctx.symbols.add('src/Service.cs', 'Service', 'class:Service', 'Class'); + + const heritage: ExtractedHeritage[] = [ + { + filePath: 'src/Service.cs', + className: 'Service', + parentName: 'IDisposable', + kind: 'extends', + }, + ]; + const map = buildHeritageMap(heritage, ctx); + expect(map.getImplementorFiles('IDisposable')).toEqual(new Set(['src/Service.cs'])); + }); + + it('records Swift extends→IMPLEMENTS via heritageDefaultEdge when parent is unresolved', () => { + // Swift provider has heritageDefaultEdge: 'IMPLEMENTS'. + // Unresolved parents should default to IMPLEMENTS (protocol conformance). + ctx.symbols.add('src/MyView.swift', 'MyView', 'class:MyView', 'Class'); + + const heritage: ExtractedHeritage[] = [ + { + filePath: 'src/MyView.swift', + className: 'MyView', + parentName: 'SomeProtocol', + kind: 'extends', + }, + ]; + const map = buildHeritageMap(heritage, ctx); + expect(map.getImplementorFiles('SomeProtocol')).toEqual(new Set(['src/MyView.swift'])); + }); + + it('records Java extends→IMPLEMENTS when parent is registered as an Interface symbol', () => { + // Java/C# path: when ctx.resolve finds a matching symbol whose type is + // Interface, resolveExtendsType returns IMPLEMENTS via the symbol lookup + // (not the interfaceNamePattern fallback). + ctx.symbols.add('src/Impl.java', 'Impl', 'class:Impl', 'Class'); + ctx.symbols.add('src/MyContract.java', 'MyContract', 'iface:MyContract', 'Interface'); + + const heritage: ExtractedHeritage[] = [ + { + filePath: 'src/Impl.java', + className: 'Impl', + parentName: 'MyContract', + kind: 'extends', + }, + ]; + const map = buildHeritageMap(heritage, ctx); + expect(map.getImplementorFiles('MyContract')).toEqual(new Set(['src/Impl.java'])); + }); + + it('records Kotlin implements edges', () => { + ctx.symbols.add('src/Impl.kt', 'Impl', 'class:Impl', 'Class'); + ctx.symbols.add('src/Iface.kt', 'Iface', 'iface:Iface', 'Interface'); + + const heritage: ExtractedHeritage[] = [ + { + filePath: 'src/Impl.kt', + className: 'Impl', + parentName: 'Iface', + kind: 'implements', + }, + ]; + const map = buildHeritageMap(heritage, ctx); + expect(map.getImplementorFiles('Iface')).toEqual(new Set(['src/Impl.kt'])); + }); + + it('records TypeScript implements edges', () => { + ctx.symbols.add('src/Service.ts', 'UserService', 'class:UserService', 'Class'); + ctx.symbols.add('src/IService.ts', 'IUserService', 'iface:IUserService', 'Interface'); + + const heritage: ExtractedHeritage[] = [ + { + filePath: 'src/Service.ts', + className: 'UserService', + parentName: 'IUserService', + kind: 'implements', + }, + ]; + const map = buildHeritageMap(heritage, ctx); + expect(map.getImplementorFiles('IUserService')).toEqual(new Set(['src/Service.ts'])); + }); + + it('records PHP implements edges', () => { + ctx.symbols.add('src/Impl.php', 'Impl', 'class:Impl', 'Class'); + ctx.symbols.add('src/Iface.php', 'Iface', 'iface:Iface', 'Interface'); + + const heritage: ExtractedHeritage[] = [ + { + filePath: 'src/Impl.php', + className: 'Impl', + parentName: 'Iface', + kind: 'implements', + }, + ]; + const map = buildHeritageMap(heritage, ctx); + expect(map.getImplementorFiles('Iface')).toEqual(new Set(['src/Impl.php'])); + }); + + it('does not record Rust trait-impl entries in the implementor index', () => { + // Documented limitation: trait-impl is intentionally not added to the + // implementor index — interface dispatch does not traverse trait objects. + ctx.symbols.add('src/point.rs', 'Point', 'struct:Point', 'Struct'); + ctx.symbols.add('src/display.rs', 'Display', 'trait:Display', 'Interface'); + + const heritage: ExtractedHeritage[] = [ + { + filePath: 'src/point.rs', + className: 'Point', + parentName: 'Display', + kind: 'trait-impl', + }, + ]; + const map = buildHeritageMap(heritage, ctx); + expect(map.getImplementorFiles('Display').size).toBe(0); + // Parent lookup still works — only the implementor index skips trait-impl. + expect(map.getParents('struct:Point')).toEqual(['trait:Display']); + }); + + it('heritage merged across chunks matches single-pass (chunk-order invariant)', () => { + ctx.symbols.add('a.java', 'A', 'class:A', 'Class'); + ctx.symbols.add('b.java', 'B', 'class:B', 'Class'); + ctx.symbols.add('iface.java', 'Iface', 'iface:Iface', 'Interface'); + + const chunk1: ExtractedHeritage[] = [ + { filePath: 'a.java', className: 'A', parentName: 'Iface', kind: 'implements' }, + ]; + const chunk2: ExtractedHeritage[] = [ + { filePath: 'b.java', className: 'B', parentName: 'Iface', kind: 'implements' }, + ]; + const oneShot = buildHeritageMap([...chunk1, ...chunk2], ctx); + expect(oneShot.getImplementorFiles('Iface')).toEqual(new Set(['a.java', 'b.java'])); + }); + }); + + // ── chunk-order invariant ─────────────────────────────────────────── + + describe('chunk-order invariant', () => { + it('produces same result regardless of heritage record order', () => { + ctx.symbols.add('src/d.ts', 'D', 'class:D', 'Class'); + ctx.symbols.add('src/c.ts', 'C', 'class:C', 'Class'); + ctx.symbols.add('src/b.ts', 'B', 'class:B', 'Class'); + ctx.symbols.add('src/a.ts', 'A', 'class:A', 'Class'); + + const heritage1: ExtractedHeritage[] = [ + { filePath: 'src/d.ts', className: 'D', parentName: 'C', kind: 'extends' }, + { filePath: 'src/c.ts', className: 'C', parentName: 'B', kind: 'extends' }, + { filePath: 'src/b.ts', className: 'B', parentName: 'A', kind: 'extends' }, + ]; + + const heritage2: ExtractedHeritage[] = [ + { filePath: 'src/b.ts', className: 'B', parentName: 'A', kind: 'extends' }, + { filePath: 'src/d.ts', className: 'D', parentName: 'C', kind: 'extends' }, + { filePath: 'src/c.ts', className: 'C', parentName: 'B', kind: 'extends' }, + ]; + + const map1 = buildHeritageMap(heritage1, ctx); + const map2 = buildHeritageMap(heritage2, ctx); + + expect(map1.getParents('class:D').sort()).toEqual(map2.getParents('class:D').sort()); + expect(map1.getAncestors('class:D').sort()).toEqual(map2.getAncestors('class:D').sort()); + }); + }); +}); From c19e76a4a334947b51b09c66e8579075b644997b Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Wed, 8 Apr 2026 20:46:45 +0100 Subject: [PATCH 10/11] feat(SM-9): Add lookupMethodByOwnerWithMRO using HeritageMap (#740) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Initial plan * feat(SM-9): add lookupMethodByOwnerWithMRO with HeritageMap parent chain walking - Export c3Linearize from mro-processor.ts for reuse - Add lookupMethodByOwnerWithMRO in call-processor.ts with MRO strategy support - Update resolveMethodByOwner to fall back to MRO walk when HeritageMap available - Thread heritageMap through walkMixedChain for chain resolution - Add 10 unit tests covering all acceptance criteria Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/cc58249b-42f1-45a9-89fb-e3917e4d0171 Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * feat(SM-9): add Java integration test with class Child extends Parent fixture Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/cc58249b-42f1-45a9-89fb-e3917e4d0171 Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * docs: address code review comments on MRO strategy documentation Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/cc58249b-42f1-45a9-89fb-e3917e4d0171 Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * perf(SM-9): address PR #740 review comments - Eliminate double direct lookup in resolveMethodByOwner: delegate straight to lookupMethodByOwnerWithMRO when a HeritageMap is available (the MRO helper already does the direct lookup before walking ancestors). Fallback path handles the no-HeritageMap case. - Memoize C3 linearization per HeritageMap via a WeakMap keyed cache. HeritageMap is immutable after build, so C3 results are stable for its lifetime; WeakMap lets the cache auto-drain when the HeritageMap is GC'd. Null sentinel caches linearization failures so cyclic hierarchies are not reprocessed. Eliminates per-call buildParentMap + c3Linearize on Python codebases. - ancestors variable typed as readonly to accept the cached result without copying. - Add four missing MRO unit tests: Kotlin implements-split, C# implements-split, JavaScript first-wins (separate provider from TS), and C++ leftmost-base diamond (first diamond test for C++). * fix(SM-9): CI prettier + address PR #740 follow-up review - Fix CI prettier failure in test/integration/resolvers/java.test.ts (auto-formatted — was introduced in 37563a31 before my first fix commit but had not been caught locally). - Pin caller on the SM-9 Java integration test (parentMethodCall.source === 'run') so a regression that misattributes the CALLS edge fails. - Add two implements-split unit tests: * Ambiguous default from two interfaces → BFS first-wins. Pins the contract that lookupMethodByOwnerWithMRO returns a defined result (full ambiguity detection is deferred to computeMRO graph pass). * Class method precedence over interface default: Child extends Base implements IFoo where both define handle() — documents that BFS visits the extends edge first, matching Java's class-wins rule. - Add @internal JSDoc on lookupMethodByOwnerWithMRO clarifying it is exported only for testing; resolveMethodByOwner is the proper entry point for callers. * test(SM-9): per-language integration fixtures and tests for inherited method resolution Extends the SM-9 integration coverage beyond Java with six new child-extends-parent fixtures, one per MRO strategy: - python-child-extends-parent → C3 strategy - typescript-child-extends-parent → first-wins - javascript-child-extends-parent → first-wins (separate provider) - kotlin-child-extends-parent → implements-split - csharp-child-extends-parent → implements-split - cpp-child-extends-parent → leftmost-base Each fixture follows the java-child-extends-parent pattern: - Parent class with a single method - Child class extending Parent, no override - App class/function that instantiates Child and calls the parent method — exercises the full ingestion pipeline, HeritageMap construction, and lookupMethodByOwnerWithMRO walk. For every fixture the matching integration test asserts: - Parent and Child classes are detected - Child → Parent EXTENDS edge is emitted - The parent-method call resolves to the correct target file - The caller is pinned (source === 'run' / 'Run') to catch edge misattribution regressions Rust is intentionally omitted — its qualified-syntax strategy returns undefined from lookupMethodByOwnerWithMRO by design, so there is no inherited-method resolution to assert against. All 1739 integration resolver tests pass (+18 new SM-9 tests across 6 languages). --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> Co-authored-by: Gergo Magyar --- gitnexus/src/core/ingestion/call-processor.ts | 154 +++++- gitnexus/src/core/ingestion/mro-processor.ts | 2 +- .../cpp-child-extends-parent/src/Child.h | 5 + .../cpp-child-extends-parent/src/Parent.h | 9 + .../cpp-child-extends-parent/src/app.cpp | 6 + .../csharp-child-extends-parent/src/App.cs | 12 + .../csharp-child-extends-parent/src/Child.cs | 5 + .../csharp-child-extends-parent/src/Parent.cs | 9 + .../src/models/Child.java | 5 + .../src/models/Parent.java | 5 + .../src/services/App.java | 10 + .../src/Child.js | 3 + .../src/Parent.js | 5 + .../src/app.js | 6 + .../kotlin-child-extends-parent/src/App.kt | 10 + .../kotlin-child-extends-parent/src/Child.kt | 3 + .../kotlin-child-extends-parent/src/Parent.kt | 7 + .../python-child-extends-parent/app.py | 6 + .../python-child-extends-parent/child.py | 5 + .../python-child-extends-parent/parent.py | 3 + .../src/Child.ts | 3 + .../src/Parent.ts | 5 + .../src/app.ts | 6 + .../test/integration/resolvers/cpp.test.ts | 32 ++ .../test/integration/resolvers/csharp.test.ts | 35 ++ .../test/integration/resolvers/java.test.ts | 35 ++ .../integration/resolvers/javascript.test.ts | 35 ++ .../test/integration/resolvers/kotlin.test.ts | 35 ++ .../test/integration/resolvers/python.test.ts | 35 ++ .../integration/resolvers/typescript.test.ts | 35 ++ gitnexus/test/unit/symbol-table.test.ts | 481 ++++++++++++++++++ 31 files changed, 1005 insertions(+), 2 deletions(-) create mode 100644 gitnexus/test/fixtures/lang-resolution/cpp-child-extends-parent/src/Child.h create mode 100644 gitnexus/test/fixtures/lang-resolution/cpp-child-extends-parent/src/Parent.h create mode 100644 gitnexus/test/fixtures/lang-resolution/cpp-child-extends-parent/src/app.cpp create mode 100644 gitnexus/test/fixtures/lang-resolution/csharp-child-extends-parent/src/App.cs create mode 100644 gitnexus/test/fixtures/lang-resolution/csharp-child-extends-parent/src/Child.cs create mode 100644 gitnexus/test/fixtures/lang-resolution/csharp-child-extends-parent/src/Parent.cs create mode 100644 gitnexus/test/fixtures/lang-resolution/java-child-extends-parent/src/models/Child.java create mode 100644 gitnexus/test/fixtures/lang-resolution/java-child-extends-parent/src/models/Parent.java create mode 100644 gitnexus/test/fixtures/lang-resolution/java-child-extends-parent/src/services/App.java create mode 100644 gitnexus/test/fixtures/lang-resolution/javascript-child-extends-parent/src/Child.js create mode 100644 gitnexus/test/fixtures/lang-resolution/javascript-child-extends-parent/src/Parent.js create mode 100644 gitnexus/test/fixtures/lang-resolution/javascript-child-extends-parent/src/app.js create mode 100644 gitnexus/test/fixtures/lang-resolution/kotlin-child-extends-parent/src/App.kt create mode 100644 gitnexus/test/fixtures/lang-resolution/kotlin-child-extends-parent/src/Child.kt create mode 100644 gitnexus/test/fixtures/lang-resolution/kotlin-child-extends-parent/src/Parent.kt create mode 100644 gitnexus/test/fixtures/lang-resolution/python-child-extends-parent/app.py create mode 100644 gitnexus/test/fixtures/lang-resolution/python-child-extends-parent/child.py create mode 100644 gitnexus/test/fixtures/lang-resolution/python-child-extends-parent/parent.py create mode 100644 gitnexus/test/fixtures/lang-resolution/typescript-child-extends-parent/src/Child.ts create mode 100644 gitnexus/test/fixtures/lang-resolution/typescript-child-extends-parent/src/Parent.ts create mode 100644 gitnexus/test/fixtures/lang-resolution/typescript-child-extends-parent/src/app.ts diff --git a/gitnexus/src/core/ingestion/call-processor.ts b/gitnexus/src/core/ingestion/call-processor.ts index e3bacbf7b..ecb778a46 100644 --- a/gitnexus/src/core/ingestion/call-processor.ts +++ b/gitnexus/src/core/ingestion/call-processor.ts @@ -31,6 +31,7 @@ import { import { buildTypeEnv, isSubclassOf } from './type-env.js'; import type { ConstructorBinding, TypeEnvironment } from './type-env.js'; import type { HeritageMap } from './heritage-map.js'; +import { c3Linearize } from './mro-processor.js'; import { getTreeSitterBufferSize } from './constants.js'; import type { ExtractedCall, @@ -1006,6 +1007,7 @@ export const processCalls = async ( file.path, ctx, makeAccessEmitter(graph, sourceId), + heritageMap, ); } } @@ -1596,21 +1598,169 @@ const resolveFieldOwnership = ( * Resolve a method by owner type name using the eagerly-populated methodByOwner index. * Returns the SymbolDefinition if an unambiguous method is found, undefined otherwise. * Falls through to undefined for: unknown type, no class-like candidates, ambiguous overloads. + * When heritageMap is provided, falls back to MRO-aware parent chain walking. */ const resolveMethodByOwner = ( receiverTypeName: string, methodName: string, filePath: string, ctx: ResolutionContext, + heritageMap?: HeritageMap, ): SymbolDefinition | undefined => { const typeResolved = ctx.resolve(receiverTypeName, filePath); if (!typeResolved) return undefined; const classDef = typeResolved.candidates.find((d) => CLASS_LIKE_TYPES.has(d.type)); if (!classDef) return undefined; + // When HeritageMap is available, delegate to MRO-aware lookup which performs + // the direct owner lookup itself before walking ancestors — avoids a double + // direct lookup on the hot path. + if (heritageMap) { + const language = getLanguageFromFilename(filePath); + if (language) { + return lookupMethodByOwnerWithMRO( + classDef.nodeId, + methodName, + heritageMap, + ctx.symbols, + language, + ); + } + } + + // Fallback when no HeritageMap (or the file extension is unrecognized): + // plain direct lookup with no ancestor walk. return ctx.symbols.lookupMethodByOwner(classDef.nodeId, methodName); }; +// --------------------------------------------------------------------------- +// MRO-aware method resolution via HeritageMap (SM-9) +// --------------------------------------------------------------------------- + +/** + * Per-HeritageMap cache of C3 linearization results keyed by owner nodeId. + * + * HeritageMap instances are immutable after construction, so C3 output is + * stable for the lifetime of a HeritageMap. WeakMap lets the cache auto-drain + * when the HeritageMap is garbage collected (end of ingestion run), so we + * never need to manually invalidate it. + * + * `null` is a sentinel for "C3 failed for this owner" (cyclic or inconsistent + * hierarchy) so we don't re-run the expensive linearization repeatedly. + */ +const c3LinearizationCache = new WeakMap>(); + +const getCachedC3Linearization = ( + ownerNodeId: string, + heritageMap: HeritageMap, +): readonly string[] | null => { + let perHmCache = c3LinearizationCache.get(heritageMap); + if (!perHmCache) { + perHmCache = new Map(); + c3LinearizationCache.set(heritageMap, perHmCache); + } + const cached = perHmCache.get(ownerNodeId); + if (cached !== undefined) return cached; + const parentMap = buildParentMapFromHeritage(ownerNodeId, heritageMap); + const result = c3Linearize(ownerNodeId, parentMap, new Map()) ?? null; + perHmCache.set(ownerNodeId, result); + return result; +}; + +/** + * Build a parentMap from HeritageMap for use with c3Linearize. + * Traverses the parent chain starting from startNodeId, collecting all + * parent→children relationships into a Map. + */ +const buildParentMapFromHeritage = ( + startNodeId: string, + heritageMap: HeritageMap, +): Map => { + const parentMap = new Map(); + const visited = new Set(); + const queue = [startNodeId]; + + while (queue.length > 0) { + const nodeId = queue.shift()!; + if (visited.has(nodeId)) continue; + visited.add(nodeId); + const parents = heritageMap.getParents(nodeId); + if (parents.length > 0) { + parentMap.set(nodeId, parents); + for (const p of parents) { + if (!visited.has(p)) queue.push(p); + } + } + } + + return parentMap; +}; + +/** + * Look up a method on an owner class, walking the parent chain via HeritageMap + * when the method isn't found on the direct owner. + * + * Respects the 5 per-language MRO strategies: + * - `first-wins`: BFS ancestor walk, first match wins (default) + * - `leftmost-base`: BFS ancestor walk, leftmost base in declaration order wins (C++); + * HeritageMap preserves insertion order matching source declaration, + * so BFS order is equivalent to leftmost-base semantics + * - `c3`: C3-linearized ancestor order, first match wins (Python) + * - `implements-split`: BFS ancestor walk, first match wins (Java/C#) — + * full ambiguity detection for multiple interface defaults + * is handled by computeMRO at graph level + * - `qualified-syntax`: No auto-resolution (Rust) — returns undefined + * + * Delegates to mro-processor.ts c3Linearize for C3 strategy. + * + * @internal Exported only to enable unit testing in isolation. The proper + * entry point for callers outside this module is {@link resolveMethodByOwner}, + * which handles receiver-type resolution before delegating here. + */ +export const lookupMethodByOwnerWithMRO = ( + ownerNodeId: string, + methodName: string, + heritageMap: HeritageMap, + symbols: SymbolTable, + language: SupportedLanguages, +): SymbolDefinition | undefined => { + // Direct lookup first (child override — no walk needed) + const direct = symbols.lookupMethodByOwner(ownerNodeId, methodName); + if (direct) return direct; + + const strategy = getProvider(language).mroStrategy; + + // Rust: requires qualified syntax (::method), no auto-resolution + if (strategy === 'qualified-syntax') return undefined; + + // Determine ancestor walk order based on MRO strategy. + // readonly to accept the cached (frozen) c3 linearization without copying. + let ancestors: readonly string[]; + if (strategy === 'c3') { + // Delegate to mro-processor.ts C3 linearization (memoized per HeritageMap + // so repeated calls for the same owner within an ingestion run reuse the + // linearization instead of rebuilding the parent map and re-running C3). + // c3Linearize returns ancestors only (excludes the owner itself), + // matching heritageMap.getAncestors() semantics. + const c3Result = getCachedC3Linearization(ownerNodeId, heritageMap); + // Fall back to BFS order if C3 fails (cyclic or inconsistent hierarchy). + // Note: BFS order may not preserve Python MRO semantics in these edge + // cases, but cyclic/inconsistent hierarchies are invalid in Python anyway. + ancestors = c3Result ?? heritageMap.getAncestors(ownerNodeId); + } else { + // first-wins, leftmost-base, implements-split: BFS order via HeritageMap + ancestors = heritageMap.getAncestors(ownerNodeId); + } + + // Walk ancestors in MRO order — first match wins + for (const ancestorId of ancestors) { + const method = symbols.lookupMethodByOwner(ancestorId, methodName); + if (method) return method; + } + + return undefined; +}; + /** * Create a deduplicated ACCESSES edge emitter for a single source node. * Each (sourceId, fieldNodeId) pair is emitted at most once per source. @@ -1649,6 +1799,7 @@ const walkMixedChain = ( filePath: string, ctx: ResolutionContext, onFieldResolved?: OnFieldResolved, + heritageMap?: HeritageMap, ): string | undefined => { let currentType: string | undefined = startType; for (const step of chain) { @@ -1675,7 +1826,7 @@ const walkMixedChain = ( // Avoids fuzzy lookup when the owner type is known and the method is unambiguous. // Note: CALLS edges for intermediate chain steps are NOT emitted here — walkMixedChain // only threads types. CALLS edges come from the outer per-call-expression loop in processCalls. - const methodDef = resolveMethodByOwner(currentType, step.name, filePath, ctx); + const methodDef = resolveMethodByOwner(currentType, step.name, filePath, ctx, heritageMap); if (methodDef?.returnType) { const fastRetType = extractReturnTypeName(methodDef.returnType); if (fastRetType) { @@ -1822,6 +1973,7 @@ export const processCallsFromExtracted = async ( effectiveCall.filePath, ctx, makeAccessEmitter(graph, effectiveCall.sourceId), + heritageMap, ); if (walkedType) { effectiveCall = { ...effectiveCall, receiverTypeName: walkedType }; diff --git a/gitnexus/src/core/ingestion/mro-processor.ts b/gitnexus/src/core/ingestion/mro-processor.ts index 51200f2bc..e44fbd3e7 100644 --- a/gitnexus/src/core/ingestion/mro-processor.ts +++ b/gitnexus/src/core/ingestion/mro-processor.ts @@ -127,7 +127,7 @@ function gatherAncestors(classId: string, parentMap: Map): str * Returns an array of ancestor IDs in C3 order (excluding the class itself), * or null if linearization fails (inconsistent or cyclic hierarchy). */ -function c3Linearize( +export function c3Linearize( classId: string, parentMap: Map, cache: Map, diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-child-extends-parent/src/Child.h b/gitnexus/test/fixtures/lang-resolution/cpp-child-extends-parent/src/Child.h new file mode 100644 index 000000000..b3ab8f00e --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-child-extends-parent/src/Child.h @@ -0,0 +1,5 @@ +#pragma once +#include "Parent.h" + +class Child : public Parent { +}; diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-child-extends-parent/src/Parent.h b/gitnexus/test/fixtures/lang-resolution/cpp-child-extends-parent/src/Parent.h new file mode 100644 index 000000000..3cb6d1afa --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-child-extends-parent/src/Parent.h @@ -0,0 +1,9 @@ +#pragma once +#include + +class Parent { +public: + std::string parentMethod() { + return "parent"; + } +}; diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-child-extends-parent/src/app.cpp b/gitnexus/test/fixtures/lang-resolution/cpp-child-extends-parent/src/app.cpp new file mode 100644 index 000000000..acd153e81 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-child-extends-parent/src/app.cpp @@ -0,0 +1,6 @@ +#include "Child.h" + +void run() { + Child c; + c.parentMethod(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-child-extends-parent/src/App.cs b/gitnexus/test/fixtures/lang-resolution/csharp-child-extends-parent/src/App.cs new file mode 100644 index 000000000..ab995a65c --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-child-extends-parent/src/App.cs @@ -0,0 +1,12 @@ +namespace Services; + +using Models; + +public class App +{ + public void Run() + { + var c = new Child(); + c.ParentMethod(); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-child-extends-parent/src/Child.cs b/gitnexus/test/fixtures/lang-resolution/csharp-child-extends-parent/src/Child.cs new file mode 100644 index 000000000..52f413894 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-child-extends-parent/src/Child.cs @@ -0,0 +1,5 @@ +namespace Models; + +public class Child : Parent +{ +} diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-child-extends-parent/src/Parent.cs b/gitnexus/test/fixtures/lang-resolution/csharp-child-extends-parent/src/Parent.cs new file mode 100644 index 000000000..aad3b2278 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-child-extends-parent/src/Parent.cs @@ -0,0 +1,9 @@ +namespace Models; + +public class Parent +{ + public string ParentMethod() + { + return "parent"; + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-child-extends-parent/src/models/Child.java b/gitnexus/test/fixtures/lang-resolution/java-child-extends-parent/src/models/Child.java new file mode 100644 index 000000000..624f4edbc --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-child-extends-parent/src/models/Child.java @@ -0,0 +1,5 @@ +package models; + +public class Child extends Parent { + public void childOnly() {} +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-child-extends-parent/src/models/Parent.java b/gitnexus/test/fixtures/lang-resolution/java-child-extends-parent/src/models/Parent.java new file mode 100644 index 000000000..cd31f52f9 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-child-extends-parent/src/models/Parent.java @@ -0,0 +1,5 @@ +package models; + +public class Parent { + public String parentMethod() { return "hello"; } +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-child-extends-parent/src/services/App.java b/gitnexus/test/fixtures/lang-resolution/java-child-extends-parent/src/services/App.java new file mode 100644 index 000000000..703a30dc2 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-child-extends-parent/src/services/App.java @@ -0,0 +1,10 @@ +package services; + +import models.Child; + +public class App { + public void run() { + Child c = new Child(); + c.parentMethod(); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/javascript-child-extends-parent/src/Child.js b/gitnexus/test/fixtures/lang-resolution/javascript-child-extends-parent/src/Child.js new file mode 100644 index 000000000..0b023766f --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/javascript-child-extends-parent/src/Child.js @@ -0,0 +1,3 @@ +import { Parent } from './Parent.js'; + +export class Child extends Parent {} diff --git a/gitnexus/test/fixtures/lang-resolution/javascript-child-extends-parent/src/Parent.js b/gitnexus/test/fixtures/lang-resolution/javascript-child-extends-parent/src/Parent.js new file mode 100644 index 000000000..f88ad3557 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/javascript-child-extends-parent/src/Parent.js @@ -0,0 +1,5 @@ +export class Parent { + parentMethod() { + return 'parent'; + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/javascript-child-extends-parent/src/app.js b/gitnexus/test/fixtures/lang-resolution/javascript-child-extends-parent/src/app.js new file mode 100644 index 000000000..061b0eb5f --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/javascript-child-extends-parent/src/app.js @@ -0,0 +1,6 @@ +import { Child } from './Child.js'; + +export function run() { + const c = new Child(); + c.parentMethod(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/kotlin-child-extends-parent/src/App.kt b/gitnexus/test/fixtures/lang-resolution/kotlin-child-extends-parent/src/App.kt new file mode 100644 index 000000000..ea773043f --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/kotlin-child-extends-parent/src/App.kt @@ -0,0 +1,10 @@ +package services + +import models.Child + +class App { + fun run() { + val c = Child() + c.parentMethod() + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/kotlin-child-extends-parent/src/Child.kt b/gitnexus/test/fixtures/lang-resolution/kotlin-child-extends-parent/src/Child.kt new file mode 100644 index 000000000..169f301a6 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/kotlin-child-extends-parent/src/Child.kt @@ -0,0 +1,3 @@ +package models + +class Child : Parent() diff --git a/gitnexus/test/fixtures/lang-resolution/kotlin-child-extends-parent/src/Parent.kt b/gitnexus/test/fixtures/lang-resolution/kotlin-child-extends-parent/src/Parent.kt new file mode 100644 index 000000000..5e7fd0733 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/kotlin-child-extends-parent/src/Parent.kt @@ -0,0 +1,7 @@ +package models + +open class Parent { + fun parentMethod(): String { + return "parent" + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/python-child-extends-parent/app.py b/gitnexus/test/fixtures/lang-resolution/python-child-extends-parent/app.py new file mode 100644 index 000000000..59f9f8bca --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/python-child-extends-parent/app.py @@ -0,0 +1,6 @@ +from child import Child + + +def run() -> None: + c = Child() + c.parent_method() diff --git a/gitnexus/test/fixtures/lang-resolution/python-child-extends-parent/child.py b/gitnexus/test/fixtures/lang-resolution/python-child-extends-parent/child.py new file mode 100644 index 000000000..be1852fa6 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/python-child-extends-parent/child.py @@ -0,0 +1,5 @@ +from parent import Parent + + +class Child(Parent): + pass diff --git a/gitnexus/test/fixtures/lang-resolution/python-child-extends-parent/parent.py b/gitnexus/test/fixtures/lang-resolution/python-child-extends-parent/parent.py new file mode 100644 index 000000000..bca4c7181 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/python-child-extends-parent/parent.py @@ -0,0 +1,3 @@ +class Parent: + def parent_method(self) -> str: + return "parent" diff --git a/gitnexus/test/fixtures/lang-resolution/typescript-child-extends-parent/src/Child.ts b/gitnexus/test/fixtures/lang-resolution/typescript-child-extends-parent/src/Child.ts new file mode 100644 index 000000000..8dc30b34e --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/typescript-child-extends-parent/src/Child.ts @@ -0,0 +1,3 @@ +import { Parent } from './Parent'; + +export class Child extends Parent {} diff --git a/gitnexus/test/fixtures/lang-resolution/typescript-child-extends-parent/src/Parent.ts b/gitnexus/test/fixtures/lang-resolution/typescript-child-extends-parent/src/Parent.ts new file mode 100644 index 000000000..343022735 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/typescript-child-extends-parent/src/Parent.ts @@ -0,0 +1,5 @@ +export class Parent { + parentMethod(): string { + return 'parent'; + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/typescript-child-extends-parent/src/app.ts b/gitnexus/test/fixtures/lang-resolution/typescript-child-extends-parent/src/app.ts new file mode 100644 index 000000000..73b8a7d07 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/typescript-child-extends-parent/src/app.ts @@ -0,0 +1,6 @@ +import { Child } from './Child'; + +export function run(): void { + const c = new Child(); + c.parentMethod(); +} diff --git a/gitnexus/test/integration/resolvers/cpp.test.ts b/gitnexus/test/integration/resolvers/cpp.test.ts index 880d63386..630c86696 100644 --- a/gitnexus/test/integration/resolvers/cpp.test.ts +++ b/gitnexus/test/integration/resolvers/cpp.test.ts @@ -1516,3 +1516,35 @@ describe('C++ out-of-class method definition with overloaded declarations', () = expect(targetNode?.properties.parameterTypes).toEqual(['int']); }); }); + +// --------------------------------------------------------------------------- +// SM-9: lookupMethodByOwnerWithMRO — c.parentMethod() via leftmost-base walk +// --------------------------------------------------------------------------- + +describe('C++ Child extends Parent — inherited method resolution (SM-9)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'cpp-child-extends-parent'), () => {}); + }, 60000); + + it('detects Parent and Child classes', () => { + const classes = getNodesByLabel(result, 'Class'); + expect(classes).toContain('Parent'); + expect(classes).toContain('Child'); + }); + + it('emits EXTENDS edge: Child → Parent', () => { + const extends_ = getRelationships(result, 'EXTENDS'); + expect(edgeSet(extends_)).toContain('Child → Parent'); + }); + + it('resolves c.parentMethod() to Parent.parentMethod via leftmost-base MRO walk', () => { + const calls = getRelationships(result, 'CALLS'); + const parentMethodCall = calls.find( + (c) => c.target === 'parentMethod' && c.targetFilePath.includes('Parent.h'), + ); + expect(parentMethodCall).toBeDefined(); + expect(parentMethodCall!.source).toBe('run'); + }); +}); diff --git a/gitnexus/test/integration/resolvers/csharp.test.ts b/gitnexus/test/integration/resolvers/csharp.test.ts index 07cf4a186..5db630bf2 100644 --- a/gitnexus/test/integration/resolvers/csharp.test.ts +++ b/gitnexus/test/integration/resolvers/csharp.test.ts @@ -1928,3 +1928,38 @@ describe('C# overloaded method disambiguation (METHOD_IMPLEMENTS)', () => { expect(ifaces).toContain('IRepository'); }); }); + +// --------------------------------------------------------------------------- +// SM-9: lookupMethodByOwnerWithMRO — c.ParentMethod() via implements-split walk +// --------------------------------------------------------------------------- + +describe('C# Child extends Parent — inherited method resolution (SM-9)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'csharp-child-extends-parent'), + () => {}, + ); + }, 60000); + + it('detects Parent and Child classes', () => { + const classes = getNodesByLabel(result, 'Class'); + expect(classes).toContain('Parent'); + expect(classes).toContain('Child'); + }); + + it('emits EXTENDS edge: Child → Parent', () => { + const extends_ = getRelationships(result, 'EXTENDS'); + expect(edgeSet(extends_)).toContain('Child → Parent'); + }); + + it('resolves c.ParentMethod() to Parent.ParentMethod via implements-split MRO walk', () => { + const calls = getRelationships(result, 'CALLS'); + const parentMethodCall = calls.find( + (c) => c.target === 'ParentMethod' && c.targetFilePath.includes('Parent.cs'), + ); + expect(parentMethodCall).toBeDefined(); + expect(parentMethodCall!.source).toBe('Run'); + }); +}); diff --git a/gitnexus/test/integration/resolvers/java.test.ts b/gitnexus/test/integration/resolvers/java.test.ts index 7ce8453bf..8a09030ed 100644 --- a/gitnexus/test/integration/resolvers/java.test.ts +++ b/gitnexus/test/integration/resolvers/java.test.ts @@ -2098,3 +2098,38 @@ describe('Cross-class method chain resolution (Java) — #575', () => { expect(cityAccess.length).toBe(1); }); }); + +// --------------------------------------------------------------------------- +// SM-9: lookupMethodByOwnerWithMRO — class Child extends Parent +// child.parentMethod() resolves to Parent#parentMethod via MRO parent walk. +// --------------------------------------------------------------------------- + +describe('Java Child extends Parent — inherited method resolution (SM-9)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'java-child-extends-parent'), () => {}); + }, 60000); + + it('detects Parent and Child classes', () => { + const classes = getNodesByLabel(result, 'Class'); + expect(classes).toContain('Parent'); + expect(classes).toContain('Child'); + }); + + it('emits EXTENDS edge: Child → Parent', () => { + const extends_ = getRelationships(result, 'EXTENDS'); + expect(edgeSet(extends_)).toContain('Child → Parent'); + }); + + it('resolves c.parentMethod() to Parent#parentMethod via MRO walk', () => { + const calls = getRelationships(result, 'CALLS'); + const parentMethodCall = calls.find( + (c) => c.target === 'parentMethod' && c.targetFilePath.includes('Parent'), + ); + expect(parentMethodCall).toBeDefined(); + // Pin the caller too — not just the target — so a regression that + // misattributes the edge to a different source would fail loudly. + expect(parentMethodCall!.source).toBe('run'); + }); +}); diff --git a/gitnexus/test/integration/resolvers/javascript.test.ts b/gitnexus/test/integration/resolvers/javascript.test.ts index 2326e0259..ccb4df618 100644 --- a/gitnexus/test/integration/resolvers/javascript.test.ts +++ b/gitnexus/test/integration/resolvers/javascript.test.ts @@ -506,3 +506,38 @@ describe('JavaScript method enrichment', () => { expect(classifyCall).toBeDefined(); }); }); + +// --------------------------------------------------------------------------- +// SM-9: lookupMethodByOwnerWithMRO — child.parentMethod() via first-wins walk +// --------------------------------------------------------------------------- + +describe('JavaScript Child extends Parent — inherited method resolution (SM-9)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'javascript-child-extends-parent'), + () => {}, + ); + }, 60000); + + it('detects Parent and Child classes', () => { + const classes = getNodesByLabel(result, 'Class'); + expect(classes).toContain('Parent'); + expect(classes).toContain('Child'); + }); + + it('emits EXTENDS edge: Child → Parent', () => { + const extends_ = getRelationships(result, 'EXTENDS'); + expect(edgeSet(extends_)).toContain('Child → Parent'); + }); + + it('resolves c.parentMethod() to Parent.parentMethod via first-wins MRO walk', () => { + const calls = getRelationships(result, 'CALLS'); + const parentMethodCall = calls.find( + (c) => c.target === 'parentMethod' && c.targetFilePath.includes('Parent.js'), + ); + expect(parentMethodCall).toBeDefined(); + expect(parentMethodCall!.source).toBe('run'); + }); +}); diff --git a/gitnexus/test/integration/resolvers/kotlin.test.ts b/gitnexus/test/integration/resolvers/kotlin.test.ts index f8b9d8116..1163538e4 100644 --- a/gitnexus/test/integration/resolvers/kotlin.test.ts +++ b/gitnexus/test/integration/resolvers/kotlin.test.ts @@ -1991,3 +1991,38 @@ describe('Kotlin overloaded method disambiguation', () => { expect(mi.length).toBe(3); }); }); + +// --------------------------------------------------------------------------- +// SM-9: lookupMethodByOwnerWithMRO — child.parentMethod() via implements-split walk +// --------------------------------------------------------------------------- + +describe('Kotlin Child extends Parent — inherited method resolution (SM-9)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'kotlin-child-extends-parent'), + () => {}, + ); + }, 60000); + + it('detects Parent and Child classes', () => { + const classes = getNodesByLabel(result, 'Class'); + expect(classes).toContain('Parent'); + expect(classes).toContain('Child'); + }); + + it('emits EXTENDS edge: Child → Parent', () => { + const extends_ = getRelationships(result, 'EXTENDS'); + expect(edgeSet(extends_)).toContain('Child → Parent'); + }); + + it('resolves c.parentMethod() to Parent.parentMethod via implements-split MRO walk', () => { + const calls = getRelationships(result, 'CALLS'); + const parentMethodCall = calls.find( + (c) => c.target === 'parentMethod' && c.targetFilePath.includes('Parent.kt'), + ); + expect(parentMethodCall).toBeDefined(); + expect(parentMethodCall!.source).toBe('run'); + }); +}); diff --git a/gitnexus/test/integration/resolvers/python.test.ts b/gitnexus/test/integration/resolvers/python.test.ts index f1108dec6..f2219463e 100644 --- a/gitnexus/test/integration/resolvers/python.test.ts +++ b/gitnexus/test/integration/resolvers/python.test.ts @@ -2111,3 +2111,38 @@ describe('Python abstract dispatch', () => { expect(edges.length).toBe(0); }); }); + +// --------------------------------------------------------------------------- +// SM-9: lookupMethodByOwnerWithMRO — child.parent_method() via C3 parent walk +// --------------------------------------------------------------------------- + +describe('Python Child extends Parent — inherited method resolution (SM-9)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'python-child-extends-parent'), + () => {}, + ); + }, 60000); + + it('detects Parent and Child classes', () => { + const classes = getNodesByLabel(result, 'Class'); + expect(classes).toContain('Parent'); + expect(classes).toContain('Child'); + }); + + it('emits EXTENDS edge: Child → Parent', () => { + const extends_ = getRelationships(result, 'EXTENDS'); + expect(edgeSet(extends_)).toContain('Child → Parent'); + }); + + it('resolves c.parent_method() to Parent.parent_method via C3 MRO walk', () => { + const calls = getRelationships(result, 'CALLS'); + const parentMethodCall = calls.find( + (c) => c.target === 'parent_method' && c.targetFilePath.includes('parent.py'), + ); + expect(parentMethodCall).toBeDefined(); + expect(parentMethodCall!.source).toBe('run'); + }); +}); diff --git a/gitnexus/test/integration/resolvers/typescript.test.ts b/gitnexus/test/integration/resolvers/typescript.test.ts index 51169e2ed..53c2287c5 100644 --- a/gitnexus/test/integration/resolvers/typescript.test.ts +++ b/gitnexus/test/integration/resolvers/typescript.test.ts @@ -2537,3 +2537,38 @@ describe('TypeScript same-arity overload cross-file resolution', () => { expect(edges.length).toBe(1); }); }); + +// --------------------------------------------------------------------------- +// SM-9: lookupMethodByOwnerWithMRO — child.parentMethod() via first-wins walk +// --------------------------------------------------------------------------- + +describe('TypeScript Child extends Parent — inherited method resolution (SM-9)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'typescript-child-extends-parent'), + () => {}, + ); + }, 60000); + + it('detects Parent and Child classes', () => { + const classes = getNodesByLabel(result, 'Class'); + expect(classes).toContain('Parent'); + expect(classes).toContain('Child'); + }); + + it('emits EXTENDS edge: Child → Parent', () => { + const extends_ = getRelationships(result, 'EXTENDS'); + expect(edgeSet(extends_)).toContain('Child → Parent'); + }); + + it('resolves c.parentMethod() to Parent.parentMethod via first-wins MRO walk', () => { + const calls = getRelationships(result, 'CALLS'); + const parentMethodCall = calls.find( + (c) => c.target === 'parentMethod' && c.targetFilePath.includes('Parent.ts'), + ); + expect(parentMethodCall).toBeDefined(); + expect(parentMethodCall!.source).toBe('run'); + }); +}); diff --git a/gitnexus/test/unit/symbol-table.test.ts b/gitnexus/test/unit/symbol-table.test.ts index bcb3df7e5..b2ba7e288 100644 --- a/gitnexus/test/unit/symbol-table.test.ts +++ b/gitnexus/test/unit/symbol-table.test.ts @@ -917,3 +917,484 @@ describe('SymbolTable', () => { }); }); }); + +// --------------------------------------------------------------------------- +// lookupMethodByOwnerWithMRO — MRO-aware method resolution via HeritageMap +// --------------------------------------------------------------------------- + +import { buildHeritageMap } from '../../src/core/ingestion/heritage-map.js'; +import { lookupMethodByOwnerWithMRO } from '../../src/core/ingestion/call-processor.js'; +import { + createResolutionContext, + type ResolutionContext, +} from '../../src/core/ingestion/resolution-context.js'; +import { SupportedLanguages } from 'gitnexus-shared'; +import type { ExtractedHeritage } from '../../src/core/ingestion/workers/parse-worker.js'; + +describe('lookupMethodByOwnerWithMRO', () => { + let ctx: ResolutionContext; + + beforeEach(() => { + ctx = createResolutionContext(); + }); + + it('child.parentMethod() resolves to Parent#parentMethod via MRO walk', () => { + ctx.symbols.add('src/parent.java', 'Parent', 'class:Parent', 'Class'); + ctx.symbols.add('src/child.java', 'Child', 'class:Child', 'Class'); + ctx.symbols.add('src/parent.java', 'parentMethod', 'method:Parent:parentMethod', 'Method', { + returnType: 'String', + ownerId: 'class:Parent', + }); + + const heritage: ExtractedHeritage[] = [ + { filePath: 'src/child.java', className: 'Child', parentName: 'Parent', kind: 'extends' }, + ]; + const map = buildHeritageMap(heritage, ctx); + + const result = lookupMethodByOwnerWithMRO( + 'class:Child', + 'parentMethod', + map, + ctx.symbols, + SupportedLanguages.Java, + ); + expect(result).toBeDefined(); + expect(result!.nodeId).toBe('method:Parent:parentMethod'); + expect(result!.returnType).toBe('String'); + }); + + it('child override returns child version (direct hit, no walk)', () => { + ctx.symbols.add('src/parent.java', 'Parent', 'class:Parent', 'Class'); + ctx.symbols.add('src/child.java', 'Child', 'class:Child', 'Class'); + ctx.symbols.add('src/parent.java', 'save', 'method:Parent:save', 'Method', { + returnType: 'void', + ownerId: 'class:Parent', + }); + ctx.symbols.add('src/child.java', 'save', 'method:Child:save', 'Method', { + returnType: 'void', + ownerId: 'class:Child', + }); + + const heritage: ExtractedHeritage[] = [ + { filePath: 'src/child.java', className: 'Child', parentName: 'Parent', kind: 'extends' }, + ]; + const map = buildHeritageMap(heritage, ctx); + + const result = lookupMethodByOwnerWithMRO( + 'class:Child', + 'save', + map, + ctx.symbols, + SupportedLanguages.Java, + ); + expect(result).toBeDefined(); + expect(result!.nodeId).toBe('method:Child:save'); + }); + + it('3-level inheritance: grandchild → child → parent, method on parent found', () => { + ctx.symbols.add('src/a.java', 'A', 'class:A', 'Class'); + ctx.symbols.add('src/b.java', 'B', 'class:B', 'Class'); + ctx.symbols.add('src/c.java', 'C', 'class:C', 'Class'); + ctx.symbols.add('src/a.java', 'greet', 'method:A:greet', 'Method', { + returnType: 'Greeting', + ownerId: 'class:A', + }); + + const heritage: ExtractedHeritage[] = [ + { filePath: 'src/c.java', className: 'C', parentName: 'B', kind: 'extends' }, + { filePath: 'src/b.java', className: 'B', parentName: 'A', kind: 'extends' }, + ]; + const map = buildHeritageMap(heritage, ctx); + + const result = lookupMethodByOwnerWithMRO( + 'class:C', + 'greet', + map, + ctx.symbols, + SupportedLanguages.Java, + ); + expect(result).toBeDefined(); + expect(result!.nodeId).toBe('method:A:greet'); + expect(result!.returnType).toBe('Greeting'); + }); + + it('diamond pattern: first-wins strategy returns first ancestor match in BFS order', () => { + ctx.symbols.add('src/a.ts', 'A', 'class:A', 'Class'); + ctx.symbols.add('src/b.ts', 'B', 'class:B', 'Class'); + ctx.symbols.add('src/c.ts', 'C', 'class:C', 'Class'); + ctx.symbols.add('src/d.ts', 'D', 'class:D', 'Class'); + ctx.symbols.add('src/b.ts', 'foo', 'method:B:foo', 'Method', { + returnType: 'String', + ownerId: 'class:B', + }); + ctx.symbols.add('src/c.ts', 'foo', 'method:C:foo', 'Method', { + returnType: 'String', + ownerId: 'class:C', + }); + + const heritage: ExtractedHeritage[] = [ + { filePath: 'src/d.ts', className: 'D', parentName: 'B', kind: 'extends' }, + { filePath: 'src/d.ts', className: 'D', parentName: 'C', kind: 'extends' }, + { filePath: 'src/b.ts', className: 'B', parentName: 'A', kind: 'extends' }, + { filePath: 'src/c.ts', className: 'C', parentName: 'A', kind: 'extends' }, + ]; + const map = buildHeritageMap(heritage, ctx); + + // TypeScript uses 'first-wins' — B is first parent, so B.foo wins + const result = lookupMethodByOwnerWithMRO( + 'class:D', + 'foo', + map, + ctx.symbols, + SupportedLanguages.TypeScript, + ); + expect(result).toBeDefined(); + expect(result!.nodeId).toBe('method:B:foo'); + }); + + it('diamond pattern: c3 strategy uses C3 linearization order', () => { + ctx.symbols.add('src/a.py', 'A', 'class:A', 'Class'); + ctx.symbols.add('src/b.py', 'B', 'class:B', 'Class'); + ctx.symbols.add('src/c.py', 'C', 'class:C', 'Class'); + ctx.symbols.add('src/d.py', 'D', 'class:D', 'Class'); + ctx.symbols.add('src/b.py', 'foo', 'method:B:foo', 'Method', { + returnType: 'str', + ownerId: 'class:B', + }); + ctx.symbols.add('src/c.py', 'foo', 'method:C:foo', 'Method', { + returnType: 'str', + ownerId: 'class:C', + }); + + const heritage: ExtractedHeritage[] = [ + { filePath: 'src/d.py', className: 'D', parentName: 'B', kind: 'extends' }, + { filePath: 'src/d.py', className: 'D', parentName: 'C', kind: 'extends' }, + { filePath: 'src/b.py', className: 'B', parentName: 'A', kind: 'extends' }, + { filePath: 'src/c.py', className: 'C', parentName: 'A', kind: 'extends' }, + ]; + const map = buildHeritageMap(heritage, ctx); + + // Python uses 'c3' — C3 linearization for D(B,C): [B, C, A] + const result = lookupMethodByOwnerWithMRO( + 'class:D', + 'foo', + map, + ctx.symbols, + SupportedLanguages.Python, + ); + expect(result).toBeDefined(); + // C3 linearization resolves to B before C in this hierarchy + expect(result!.nodeId).toBe('method:B:foo'); + }); + + it('qualified-syntax (Rust): returns undefined for inherited methods', () => { + ctx.symbols.add('src/parent.rs', 'Parent', 'class:Parent', 'Class'); + ctx.symbols.add('src/child.rs', 'Child', 'class:Child', 'Class'); + ctx.symbols.add('src/parent.rs', 'process', 'method:Parent:process', 'Method', { + returnType: 'void', + ownerId: 'class:Parent', + }); + + const heritage: ExtractedHeritage[] = [ + { filePath: 'src/child.rs', className: 'Child', parentName: 'Parent', kind: 'extends' }, + ]; + const map = buildHeritageMap(heritage, ctx); + + const result = lookupMethodByOwnerWithMRO( + 'class:Child', + 'process', + map, + ctx.symbols, + SupportedLanguages.Rust, + ); + // Rust requires qualified syntax — no auto-resolution + expect(result).toBeUndefined(); + }); + + it('method not on any ancestor returns undefined', () => { + ctx.symbols.add('src/parent.java', 'Parent', 'class:Parent', 'Class'); + ctx.symbols.add('src/child.java', 'Child', 'class:Child', 'Class'); + + const heritage: ExtractedHeritage[] = [ + { filePath: 'src/child.java', className: 'Child', parentName: 'Parent', kind: 'extends' }, + ]; + const map = buildHeritageMap(heritage, ctx); + + const result = lookupMethodByOwnerWithMRO( + 'class:Child', + 'nonExistent', + map, + ctx.symbols, + SupportedLanguages.Java, + ); + expect(result).toBeUndefined(); + }); + + it('leftmost-base (C++): walks ancestors in BFS order', () => { + ctx.symbols.add('src/a.cpp', 'A', 'class:A', 'Class'); + ctx.symbols.add('src/b.cpp', 'B', 'class:B', 'Class'); + ctx.symbols.add('src/c.cpp', 'C', 'class:C', 'Class'); + ctx.symbols.add('src/a.cpp', 'render', 'method:A:render', 'Method', { + returnType: 'void', + ownerId: 'class:A', + }); + + const heritage: ExtractedHeritage[] = [ + { filePath: 'src/c.cpp', className: 'C', parentName: 'B', kind: 'extends' }, + { filePath: 'src/b.cpp', className: 'B', parentName: 'A', kind: 'extends' }, + ]; + const map = buildHeritageMap(heritage, ctx); + + const result = lookupMethodByOwnerWithMRO( + 'class:C', + 'render', + map, + ctx.symbols, + SupportedLanguages.CPlusPlus, + ); + expect(result).toBeDefined(); + expect(result!.nodeId).toBe('method:A:render'); + }); + + it('implements-split (Java): walks ancestors to find inherited method', () => { + ctx.symbols.add('src/base.java', 'Base', 'class:Base', 'Class'); + ctx.symbols.add('src/iface.java', 'IRepo', 'iface:IRepo', 'Interface'); + ctx.symbols.add('src/child.java', 'Child', 'class:Child', 'Class'); + ctx.symbols.add('src/base.java', 'save', 'method:Base:save', 'Method', { + returnType: 'void', + ownerId: 'class:Base', + }); + + const heritage: ExtractedHeritage[] = [ + { filePath: 'src/child.java', className: 'Child', parentName: 'Base', kind: 'extends' }, + { + filePath: 'src/child.java', + className: 'Child', + parentName: 'IRepo', + kind: 'implements', + }, + ]; + const map = buildHeritageMap(heritage, ctx); + + const result = lookupMethodByOwnerWithMRO( + 'class:Child', + 'save', + map, + ctx.symbols, + SupportedLanguages.Java, + ); + expect(result).toBeDefined(); + expect(result!.nodeId).toBe('method:Base:save'); + }); + + it('implements-split (Java): ambiguous default from two interfaces → BFS first-wins', () => { + // Java: class C implements I1, I2; both I1 and I2 declare the same + // default method. Full ambiguity detection (Java's "class must override + // conflicting defaults" rule) is deferred to computeMRO at the graph + // level. lookupMethodByOwnerWithMRO itself uses BFS order and returns + // the first match — this test pins that contract so a future regression + // that starts returning undefined (or flips the order) fails loudly. + ctx.symbols.add('src/I1.java', 'I1', 'iface:I1', 'Interface'); + ctx.symbols.add('src/I2.java', 'I2', 'iface:I2', 'Interface'); + ctx.symbols.add('src/C.java', 'C', 'class:C', 'Class'); + ctx.symbols.add('src/I1.java', 'handle', 'method:I1:handle', 'Method', { + returnType: 'void', + ownerId: 'iface:I1', + }); + ctx.symbols.add('src/I2.java', 'handle', 'method:I2:handle', 'Method', { + returnType: 'void', + ownerId: 'iface:I2', + }); + + // Insertion order is I1 then I2, so BFS returns I1 first. + const heritage: ExtractedHeritage[] = [ + { filePath: 'src/C.java', className: 'C', parentName: 'I1', kind: 'implements' }, + { filePath: 'src/C.java', className: 'C', parentName: 'I2', kind: 'implements' }, + ]; + const map = buildHeritageMap(heritage, ctx); + + const result = lookupMethodByOwnerWithMRO( + 'class:C', + 'handle', + map, + ctx.symbols, + SupportedLanguages.Java, + ); + expect(result).toBeDefined(); + // BFS first-wins — I1 was declared first, so it wins. + expect(result!.nodeId).toBe('method:I1:handle'); + }); + + it('implements-split (Java): class method takes precedence over interface default in BFS order', () => { + // Child extends Base implements IFoo. Both Base (class) and IFoo + // (interface) declare the same method. HeritageMap records extends + // before implements in the emitter's declaration order, so BFS visits + // Base before IFoo — class wins. Documents the current BFS-level + // behavior; the strict Java "class always wins" rule is enforced at + // the mro-processor graph pass. + ctx.symbols.add('src/Base.java', 'Base', 'class:Base', 'Class'); + ctx.symbols.add('src/IFoo.java', 'IFoo', 'iface:IFoo', 'Interface'); + ctx.symbols.add('src/Child.java', 'Child', 'class:Child', 'Class'); + ctx.symbols.add('src/Base.java', 'handle', 'method:Base:handle', 'Method', { + returnType: 'void', + ownerId: 'class:Base', + }); + ctx.symbols.add('src/IFoo.java', 'handle', 'method:IFoo:handle', 'Method', { + returnType: 'void', + ownerId: 'iface:IFoo', + }); + + const heritage: ExtractedHeritage[] = [ + { filePath: 'src/Child.java', className: 'Child', parentName: 'Base', kind: 'extends' }, + { filePath: 'src/Child.java', className: 'Child', parentName: 'IFoo', kind: 'implements' }, + ]; + const map = buildHeritageMap(heritage, ctx); + + const result = lookupMethodByOwnerWithMRO( + 'class:Child', + 'handle', + map, + ctx.symbols, + SupportedLanguages.Java, + ); + expect(result).toBeDefined(); + expect(result!.nodeId).toBe('method:Base:handle'); + }); + + it('implements-split (Kotlin): walks ancestors to find inherited method', () => { + ctx.symbols.add('src/base.kt', 'Base', 'class:Base', 'Class'); + ctx.symbols.add('src/child.kt', 'Child', 'class:Child', 'Class'); + ctx.symbols.add('src/base.kt', 'handle', 'method:Base:handle', 'Method', { + returnType: 'Unit', + ownerId: 'class:Base', + }); + + const heritage: ExtractedHeritage[] = [ + { filePath: 'src/child.kt', className: 'Child', parentName: 'Base', kind: 'extends' }, + ]; + const map = buildHeritageMap(heritage, ctx); + + const result = lookupMethodByOwnerWithMRO( + 'class:Child', + 'handle', + map, + ctx.symbols, + SupportedLanguages.Kotlin, + ); + expect(result).toBeDefined(); + expect(result!.nodeId).toBe('method:Base:handle'); + }); + + it('implements-split (C#): walks ancestors to find inherited method', () => { + ctx.symbols.add('src/Base.cs', 'Base', 'class:Base', 'Class'); + ctx.symbols.add('src/Child.cs', 'Child', 'class:Child', 'Class'); + ctx.symbols.add('src/Base.cs', 'Execute', 'method:Base:Execute', 'Method', { + returnType: 'void', + ownerId: 'class:Base', + }); + + const heritage: ExtractedHeritage[] = [ + { filePath: 'src/Child.cs', className: 'Child', parentName: 'Base', kind: 'extends' }, + ]; + const map = buildHeritageMap(heritage, ctx); + + const result = lookupMethodByOwnerWithMRO( + 'class:Child', + 'Execute', + map, + ctx.symbols, + SupportedLanguages.CSharp, + ); + expect(result).toBeDefined(); + expect(result!.nodeId).toBe('method:Base:Execute'); + }); + + it('first-wins (JavaScript): walks ancestors to find inherited method', () => { + // JavaScript provider is wired separately from TypeScript — this guards + // the provider wiring independent of the TS path. + ctx.symbols.add('src/animal.js', 'Animal', 'class:Animal', 'Class'); + ctx.symbols.add('src/dog.js', 'Dog', 'class:Dog', 'Class'); + ctx.symbols.add('src/animal.js', 'speak', 'method:Animal:speak', 'Method', { + returnType: 'string', + ownerId: 'class:Animal', + }); + + const heritage: ExtractedHeritage[] = [ + { filePath: 'src/dog.js', className: 'Dog', parentName: 'Animal', kind: 'extends' }, + ]; + const map = buildHeritageMap(heritage, ctx); + + const result = lookupMethodByOwnerWithMRO( + 'class:Dog', + 'speak', + map, + ctx.symbols, + SupportedLanguages.JavaScript, + ); + expect(result).toBeDefined(); + expect(result!.nodeId).toBe('method:Animal:speak'); + }); + + it('leftmost-base (C++): diamond inheritance resolves leftmost branch first', () => { + // Diamond: D extends B, C; B extends A; C extends A. + // Both B and C define render(). leftmost-base must return B#render (first + // branch in declaration order), not A#render or C#render. + ctx.symbols.add('src/a.cpp', 'A', 'class:A', 'Class'); + ctx.symbols.add('src/b.cpp', 'B', 'class:B', 'Class'); + ctx.symbols.add('src/c.cpp', 'C', 'class:C', 'Class'); + ctx.symbols.add('src/d.cpp', 'D', 'class:D', 'Class'); + ctx.symbols.add('src/a.cpp', 'render', 'method:A:render', 'Method', { + returnType: 'void', + ownerId: 'class:A', + }); + ctx.symbols.add('src/b.cpp', 'render', 'method:B:render', 'Method', { + returnType: 'void', + ownerId: 'class:B', + }); + ctx.symbols.add('src/c.cpp', 'render', 'method:C:render', 'Method', { + returnType: 'void', + ownerId: 'class:C', + }); + + const heritage: ExtractedHeritage[] = [ + // Declaration order matters: B before C for leftmost-base semantics. + { filePath: 'src/d.cpp', className: 'D', parentName: 'B', kind: 'extends' }, + { filePath: 'src/d.cpp', className: 'D', parentName: 'C', kind: 'extends' }, + { filePath: 'src/b.cpp', className: 'B', parentName: 'A', kind: 'extends' }, + { filePath: 'src/c.cpp', className: 'C', parentName: 'A', kind: 'extends' }, + ]; + const map = buildHeritageMap(heritage, ctx); + + const result = lookupMethodByOwnerWithMRO( + 'class:D', + 'render', + map, + ctx.symbols, + SupportedLanguages.CPlusPlus, + ); + expect(result).toBeDefined(); + // BFS via HeritageMap visits B before C (insertion order), so leftmost + // branch wins — matches C++ leftmost-base semantics for non-virtual base. + expect(result!.nodeId).toBe('method:B:render'); + }); + + it('returns direct method on owner without walking (no heritage needed)', () => { + ctx.symbols.add('src/user.java', 'User', 'class:User', 'Class'); + ctx.symbols.add('src/user.java', 'getName', 'method:User:getName', 'Method', { + returnType: 'String', + ownerId: 'class:User', + }); + + const map = buildHeritageMap([], ctx); + + const result = lookupMethodByOwnerWithMRO( + 'class:User', + 'getName', + map, + ctx.symbols, + SupportedLanguages.Java, + ); + expect(result).toBeDefined(); + expect(result!.nodeId).toBe('method:User:getName'); + }); +}); From d9ba9aa998f6db8ac7763427b00a962fb13893e4 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Wed, 8 Apr 2026 23:24:07 +0100 Subject: [PATCH 11/11] SM-10: Add MRO fast path before D2 fuzzy widening in resolveCallTarget (#741) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Initial plan * Add MRO fast path before D2 fuzzy widening in resolveCallTarget When receiverTypeName is known, try resolveMethodByOwner (owner-scoped + MRO lookup) before falling back to the expensive lookupFuzzy in D2. This short-circuits cross-file member call resolution for the common non-overloaded case. The fast path is skipped when overload disambiguation hints are available (overloadHints or preComputedArgTypes) to avoid picking the wrong overload for same-return-type overloaded methods. Passes heritageMap to resolveCallTarget from all 4 call sites: - Language seed path (processCalls) - Sequential path (processCalls) - walkMixedChain fallback - Worker path (processCallsFromExtracted) Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/9e49521f-2472-47bc-96e9-be4a46b073f0 Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * fix(SM-10): address PR #741 review Correctness: - Module-alias guard for D0. When call.receiverName matches an active entry in ctx.moduleAliasMap for the current file, D0 is now skipped and resolution falls through to D1-D4 which respects the alias-narrowed candidate pool. Prevents a homonymous class in a different file from being picked by ctx.resolve(receiverTypeName) inside resolveMethodByOwner. New unit test pins the contract. Unit tests (call-processor.test.ts — 3 new): - D0 hit: child.parentMethod() resolves via MRO walk when heritageMap is provided. - D0 skipped: same scenario still resolves via D1-D4 when heritageMap is undefined (backward-compat guard). - Module-alias guard: two files both define class User with a save() method; 'import auth_mod as auth' in app.py must resolve auth.user.save() to auth_mod.py, not user_mod.py. Integration language coverage (+3 fixtures/tests): - swift-child-extends-parent — first-wins, gated on swiftAvailable. - ruby-child-extends-parent — first-wins. - php-child-extends-parent — first-wins (uses ParentClass since 'Parent' is a PHP reserved word). * test(SM-10): address second PR #741 review round Unit tests (call-processor.test.ts, +2 new): - overloadHints guard: Java source with two same-return-type overloads method(int) and method(String), int added first so lookupMethodByOwner would return it. processCalls auto-generates overloadHints for Java, forcing D0 to be skipped. o.method("hello") must resolve to method(String) via literal-inferred disambiguation. - preComputedArgTypes guard: worker-path equivalent via processCallsFromExtracted with ExtractedCall.argTypes=['String']. Same two overloads, same correctness guarantee. Integration tests (+2 fixtures + test blocks): - go-child-extends-parent — struct embedding, first-wins (Go structs are labeled 'Struct' not 'Class' in GitNexus). - dart-child-extends-parent — extends, first-wins, gated on dartAvailable like other Dart tests. Documentation: - Expanded the fallthrough comment in resolveMethodByOwner to clarify that unknown-extension paths land on plain lookupMethodByOwner without an ancestor walk, and that D1-D4 still runs on D0 miss. * test(SM-10): D0 miss with heritageMap present falls through to D1-D4 Closes the last remaining gap from PR #741 review round 3. The existing 'D0 skipped' test only covered the heritageMap=undefined case, leaving the miss-with-heritageMap path implicitly covered by integration tests only. This adds a focused unit test where: - Class Obj has a method doWork findable via tiered resolution (import-scoped) but intentionally NOT registered in methodByOwner (no ownerId), so lookupMethodByOwner misses. - heritageMap is provided but built from an empty heritage array, so getAncestors(class:Obj) returns []. The MRO walk yields no parents. - lookupMethodByOwnerWithMRO therefore returns undefined → D0 miss. - D1 resolves the receiver type; D2 widens via lookupFuzzy; D3 file-filter picks the single matching candidate. - A CALLS edge must still be emitted — D0 miss must not swallow the call. --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> Co-authored-by: Gergo Magyar --- gitnexus/src/core/ingestion/call-processor.ts | 47 ++- .../dart-child-extends-parent/app.dart | 8 + .../dart-child-extends-parent/child.dart | 3 + .../dart-child-extends-parent/parent.dart | 5 + .../go-child-extends-parent/go.mod | 3 + .../go-child-extends-parent/models/child.go | 5 + .../go-child-extends-parent/models/parent.go | 7 + .../go-child-extends-parent/services/app.go | 8 + .../php-child-extends-parent/src/App.php | 14 + .../php-child-extends-parent/src/Child.php | 7 + .../php-child-extends-parent/src/Parent.php | 11 + .../ruby-child-extends-parent/lib/app.rb | 8 + .../ruby-child-extends-parent/lib/child.rb | 4 + .../ruby-child-extends-parent/lib/parent.rb | 5 + .../Sources/App.swift | 6 + .../Sources/Child.swift | 2 + .../Sources/Parent.swift | 5 + .../test/integration/resolvers/dart.test.ts | 38 ++ .../test/integration/resolvers/go.test.ts | 32 ++ .../test/integration/resolvers/php.test.ts | 27 ++ .../test/integration/resolvers/ruby.test.ts | 27 ++ .../test/integration/resolvers/swift.test.ts | 33 ++ gitnexus/test/unit/call-processor.test.ts | 345 ++++++++++++++++++ 23 files changed, 648 insertions(+), 2 deletions(-) create mode 100644 gitnexus/test/fixtures/lang-resolution/dart-child-extends-parent/app.dart create mode 100644 gitnexus/test/fixtures/lang-resolution/dart-child-extends-parent/child.dart create mode 100644 gitnexus/test/fixtures/lang-resolution/dart-child-extends-parent/parent.dart create mode 100644 gitnexus/test/fixtures/lang-resolution/go-child-extends-parent/go.mod create mode 100644 gitnexus/test/fixtures/lang-resolution/go-child-extends-parent/models/child.go create mode 100644 gitnexus/test/fixtures/lang-resolution/go-child-extends-parent/models/parent.go create mode 100644 gitnexus/test/fixtures/lang-resolution/go-child-extends-parent/services/app.go create mode 100644 gitnexus/test/fixtures/lang-resolution/php-child-extends-parent/src/App.php create mode 100644 gitnexus/test/fixtures/lang-resolution/php-child-extends-parent/src/Child.php create mode 100644 gitnexus/test/fixtures/lang-resolution/php-child-extends-parent/src/Parent.php create mode 100644 gitnexus/test/fixtures/lang-resolution/ruby-child-extends-parent/lib/app.rb create mode 100644 gitnexus/test/fixtures/lang-resolution/ruby-child-extends-parent/lib/child.rb create mode 100644 gitnexus/test/fixtures/lang-resolution/ruby-child-extends-parent/lib/parent.rb create mode 100644 gitnexus/test/fixtures/lang-resolution/swift-child-extends-parent/Sources/App.swift create mode 100644 gitnexus/test/fixtures/lang-resolution/swift-child-extends-parent/Sources/Child.swift create mode 100644 gitnexus/test/fixtures/lang-resolution/swift-child-extends-parent/Sources/Parent.swift diff --git a/gitnexus/src/core/ingestion/call-processor.ts b/gitnexus/src/core/ingestion/call-processor.ts index ecb778a46..deb58e9e9 100644 --- a/gitnexus/src/core/ingestion/call-processor.ts +++ b/gitnexus/src/core/ingestion/call-processor.ts @@ -787,6 +787,8 @@ export const processCalls = async ( ctx, undefined, widenCache, + undefined, + heritageMap, ); if (!resolved) return; @@ -1033,6 +1035,8 @@ export const processCalls = async ( ctx, hints, widenCache, + undefined, + heritageMap, ); if (!resolved) return; @@ -1285,6 +1289,7 @@ const resolveCallTarget = ( overloadHints?: OverloadHints, widenCache?: WidenCache, preComputedArgTypes?: (string | undefined)[], + heritageMap?: HeritageMap, ): ResolveResult | null => { const tiered = ctx.resolve(call.calledName, currentFile); if (!tiered) return null; @@ -1360,6 +1365,35 @@ const resolveCallTarget = ( // belong to the wrong class (e.g. super.save() should hit the parent's save, // not the child's own save method in the same file). if (call.callForm === 'member' && call.receiverTypeName) { + // D0. MRO fast path: when heritageMap is available, try owner-scoped + MRO + // lookup before falling back to the expensive D2 fuzzy widening. + // This short-circuits the lookupFuzzy call for every cross-file member call. + // Skip conditions: + // (a) overloadHints or preComputedArgTypes present — the MRO lookup may + // pick the wrong overload for same-return-type overloads since it + // does not consider argument types. D2-D4+E handles those correctly. + // (b) A module alias on call.receiverName is active for this file — the + // alias block above already narrowed `filteredCandidates` to a + // specific file (e.g. Python `import auth; auth.user.save()`). + // resolveMethodByOwner re-resolves `receiverTypeName` from scratch + // via `ctx.resolve`, which ignores that narrowing and could pick a + // homonymous class from the wrong file. Fall through to D1-D4 which + // respects the alias-filtered candidate pool. + const hasActiveModuleAlias = + !!call.receiverName && ctx.moduleAliasMap?.get(currentFile)?.has(call.receiverName) === true; + if (!overloadHints && !preComputedArgTypes && !hasActiveModuleAlias) { + const mroResult = resolveMethodByOwner( + call.receiverTypeName, + call.calledName, + currentFile, + ctx, + heritageMap, + ); + if (mroResult) { + return toResolveResult(mroResult, tiered.tier); + } + } + // D1. Resolve the receiver type const typeResolved = ctx.resolve(call.receiverTypeName, currentFile); if (typeResolved && typeResolved.candidates.length > 0) { @@ -1628,8 +1662,12 @@ const resolveMethodByOwner = ( } } - // Fallback when no HeritageMap (or the file extension is unrecognized): - // plain direct lookup with no ancestor walk. + // Fallback when no HeritageMap (or the file extension is unrecognized by + // `getLanguageFromFilename`, e.g. a synthetic path or an extension that is + // not registered in supported-languages.ts): plain direct lookup with no + // ancestor walk. All primary languages register their extensions, so this + // branch is only reached for edge cases where the MRO walk would not be + // applicable anyway. D1-D4 in resolveCallTarget still runs on D0 miss. return ctx.symbols.lookupMethodByOwner(classDef.nodeId, methodName); }; @@ -1839,6 +1877,10 @@ const walkMixedChain = ( { calledName: step.name, callForm: 'member', receiverTypeName: currentType }, filePath, ctx, + undefined, + undefined, + undefined, + heritageMap, ); if (!resolved) { // Stdlib passthrough: unwrap(), clone(), etc. preserve the receiver type @@ -1988,6 +2030,7 @@ export const processCallsFromExtracted = async ( undefined, widenCache, effectiveCall.argTypes, + heritageMap, ); if (!resolved) { // Vue template component fallback: match calledName against imported .vue basenames diff --git a/gitnexus/test/fixtures/lang-resolution/dart-child-extends-parent/app.dart b/gitnexus/test/fixtures/lang-resolution/dart-child-extends-parent/app.dart new file mode 100644 index 000000000..0c431a071 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/dart-child-extends-parent/app.dart @@ -0,0 +1,8 @@ +import 'child.dart'; + +class App { + void run() { + final c = Child(); + c.parentMethod(); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/dart-child-extends-parent/child.dart b/gitnexus/test/fixtures/lang-resolution/dart-child-extends-parent/child.dart new file mode 100644 index 000000000..fafe63719 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/dart-child-extends-parent/child.dart @@ -0,0 +1,3 @@ +import 'parent.dart'; + +class Child extends Parent {} diff --git a/gitnexus/test/fixtures/lang-resolution/dart-child-extends-parent/parent.dart b/gitnexus/test/fixtures/lang-resolution/dart-child-extends-parent/parent.dart new file mode 100644 index 000000000..28c09da37 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/dart-child-extends-parent/parent.dart @@ -0,0 +1,5 @@ +class Parent { + String parentMethod() { + return 'parent'; + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/go-child-extends-parent/go.mod b/gitnexus/test/fixtures/lang-resolution/go-child-extends-parent/go.mod new file mode 100644 index 000000000..192e075e8 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/go-child-extends-parent/go.mod @@ -0,0 +1,3 @@ +module example.com/app + +go 1.21 diff --git a/gitnexus/test/fixtures/lang-resolution/go-child-extends-parent/models/child.go b/gitnexus/test/fixtures/lang-resolution/go-child-extends-parent/models/child.go new file mode 100644 index 000000000..aac88e503 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/go-child-extends-parent/models/child.go @@ -0,0 +1,5 @@ +package models + +type Child struct { + Parent +} diff --git a/gitnexus/test/fixtures/lang-resolution/go-child-extends-parent/models/parent.go b/gitnexus/test/fixtures/lang-resolution/go-child-extends-parent/models/parent.go new file mode 100644 index 000000000..4aa63ee03 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/go-child-extends-parent/models/parent.go @@ -0,0 +1,7 @@ +package models + +type Parent struct{} + +func (p *Parent) ParentMethod() string { + return "parent" +} diff --git a/gitnexus/test/fixtures/lang-resolution/go-child-extends-parent/services/app.go b/gitnexus/test/fixtures/lang-resolution/go-child-extends-parent/services/app.go new file mode 100644 index 000000000..cde995386 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/go-child-extends-parent/services/app.go @@ -0,0 +1,8 @@ +package services + +import "example.com/app/models" + +func Run() { + c := &models.Child{} + c.ParentMethod() +} diff --git a/gitnexus/test/fixtures/lang-resolution/php-child-extends-parent/src/App.php b/gitnexus/test/fixtures/lang-resolution/php-child-extends-parent/src/App.php new file mode 100644 index 000000000..71ddc605a --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/php-child-extends-parent/src/App.php @@ -0,0 +1,14 @@ +parentMethod(); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/php-child-extends-parent/src/Child.php b/gitnexus/test/fixtures/lang-resolution/php-child-extends-parent/src/Child.php new file mode 100644 index 000000000..a031de193 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/php-child-extends-parent/src/Child.php @@ -0,0 +1,7 @@ + String { + return "parent" + } +} diff --git a/gitnexus/test/integration/resolvers/dart.test.ts b/gitnexus/test/integration/resolvers/dart.test.ts index 7f995e09d..82fa77f22 100644 --- a/gitnexus/test/integration/resolvers/dart.test.ts +++ b/gitnexus/test/integration/resolvers/dart.test.ts @@ -474,3 +474,41 @@ describe.skipIf(!dartAvailable)('Dart interface dispatch (METHOD_IMPLEMENTS)', ( expect(saveEdge).toBeDefined(); }); }); + +// --------------------------------------------------------------------------- +// SM-9/SM-10: lookupMethodByOwnerWithMRO + D0 fast path — Dart first-wins +// --------------------------------------------------------------------------- + +describe.skipIf(!dartAvailable)( + 'Dart Child extends Parent — inherited method resolution (SM-9)', + () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'dart-child-extends-parent'), + () => {}, + ); + }, 60000); + + it('detects Parent and Child classes', () => { + const classes = getNodesByLabel(result, 'Class'); + expect(classes).toContain('Parent'); + expect(classes).toContain('Child'); + }); + + it('emits EXTENDS edge: Child → Parent', () => { + const extends_ = getRelationships(result, 'EXTENDS'); + expect(edgeSet(extends_)).toContain('Child → Parent'); + }); + + it('resolves c.parentMethod() to Parent.parentMethod via first-wins MRO walk', () => { + const calls = getRelationships(result, 'CALLS'); + const parentMethodCall = calls.find( + (c) => c.target === 'parentMethod' && c.targetFilePath.includes('parent.dart'), + ); + expect(parentMethodCall).toBeDefined(); + expect(parentMethodCall!.source).toBe('run'); + }); + }, +); diff --git a/gitnexus/test/integration/resolvers/go.test.ts b/gitnexus/test/integration/resolvers/go.test.ts index 0ec9b2f6c..69761f889 100644 --- a/gitnexus/test/integration/resolvers/go.test.ts +++ b/gitnexus/test/integration/resolvers/go.test.ts @@ -1345,3 +1345,35 @@ describe('Go method enrichment', () => { expect(classifyCall).toBeDefined(); }); }); + +// --------------------------------------------------------------------------- +// SM-9/SM-10: lookupMethodByOwnerWithMRO + D0 fast path — Go struct embedding +// --------------------------------------------------------------------------- + +describe('Go Child embeds Parent — inherited method resolution (SM-9)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'go-child-extends-parent'), () => {}); + }, 60000); + + it('detects Parent and Child structs', () => { + const structs = getNodesByLabel(result, 'Struct'); + expect(structs).toContain('Parent'); + expect(structs).toContain('Child'); + }); + + it('emits EXTENDS edge: Child → Parent (struct embedding)', () => { + const extends_ = getRelationships(result, 'EXTENDS'); + expect(edgeSet(extends_)).toContain('Child → Parent'); + }); + + it('resolves c.ParentMethod() to Parent.ParentMethod via first-wins MRO walk', () => { + const calls = getRelationships(result, 'CALLS'); + const parentMethodCall = calls.find( + (c) => c.target === 'ParentMethod' && c.targetFilePath.includes('parent.go'), + ); + expect(parentMethodCall).toBeDefined(); + expect(parentMethodCall!.source).toBe('Run'); + }); +}); diff --git a/gitnexus/test/integration/resolvers/php.test.ts b/gitnexus/test/integration/resolvers/php.test.ts index a72da9439..e120d92f4 100644 --- a/gitnexus/test/integration/resolvers/php.test.ts +++ b/gitnexus/test/integration/resolvers/php.test.ts @@ -1780,3 +1780,30 @@ describe('PHP abstract dispatch', () => { expect(names).toEqual(['find', 'save']); }); }); + +// --------------------------------------------------------------------------- +// SM-9/SM-10: lookupMethodByOwnerWithMRO + D0 fast path — PHP first-wins +// --------------------------------------------------------------------------- + +describe('PHP Child extends ParentClass — inherited method resolution (SM-9)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'php-child-extends-parent'), () => {}); + }, 60000); + + it('detects ParentClass and Child classes', () => { + const classes = getNodesByLabel(result, 'Class'); + expect(classes).toContain('ParentClass'); + expect(classes).toContain('Child'); + }); + + it('resolves $c->parentMethod() to ParentClass::parentMethod via first-wins MRO walk', () => { + const calls = getRelationships(result, 'CALLS'); + const parentMethodCall = calls.find( + (c) => c.target === 'parentMethod' && c.targetFilePath.includes('Parent.php'), + ); + expect(parentMethodCall).toBeDefined(); + expect(parentMethodCall!.source).toBe('run'); + }); +}); diff --git a/gitnexus/test/integration/resolvers/ruby.test.ts b/gitnexus/test/integration/resolvers/ruby.test.ts index 865c41c4a..7e508e756 100644 --- a/gitnexus/test/integration/resolvers/ruby.test.ts +++ b/gitnexus/test/integration/resolvers/ruby.test.ts @@ -1330,3 +1330,30 @@ describe('Ruby overload dispatch (format vs format_with_prefix)', () => { expect(methods).toContain('run'); }); }); + +// --------------------------------------------------------------------------- +// SM-9/SM-10: lookupMethodByOwnerWithMRO + D0 fast path — Ruby first-wins +// --------------------------------------------------------------------------- + +describe('Ruby Child extends Parent — inherited method resolution (SM-9)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'ruby-child-extends-parent'), () => {}); + }, 60000); + + it('detects Parent and Child classes', () => { + const classes = getNodesByLabel(result, 'Class'); + expect(classes).toContain('Parent'); + expect(classes).toContain('Child'); + }); + + it('resolves c.parent_method to Parent#parent_method via first-wins MRO walk', () => { + const calls = getRelationships(result, 'CALLS'); + const parentMethodCall = calls.find( + (c) => c.target === 'parent_method' && c.targetFilePath.includes('parent.rb'), + ); + expect(parentMethodCall).toBeDefined(); + expect(parentMethodCall!.source).toBe('run'); + }); +}); diff --git a/gitnexus/test/integration/resolvers/swift.test.ts b/gitnexus/test/integration/resolvers/swift.test.ts index 272770527..f00435165 100644 --- a/gitnexus/test/integration/resolvers/swift.test.ts +++ b/gitnexus/test/integration/resolvers/swift.test.ts @@ -865,3 +865,36 @@ describe.skipIf(!swiftAvailable)('Swift overloaded method disambiguation', () => expect(mi.length).toBe(3); }); }); + +// --------------------------------------------------------------------------- +// SM-9/SM-10: lookupMethodByOwnerWithMRO + D0 fast path — Swift first-wins +// --------------------------------------------------------------------------- + +describe.skipIf(!swiftAvailable)( + 'Swift Child extends Parent — inherited method resolution (SM-9)', + () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'swift-child-extends-parent'), + () => {}, + ); + }, 60000); + + it('detects Parent and Child classes', () => { + const classes = getNodesByLabel(result, 'Class'); + expect(classes).toContain('Parent'); + expect(classes).toContain('Child'); + }); + + it('resolves c.parentMethod() to Parent.parentMethod via first-wins MRO walk', () => { + const calls = getRelationships(result, 'CALLS'); + const parentMethodCall = calls.find( + (c) => c.target === 'parentMethod' && c.targetFilePath.includes('Parent.swift'), + ); + expect(parentMethodCall).toBeDefined(); + expect(parentMethodCall!.source).toBe('run'); + }); + }, +); diff --git a/gitnexus/test/unit/call-processor.test.ts b/gitnexus/test/unit/call-processor.test.ts index bb4590d5a..81dc9f042 100644 --- a/gitnexus/test/unit/call-processor.test.ts +++ b/gitnexus/test/unit/call-processor.test.ts @@ -1591,3 +1591,348 @@ describe('processCallsFromExtracted — interface dispatch', () => { expect(toB?.reason).toBe('interface-dispatch'); }); }); + +// --------------------------------------------------------------------------- +// SM-10: D0 MRO fast path in resolveCallTarget +// --------------------------------------------------------------------------- + +describe('processCalls — D0 MRO fast path (SM-10)', () => { + let graph: ReturnType; + let ctx: ResolutionContext; + + beforeEach(() => { + graph = createKnowledgeGraph(); + ctx = createResolutionContext(); + }); + + const setupChildParent = () => { + const parentFile = 'src/models/Parent.java'; + const childFile = 'src/models/Child.java'; + const appFile = 'src/services/App.java'; + const parentId = 'class:models/Parent.java:Parent'; + const childId = 'class:models/Child.java:Child'; + const parentMethodId = 'method:models/Parent.java:parentMethod'; + + ctx.symbols.add(parentFile, 'Parent', parentId, 'Class'); + ctx.symbols.add(childFile, 'Child', childId, 'Class'); + ctx.symbols.add(parentFile, 'parentMethod', parentMethodId, 'Method', { + ownerId: parentId, + returnType: 'String', + }); + ctx.importMap.set(appFile, new Set([childFile, parentFile])); + return { parentFile, childFile, appFile, parentId, childId, parentMethodId }; + }; + + it('D0 hit: child.parentMethod() resolves via MRO walk when heritageMap is provided', async () => { + const { parentMethodId, appFile, parentFile, childFile } = setupChildParent(); + + const heritage: ExtractedHeritage[] = [ + { + filePath: childFile, + className: 'Child', + parentName: 'Parent', + kind: 'extends', + }, + ]; + const heritageMap = buildHeritageMap(heritage, ctx); + + await processCalls( + graph, + [ + { + path: parentFile, + content: + 'package models;\npublic class Parent {\n public String parentMethod() { return ""; }\n}\n', + }, + { + path: childFile, + content: 'package models;\npublic class Child extends Parent {}\n', + }, + { + path: appFile, + content: + 'package services;\nimport models.Child;\npublic class App {\n public void run() {\n Child c = new Child();\n c.parentMethod();\n }\n}\n', + }, + ], + createASTCache(), + ctx, + undefined, + undefined, + undefined, + undefined, + undefined, + heritageMap, + ); + + const parentMethodCalls = graph.relationships.filter( + (r) => r.type === 'CALLS' && r.targetId === parentMethodId, + ); + expect(parentMethodCalls).toHaveLength(1); + }); + + it('D0 miss: heritageMap provided but method not in MRO chain falls through to D1-D4', async () => { + // Setup: Class Obj has a method `doWork` that is findable via tiered + // resolution (import-scoped lookup), but intentionally NOT registered in + // methodByOwner (no `ownerId` property). heritageMap is provided but has + // no ancestry entry for class:Obj. Expected flow: + // D0: lookupMethodByOwner(classId, 'doWork') → undefined + // heritageMap.getAncestors(classId) → [] + // lookupMethodByOwnerWithMRO returns undefined → D0 miss + // D1-D4: receiver type resolves to Obj; D2 widens via lookupFuzzy; + // D3 file-filter picks the only candidate in Obj's file. + // Guarantees D0 miss does not swallow the call — D1-D4 still runs. + const classFile = 'src/models/Obj.java'; + const appFile = 'src/services/App.java'; + const classId = 'class:models/Obj.java:Obj'; + const doWorkId = 'method:models/Obj.java:doWork'; + + ctx.symbols.add(classFile, 'Obj', classId, 'Class'); + // Intentionally omit ownerId so methodByOwner has no entry — forces D0 miss. + ctx.symbols.add(classFile, 'doWork', doWorkId, 'Method', { + returnType: 'void', + parameterCount: 0, + }); + ctx.importMap.set(appFile, new Set([classFile])); + + // Empty heritage — no ancestry for Obj, so the MRO walk yields no parents. + const heritageMap = buildHeritageMap([], ctx); + + const calls: ExtractedCall[] = [ + { + filePath: appFile, + calledName: 'doWork', + sourceId: 'method:services/App.java:run', + argCount: 0, + callForm: 'member', + receiverTypeName: 'Obj', + }, + ]; + + await processCallsFromExtracted(graph, calls, ctx, undefined, undefined, heritageMap); + + const doWorkCalls = graph.relationships.filter( + (r) => r.type === 'CALLS' && r.targetId === doWorkId, + ); + expect(doWorkCalls).toHaveLength(1); + }); + + it('D0 skipped: same scenario still resolves via D1-D4 when heritageMap is undefined', async () => { + const { parentMethodId, appFile, parentFile, childFile } = setupChildParent(); + + await processCalls( + graph, + [ + { + path: parentFile, + content: + 'package models;\npublic class Parent {\n public String parentMethod() { return ""; }\n}\n', + }, + { + path: childFile, + content: 'package models;\npublic class Child extends Parent {}\n', + }, + { + path: appFile, + content: + 'package services;\nimport models.Child;\npublic class App {\n public void run() {\n Child c = new Child();\n c.parentMethod();\n }\n}\n', + }, + ], + createASTCache(), + ctx, + // no heritageMap — D0 fast path must be skipped, D1-D4 must still resolve + ); + + const parentMethodCalls = graph.relationships.filter( + (r) => r.type === 'CALLS' && r.targetId === parentMethodId, + ); + expect(parentMethodCalls).toHaveLength(1); + }); + + it('overloadHints guard: D0 skipped so literal-inferred overload disambiguation picks the right overload', async () => { + // Java sequential path: processCalls auto-generates `overloadHints` for + // languages whose provider exposes `inferLiteralType` (Java/Kotlin/C#/C++). + // When two overloads share the same return type, lookupMethodByOwner + // returns defs[0] (the first-added overload) regardless of argument + // types. Without the D0 guard this would mis-resolve `o.method("hello")` + // to method(int). With the guard, D0 is skipped because overloadHints + // is present, and the literal-inferred overload path in D2-D4+E picks + // method(String) correctly. + const classFile = 'src/models/Obj.java'; + const appFile = 'src/services/App.java'; + const classId = 'class:models/Obj.java:Obj'; + const methodIntId = 'method:models/Obj.java:method(int)'; + const methodStringId = 'method:models/Obj.java:method(String)'; + + ctx.symbols.add(classFile, 'Obj', classId, 'Class'); + // int overload added FIRST so lookupMethodByOwner would return it. + ctx.symbols.add(classFile, 'method', methodIntId, 'Method', { + ownerId: classId, + returnType: 'String', + parameterCount: 1, + parameterTypes: ['int'], + }); + ctx.symbols.add(classFile, 'method', methodStringId, 'Method', { + ownerId: classId, + returnType: 'String', + parameterCount: 1, + parameterTypes: ['String'], + }); + ctx.importMap.set(appFile, new Set([classFile])); + + const heritageMap = buildHeritageMap([], ctx); + + await processCalls( + graph, + [ + { + path: classFile, + content: + 'package models;\npublic class Obj {\n public String method(int x) { return ""; }\n public String method(String s) { return ""; }\n}\n', + }, + { + path: appFile, + content: + 'package services;\nimport models.Obj;\npublic class App {\n public void run() {\n Obj o = new Obj();\n o.method("hello");\n }\n}\n', + }, + ], + createASTCache(), + ctx, + undefined, + undefined, + undefined, + undefined, + undefined, + heritageMap, + ); + + // Exactly one resolved call, and it must target the String overload. + const methodCalls = graph.relationships.filter( + (r) => r.type === 'CALLS' && (r.targetId === methodIntId || r.targetId === methodStringId), + ); + expect(methodCalls).toHaveLength(1); + expect(methodCalls[0].targetId).toBe(methodStringId); + }); + + it('preComputedArgTypes guard: D0 skipped so arg-type disambiguation picks the right overload', async () => { + // Two overloads of the same method with identical return types live on + // the same owner class. Without the D0 guard, lookupMethodByOwner would + // return defs[0] (the first overload added) regardless of argument types, + // silently mis-resolving an `obj.method("hello")` call to method(int). + // With the guard, preComputedArgTypes forces D0 to be skipped and D2-D4+E + // disambiguates by parameter type. + const classFile = 'src/models/Obj.java'; + const appFile = 'src/services/App.java'; + const classId = 'class:models/Obj.java:Obj'; + const methodIntId = 'method:models/Obj.java:method(int)'; + const methodStringId = 'method:models/Obj.java:method(String)'; + + ctx.symbols.add(classFile, 'Obj', classId, 'Class'); + // int overload added FIRST — without the guard this would be returned by + // lookupMethodByOwner's same-return-type fast path. + ctx.symbols.add(classFile, 'method', methodIntId, 'Method', { + ownerId: classId, + returnType: 'String', + parameterCount: 1, + parameterTypes: ['int'], + }); + ctx.symbols.add(classFile, 'method', methodStringId, 'Method', { + ownerId: classId, + returnType: 'String', + parameterCount: 1, + parameterTypes: ['String'], + }); + ctx.importMap.set(appFile, new Set([classFile])); + + const heritageMap = buildHeritageMap([], ctx); + + const calls: ExtractedCall[] = [ + { + filePath: appFile, + calledName: 'method', + sourceId: 'method:services/App.java:run', + argCount: 1, + callForm: 'member', + receiverTypeName: 'Obj', + argTypes: ['String'], + }, + ]; + + await processCallsFromExtracted(graph, calls, ctx, undefined, undefined, heritageMap); + + const methodCalls = graph.relationships.filter((r) => r.type === 'CALLS'); + // Exactly one resolved call, and it must target the String overload — + // NOT the int overload that lookupMethodByOwner would have returned. + expect(methodCalls).toHaveLength(1); + expect(methodCalls[0].targetId).toBe(methodStringId); + }); + + it('module-alias guard: D0 skipped when receiverName matches an active module alias', async () => { + // Setup: two files each define a class named User with a method save(). + // The caller has a Python-style module alias `import auth_mod as auth`, + // so auth.User().save() must resolve to auth_mod.py, NOT user_mod.py. + // D0 would call ctx.resolve('User') and could pick the wrong file; the + // alias guard must short-circuit D0 so the alias-filtered D1-D4 path + // runs and picks the correct file. + const authModFile = 'auth_mod.py'; + const userModFile = 'user_mod.py'; + const appFile = 'app.py'; + const authUserId = 'class:auth_mod.py:User'; + const userUserId = 'class:user_mod.py:User'; + const authSaveId = 'method:auth_mod.py:save'; + const userSaveId = 'method:user_mod.py:save'; + + ctx.symbols.add(authModFile, 'User', authUserId, 'Class'); + ctx.symbols.add(userModFile, 'User', userUserId, 'Class'); + ctx.symbols.add(authModFile, 'save', authSaveId, 'Method', { + ownerId: authUserId, + returnType: 'bool', + }); + ctx.symbols.add(userModFile, 'save', userSaveId, 'Method', { + ownerId: userUserId, + returnType: 'bool', + }); + // Register the module alias: in app.py, `auth` points to auth_mod.py. + const aliasMap = new Map([['auth', authModFile]]); + ctx.moduleAliasMap.set(appFile, aliasMap); + ctx.importMap.set(appFile, new Set([authModFile])); + + const heritageMap = buildHeritageMap([], ctx); + + await processCalls( + graph, + [ + { + path: authModFile, + content: 'class User:\n def save(self):\n return True\n', + }, + { + path: userModFile, + content: 'class User:\n def save(self):\n return True\n', + }, + { + path: appFile, + content: + 'import auth_mod as auth\n\ndef run():\n user = auth.User()\n user.save()\n', + }, + ], + createASTCache(), + ctx, + undefined, + undefined, + undefined, + undefined, + undefined, + heritageMap, + ); + + // save() must resolve to auth_mod.py, NOT user_mod.py. + const authSave = graph.relationships.find( + (r) => r.type === 'CALLS' && r.targetId === authSaveId, + ); + const userSave = graph.relationships.find( + (r) => r.type === 'CALLS' && r.targetId === userSaveId, + ); + expect(authSave).toBeDefined(); + expect(userSave).toBeUndefined(); + }); +});