Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
198 changes: 187 additions & 11 deletions gitnexus/src/core/ingestion/call-processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,11 @@ import {
countCallArguments,
inferCallForm,
extractReceiverName,
extractReceiverNode,
findEnclosingClassId,
CALL_EXPRESSION_TYPES,
MAX_CHAIN_DEPTH,
extractCallChain,
} from './utils.js';
import { buildTypeEnv } from './type-env.js';
import type { ConstructorBinding } from './type-env.js';
Expand Down Expand Up @@ -72,7 +76,7 @@ const verifyConstructorBindings = (
const isClass = tiered?.candidates.some(def => def.type === 'Class') ?? false;

if (isClass) {
verified.set(receiverKey(extractFuncNameFromScope(scope), varName), calleeName);
verified.set(receiverKey(scope, varName), calleeName);
} else {
let callableDefs = tiered?.candidates.filter(d =>
d.type === 'Function' || d.type === 'Method'
Expand Down Expand Up @@ -105,7 +109,7 @@ const verifyConstructorBindings = (
if (callableDefs && callableDefs.length === 1 && callableDefs[0].returnType) {
const typeName = extractReturnTypeName(callableDefs[0].returnType);
if (typeName) {
verified.set(receiverKey(extractFuncNameFromScope(scope), varName), typeName);
verified.set(receiverKey(scope, varName), typeName);
}
}
}
Expand Down Expand Up @@ -253,8 +257,47 @@ export const processCalls = async (
if (!receiverTypeName && receiverName && verifiedReceivers.size > 0) {
const enclosingFunc = findEnclosingFunction(callNode, file.path, ctx);
const funcName = enclosingFunc ? extractFuncNameFromSourceId(enclosingFunc) : '';
receiverTypeName = verifiedReceivers.get(receiverKey(funcName, receiverName))
?? verifiedReceivers.get(receiverKey('', receiverName));
receiverTypeName = lookupReceiverType(verifiedReceivers, funcName, receiverName);
}
// Fall back to class-as-receiver for static method calls (e.g. UserService.find_user()).
// When the receiver name is not a variable in TypeEnv but resolves to a Class/Struct/Interface
// through the standard tiered resolution, use it directly as the receiver type.
if (!receiverTypeName && receiverName && callForm === 'member') {
const typeResolved = ctx.resolve(receiverName, file.path);
if (typeResolved && typeResolved.candidates.some(
d => d.type === 'Class' || d.type === 'Interface' || d.type === 'Struct' || d.type === 'Enum',
)) {
receiverTypeName = receiverName;
}
}
// Fall back to chained call resolution when the receiver is a call expression
// (e.g. svc.getUser().save() — receiver of save() is getUser(), not a simple identifier).
if (callForm === 'member' && !receiverTypeName && !receiverName) {
const receiverNode = extractReceiverNode(nameNode);
if (receiverNode && CALL_EXPRESSION_TYPES.has(receiverNode.type)) {
const extracted = extractCallChain(receiverNode);
if (extracted) {
// Resolve the base receiver type if possible
let baseType = extracted.baseReceiverName && typeEnv
? typeEnv.lookup(extracted.baseReceiverName, callNode)
: undefined;
if (!baseType && extracted.baseReceiverName && verifiedReceivers.size > 0) {
const enclosingFunc = findEnclosingFunction(callNode, file.path, ctx);
const funcName = enclosingFunc ? extractFuncNameFromSourceId(enclosingFunc) : '';
baseType = lookupReceiverType(verifiedReceivers, funcName, extracted.baseReceiverName);
}
// Class-as-receiver for chain base (e.g. UserService.find_user().save())
if (!baseType && extracted.baseReceiverName) {
const cr = ctx.resolve(extracted.baseReceiverName, file.path);
if (cr?.candidates.some(d =>
d.type === 'Class' || d.type === 'Interface' || d.type === 'Struct' || d.type === 'Enum',
)) {
baseType = extracted.baseReceiverName;
}
}
receiverTypeName = resolveChainedReceiver(extracted.chain, baseType, file.path, ctx);
}
}
}

const resolved = resolveCallTarget({
Expand Down Expand Up @@ -352,6 +395,47 @@ const toResolveResult = (
reason: tier === 'same-file' ? 'same-file' : tier === 'import-scoped' ? 'import-resolved' : 'global',
});

/**
* Resolve a chain of intermediate method calls to find the receiver type for a
* final member call. Called when the receiver of a call is itself a call
* expression (e.g. `svc.getUser().save()`).
*
* @param chainNames Ordered list of method names from outermost to innermost
* intermediate call (e.g. ['getUser'] for `svc.getUser().save()`).
* @param baseReceiverTypeName The already-resolved type of the base receiver
* (e.g. 'UserService' for `svc`), or undefined.
* @param currentFile The file path for resolution context.
* @param ctx The resolution context for symbol lookup.
* @returns The type name of the final intermediate call's return type, or undefined
* if resolution fails at any step.
*/
function resolveChainedReceiver(
chainNames: string[],
baseReceiverTypeName: string | undefined,
currentFile: string,
ctx: ResolutionContext,
): string | undefined {
let currentType = baseReceiverTypeName;
for (const name of chainNames) {
const resolved = resolveCallTarget(
{ calledName: name, callForm: 'member', receiverTypeName: currentType },
currentFile,
ctx,
);
if (!resolved) return undefined;

const candidates = ctx.symbols.lookupFuzzy(name);
const symDef = candidates.find(c => c.nodeId === resolved.nodeId);
if (!symDef?.returnType) return undefined;

const returnTypeName = extractReturnTypeName(symDef.returnType);
if (!returnTypeName) return undefined;

currentType = returnTypeName;
}
return currentType;
}

/**
* Resolve a function call to its target node ID using priority strategy:
* A. Narrow candidates by scope tier via ctx.resolve()
Expand Down Expand Up @@ -491,7 +575,8 @@ function extractFirstTypeArg(args: string): string {
return args.trim();
}

export const extractReturnTypeName = (raw: string): string | undefined => {
export const extractReturnTypeName = (raw: string, depth = 0): string | undefined => {
if (depth > 10) return undefined;
let text = raw.trim();
if (!text) return undefined;

Expand Down Expand Up @@ -519,7 +604,7 @@ export const extractReturnTypeName = (raw: string): string | undefined => {
// so that nested generics like Result<User, Error> are not split at the inner
// comma. Lifetime parameters (Rust 'a, '_) are skipped.
const firstArg = extractFirstTypeArg(args);
return extractReturnTypeName(firstArg);
return extractReturnTypeName(firstArg, depth + 1);
}
// Non-wrapper generic: return the base type (e.g., Map<K,V> → Map)
return PRIMITIVE_TYPES.has(base.toLowerCase()) ? undefined : base;
Expand Down Expand Up @@ -548,6 +633,11 @@ export const extractReturnTypeName = (raw: string): string | undefined => {
// Source IDs use "Label:filepath:funcName" (produced by parse-worker.ts).
// NUL (\0) is used as a composite-key separator because it cannot appear
// in source-code identifiers, preventing ambiguous concatenation.
//
// receiverKey stores the FULL scope (funcName@startIndex) to prevent
// collisions between overloaded methods with the same name in different
// classes (e.g. User.save@100 and Repo.save@200 are distinct keys).
// Lookup uses a secondary funcName-only index built in lookupReceiverType.

/** Extract the function name from a scope key ("funcName@startIndex" → "funcName"). */
const extractFuncNameFromScope = (scope: string): string =>
Expand All @@ -559,9 +649,58 @@ const extractFuncNameFromSourceId = (sourceId: string): string => {
return lastColon >= 0 ? sourceId.slice(lastColon + 1) : '';
};

/** Build a scope-aware composite key for receiver type lookup. */
const receiverKey = (funcName: string, varName: string): string =>
`${funcName}\0${varName}`;
/**
* Build a composite key for receiver type storage.
* Uses the full scope string (e.g. "save@100") to distinguish overloaded
* methods with the same name in different classes.
*/
const receiverKey = (scope: string, varName: string): string =>
`${scope}\0${varName}`;

/**
* Look up a receiver type from a verified receiver map.
* The map is keyed by `scope\0varName` (full scope with @startIndex).
* Since the lookup side only has `funcName` (no startIndex), we scan for
* all entries whose key starts with `funcName@` and has the matching varName.
* If exactly one unique type is found, return it. If multiple distinct types
* exist (true overload collision), return undefined (refuse to guess).
* Falls back to the file-level scope key `\0varName` (empty funcName).
*/
const lookupReceiverType = (
map: Map<string, string>,
funcName: string,
varName: string,
): string | undefined => {
// Fast path: file-level scope (empty funcName — used as fallback)
const fileLevelKey = receiverKey('', varName);

const prefix = `${funcName}@`;
const suffix = `\0${varName}`;
let found: string | undefined;
let ambiguous = false;

for (const [key, value] of map) {
if (key === fileLevelKey) continue; // handled separately below
if (key.startsWith(prefix) && key.endsWith(suffix)) {
// Verify the key is exactly "funcName@<digits>\0varName" with no extra chars.
// The part between prefix and suffix should be the startIndex (digits only),
// but we accept any non-empty segment to be forward-compatible.
const middle = key.slice(prefix.length, key.length - suffix.length);
if (middle.length === 0) continue; // malformed key — skip
if (found === undefined) {
found = value;
} else if (found !== value) {
ambiguous = true;
break;
}
}
}

if (!ambiguous && found !== undefined) return found;

// Fallback: file-level scope (bindings outside any function)
return map.get(fileLevelKey);
};

/**
* Fast path: resolve pre-extracted call sites from workers.
Expand Down Expand Up @@ -609,15 +748,52 @@ export const processCallsFromExtracted = async (

for (const call of calls) {
let effectiveCall = call;

// Step 1: resolve receiver type from constructor bindings
if (!call.receiverTypeName && call.receiverName && receiverMap) {
const callFuncName = extractFuncNameFromSourceId(call.sourceId);
const resolvedType = receiverMap.get(receiverKey(callFuncName, call.receiverName))
?? receiverMap.get(receiverKey('', call.receiverName)); // fall back to file-level scope
const resolvedType = lookupReceiverType(receiverMap, callFuncName, call.receiverName);
if (resolvedType) {
effectiveCall = { ...call, receiverTypeName: resolvedType };
}
}

// Step 1b: class-as-receiver for static method calls (e.g. UserService.find_user())
if (!effectiveCall.receiverTypeName && effectiveCall.receiverName && effectiveCall.callForm === 'member') {
const typeResolved = ctx.resolve(effectiveCall.receiverName, effectiveCall.filePath);
if (typeResolved && typeResolved.candidates.some(
d => d.type === 'Class' || d.type === 'Interface' || d.type === 'Struct' || d.type === 'Enum',
)) {
effectiveCall = { ...effectiveCall, receiverTypeName: effectiveCall.receiverName };
}
}

// Step 2: if the call has a receiver call chain (e.g. svc.getUser().save()),
// resolve the chain to determine the final receiver type.
// This runs whenever receiverCallChain is present — even when Step 1 set a
// receiverTypeName, that type is the BASE receiver (e.g. UserService for svc),
// and the chain must be walked to produce the FINAL receiver (e.g. User from
// getUser() : User).
if (effectiveCall.receiverCallChain?.length) {
// Step 1 may have resolved the base receiver type (e.g. svc → UserService).
// Use it as the starting point for chain resolution.
let baseType = effectiveCall.receiverTypeName;
// If Step 1 didn't resolve it, try the receiver map directly.
if (!baseType && effectiveCall.receiverName && receiverMap) {
const callFuncName = extractFuncNameFromSourceId(effectiveCall.sourceId);
baseType = lookupReceiverType(receiverMap, callFuncName, effectiveCall.receiverName);
}
const chainedType = resolveChainedReceiver(
effectiveCall.receiverCallChain,
baseType,
effectiveCall.filePath,
ctx,
);
if (chainedType) {
effectiveCall = { ...effectiveCall, receiverTypeName: chainedType };
}
}

const resolved = resolveCallTarget(effectiveCall, effectiveCall.filePath, ctx);
if (!resolved) continue;

Expand Down
50 changes: 47 additions & 3 deletions gitnexus/src/core/ingestion/type-env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -265,7 +265,9 @@ const createClassNameLookup = (
if (localNames.has(name)) return true;
const cached = memo.get(name);
if (cached !== undefined) return cached;
const result = symbolTable.lookupFuzzy(name).some(def => def.type === 'Class');
const result = symbolTable.lookupFuzzy(name).some(def =>
def.type === 'Class' || def.type === 'Enum' || def.type === 'Struct',
);
memo.set(name, result);
return result;
},
Expand All @@ -292,18 +294,37 @@ export const buildTypeEnv = (
const config = typeConfigs[language];
const bindings: ConstructorBinding[] = [];
const pendingAssignments: Array<{ scope: string; lhs: string; rhs: string }> = [];
// Maps `scope\0varName` → the type annotation AST node from the original declaration.
// Allows pattern extractors to navigate back to the declaration's generic type arguments
// (e.g., to extract T from Result<T, E> for `if let Ok(x) = res`).
const declarationTypeNodes = new Map<string, SyntaxNode>();

/**
* Try to extract a (variableName → typeName) binding from a single AST node.
*
* Resolution tiers (first match wins):
* - Tier 0: explicit type annotations via extractDeclaration / extractForLoopBinding
* - Tier 1: constructor-call inference via extractInitializer (fallback)
*
* Side effect: populates declarationTypeNodes for variables that have an explicit
* type annotation field on the declaration node. This allows pattern extractors to
* retrieve generic type arguments from the original declaration (e.g., extracting T
* from Result<T, E> for `if let Ok(x) = res`).
*/
const extractTypeBinding = (node: SyntaxNode, scopeEnv: Map<string, string>): void => {
const extractTypeBinding = (node: SyntaxNode, scopeEnv: Map<string, string>, scope: string): void => {
// This guard eliminates 90%+ of calls before any language dispatch.
if (TYPED_PARAMETER_TYPES.has(node.type)) {
const keysBefore = new Set(scopeEnv.keys());
config.extractParameter(node, scopeEnv);
// Capture the type node for newly introduced parameter bindings
const typeNode = node.childForFieldName('type');
if (typeNode) {
for (const varName of scopeEnv.keys()) {
if (!keysBefore.has(varName)) {
declarationTypeNodes.set(`${scope}\0${varName}`, typeNode);
}
}
}
return;
}
// For-each loop variable bindings (Java/C#/Kotlin): explicit element types in the AST.
Expand All @@ -313,7 +334,19 @@ export const buildTypeEnv = (
return;
}
if (config.declarationNodeTypes.has(node.type)) {
const keysBefore = new Set(scopeEnv.keys());
config.extractDeclaration(node, scopeEnv);
// Capture the type annotation AST node for newly introduced bindings.
// Only declarations with an explicit 'type' field are recorded — constructor
// inferences (Tier 1) don't have a type annotation node to preserve.
const typeNode = node.childForFieldName('type');
if (typeNode) {
for (const varName of scopeEnv.keys()) {
if (!keysBefore.has(varName)) {
declarationTypeNodes.set(`${scope}\0${varName}`, typeNode);
}
}
}
// Tier 1: constructor-call inference as fallback.
// Always called when available — each language's extractInitializer
// internally skips declarators that already have explicit annotations,
Expand Down Expand Up @@ -346,7 +379,18 @@ export const buildTypeEnv = (
if (!env.has(scope)) env.set(scope, new Map());
const scopeEnv = env.get(scope)!;

extractTypeBinding(node, scopeEnv);
extractTypeBinding(node, scopeEnv, scope);

// Pattern binding extraction: handles constructs that introduce NEW typed variables
// via pattern matching (e.g. `if let Some(x) = opt`, `x instanceof T t`).
// Runs after Tier 0/1 so scopeEnv already contains the source variable's type.
// Conservative: extractor returns undefined when source type is unknown.
if (config.extractPatternBinding) {
const patternBinding = config.extractPatternBinding(node, scopeEnv, declarationTypeNodes, scope);
if (patternBinding && !scopeEnv.has(patternBinding.varName)) {
scopeEnv.set(patternBinding.varName, patternBinding.typeName);
}
}

// Tier 2: collect plain-identifier RHS assignments for post-walk propagation.
// Delegates to per-language extractPendingAssignment — AST shapes differ widely
Expand Down
1 change: 1 addition & 0 deletions gitnexus/src/core/ingestion/type-extractors/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export type {
ConstructorBindingScanner,
ForLoopExtractor,
PendingAssignmentExtractor,
PatternBindingExtractor,
} from './types.js';
export {
TYPED_PARAMETER_TYPES,
Expand Down
Loading
Loading