mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-09 03:17:54 +00:00
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:
parent
acf6fbdd39
commit
e2de9271fc
22 changed files with 873 additions and 116 deletions
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
41
gitnexus/src/core/ingestion/call-sites/java.ts
Normal file
41
gitnexus/src/core/ingestion/call-sites/java.ts
Normal 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;
|
||||
};
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
6
gitnexus/test/fixtures/lang-resolution/java-interface-dispatch/contracts/Action.java
vendored
Normal file
6
gitnexus/test/fixtures/lang-resolution/java-interface-dispatch/contracts/Action.java
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
package contracts;
|
||||
|
||||
public interface Action {
|
||||
String execute();
|
||||
int priority();
|
||||
}
|
||||
13
gitnexus/test/fixtures/lang-resolution/java-interface-dispatch/impl/LogEvent.java
vendored
Normal file
13
gitnexus/test/fixtures/lang-resolution/java-interface-dispatch/impl/LogEvent.java
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
13
gitnexus/test/fixtures/lang-resolution/java-interface-dispatch/impl/SendEmail.java
vendored
Normal file
13
gitnexus/test/fixtures/lang-resolution/java-interface-dispatch/impl/SendEmail.java
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
16
gitnexus/test/fixtures/lang-resolution/java-interface-dispatch/services/ActionRunner.java
vendored
Normal file
16
gitnexus/test/fixtures/lang-resolution/java-interface-dispatch/services/ActionRunner.java
vendored
Normal 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();
|
||||
}
|
||||
}
|
||||
7
gitnexus/test/fixtures/lang-resolution/java-method-reference/models/BaseHandler.java
vendored
Normal file
7
gitnexus/test/fixtures/lang-resolution/java-method-reference/models/BaseHandler.java
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
package models;
|
||||
|
||||
public class BaseHandler {
|
||||
protected String transform(String value) {
|
||||
return value.toUpperCase();
|
||||
}
|
||||
}
|
||||
12
gitnexus/test/fixtures/lang-resolution/java-method-reference/models/ChildHandler.java
vendored
Normal file
12
gitnexus/test/fixtures/lang-resolution/java-method-reference/models/ChildHandler.java
vendored
Normal 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());
|
||||
}
|
||||
}
|
||||
7
gitnexus/test/fixtures/lang-resolution/java-method-reference/models/ResponseBuilder.java
vendored
Normal file
7
gitnexus/test/fixtures/lang-resolution/java-method-reference/models/ResponseBuilder.java
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
package models;
|
||||
|
||||
public class ResponseBuilder {
|
||||
public String buildResponse(String input) {
|
||||
return "built:" + input;
|
||||
}
|
||||
}
|
||||
17
gitnexus/test/fixtures/lang-resolution/java-method-reference/models/User.java
vendored
Normal file
17
gitnexus/test/fixtures/lang-resolution/java-method-reference/models/User.java
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
60
gitnexus/test/fixtures/lang-resolution/java-method-reference/services/MethodRefService.java
vendored
Normal file
60
gitnexus/test/fixtures/lang-resolution/java-method-reference/services/MethodRefService.java
vendored
Normal 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();
|
||||
}
|
||||
}
|
||||
7
gitnexus/test/fixtures/lang-resolution/java-method-reference/util/FormatUtil.java
vendored
Normal file
7
gitnexus/test/fixtures/lang-resolution/java-method-reference/util/FormatUtil.java
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
package util;
|
||||
|
||||
public final class FormatUtil {
|
||||
public static String format(Object value) {
|
||||
return value == null ? "" : value.toString();
|
||||
}
|
||||
}
|
||||
7
gitnexus/test/fixtures/lang-resolution/java-overload-typed-args/models/Request.java
vendored
Normal file
7
gitnexus/test/fixtures/lang-resolution/java-overload-typed-args/models/Request.java
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
package models;
|
||||
|
||||
public class Request {
|
||||
public String getData() {
|
||||
return "data";
|
||||
}
|
||||
}
|
||||
7
gitnexus/test/fixtures/lang-resolution/java-overload-typed-args/models/Response.java
vendored
Normal file
7
gitnexus/test/fixtures/lang-resolution/java-overload-typed-args/models/Response.java
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
package models;
|
||||
|
||||
public class Response {
|
||||
public String getBody() {
|
||||
return "body";
|
||||
}
|
||||
}
|
||||
22
gitnexus/test/fixtures/lang-resolution/java-overload-typed-args/services/Validator.java
vendored
Normal file
22
gitnexus/test/fixtures/lang-resolution/java-overload-typed-args/services/Validator.java
vendored
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue