feat(java): method references, worker overload disambiguation, interface dispatch (#540)

* feat(java): method references + worker overload disambiguation (TypeEnv + argTypes)

Fix two Java gaps: (1) method references (obj::method) via
  tree-sitter @call + parseJavaMethodReference wired through extractLanguageCallSiteSeed for parse-worker and call-processor; (2) overloaded calls with typed
  non-literal args by extending OverloadHints with TypeEnv for identifiers and adding ExtractedCall.argTypes from extractCallArgTypes on the worker path with
  matchCandidatesByArgTypes (inferJvmLiteralType remains for literals).

* test(csharp): expect interface-dispatch edge for IRepository.Save in heritage fixture

* refactor(ingestion): move parseJavaMethodReference to call-sites/java.ts

* refactor(ingestion): defer worker call resolution until implementor map is complete

* style: prettier + remove unused import for CI quality checks

Made-with: Cursor

* fix(ingestion): implementor map for C# base_list + sequential pipeline path

- buildImplementorMap: treat extends rows as implements when resolveExtendsType
  says IMPLEMENTS (worker heritage mirrors parse-worker, all base_list as extends)
- Worker path: pass ctx into buildImplementorMap(deferredWorkerHeritage, ctx)
- Sequential path: extract heritage before processCalls and pass implementor map
  so small repos get interface-dispatch CALLS (fixes csharp-proj integration test)

Made-with: Cursor

* perf(pipeline): accumulate sequential implementor map without O(E) per chunk

- Merge buildImplementorMap(chunk heritage) into one map each sequential chunk so
  work is O(heritage) per chunk and interface dispatch sees prior chunks (worker parity)
- Drop unused globalImplementorMap + redundant merge after worker pass

Made-with: Cursor
This commit is contained in:
Chirag Nighut 2026-03-28 22:29:28 +05:30 • committed by GitHub
parent acf6fbdd39
commit e2de9271fc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 873 additions and 116 deletions

View file

@ -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<string, ReadonlySet<string>>;
/**
* 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<string, Set<string>> => {
const map = new Map<string, Set<string>>();
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<string, Set<string>>,
source: ReadonlyMap<string, ReadonlySet<string>>,
): 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<string, ReadonlyMap<string, string>>,
/** Phase 14 E3: cross-file RAW return types for for-loop element extraction. Keyed by filePath → Map<calleeName, rawReturnType>. */
importedRawReturnTypesMap?: ReadonlyMap<string, ReadonlyMap<string, string>>,
implementorMap?: ImplementorMap,
): Promise<ExtractedHeritage[]> => {
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<Record<string, string>> = {
Int: 'int',
@ -886,55 +1003,12 @@ const KOTLIN_BOXED_TO_PRIMITIVE: Readonly<Record<string, string>> = {
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();

View file

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

View file

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

View file

@ -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<ExtractedHeritage[]> {
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<string, any> = {};
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;
}

View file

@ -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<typeof createKnowledgeGraph>,
@ -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<string, Set<string>>();
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<string, Set<string>>();
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) {

View file

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

View file

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

View file

@ -0,0 +1,6 @@
package contracts;
public interface Action {
String execute();
int priority();
}

View file

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

View file

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

View file

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

View file

@ -0,0 +1,7 @@
package models;
public class BaseHandler {
protected String transform(String value) {
return value.toUpperCase();
}
}

View file

@ -0,0 +1,12 @@
package models;
import java.util.List;
import java.util.stream.Collectors;
public class ChildHandler extends BaseHandler {
public List<String> wrapTransform(List<String> values) {
return values.stream()
.map(super::transform)
.collect(Collectors.toList());
}
}

View file

@ -0,0 +1,7 @@
package models;
public class ResponseBuilder {
public String buildResponse(String input) {
return "built:" + input;
}
}

View file

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

View file

@ -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<String> mapViaInstanceBuilder(List<String> inputs) {
return inputs.stream()
.map(responseBuilder::buildResponse)
.collect(Collectors.toList());
}
/**
* Static method on a project class (not JDK) — {@code Util::staticMethod}.
*/
public List<String> mapViaStaticUtil(List<Object> values) {
return values.stream()
.map(FormatUtil::format)
.collect(Collectors.toList());
}
/**
* Unbound instance method reference — {@code Type::instanceMethod}.
*/
public List<String> mapUserNames(List<User> users) {
return users.stream()
.map(User::getName)
.collect(Collectors.toList());
}
/**
* Constructor reference — {@code Type::new}.
*/
public List<User> mapNewUsers(List<String> names) {
return names.stream()
.map(User::new)
.collect(Collectors.toList());
}
/**
* {@code this::instanceMethod} on enclosing class.
*/
public List<Boolean> mapSaves(List<User> users) {
return users.stream()
.map(this::saveOne)
.collect(Collectors.toList());
}
private boolean saveOne(User user) {
return user.save();
}
}

View file

@ -0,0 +1,7 @@
package util;
public final class FormatUtil {
public static String format(Object value) {
return value == null ? "" : value.toString();
}
}

View file

@ -0,0 +1,7 @@
package models;
public class Request {
public String getData() {
return "data";
}
}

View file

@ -0,0 +1,7 @@
package models;
public class Response {
public String getBody() {
return "body";
}
}

View file

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

View file

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

View file

@ -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<string, Set<string>>();
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<string, Set<string>>();
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<typeof createKnowledgeGraph>;
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<string, ReadonlySet<string>>([
['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');
});
});