diff --git a/gitnexus/src/core/ingestion/call-processor.ts b/gitnexus/src/core/ingestion/call-processor.ts index 01d202f1a..ec7262c3b 100644 --- a/gitnexus/src/core/ingestion/call-processor.ts +++ b/gitnexus/src/core/ingestion/call-processor.ts @@ -21,10 +21,12 @@ import { extractReceiverName, extractReceiverNode, extractMixedChain, + extractCallArgTypes, type MixedChainStep, } from './utils/call-analysis.js'; import { buildTypeEnv, isSubclassOf } from './type-env.js'; -import type { ConstructorBinding } from './type-env.js'; +import type { ConstructorBinding, TypeEnvironment } from './type-env.js'; +import { resolveExtendsType } from './heritage-processor.js'; import { getTreeSitterBufferSize } from './constants.js'; import type { ExtractedCall, @@ -327,6 +329,111 @@ const verifyConstructorBindings = ( return verified; }; +/** + * Resolution result with confidence scoring + */ +interface ResolveResult { + nodeId: string; + confidence: number; + reason: string; + 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 + * results with lower confidence ('interface-dispatch'). + */ +function findInterfaceDispatchTargets( + calledName: string, + receiverTypeName: string, + currentFile: string, + ctx: ResolutionContext, + implementorMap: ImplementorMap, + primaryNodeId: string, +): ResolveResult[] { + const implFiles = implementorMap.get(receiverTypeName); + if (!implFiles || implFiles.size === 0) return []; + + const typeResolved = ctx.resolve(receiverTypeName, currentFile); + if (!typeResolved) return []; + if (!typeResolved.candidates.some((c) => c.type === 'Interface')) return []; + + const results: ResolveResult[] = []; + for (const implFile of implFiles) { + const methods = ctx.symbols.lookupExactAll(implFile, calledName); + for (const method of methods) { + if (method.nodeId !== primaryNodeId) { + results.push({ + nodeId: method.nodeId, + confidence: 0.7, + reason: 'interface-dispatch', + }); + } + } + } + return results; +} + export const processCalls = async ( graph: KnowledgeGraph, files: { path: string; content: string }[], @@ -341,6 +448,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, ): Promise => { const parser = await loadParser(); const collectedHeritage: ExtractedHeritage[] = []; @@ -734,7 +842,7 @@ export const processCalls = async ( // Only used when multiple candidates survive arity filtering — ~1-3% of calls. const langConfig = provider.typeConfig; const hints: OverloadHints | undefined = langConfig?.inferLiteralType - ? { callNode, inferLiteralType: langConfig.inferLiteralType } + ? { callNode, inferLiteralType: langConfig.inferLiteralType, typeEnv } : undefined; const resolved = resolveCallTarget( @@ -762,6 +870,27 @@ export const processCalls = async ( confidence: resolved.confidence, reason: resolved.reason, }); + + if (implementorMap && callForm === 'member' && receiverTypeName) { + const implTargets = findInterfaceDispatchTargets( + calledName, + receiverTypeName, + file.path, + ctx, + implementorMap, + resolved.nodeId, + ); + for (const impl of implTargets) { + graph.addRelationship({ + id: generateId('CALLS', `${sourceId}:${calledName}->${impl.nodeId}`), + sourceId, + targetId: impl.nodeId, + type: 'CALLS', + confidence: impl.confidence, + reason: impl.reason, + }); + } + } }); ctx.clearCache(); @@ -799,16 +928,6 @@ export const processCalls = async ( return collectedHeritage; }; -/** - * Resolution result with confidence scoring - */ -interface ResolveResult { - nodeId: string; - confidence: number; - reason: string; - returnType?: string; -} - const CALLABLE_SYMBOL_TYPES = new Set(['Function', 'Method', 'Constructor', 'Macro', 'Delegate']); const CONSTRUCTOR_TARGET_TYPES = new Set(['Constructor', 'Class', 'Struct', 'Record']); @@ -862,16 +981,14 @@ const toResolveResult = (definition: SymbolDefinition, tier: ResolutionTier): Re interface OverloadHints { callNode: SyntaxNode; inferLiteralType: LiteralTypeInferrer; + typeEnv?: TypeEnvironment; } /** - * Kotlin (and JVM in general) uses boxed type names in parameter declarations - * (e.g. `Int`, `Long`, `Boolean`) while inferJvmLiteralType returns unboxed - * primitives (`int`, `long`, `boolean`). Normalise both sides to lowercase so - * that the comparison `'Int' === 'int'` does not fail. - * - * Only applied to single-word identifiers that look like a JVM primitive alias; - * multi-word or qualified names are left untouched. + * Kotlin often declares parameters with boxed names (`Int`, `Boolean`, …) while + * literal inference yields JVM primitives (`int`, `boolean`). This map aligns + * those for overload matching. Java parameter text is usually already primitive + * spellings, so lookups here are typically unchanged. */ const KOTLIN_BOXED_TO_PRIMITIVE: Readonly> = { Int: 'int', @@ -886,55 +1003,12 @@ const KOTLIN_BOXED_TO_PRIMITIVE: Readonly> = { const normalizeJvmTypeName = (name: string): string => KOTLIN_BOXED_TO_PRIMITIVE[name] ?? name; -/** - * Try to disambiguate overloaded candidates using argument literal types. - * Only invoked when filteredCandidates.length > 1 and at least one has parameterTypes. - * Returns the single matching candidate, or null if ambiguous/inconclusive. - */ -const tryOverloadDisambiguation = ( +const matchCandidatesByArgTypes = ( candidates: SymbolDefinition[], - hints: OverloadHints, + argTypes: (string | undefined)[], ): SymbolDefinition | null => { if (!candidates.some((c) => c.parameterTypes)) return null; - // Find the argument list node in the call expression. - // Kotlin wraps value_arguments inside a call_suffix child, so we must also - // search one level deeper when a direct match is not found. - let argList: any = - hints.callNode.childForFieldName?.('arguments') ?? - hints.callNode.children.find( - (c: any) => - c.type === 'arguments' || c.type === 'argument_list' || c.type === 'value_arguments', - ); - if (!argList) { - // Kotlin: call_expression → call_suffix → value_arguments - const callSuffix = hints.callNode.children.find((c: any) => c.type === 'call_suffix'); - if (callSuffix) { - argList = callSuffix.children.find((c: any) => c.type === 'value_arguments'); - } - } - if (!argList) return null; - - const argTypes: (string | undefined)[] = []; - for (const arg of argList.namedChildren) { - if (arg.type === 'comment') continue; - // Unwrap argument wrapper nodes before passing to inferLiteralType: - // - Kotlin value_argument: has 'value' field containing the literal - // - C# argument: has 'expression' field (handles named args like `name: "alice"` - // where firstNamedChild would return name_colon instead of the value) - // - Java/others: arg IS the literal directly (no unwrapping needed) - const valueNode = - arg.childForFieldName?.('value') ?? - arg.childForFieldName?.('expression') ?? - (arg.type === 'argument' || arg.type === 'value_argument' - ? (arg.firstNamedChild ?? arg) - : arg); - argTypes.push(hints.inferLiteralType(valueNode)); - } - - // If no literal types could be inferred, can't disambiguate - if (argTypes.every((t) => t === undefined)) return null; - const matched = candidates.filter((c) => { // Keep candidates without type info — conservative: partially-annotated codebases // (e.g. C++ with some missing declarations) may have mixed typed/untyped overloads. @@ -959,6 +1033,24 @@ const tryOverloadDisambiguation = ( return null; }; +/** + * Try to disambiguate overloaded candidates using argument literal types. + * Only invoked when filteredCandidates.length > 1 and at least one has parameterTypes. + * Returns the single matching candidate, or null if ambiguous/inconclusive. + */ +const tryOverloadDisambiguation = ( + candidates: SymbolDefinition[], + hints: OverloadHints, +): SymbolDefinition | null => { + const argTypes = extractCallArgTypes( + hints.callNode, + hints.inferLiteralType, + hints.typeEnv ? (varName, cn) => hints.typeEnv!.lookup(varName, cn) : undefined, + ); + if (!argTypes) return null; + return matchCandidatesByArgTypes(candidates, argTypes); +}; + /** * Resolve a function call to its target node ID using priority strategy: * A. Narrow candidates by scope tier via ctx.resolve() @@ -981,6 +1073,7 @@ const resolveCallTarget = ( ctx: ResolutionContext, overloadHints?: OverloadHints, widenCache?: WidenCache, + preComputedArgTypes?: (string | undefined)[], ): ResolveResult | null => { const tiered = ctx.resolve(call.calledName, currentFile); if (!tiered) return null; @@ -1087,20 +1180,28 @@ const resolveCallTarget = ( return toResolveResult(ownerFiltered[0], tiered.tier); } // E. Try overload disambiguation on the narrowed pool - if ((fileFiltered.length > 1 || ownerFiltered.length > 1) && overloadHints) { + if (fileFiltered.length > 1 || ownerFiltered.length > 1) { const overloadPool = ownerFiltered.length > 1 ? ownerFiltered : fileFiltered; - const disambiguated = tryOverloadDisambiguation(overloadPool, overloadHints); + const disambiguated = overloadHints + ? tryOverloadDisambiguation(overloadPool, overloadHints) + : preComputedArgTypes + ? matchCandidatesByArgTypes(overloadPool, preComputedArgTypes) + : null; if (disambiguated) return toResolveResult(disambiguated, tiered.tier); + return null; } - if (fileFiltered.length > 1 || ownerFiltered.length > 1) return null; } } // E. Overload disambiguation: when multiple candidates survive arity + receiver filtering, - // try matching argument literal types against parameter types (Phase P). - // Only available on sequential path (has AST); worker path falls through gracefully. - if (filteredCandidates.length > 1 && overloadHints) { - const disambiguated = tryOverloadDisambiguation(filteredCandidates, overloadHints); + // try matching argument types against parameter types (Phase P). + // Sequential path uses AST-based hints; worker path uses pre-computed argTypes. + if (filteredCandidates.length > 1) { + const disambiguated = overloadHints + ? tryOverloadDisambiguation(filteredCandidates, overloadHints) + : preComputedArgTypes + ? matchCandidatesByArgTypes(filteredCandidates, preComputedArgTypes) + : null; if (disambiguated) return toResolveResult(disambiguated, tiered.tier); } @@ -1372,6 +1473,7 @@ export const processCallsFromExtracted = async ( ctx: ResolutionContext, onProgress?: (current: number, total: number) => void, constructorBindings?: FileConstructorBindings[], + implementorMap?: ImplementorMap, ) => { // Scope-aware receiver types: keyed by filePath → "funcName\0varName" → typeName. // The scope dimension prevents collisions when two functions in the same file @@ -1486,6 +1588,7 @@ export const processCallsFromExtracted = async ( ctx, undefined, widenCache, + effectiveCall.argTypes, ); if (!resolved) continue; @@ -1501,6 +1604,30 @@ export const processCallsFromExtracted = async ( confidence: resolved.confidence, reason: resolved.reason, }); + + if (implementorMap && effectiveCall.callForm === 'member' && effectiveCall.receiverTypeName) { + const implTargets = findInterfaceDispatchTargets( + effectiveCall.calledName, + effectiveCall.receiverTypeName, + effectiveCall.filePath, + ctx, + implementorMap, + resolved.nodeId, + ); + for (const impl of implTargets) { + graph.addRelationship({ + id: generateId( + 'CALLS', + `${effectiveCall.sourceId}:${effectiveCall.calledName}->${impl.nodeId}`, + ), + sourceId: effectiveCall.sourceId, + targetId: impl.nodeId, + type: 'CALLS', + confidence: impl.confidence, + reason: impl.reason, + }); + } + } } ctx.clearCache(); diff --git a/gitnexus/src/core/ingestion/call-sites/extract-language-call-site.ts b/gitnexus/src/core/ingestion/call-sites/extract-language-call-site.ts new file mode 100644 index 000000000..feed2cd70 --- /dev/null +++ b/gitnexus/src/core/ingestion/call-sites/extract-language-call-site.ts @@ -0,0 +1,33 @@ +/** Non-generic @call shapes → { calledName, callForm, receiverName? } (used from call-processor / parse-worker). */ + +import { SupportedLanguages } from '../../../config/supported-languages.js'; +import type { SyntaxNode } from '../utils/ast-helpers.js'; +import { parseJavaMethodReference } from './java.js'; + +export type ParsedCallSite = { + calledName: string; + callForm: 'free' | 'member' | 'constructor'; + receiverName?: string; +}; + +/** Non-null → seed replaces @call.name; null → use @call.name + inferCallForm / extractReceiverName. */ +export function extractParsedCallSite( + language: SupportedLanguages, + callNode: SyntaxNode, +): ParsedCallSite | null { + switch (language) { + case SupportedLanguages.Java: + if (callNode.type === 'method_reference') { + const parsed = parseJavaMethodReference(callNode); + if (!parsed) return null; + return { + calledName: parsed.calledName, + callForm: parsed.callForm, + ...(parsed.receiverName !== undefined ? { receiverName: parsed.receiverName } : {}), + }; + } + return null; + default: + return null; + } +} diff --git a/gitnexus/src/core/ingestion/call-sites/java.ts b/gitnexus/src/core/ingestion/call-sites/java.ts new file mode 100644 index 000000000..e22c71cca --- /dev/null +++ b/gitnexus/src/core/ingestion/call-sites/java.ts @@ -0,0 +1,41 @@ +/** Java `method_reference` (`::`) nodes (tree-sitter-java). `super::` still lacks TypeEnv receiver typing. */ + +import type { SyntaxNode } from '../utils/ast-helpers.js'; + +export type ParsedJavaMethodReference = { + calledName: string; + callForm: 'member' | 'constructor'; + receiverName?: string; +}; + +/** Parse `expr::method`, `Type::new`, `this::m`, `super::m`. */ +export const parseJavaMethodReference = ( + callNode: SyntaxNode, +): ParsedJavaMethodReference | null => { + if (callNode.type !== 'method_reference') return null; + + const recv = callNode.namedChild(0); + if (!recv) return null; + + for (const c of callNode.children) { + if (c.type === 'new') { + if (recv.type !== 'identifier') return null; + return { calledName: recv.text, callForm: 'constructor' }; + } + } + + const rhs = callNode.child(callNode.childCount - 1); + if (!rhs || rhs.type !== 'identifier') return null; + const methodName = rhs.text; + + if (recv.type === 'identifier') { + return { calledName: methodName, callForm: 'member', receiverName: recv.text }; + } + if (recv.type === 'this') { + return { calledName: methodName, callForm: 'member', receiverName: 'this' }; + } + if (recv.type === 'super') { + return { calledName: methodName, callForm: 'member', receiverName: 'super' }; + } + return null; +}; diff --git a/gitnexus/src/core/ingestion/heritage-processor.ts b/gitnexus/src/core/ingestion/heritage-processor.ts index 497ed09af..25085ddd8 100644 --- a/gitnexus/src/core/ingestion/heritage-processor.ts +++ b/gitnexus/src/core/ingestion/heritage-processor.ts @@ -37,7 +37,8 @@ import { TIER_CONFIDENCE } from './resolution-context.js'; * - heritageDefaultEdge: 'IMPLEMENTS' causes all unresolved parents to map to IMPLEMENTS * - All others: default EXTENDS */ -const resolveExtendsType = ( +/** Exported for implementor-map construction (C#/Java: `extends` rows in base_list may be interfaces). */ +export const resolveExtendsType = ( parentName: string, currentFilePath: string, ctx: ResolutionContext, @@ -367,3 +368,91 @@ export const processHeritageFromExtracted = async ( onProgress?.(total, total); }; + +/** + * 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 + * `processCalls` (worker path defers calls until heritage from all chunks exists). + */ +export async function extractExtractedHeritageFromFiles( + files: { path: string; content: string }[], + astCache: ASTCache, +): Promise { + const parser = await loadParser(); + const out: ExtractedHeritage[] = []; + + for (const file of files) { + const language = getLanguageFromFilename(file.path); + if (!language || !isLanguageAvailable(language)) continue; + + const provider = getProvider(language); + const queryStr = provider.treeSitterQueries; + if (!queryStr) continue; + + await loadLanguage(language, file.path); + + let tree = astCache.get(file.path); + if (!tree) { + try { + tree = parser.parse(file.content, undefined, { + bufferSize: getTreeSitterBufferSize(file.content.length), + }); + } catch { + continue; + } + astCache.set(file.path, tree); + } + + let matches; + try { + const lang = parser.getLanguage(); + const query = new Parser.Query(lang, queryStr); + matches = query.matches(tree.rootNode); + } catch { + continue; + } + + for (const match of matches) { + const captureMap: Record = {}; + match.captures.forEach((c) => { + captureMap[c.name] = c.node; + }); + + if (captureMap['heritage.class']) { + if (captureMap['heritage.extends']) { + const extendsNode = captureMap['heritage.extends']; + const fieldDecl = extendsNode.parent; + const isNamedField = + fieldDecl?.type === 'field_declaration' && fieldDecl.childForFieldName('name'); + if (!isNamedField) { + out.push({ + filePath: file.path, + className: captureMap['heritage.class'].text, + parentName: captureMap['heritage.extends'].text, + kind: 'extends', + }); + } + } + if (captureMap['heritage.implements']) { + out.push({ + filePath: file.path, + className: captureMap['heritage.class'].text, + parentName: captureMap['heritage.implements'].text, + kind: 'implements', + }); + } + if (captureMap['heritage.trait']) { + out.push({ + filePath: file.path, + className: captureMap['heritage.class'].text, + parentName: captureMap['heritage.trait'].text, + kind: 'trait-impl', + }); + } + } + } + } + + return out; +} diff --git a/gitnexus/src/core/ingestion/pipeline.ts b/gitnexus/src/core/ingestion/pipeline.ts index 681ab7d93..5f7d21fe1 100644 --- a/gitnexus/src/core/ingestion/pipeline.ts +++ b/gitnexus/src/core/ingestion/pipeline.ts @@ -21,6 +21,8 @@ import { buildImportedRawReturnTypes, type ExportedTypeMap, buildExportedTypeMapFromGraph, + buildImplementorMap, + mergeImplementorMaps, } from './call-processor.js'; import { nextjsFileToRouteURL, normalizeFetchURL } from './route-extractors/nextjs.js'; import { expoFileToRouteURL } from './route-extractors/expo.js'; @@ -37,13 +39,21 @@ import { } from './route-extractors/middleware.js'; import { generateId } from '../../lib/utils.js'; import type { - ExtractedFetchCall, - ExtractedRoute, + ExtractedAssignment, + ExtractedCall, ExtractedDecoratorRoute, - ExtractedToolDef, + ExtractedFetchCall, + ExtractedHeritage, ExtractedORMQuery, + ExtractedRoute, + ExtractedToolDef, + FileConstructorBindings, } from './workers/parse-worker.js'; -import { processHeritage, processHeritageFromExtracted } from './heritage-processor.js'; +import { + processHeritage, + processHeritageFromExtracted, + extractExtractedHeritageFromFiles, +} from './heritage-processor.js'; import { computeMRO } from './mro-processor.js'; import { processCommunities } from './community-processor.js'; import { processProcesses } from './process-processor.js'; @@ -607,7 +617,8 @@ async function runScanAndStructure( * 1. Parse via worker pool (or sequential fallback) * 2. Resolve imports from extracted data * 3. Synthesize wildcard import bindings (Go/Ruby/C++/Swift/Python) - * 4. Resolve calls, heritage, routes concurrently (Promise.all) + * 4. Resolve heritage + routes per chunk; defer worker CALLS until all chunks + * have contributed heritage so interface-dispatch implementor map is complete * 5. Collect TypeEnv bindings for cross-file propagation * * State accumulated across chunks: symbolTable, importMap, namedImportMap, @@ -617,6 +628,9 @@ async function runScanAndStructure( * @reads allPaths (from scan phase) * @writes graph (Symbol nodes, IMPORTS/CALLS/EXTENDS/IMPLEMENTS/ACCESSES edges) * @writes ctx.symbolTable, ctx.importMap, ctx.namedImportMap, ctx.moduleAliasMap + * + * Follow-up from PR review: MethodExtractor (FieldExtractor parity) and optional + * METHOD_IMPLEMENTS graph edges to make dispatch queryable without an in-memory map. */ async function runChunkedParseAndResolve( graph: ReturnType, @@ -748,10 +762,10 @@ async function runChunkedParseAndResolve( const importCtx = buildImportResolutionContext(allPaths); const allPathObjects = allPaths.map((p) => ({ path: p })); - // Single-pass: parse + resolve imports/calls/heritage per chunk. - // Calls/heritage use the symbol table built so far (symbols from earlier chunks - // are already registered). This trades ~5% cross-chunk resolution accuracy for - // 200-400MB less memory — critical for Linux-kernel-scale repos. + // Worker path: parse + imports + heritage per chunk; buffer extracted calls and + // run processCallsFromExtracted once after all chunks so interface-dispatch uses a + // complete implementor map (heritage from every chunk). Costs peak RAM for buffered + // call rows vs streaming resolution per chunk. const sequentialChunkPaths: string[][] = []; // Pre-compute which chunks need synthesis — O(1) lookup per chunk. const chunkNeedsSynthesis = chunks.map((paths) => @@ -773,6 +787,10 @@ async function runChunkedParseAndResolve( // Accumulate MCP/RPC tool definitions (@mcp.tool(), @app.tool(), etc.) const allToolDefs: ExtractedToolDef[] = []; const allORMQueries: ExtractedORMQuery[] = []; + const deferredWorkerCalls: ExtractedCall[] = []; + const deferredWorkerHeritage: ExtractedHeritage[] = []; + const deferredConstructorBindings: FileConstructorBindings[] = []; + const deferredAssignments: ExtractedAssignment[] = []; try { for (let chunkIdx = 0; chunkIdx < numChunks; chunkIdx++) { @@ -855,29 +873,16 @@ async function runChunkedParseAndResolve( ); } } - // Calls + Heritage + Routes — resolve in parallel (no shared mutable state between them) - // This is safe because each writes disjoint relationship types into idempotent id-keyed Maps, - // and the single-threaded event loop prevents races between synchronous addRelationship calls. + deferredWorkerCalls.push(...chunkWorkerData.calls); + deferredWorkerHeritage.push(...chunkWorkerData.heritage); + deferredConstructorBindings.push(...chunkWorkerData.constructorBindings); + if (chunkWorkerData.assignments?.length) { + deferredAssignments.push(...chunkWorkerData.assignments); + } + + // Heritage + Routes — calls deferred until all chunks have contributed heritage + // (complete implementor map for interface dispatch). await Promise.all([ - processCallsFromExtracted( - graph, - chunkWorkerData.calls, - ctx, - (current, total) => { - onProgress({ - phase: 'parsing', - percent: Math.round(chunkBasePercent), - message: `Resolving calls (chunk ${chunkIdx + 1}/${numChunks})...`, - detail: `${current}/${total} files`, - stats: { - filesProcessed: filesParsedSoFar, - totalFiles: totalParseable, - nodesCreated: graph.nodeCount, - }, - }); - }, - chunkWorkerData.constructorBindings, - ), processHeritageFromExtracted(graph, chunkWorkerData.heritage, ctx, (current, total) => { onProgress({ phase: 'parsing', @@ -905,15 +910,6 @@ async function runChunkedParseAndResolve( }); }), ]); - // Process field write assignments (synchronous, runs after calls resolve) - if (chunkWorkerData.assignments?.length) { - processAssignmentsFromExtracted( - graph, - chunkWorkerData.assignments, - ctx, - chunkWorkerData.constructorBindings, - ); - } // Collect TypeEnv file-scope bindings for exported type enrichment if (chunkWorkerData.typeEnvBindings?.length) { workerTypeEnvBindings.push(...chunkWorkerData.typeEnvBindings); @@ -945,6 +941,44 @@ async function runChunkedParseAndResolve( astCache.clear(); // 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>(); + + if (deferredWorkerCalls.length > 0) { + await processCallsFromExtracted( + graph, + deferredWorkerCalls, + ctx, + (current, total) => { + onProgress({ + phase: 'parsing', + percent: 82, + message: 'Resolving calls (all chunks)...', + detail: `${current}/${total} files`, + stats: { + filesProcessed: filesParsedSoFar, + totalFiles: totalParseable, + nodesCreated: graph.nodeCount, + }, + }); + }, + deferredConstructorBindings.length > 0 ? deferredConstructorBindings : undefined, + fullWorkerImplementorMap, + ); + } + + if (deferredAssignments.length > 0) { + processAssignmentsFromExtracted( + graph, + deferredAssignments, + ctx, + deferredConstructorBindings.length > 0 ? deferredConstructorBindings : undefined, + ); + } } finally { await workerPool?.terminate(); } @@ -953,12 +987,17 @@ 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>(); 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)! })); astCache = createASTCache(chunkFiles.length); + const sequentialHeritage = await extractExtractedHeritageFromFiles(chunkFiles, astCache); + mergeImplementorMaps(sequentialImplementorMap, buildImplementorMap(sequentialHeritage, ctx)); const rubyHeritage = await processCalls( graph, chunkFiles, @@ -966,6 +1005,10 @@ async function runChunkedParseAndResolve( ctx, undefined, exportedTypeMap, + undefined, + undefined, + undefined, + sequentialImplementorMap, ); await processHeritage(graph, chunkFiles, astCache, ctx); if (rubyHeritage.length > 0) { diff --git a/gitnexus/src/core/ingestion/utils/call-analysis.ts b/gitnexus/src/core/ingestion/utils/call-analysis.ts index c25793b12..c7eb8e45f 100644 --- a/gitnexus/src/core/ingestion/utils/call-analysis.ts +++ b/gitnexus/src/core/ingestion/utils/call-analysis.ts @@ -618,3 +618,43 @@ export function extractMixedChain( return chain.length > 0 ? { chain, baseReceiverName: undefined } : undefined; } + +/** Arg types per call position (literals + optional TypeEnv for ids); undefined if unusable */ +export const extractCallArgTypes = ( + callNode: SyntaxNode, + inferLiteralType: (node: SyntaxNode) => string | undefined, + typeEnvLookup?: (varName: string, callNode: SyntaxNode) => string | undefined, +): (string | undefined)[] | undefined => { + let argList: SyntaxNode | undefined = + callNode.childForFieldName?.('arguments') ?? + callNode.children.find( + (c: SyntaxNode) => + c.type === 'arguments' || c.type === 'argument_list' || c.type === 'value_arguments', + ); + if (!argList) { + const callSuffix = callNode.children.find((c: SyntaxNode) => c.type === 'call_suffix'); + if (callSuffix) { + argList = callSuffix.children.find((c: SyntaxNode) => c.type === 'value_arguments'); + } + } + if (!argList) return undefined; + + const argTypes: (string | undefined)[] = []; + for (const arg of argList.namedChildren) { + if (arg.type === 'comment') continue; + const valueNode = + arg.childForFieldName?.('value') ?? + arg.childForFieldName?.('expression') ?? + (arg.type === 'argument' || arg.type === 'value_argument' + ? (arg.firstNamedChild ?? arg) + : arg); + let inferred = inferLiteralType(valueNode); + if (!inferred && typeEnvLookup && valueNode.type === 'identifier') { + inferred = typeEnvLookup(valueNode.text, callNode); + } + argTypes.push(inferred); + } + + if (argTypes.every((t) => t === undefined)) return undefined; + return argTypes; +}; diff --git a/gitnexus/src/core/ingestion/workers/parse-worker.ts b/gitnexus/src/core/ingestion/workers/parse-worker.ts index eeb184e0b..7bfef15bf 100644 --- a/gitnexus/src/core/ingestion/workers/parse-worker.ts +++ b/gitnexus/src/core/ingestion/workers/parse-worker.ts @@ -52,8 +52,10 @@ import { extractReceiverName, extractReceiverNode, extractMixedChain, + extractCallArgTypes, type MixedChainStep, } from '../utils/call-analysis.js'; +import { extractParsedCallSite } from '../call-sites/extract-language-call-site.js'; import { buildTypeEnv } from '../type-env.js'; import type { ConstructorBinding } from '../type-env.js'; import { detectFrameworkFromAST } from '../framework-detection.js'; @@ -130,6 +132,7 @@ export interface ExtractedCall { calledName: string; /** generateId of enclosing function, or generateId('File', filePath) for top-level */ sourceId: string; + /** From call AST; omitted for some seeds (e.g. Java `::`) so arity filter is skipped */ argCount?: number; /** Discriminates free function calls from member/constructor calls */ callForm?: 'free' | 'member' | 'constructor'; @@ -146,6 +149,7 @@ export interface ExtractedCall { * Length is capped at MAX_CHAIN_DEPTH (3). */ receiverMixedChain?: MixedChainStep[]; + argTypes?: (string | undefined)[]; } export interface ExtractedAssignment { @@ -1315,6 +1319,42 @@ const processFileGroup = ( // Extract call sites if (captureMap['call']) { + const callNode0 = captureMap['call']; + const languageSeed = extractParsedCallSite(language, callNode0); + if (languageSeed) { + if (!provider.isBuiltInName(languageSeed.calledName)) { + const sourceId = + findEnclosingFunctionId(callNode0, file.path, provider) || + generateId('File', file.path); + const receiverName = + languageSeed.callForm === 'member' ? languageSeed.receiverName : undefined; + let receiverTypeName = receiverName + ? typeEnv.lookup(receiverName, callNode0) + : undefined; + // Type-as-receiver (e.g. Java `User::getName`): no TypeEnv binding for the class name + if ( + receiverName !== undefined && + receiverTypeName === undefined && + languageSeed.callForm === 'member' && + (language === SupportedLanguages.Java || + language === SupportedLanguages.CSharp || + language === SupportedLanguages.Kotlin) + ) { + const c0 = receiverName.charCodeAt(0); + if (c0 >= 65 && c0 <= 90) receiverTypeName = receiverName; + } + result.calls.push({ + filePath: file.path, + calledName: languageSeed.calledName, + sourceId, + callForm: languageSeed.callForm, + ...(receiverName !== undefined ? { receiverName } : {}), + ...(receiverTypeName !== undefined ? { receiverTypeName } : {}), + }); + } + continue; + } + const callNameNode = captureMap['call.name']; if (callNameNode) { const calledName = callNameNode.text; @@ -1473,6 +1513,18 @@ const processFileGroup = ( } } + const inferLiteralType = provider.typeConfig?.inferLiteralType; + const argCountForOverloadHints = countCallArguments(callNode); + // Skip when no arg list / zero args: nothing to infer for overload typing; saves AST walks + payload size. + const argTypes = + inferLiteralType && + argCountForOverloadHints !== undefined && + argCountForOverloadHints > 0 + ? extractCallArgTypes(callNode, inferLiteralType, (varName, cn) => + typeEnv.lookup(varName, cn), + ) + : undefined; + result.calls.push({ filePath: file.path, calledName, @@ -1482,6 +1534,7 @@ const processFileGroup = ( ...(receiverName !== undefined ? { receiverName } : {}), ...(receiverTypeName !== undefined ? { receiverTypeName } : {}), ...(receiverMixedChain !== undefined ? { receiverMixedChain } : {}), + ...(argTypes !== undefined ? { argTypes } : {}), }); } } diff --git a/gitnexus/test/fixtures/lang-resolution/java-interface-dispatch/contracts/Action.java b/gitnexus/test/fixtures/lang-resolution/java-interface-dispatch/contracts/Action.java new file mode 100644 index 000000000..93d590246 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-interface-dispatch/contracts/Action.java @@ -0,0 +1,6 @@ +package contracts; + +public interface Action { + String execute(); + int priority(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-interface-dispatch/impl/LogEvent.java b/gitnexus/test/fixtures/lang-resolution/java-interface-dispatch/impl/LogEvent.java new file mode 100644 index 000000000..bbcfabd66 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-interface-dispatch/impl/LogEvent.java @@ -0,0 +1,13 @@ +package impl; + +import contracts.Action; + +public class LogEvent implements Action { + public String execute() { + return "event logged"; + } + + public int priority() { + return 2; + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-interface-dispatch/impl/SendEmail.java b/gitnexus/test/fixtures/lang-resolution/java-interface-dispatch/impl/SendEmail.java new file mode 100644 index 000000000..2ff921fb9 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-interface-dispatch/impl/SendEmail.java @@ -0,0 +1,13 @@ +package impl; + +import contracts.Action; + +public class SendEmail implements Action { + public String execute() { + return "email sent"; + } + + public int priority() { + return 1; + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-interface-dispatch/services/ActionRunner.java b/gitnexus/test/fixtures/lang-resolution/java-interface-dispatch/services/ActionRunner.java new file mode 100644 index 000000000..f74f64aec --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-interface-dispatch/services/ActionRunner.java @@ -0,0 +1,16 @@ +package services; + +import contracts.Action; + +public class ActionRunner { + private final Action action; + + public ActionRunner(Action action) { + this.action = action; + } + + public void run() { + action.execute(); + action.priority(); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-method-reference/models/BaseHandler.java b/gitnexus/test/fixtures/lang-resolution/java-method-reference/models/BaseHandler.java new file mode 100644 index 000000000..1d5abbfc2 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-method-reference/models/BaseHandler.java @@ -0,0 +1,7 @@ +package models; + +public class BaseHandler { + protected String transform(String value) { + return value.toUpperCase(); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-method-reference/models/ChildHandler.java b/gitnexus/test/fixtures/lang-resolution/java-method-reference/models/ChildHandler.java new file mode 100644 index 000000000..944d7038d --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-method-reference/models/ChildHandler.java @@ -0,0 +1,12 @@ +package models; + +import java.util.List; +import java.util.stream.Collectors; + +public class ChildHandler extends BaseHandler { + public List wrapTransform(List values) { + return values.stream() + .map(super::transform) + .collect(Collectors.toList()); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-method-reference/models/ResponseBuilder.java b/gitnexus/test/fixtures/lang-resolution/java-method-reference/models/ResponseBuilder.java new file mode 100644 index 000000000..583ce8fdb --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-method-reference/models/ResponseBuilder.java @@ -0,0 +1,7 @@ +package models; + +public class ResponseBuilder { + public String buildResponse(String input) { + return "built:" + input; + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-method-reference/models/User.java b/gitnexus/test/fixtures/lang-resolution/java-method-reference/models/User.java new file mode 100644 index 000000000..dc9b30a04 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-method-reference/models/User.java @@ -0,0 +1,17 @@ +package models; + +public class User { + private final String name; + + public User(String name) { + this.name = name; + } + + public String getName() { + return name; + } + + public boolean save() { + return true; + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-method-reference/services/MethodRefService.java b/gitnexus/test/fixtures/lang-resolution/java-method-reference/services/MethodRefService.java new file mode 100644 index 000000000..ccefb3614 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-method-reference/services/MethodRefService.java @@ -0,0 +1,60 @@ +package services; + +import java.util.List; +import java.util.stream.Collectors; +import models.ResponseBuilder; +import models.User; +import util.FormatUtil; + +public class MethodRefService { + private ResponseBuilder responseBuilder; + + /** + * Instance-bound reference (Synapse-style: mapVar::method). + */ + public List mapViaInstanceBuilder(List inputs) { + return inputs.stream() + .map(responseBuilder::buildResponse) + .collect(Collectors.toList()); + } + + /** + * Static method on a project class (not JDK) — {@code Util::staticMethod}. + */ + public List mapViaStaticUtil(List values) { + return values.stream() + .map(FormatUtil::format) + .collect(Collectors.toList()); + } + + /** + * Unbound instance method reference — {@code Type::instanceMethod}. + */ + public List mapUserNames(List users) { + return users.stream() + .map(User::getName) + .collect(Collectors.toList()); + } + + /** + * Constructor reference — {@code Type::new}. + */ + public List mapNewUsers(List names) { + return names.stream() + .map(User::new) + .collect(Collectors.toList()); + } + + /** + * {@code this::instanceMethod} on enclosing class. + */ + public List mapSaves(List users) { + return users.stream() + .map(this::saveOne) + .collect(Collectors.toList()); + } + + private boolean saveOne(User user) { + return user.save(); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-method-reference/util/FormatUtil.java b/gitnexus/test/fixtures/lang-resolution/java-method-reference/util/FormatUtil.java new file mode 100644 index 000000000..ac74d020f --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-method-reference/util/FormatUtil.java @@ -0,0 +1,7 @@ +package util; + +public final class FormatUtil { + public static String format(Object value) { + return value == null ? "" : value.toString(); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-overload-typed-args/models/Request.java b/gitnexus/test/fixtures/lang-resolution/java-overload-typed-args/models/Request.java new file mode 100644 index 000000000..324605ae8 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-overload-typed-args/models/Request.java @@ -0,0 +1,7 @@ +package models; + +public class Request { + public String getData() { + return "data"; + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-overload-typed-args/models/Response.java b/gitnexus/test/fixtures/lang-resolution/java-overload-typed-args/models/Response.java new file mode 100644 index 000000000..a62f4f783 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-overload-typed-args/models/Response.java @@ -0,0 +1,7 @@ +package models; + +public class Response { + public String getBody() { + return "body"; + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-overload-typed-args/services/Validator.java b/gitnexus/test/fixtures/lang-resolution/java-overload-typed-args/services/Validator.java new file mode 100644 index 000000000..4c5719d10 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-overload-typed-args/services/Validator.java @@ -0,0 +1,22 @@ +package services; + +import models.Request; +import models.Response; + +public class Validator { + public static boolean isValid(Request request) { + return request.getData() != null; + } + + public static boolean isValid(Response response) { + return response.getBody() != null; + } + + public static boolean checkRequest(Request request) { + return isValid(request); + } + + public static boolean checkResponse(Response response) { + return isValid(response); + } +} diff --git a/gitnexus/test/integration/resolvers/csharp.test.ts b/gitnexus/test/integration/resolvers/csharp.test.ts index 66ca0648c..92c8fe764 100644 --- a/gitnexus/test/integration/resolvers/csharp.test.ts +++ b/gitnexus/test/integration/resolvers/csharp.test.ts @@ -46,22 +46,24 @@ describe('C# heritage resolution', () => { it('emits CALLS edges from CreateUser (constructor + member calls)', () => { const calls = getRelationships(result, 'CALLS'); - expect(calls.length).toBe(4); + // _repo.Save() → IRepository.Save (primary) plus interface-dispatch → User.Save (impl) + expect(calls.length).toBe(5); const targets = edgeSet(calls); expect(targets).toContain('CreateUser → User'); // new User() constructor expect(targets).toContain('CreateUser → Validate'); // user.Validate() — receiver-typed - expect(targets).toContain('CreateUser → Save'); // _repo.Save() — receiver-typed + expect(targets).toContain('CreateUser → Save'); // _repo.Save() — IRepository + User (dispatch) expect(targets).toContain('CreateUser → Log'); // _logger.Log() — receiver-typed }); - it('resolves all CALLS from CreateUser via import-resolved or unique-global', () => { + it('resolves all CALLS from CreateUser via import-resolved, unique-global, or interface-dispatch', () => { const calls = getRelationships(result, 'CALLS'); // C# non-aliased `using Namespace;` imports don't populate NamedImportMap // (namespace-scoped imports can't bind to individual symbols). // Calls resolve via directory-based PackageMap (import-resolved) when ambiguous, // or via unique-global when the symbol name is globally unique. + // _repo.Save() also emits interface-dispatch to User.Save (IRepository has one impl in-repo). for (const call of calls) { - expect(['import-resolved', 'global']).toContain(call.rel.reason); + expect(['import-resolved', 'global', 'interface-dispatch']).toContain(call.rel.reason); } }); diff --git a/gitnexus/test/unit/call-processor.test.ts b/gitnexus/test/unit/call-processor.test.ts index 63a8444e4..6f1390b45 100644 --- a/gitnexus/test/unit/call-processor.test.ts +++ b/gitnexus/test/unit/call-processor.test.ts @@ -4,6 +4,8 @@ import { seedCrossFileReceiverTypes, extractConsumerAccessedKeys, processNextjsFetchRoutes, + buildImplementorMap, + mergeImplementorMaps, } from '../../src/core/ingestion/call-processor.js'; import { extractReturnTypeName } from '../../src/core/ingestion/type-extractors/shared.js'; import { @@ -14,6 +16,7 @@ import { createKnowledgeGraph } from '../../src/core/graph/graph.js'; import type { ExtractedCall, ExtractedFetchCall, + ExtractedHeritage, FileConstructorBindings, } from '../../src/core/ingestion/workers/parse-worker.js'; @@ -1393,3 +1396,135 @@ describe('processNextjsFetchRoutes', () => { expect(rels[0].reason).not.toContain('|fetches:'); }); }); + +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; + + beforeEach(() => { + graph = createKnowledgeGraph(); + ctx = createResolutionContext(); + const ifaceFile = 'contracts/Action.java'; + const runnerFile = 'runner.java'; + const implA = 'impl/A.java'; + const implB = 'impl/B.java'; + const actionIfaceId = 'Interface:contracts/Action.java:Action'; + const ifaceExecuteId = 'Method:contracts/Action.java:execute'; + const implAExecuteId = 'Method:impl/A.java:execute'; + const implBExecuteId = 'Method:impl/B.java:execute'; + + ctx.symbols.add(ifaceFile, 'Action', actionIfaceId, 'Interface'); + ctx.symbols.add(ifaceFile, 'execute', ifaceExecuteId, 'Method', { ownerId: actionIfaceId }); + ctx.symbols.add(implA, 'execute', implAExecuteId, 'Method'); + ctx.symbols.add(implB, 'execute', implBExecuteId, 'Method'); + ctx.importMap.set(runnerFile, new Set([ifaceFile])); + + graph.addNode({ + id: 'Function:runner.java:run', + label: 'Function', + properties: { name: 'run', filePath: runnerFile }, + }); + graph.addNode({ + id: actionIfaceId, + label: 'Interface', + properties: { name: 'Action', filePath: ifaceFile }, + }); + graph.addNode({ + id: ifaceExecuteId, + label: 'Method', + properties: { name: 'execute', filePath: ifaceFile }, + }); + graph.addNode({ + id: implAExecuteId, + label: 'Method', + properties: { name: 'execute', filePath: implA }, + }); + graph.addNode({ + id: implBExecuteId, + label: 'Method', + properties: { name: 'execute', filePath: implB }, + }); + }); + + 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 calls: ExtractedCall[] = [ + { + filePath: 'runner.java', + calledName: 'execute', + sourceId: 'Function:runner.java:run', + callForm: 'member', + receiverName: 'action', + receiverTypeName: 'Action', + }, + ]; + + await processCallsFromExtracted(graph, calls, ctx, undefined, undefined, implementorMap); + + const rels = graph.relationships.filter((r) => r.type === 'CALLS'); + expect(rels).toHaveLength(3); + + const primary = rels.find((r) => r.targetId === 'Method:contracts/Action.java:execute'); + const toA = rels.find((r) => r.targetId === 'Method:impl/A.java:execute'); + const toB = rels.find((r) => r.targetId === 'Method:impl/B.java:execute'); + expect(primary).toBeDefined(); + expect(primary!.confidence).toBeGreaterThan(0.7); + expect(toA?.confidence).toBe(0.7); + expect(toA?.reason).toBe('interface-dispatch'); + expect(toB?.confidence).toBe(0.7); + expect(toB?.reason).toBe('interface-dispatch'); + }); +});