mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-29 06:32:28 +00:00
feat: Phase 5 type resolution — chained calls, pattern matching, class-as-receiver (#315)
* feat: Phase 5 type resolution — chained calls, pattern matching, class-as-receiver, code review fixes Phase 5.1: Chained method call resolution (depth-capped at 3) - resolveChainedReceiver() resolves a.getUser().save() by walking the chain and looking up intermediate return types from the SymbolTable - extractReceiverNode() + extractCallChain() shared in utils.ts - receiverCallChain on ExtractedCall for worker path parity - MAX_CHAIN_DEPTH=3 enforced in both extraction and resolution Phase 5.2: Pattern matching binding extractors - PatternBindingExtractor type added to LanguageTypeConfig - declarationTypeNodes map tracks original type AST nodes for generic unwrapping - Rust: if let Some(x)/Ok(x) unwrapping with extractGenericTypeArgs - Java: instanceof pattern variables (Java 16+) - C#: is-pattern disambiguation fixture (already working via extractDeclaration) Phase 5.5d: Python standalone type annotations (name: str) - expression_statement with type child now captured in DECLARATION_NODE_TYPES Phase 5.5e: ReceiverKey collision fix for overloaded methods - receiverKey preserves @startIndex to prevent same-name method collisions - lookupReceiverType does prefix scan with ambiguity refusal Class-as-receiver for static method calls (#289) - UserService.find_user() now resolves via ctx.resolve() tiered lookup - Respects import scoping — no false positives from unrelated packages Code review fixes: - Extracted CALL_EXPRESSION_TYPES + extractCallChain to utils.ts (eliminated duplication) - Converted resolveChainedReceiver from recursion to loop (no exposed depth param) - Added depth cap to extractReturnTypeName (defense against nested wrapper types) - Replaced lookupFuzzy with ctx.resolve for class-as-receiver (architecturally consistent) Closes #289 Test coverage: 6 new fixtures, 12+ new unit tests, 7 new integration test suites * fix: Ruby chain calls, Rust Err(x) unwrap, Enum class-as-receiver (#315) Address three per-language gaps identified in Phase 5 code review: - Ruby: add `method`/`receiver` field fallbacks to extractCallChain (tree-sitter-ruby uses different field names than other grammars) - Rust: handle `Err(e)` pattern binding via typeArgs[1] from Result<T,E> - Enum: include Enum type in class-as-receiver filter (both paths) Integration tests added for all three fixes. * fix: chain base type resolution parity between serial and worker paths (#315) - Worker path: add typeEnv.lookup for chain base receiver after extraction (typed parameters like `fn process(svc: &UserService)` were silently lost) - Serial path: add ctx.resolve class-as-receiver fallback for chain base (class-name chains like `UserService.find_user().save()` failed) - Fix misleading comment in parse-worker.ts that described unimplemented logic - Integration tests: typed-parameter chain, static class-name chain * fix: Kotlin chain call extraction, createClassNameLookup Enum/Struct (#315) - Kotlin: extractCallChain now handles navigation_expression → navigation_suffix AST structure (Kotlin's call_expression has no 'function' field) - createClassNameLookup: include Enum and Struct alongside Class for consistent constructor recognition in extractInitializer - Integration test: kotlin-chain-call fixture verifying svc.getUser().save()
This commit is contained in:
parent
cb79a6ab9d
commit
ce45f59f96
68 changed files with 1743 additions and 34 deletions
|
|
@ -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';
|
||||
|
|
@ -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'
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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({
|
||||
|
|
@ -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()
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
@ -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;
|
||||
|
|
@ -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 =>
|
||||
|
|
@ -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.
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
},
|
||||
|
|
@ -292,6 +294,10 @@ 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.
|
||||
|
|
@ -299,11 +305,26 @@ export const buildTypeEnv = (
|
|||
* 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.
|
||||
|
|
@ -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,
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ export type {
|
|||
ConstructorBindingScanner,
|
||||
ForLoopExtractor,
|
||||
PendingAssignmentExtractor,
|
||||
PatternBindingExtractor,
|
||||
} from './types.js';
|
||||
export {
|
||||
TYPED_PARAMETER_TYPES,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import type { SyntaxNode } from '../utils.js';
|
||||
import type { LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor, InitializerExtractor, ClassNameLookup, ConstructorBindingScanner, ForLoopExtractor, PendingAssignmentExtractor } from './types.js';
|
||||
import type { LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor, InitializerExtractor, ClassNameLookup, ConstructorBindingScanner, ForLoopExtractor, PendingAssignmentExtractor, PatternBindingExtractor } from './types.js';
|
||||
import { extractSimpleTypeName, extractVarName, findChildByType } from './shared.js';
|
||||
|
||||
// ── Java ──────────────────────────────────────────────────────────────────
|
||||
|
|
@ -114,6 +114,33 @@ const extractJavaPendingAssignment: PendingAssignmentExtractor = (node, scopeEnv
|
|||
return undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Java 16+ `instanceof` pattern variable: `x instanceof User user`
|
||||
*
|
||||
* AST structure:
|
||||
* instanceof_expression
|
||||
* left: expression (the variable being tested)
|
||||
* instanceof keyword
|
||||
* right: type (the type to test against)
|
||||
* name: identifier (the pattern variable — optional, Java 16+)
|
||||
*
|
||||
* Conservative: returns undefined when the `name` field is absent (plain instanceof
|
||||
* without pattern variable, e.g. `x instanceof User`) or when the type cannot be
|
||||
* extracted. The source variable's existing type is NOT used — the pattern explicitly
|
||||
* declares the new type, so no scopeEnv lookup is needed.
|
||||
*/
|
||||
const extractJavaPatternBinding: PatternBindingExtractor = (node) => {
|
||||
if (node.type !== 'instanceof_expression') return undefined;
|
||||
const nameNode = node.childForFieldName('name');
|
||||
if (!nameNode) return undefined;
|
||||
const typeNode = node.childForFieldName('right');
|
||||
if (!typeNode) return undefined;
|
||||
const typeName = extractSimpleTypeName(typeNode);
|
||||
const varName = extractVarName(nameNode);
|
||||
if (!typeName || !varName) return undefined;
|
||||
return { varName, typeName };
|
||||
};
|
||||
|
||||
export const javaTypeConfig: LanguageTypeConfig = {
|
||||
declarationNodeTypes: JAVA_DECLARATION_NODE_TYPES,
|
||||
extractDeclaration: extractJavaDeclaration,
|
||||
|
|
@ -123,6 +150,7 @@ export const javaTypeConfig: LanguageTypeConfig = {
|
|||
forLoopNodeTypes: JAVA_FOR_LOOP_NODE_TYPES,
|
||||
extractForLoopBinding: extractJavaForLoopBinding,
|
||||
extractPendingAssignment: extractJavaPendingAssignment,
|
||||
extractPatternBinding: extractJavaPatternBinding,
|
||||
};
|
||||
|
||||
// ── Kotlin ────────────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -5,12 +5,39 @@ import { extractSimpleTypeName, extractVarName } from './shared.js';
|
|||
const DECLARATION_NODE_TYPES: ReadonlySet<string> = new Set([
|
||||
'assignment',
|
||||
'named_expression',
|
||||
'expression_statement',
|
||||
]);
|
||||
|
||||
/** Python: x: Foo = ... (PEP 484 annotations) */
|
||||
/** Python: x: Foo = ... (PEP 484 annotated assignment) or x: Foo (standalone annotation).
|
||||
*
|
||||
* tree-sitter-python grammar produces two distinct shapes:
|
||||
*
|
||||
* 1. Annotated assignment with value: `name: str = ""`
|
||||
* Node type: `assignment`
|
||||
* Fields: left=identifier, type=identifier/type, right=value
|
||||
*
|
||||
* 2. Standalone annotation (no value): `name: str`
|
||||
* Node type: `expression_statement`
|
||||
* Child: `type` node with fields name=identifier, type=identifier/type
|
||||
*
|
||||
* Both appear at file scope and inside class bodies (PEP 526 class variable annotations).
|
||||
*/
|
||||
const extractDeclaration: TypeBindingExtractor = (node: SyntaxNode, env: Map<string, string>): void => {
|
||||
// Python annotated assignment: left : type = value
|
||||
// tree-sitter represents this differently based on grammar version
|
||||
if (node.type === 'expression_statement') {
|
||||
// Standalone annotation: expression_statement > type { name: identifier, type: identifier }
|
||||
const typeChild = node.firstNamedChild;
|
||||
if (!typeChild || typeChild.type !== 'type') return;
|
||||
const nameNode = typeChild.childForFieldName('name');
|
||||
const typeNode = typeChild.childForFieldName('type');
|
||||
if (!nameNode || !typeNode) return;
|
||||
const varName = extractVarName(nameNode);
|
||||
const inner = typeNode.type === 'type' ? (typeNode.firstNamedChild ?? typeNode) : typeNode;
|
||||
const typeName = extractSimpleTypeName(inner) ?? inner.text;
|
||||
if (varName && typeName) env.set(varName, typeName);
|
||||
return;
|
||||
}
|
||||
|
||||
// Annotated assignment: left : type = value
|
||||
const left = node.childForFieldName('left');
|
||||
const typeNode = node.childForFieldName('type');
|
||||
if (!left || !typeNode) return;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import type { SyntaxNode } from '../utils.js';
|
||||
import type { LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor, InitializerExtractor, ClassNameLookup, ConstructorBindingScanner, PendingAssignmentExtractor } from './types.js';
|
||||
import { extractSimpleTypeName, extractVarName, hasTypeAnnotation, unwrapAwait } from './shared.js';
|
||||
import type { LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor, InitializerExtractor, ClassNameLookup, ConstructorBindingScanner, PendingAssignmentExtractor, PatternBindingExtractor } from './types.js';
|
||||
import { extractSimpleTypeName, extractVarName, hasTypeAnnotation, unwrapAwait, extractGenericTypeArgs } from './shared.js';
|
||||
|
||||
const DECLARATION_NODE_TYPES: ReadonlySet<string> = new Set([
|
||||
'let_declaration',
|
||||
|
|
@ -193,6 +193,82 @@ const extractPendingAssignment: PendingAssignmentExtractor = (node, scopeEnv) =>
|
|||
return undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Rust pattern binding extractor for `if let` / `while let` constructs that unwrap
|
||||
* enum variants and introduce new typed variables.
|
||||
*
|
||||
* Supported patterns:
|
||||
* - `if let Some(x) = opt` → x: T (opt: Option<T>, T already in scopeEnv via NULLABLE_WRAPPER_TYPES)
|
||||
* - `if let Ok(x) = res` → x: T (res: Result<T, E>, T extracted from declarationTypeNodes)
|
||||
*
|
||||
* These complement the captured_pattern support in extractDeclaration (which handles
|
||||
* `if let x @ Struct { .. } = expr` but NOT tuple struct unwrapping like Some(x) / Ok(x)).
|
||||
*
|
||||
* Conservative: returns undefined when:
|
||||
* - The source variable's type is unknown (not in scopeEnv)
|
||||
* - The wrapper is not a known single-unwrap variant (Some / Ok)
|
||||
* - The value side is not a simple identifier
|
||||
*/
|
||||
const extractPatternBinding: PatternBindingExtractor = (
|
||||
node,
|
||||
scopeEnv,
|
||||
declarationTypeNodes,
|
||||
scope,
|
||||
) => {
|
||||
if (node.type !== 'let_condition') return undefined;
|
||||
|
||||
const patternNode = node.childForFieldName('pattern');
|
||||
const valueNode = node.childForFieldName('value');
|
||||
if (!patternNode || !valueNode) return undefined;
|
||||
|
||||
// Only handle tuple_struct_pattern: Some(x) or Ok(x)
|
||||
if (patternNode.type !== 'tuple_struct_pattern') return undefined;
|
||||
|
||||
// Extract the wrapper type name: Some | Ok
|
||||
const wrapperTypeNode = patternNode.childForFieldName('type');
|
||||
if (!wrapperTypeNode) return undefined;
|
||||
const wrapperName = extractSimpleTypeName(wrapperTypeNode);
|
||||
if (wrapperName !== 'Some' && wrapperName !== 'Ok' && wrapperName !== 'Err') return undefined;
|
||||
|
||||
// Extract the inner variable name from the single child of the tuple_struct_pattern.
|
||||
// `Some(x)` → the first named child after the type field is the identifier.
|
||||
// tree-sitter-rust: tuple_struct_pattern has 'type' field + unnamed children for args.
|
||||
let innerVar: string | undefined;
|
||||
for (let i = 0; i < patternNode.namedChildCount; i++) {
|
||||
const child = patternNode.namedChild(i);
|
||||
if (!child) continue;
|
||||
// Skip the type node itself
|
||||
if (child === wrapperTypeNode) continue;
|
||||
if (child.type === 'identifier') {
|
||||
innerVar = child.text;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!innerVar) return undefined;
|
||||
|
||||
// The value must be a simple identifier so we can look it up in scopeEnv
|
||||
const sourceVarName = valueNode.type === 'identifier' ? valueNode.text : undefined;
|
||||
if (!sourceVarName) return undefined;
|
||||
|
||||
// For `Some(x)`: Option<T> is already unwrapped to T in scopeEnv (via NULLABLE_WRAPPER_TYPES).
|
||||
// For `Ok(x)`: Result<T, E> stores "Result" in scopeEnv — must use declarationTypeNodes.
|
||||
if (wrapperName === 'Some') {
|
||||
const innerType = scopeEnv.get(sourceVarName);
|
||||
if (!innerType) return undefined;
|
||||
return { varName: innerVar, typeName: innerType };
|
||||
}
|
||||
|
||||
// wrapperName === 'Ok' or 'Err': look up the Result<T, E> type AST node.
|
||||
// Ok(x) → extract T (typeArgs[0]), Err(e) → extract E (typeArgs[1]).
|
||||
const typeNodeKey = `${scope}\0${sourceVarName}`;
|
||||
const typeAstNode = declarationTypeNodes.get(typeNodeKey);
|
||||
if (!typeAstNode) return undefined;
|
||||
const typeArgs = extractGenericTypeArgs(typeAstNode);
|
||||
const argIndex = wrapperName === 'Err' ? 1 : 0;
|
||||
if (typeArgs.length < argIndex + 1) return undefined;
|
||||
return { varName: innerVar, typeName: typeArgs[argIndex] };
|
||||
};
|
||||
|
||||
export const typeConfig: LanguageTypeConfig = {
|
||||
declarationNodeTypes: DECLARATION_NODE_TYPES,
|
||||
extractDeclaration,
|
||||
|
|
@ -200,4 +276,5 @@ export const typeConfig: LanguageTypeConfig = {
|
|||
extractParameter,
|
||||
scanConstructorBinding,
|
||||
extractPendingAssignment,
|
||||
extractPatternBinding,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -38,6 +38,23 @@ export type PendingAssignmentExtractor = (
|
|||
scopeEnv: ReadonlyMap<string, string>,
|
||||
) => { lhs: string; rhs: string } | undefined;
|
||||
|
||||
/** Extracts a typed variable binding from a pattern-matching construct.
|
||||
* Returns { varName, typeName } for patterns that introduce NEW variables.
|
||||
* Examples: `if let Some(user) = opt` (Rust), `x instanceof User user` (Java).
|
||||
* Conservative: returns undefined when the source variable's type is unknown.
|
||||
*
|
||||
* @param scopeEnv Read-only view of already-resolved type bindings in the current scope.
|
||||
* @param declarationTypeNodes Maps `scope\0varName` to the original declaration's type
|
||||
* annotation AST node. Allows extracting generic type arguments (e.g., T from Result<T,E>)
|
||||
* that are stripped during normal TypeEnv extraction.
|
||||
* @param scope Current scope key (e.g. `"process@42"`) for declarationTypeNodes lookups. */
|
||||
export type PatternBindingExtractor = (
|
||||
node: SyntaxNode,
|
||||
scopeEnv: ReadonlyMap<string, string>,
|
||||
declarationTypeNodes: ReadonlyMap<string, SyntaxNode>,
|
||||
scope: string,
|
||||
) => { varName: string; typeName: string } | undefined;
|
||||
|
||||
/** Per-language type extraction configuration */
|
||||
export interface LanguageTypeConfig {
|
||||
/** Node types that represent typed declarations for this language */
|
||||
|
|
@ -66,4 +83,10 @@ export interface LanguageTypeConfig {
|
|||
* Called on declaration/assignment nodes; returns {lhs, rhs} when the RHS is a bare identifier
|
||||
* and the LHS has no resolved type yet. Language-specific because AST shapes differ widely. */
|
||||
extractPendingAssignment?: PendingAssignmentExtractor;
|
||||
/** Extract a typed variable binding from a pattern-matching construct.
|
||||
* Called on every AST node; returns { varName, typeName } when the node introduces a new
|
||||
* typed variable via pattern matching (e.g. `if let Some(x) = opt`, `x instanceof T t`).
|
||||
* The extractor receives the current scope's resolved bindings (read-only) to look up the
|
||||
* source variable's type. Returns undefined for non-matching nodes or unknown source types. */
|
||||
extractPatternBinding?: PatternBindingExtractor;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -921,6 +921,73 @@ export const extractReceiverName = (
|
|||
return undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Extract the raw receiver AST node for a member call.
|
||||
* Unlike extractReceiverName, this returns the receiver node regardless of its type —
|
||||
* including call_expression / method_invocation nodes that appear in chained calls
|
||||
* like `svc.getUser().save()`.
|
||||
*
|
||||
* Returns undefined when the call is not a member call or when no receiver node
|
||||
* can be found (e.g. top-level free calls).
|
||||
*/
|
||||
export const extractReceiverNode = (
|
||||
nameNode: SyntaxNode,
|
||||
): SyntaxNode | undefined => {
|
||||
const parent = nameNode.parent;
|
||||
if (!parent) return undefined;
|
||||
|
||||
const callNode = parent.parent ?? parent;
|
||||
|
||||
let receiver: SyntaxNode | null = null;
|
||||
|
||||
receiver = parent.childForFieldName('object')
|
||||
?? parent.childForFieldName('value')
|
||||
?? parent.childForFieldName('operand')
|
||||
?? parent.childForFieldName('expression')
|
||||
?? parent.childForFieldName('argument');
|
||||
|
||||
if (!receiver && callNode.type === 'method_invocation') {
|
||||
receiver = callNode.childForFieldName('object');
|
||||
}
|
||||
|
||||
if (!receiver && (callNode.type === 'member_call_expression' || callNode.type === 'nullsafe_member_call_expression')) {
|
||||
receiver = callNode.childForFieldName('object');
|
||||
}
|
||||
|
||||
if (!receiver && parent.type === 'call') {
|
||||
receiver = parent.childForFieldName('receiver');
|
||||
}
|
||||
|
||||
if (!receiver && (parent.type === 'scoped_call_expression' || callNode.type === 'scoped_call_expression')) {
|
||||
const scopedCall = parent.type === 'scoped_call_expression' ? parent : callNode;
|
||||
receiver = scopedCall.childForFieldName('scope');
|
||||
if (receiver?.type === 'relative_scope') {
|
||||
receiver = receiver.firstChild;
|
||||
}
|
||||
}
|
||||
|
||||
if (!receiver && parent.type === 'member_binding_expression') {
|
||||
const condAccess = parent.parent;
|
||||
if (condAccess?.type === 'conditional_access_expression') {
|
||||
receiver = condAccess.firstNamedChild;
|
||||
}
|
||||
}
|
||||
|
||||
if (!receiver && parent.type === 'navigation_suffix') {
|
||||
const navExpr = parent.parent;
|
||||
if (navExpr?.type === 'navigation_expression') {
|
||||
for (const child of navExpr.children) {
|
||||
if (child.isNamed && child !== parent) {
|
||||
receiver = child;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return receiver ?? undefined;
|
||||
};
|
||||
|
||||
export const isVerboseIngestionEnabled = (): boolean => {
|
||||
const raw = process.env.GITNEXUS_VERBOSE;
|
||||
if (!raw) return false;
|
||||
|
|
@ -928,6 +995,106 @@ export const isVerboseIngestionEnabled = (): boolean => {
|
|||
return value === '1' || value === 'true' || value === 'yes';
|
||||
};
|
||||
|
||||
// ── Chained-call extraction ───────────────────────────────────────────────
|
||||
|
||||
/** Node types representing call expressions across supported languages. */
|
||||
export const CALL_EXPRESSION_TYPES = new Set([
|
||||
'call_expression', // TS/JS/C/C++/Go/Rust
|
||||
'method_invocation', // Java
|
||||
'member_call_expression', // PHP
|
||||
'nullsafe_member_call_expression', // PHP ?.
|
||||
'call', // Python/Ruby
|
||||
'invocation_expression', // C#
|
||||
]);
|
||||
|
||||
/**
|
||||
* Hard limit on chain depth to prevent runaway recursion.
|
||||
* For `a.b().c().d()`, the chain has depth 2 (b and c before d).
|
||||
*/
|
||||
export const MAX_CHAIN_DEPTH = 3;
|
||||
|
||||
/**
|
||||
* Walk a receiver AST node that is itself a call expression, accumulating the
|
||||
* chain of intermediate method names up to MAX_CHAIN_DEPTH.
|
||||
*
|
||||
* For `svc.getUser().save()`, called with the receiver of `save` (getUser() call):
|
||||
* returns { chain: ['getUser'], baseReceiverName: 'svc' }
|
||||
*
|
||||
* For `a.b().c().d()`, called with the receiver of `d` (c() call):
|
||||
* returns { chain: ['b', 'c'], baseReceiverName: 'a' }
|
||||
*/
|
||||
export function extractCallChain(
|
||||
receiverCallNode: SyntaxNode,
|
||||
): { chain: string[]; baseReceiverName: string | undefined } | undefined {
|
||||
const chain: string[] = [];
|
||||
let current: SyntaxNode = receiverCallNode;
|
||||
|
||||
while (CALL_EXPRESSION_TYPES.has(current.type) && chain.length < MAX_CHAIN_DEPTH) {
|
||||
// Extract the method name from this call node.
|
||||
const funcNode = current.childForFieldName?.('function')
|
||||
?? current.childForFieldName?.('name')
|
||||
?? current.childForFieldName?.('method'); // Ruby `call` node
|
||||
let methodName: string | undefined;
|
||||
let innerReceiver: SyntaxNode | null = null;
|
||||
if (funcNode) {
|
||||
// member_expression / attribute: last named child is the method identifier
|
||||
methodName = funcNode.lastNamedChild?.text ?? funcNode.text;
|
||||
}
|
||||
// Kotlin/Swift: call_expression exposes callee as firstNamedChild, not a field.
|
||||
// navigation_expression: method name is in navigation_suffix → simple_identifier.
|
||||
if (!funcNode && current.type === 'call_expression') {
|
||||
const callee = current.firstNamedChild;
|
||||
if (callee?.type === 'navigation_expression') {
|
||||
const suffix = callee.lastNamedChild;
|
||||
if (suffix?.type === 'navigation_suffix') {
|
||||
methodName = suffix.lastNamedChild?.text;
|
||||
// The receiver is the part of navigation_expression before the suffix
|
||||
for (let i = 0; i < callee.namedChildCount; i++) {
|
||||
const child = callee.namedChild(i);
|
||||
if (child && child.type !== 'navigation_suffix') {
|
||||
innerReceiver = child;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!methodName) break;
|
||||
chain.unshift(methodName); // build chain outermost-last
|
||||
|
||||
// Walk into the receiver of this call to continue the chain
|
||||
if (!innerReceiver && funcNode) {
|
||||
innerReceiver = funcNode.childForFieldName?.('object')
|
||||
?? funcNode.childForFieldName?.('value')
|
||||
?? funcNode.childForFieldName?.('operand')
|
||||
?? funcNode.childForFieldName?.('expression');
|
||||
}
|
||||
// Java method_invocation: object field is on the call node
|
||||
if (!innerReceiver && current.type === 'method_invocation') {
|
||||
innerReceiver = current.childForFieldName?.('object');
|
||||
}
|
||||
// PHP member_call_expression
|
||||
if (!innerReceiver && (current.type === 'member_call_expression' || current.type === 'nullsafe_member_call_expression')) {
|
||||
innerReceiver = current.childForFieldName?.('object');
|
||||
}
|
||||
// Ruby `call` node: receiver field is on the call node itself
|
||||
if (!innerReceiver && current.type === 'call') {
|
||||
innerReceiver = current.childForFieldName?.('receiver');
|
||||
}
|
||||
|
||||
if (!innerReceiver) break;
|
||||
|
||||
if (CALL_EXPRESSION_TYPES.has(innerReceiver.type)) {
|
||||
current = innerReceiver; // continue walking
|
||||
} else {
|
||||
// Reached a simple identifier — the base receiver
|
||||
return { chain, baseReceiverName: innerReceiver.text || undefined };
|
||||
}
|
||||
}
|
||||
|
||||
return chain.length > 0 ? { chain, baseReceiverName: undefined } : undefined;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ try { Swift = _require('tree-sitter-swift'); } catch {}
|
|||
// tree-sitter-kotlin is an optionalDependency — may not be installed
|
||||
let Kotlin: any = null;
|
||||
try { Kotlin = _require('tree-sitter-kotlin'); } catch {}
|
||||
import {
|
||||
import {
|
||||
getLanguageFromFilename,
|
||||
FUNCTION_NODE_TYPES,
|
||||
extractFunctionName,
|
||||
|
|
@ -34,7 +34,10 @@ import {
|
|||
extractMethodSignature,
|
||||
countCallArguments,
|
||||
inferCallForm,
|
||||
extractReceiverName
|
||||
extractReceiverName,
|
||||
extractReceiverNode,
|
||||
CALL_EXPRESSION_TYPES,
|
||||
extractCallChain,
|
||||
} from '../utils.js';
|
||||
import { buildTypeEnv } from '../type-env.js';
|
||||
import type { ConstructorBinding } from '../type-env.js';
|
||||
|
|
@ -107,6 +110,14 @@ export interface ExtractedCall {
|
|||
receiverName?: string;
|
||||
/** Resolved type name of the receiver (e.g., 'User' for user.save() when user: User) */
|
||||
receiverTypeName?: string;
|
||||
/**
|
||||
* Chained call names when the receiver is itself a call expression.
|
||||
* For `svc.getUser().save()`, the `save` ExtractedCall gets receiverCallChain = ['getUser']
|
||||
* with receiverName = 'svc'. The chain is ordered outermost-last, e.g.:
|
||||
* `a.b().c().d()` → calledName='d', receiverCallChain=['b','c'], receiverName='a'
|
||||
* Length is capped at MAX_CHAIN_DEPTH (3).
|
||||
*/
|
||||
receiverCallChain?: string[];
|
||||
}
|
||||
|
||||
export interface ExtractedHeritage {
|
||||
|
|
@ -987,8 +998,36 @@ const processFileGroup = (
|
|||
const sourceId = findEnclosingFunctionId(callNode, file.path)
|
||||
|| generateId('File', file.path);
|
||||
const callForm = inferCallForm(callNode, callNameNode);
|
||||
const receiverName = callForm === 'member' ? extractReceiverName(callNameNode) : undefined;
|
||||
const receiverTypeName = receiverName ? typeEnv.lookup(receiverName, callNode) : undefined;
|
||||
let receiverName = callForm === 'member' ? extractReceiverName(callNameNode) : undefined;
|
||||
let receiverTypeName = receiverName ? typeEnv.lookup(receiverName, callNode) : undefined;
|
||||
let receiverCallChain: string[] | undefined;
|
||||
|
||||
// When the receiver is a call_expression (e.g. svc.getUser().save()),
|
||||
// extractReceiverName returns undefined because it refuses complex expressions.
|
||||
// Instead, walk the receiver node to build a call chain for deferred resolution.
|
||||
// We capture the base receiver name so processCallsFromExtracted can look it up
|
||||
// from constructor bindings. receiverTypeName is intentionally left unset here —
|
||||
// the chain resolver in processCallsFromExtracted needs the base type as input and
|
||||
// produces the final receiver type as output.
|
||||
if (callForm === 'member' && receiverName === undefined && !receiverTypeName) {
|
||||
const receiverNode = extractReceiverNode(callNameNode);
|
||||
if (receiverNode && CALL_EXPRESSION_TYPES.has(receiverNode.type)) {
|
||||
const extracted = extractCallChain(receiverNode);
|
||||
if (extracted) {
|
||||
receiverCallChain = extracted.chain;
|
||||
// Set receiverName to the base object so Step 1 in processCallsFromExtracted
|
||||
// can resolve it via constructor bindings to a base type for the chain.
|
||||
receiverName = extracted.baseReceiverName;
|
||||
// Also try the type environment immediately (covers explicitly-typed locals
|
||||
// and annotated parameters like `fn process(svc: &UserService)`).
|
||||
// This sets a base type that chain resolution (Step 2) will use as input.
|
||||
if (receiverName) {
|
||||
receiverTypeName = typeEnv.lookup(receiverName, callNode);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result.calls.push({
|
||||
filePath: file.path,
|
||||
calledName,
|
||||
|
|
@ -997,6 +1036,7 @@ const processFileGroup = (
|
|||
...(callForm !== undefined ? { callForm } : {}),
|
||||
...(receiverName !== undefined ? { receiverName } : {}),
|
||||
...(receiverTypeName !== undefined ? { receiverTypeName } : {}),
|
||||
...(receiverCallChain !== undefined ? { receiverCallChain } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
5
gitnexus/test/fixtures/lang-resolution/csharp-is-pattern/IsPatternProj.csproj
vendored
Normal file
5
gitnexus/test/fixtures/lang-resolution/csharp-is-pattern/IsPatternProj.csproj
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
6
gitnexus/test/fixtures/lang-resolution/csharp-is-pattern/models/Repo.cs
vendored
Normal file
6
gitnexus/test/fixtures/lang-resolution/csharp-is-pattern/models/Repo.cs
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
namespace IsPattern.Models;
|
||||
|
||||
public class Repo
|
||||
{
|
||||
public void Save() {}
|
||||
}
|
||||
6
gitnexus/test/fixtures/lang-resolution/csharp-is-pattern/models/User.cs
vendored
Normal file
6
gitnexus/test/fixtures/lang-resolution/csharp-is-pattern/models/User.cs
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
namespace IsPattern.Models;
|
||||
|
||||
public class User
|
||||
{
|
||||
public void Save() {}
|
||||
}
|
||||
14
gitnexus/test/fixtures/lang-resolution/csharp-is-pattern/services/App.cs
vendored
Normal file
14
gitnexus/test/fixtures/lang-resolution/csharp-is-pattern/services/App.cs
vendored
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
using IsPattern.Models;
|
||||
|
||||
namespace IsPattern.Services;
|
||||
|
||||
public class App
|
||||
{
|
||||
public void Process(object obj)
|
||||
{
|
||||
if (obj is User user)
|
||||
{
|
||||
user.Save();
|
||||
}
|
||||
}
|
||||
}
|
||||
8
gitnexus/test/fixtures/lang-resolution/java-chain-call/App.java
vendored
Normal file
8
gitnexus/test/fixtures/lang-resolution/java-chain-call/App.java
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import services.UserService;
|
||||
|
||||
public class App {
|
||||
public static void processUser() {
|
||||
UserService svc = new UserService();
|
||||
svc.getUser().save();
|
||||
}
|
||||
}
|
||||
7
gitnexus/test/fixtures/lang-resolution/java-chain-call/models/Repo.java
vendored
Normal file
7
gitnexus/test/fixtures/lang-resolution/java-chain-call/models/Repo.java
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
package models;
|
||||
|
||||
public class Repo {
|
||||
public boolean save() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
7
gitnexus/test/fixtures/lang-resolution/java-chain-call/models/User.java
vendored
Normal file
7
gitnexus/test/fixtures/lang-resolution/java-chain-call/models/User.java
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
package models;
|
||||
|
||||
public class User {
|
||||
public boolean save() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
9
gitnexus/test/fixtures/lang-resolution/java-chain-call/services/UserService.java
vendored
Normal file
9
gitnexus/test/fixtures/lang-resolution/java-chain-call/services/UserService.java
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
package services;
|
||||
|
||||
import models.User;
|
||||
|
||||
public class UserService {
|
||||
public User getUser() {
|
||||
return new User();
|
||||
}
|
||||
}
|
||||
6
gitnexus/test/fixtures/lang-resolution/java-enum-static-call/src/App.java
vendored
Normal file
6
gitnexus/test/fixtures/lang-resolution/java-enum-static-call/src/App.java
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
public class App {
|
||||
public void process() {
|
||||
Status s = Status.fromCode(200);
|
||||
s.label();
|
||||
}
|
||||
}
|
||||
12
gitnexus/test/fixtures/lang-resolution/java-enum-static-call/src/Status.java
vendored
Normal file
12
gitnexus/test/fixtures/lang-resolution/java-enum-static-call/src/Status.java
vendored
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
public enum Status {
|
||||
OK,
|
||||
ERROR;
|
||||
|
||||
public static Status fromCode(int code) {
|
||||
return code == 200 ? OK : ERROR;
|
||||
}
|
||||
|
||||
public String label() {
|
||||
return this.name().toLowerCase();
|
||||
}
|
||||
}
|
||||
5
gitnexus/test/fixtures/lang-resolution/java-instanceof-pattern/models/Repo.java
vendored
Normal file
5
gitnexus/test/fixtures/lang-resolution/java-instanceof-pattern/models/Repo.java
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
package models;
|
||||
|
||||
public class Repo {
|
||||
public void save() {}
|
||||
}
|
||||
5
gitnexus/test/fixtures/lang-resolution/java-instanceof-pattern/models/User.java
vendored
Normal file
5
gitnexus/test/fixtures/lang-resolution/java-instanceof-pattern/models/User.java
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
package models;
|
||||
|
||||
public class User {
|
||||
public void save() {}
|
||||
}
|
||||
12
gitnexus/test/fixtures/lang-resolution/java-instanceof-pattern/services/App.java
vendored
Normal file
12
gitnexus/test/fixtures/lang-resolution/java-instanceof-pattern/services/App.java
vendored
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
package services;
|
||||
|
||||
import models.User;
|
||||
import models.Repo;
|
||||
|
||||
public class App {
|
||||
public void process(Object obj) {
|
||||
if (obj instanceof User user) {
|
||||
user.save();
|
||||
}
|
||||
}
|
||||
}
|
||||
7
gitnexus/test/fixtures/lang-resolution/kotlin-chain-call/src/App.kt
vendored
Normal file
7
gitnexus/test/fixtures/lang-resolution/kotlin-chain-call/src/App.kt
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import models.User
|
||||
import services.UserService
|
||||
|
||||
fun processUser() {
|
||||
val svc = UserService()
|
||||
svc.getUser().save()
|
||||
}
|
||||
5
gitnexus/test/fixtures/lang-resolution/kotlin-chain-call/src/Repo.kt
vendored
Normal file
5
gitnexus/test/fixtures/lang-resolution/kotlin-chain-call/src/Repo.kt
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
package models
|
||||
|
||||
class Repo {
|
||||
fun save() {}
|
||||
}
|
||||
5
gitnexus/test/fixtures/lang-resolution/kotlin-chain-call/src/User.kt
vendored
Normal file
5
gitnexus/test/fixtures/lang-resolution/kotlin-chain-call/src/User.kt
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
package models
|
||||
|
||||
class User {
|
||||
fun save() {}
|
||||
}
|
||||
9
gitnexus/test/fixtures/lang-resolution/kotlin-chain-call/src/UserService.kt
vendored
Normal file
9
gitnexus/test/fixtures/lang-resolution/kotlin-chain-call/src/UserService.kt
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
package services
|
||||
|
||||
import models.User
|
||||
|
||||
class UserService {
|
||||
fun getUser(): User {
|
||||
return User()
|
||||
}
|
||||
}
|
||||
|
|
@ -1,3 +1,5 @@
|
|||
class Repo:
|
||||
name: str = ""
|
||||
|
||||
def save(self):
|
||||
return False
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
class User:
|
||||
name: str = ""
|
||||
|
||||
def save(self):
|
||||
return True
|
||||
|
|
|
|||
8
gitnexus/test/fixtures/lang-resolution/ruby-chain-call/lib/app.rb
vendored
Normal file
8
gitnexus/test/fixtures/lang-resolution/ruby-chain-call/lib/app.rb
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
require_relative './user_service'
|
||||
|
||||
class App
|
||||
def process
|
||||
svc = UserService.new
|
||||
svc.get_user.save
|
||||
end
|
||||
end
|
||||
5
gitnexus/test/fixtures/lang-resolution/ruby-chain-call/lib/repo.rb
vendored
Normal file
5
gitnexus/test/fixtures/lang-resolution/ruby-chain-call/lib/repo.rb
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
class Repo
|
||||
def save
|
||||
true
|
||||
end
|
||||
end
|
||||
5
gitnexus/test/fixtures/lang-resolution/ruby-chain-call/lib/user.rb
vendored
Normal file
5
gitnexus/test/fixtures/lang-resolution/ruby-chain-call/lib/user.rb
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
class User
|
||||
def save
|
||||
true
|
||||
end
|
||||
end
|
||||
8
gitnexus/test/fixtures/lang-resolution/ruby-chain-call/lib/user_service.rb
vendored
Normal file
8
gitnexus/test/fixtures/lang-resolution/ruby-chain-call/lib/user_service.rb
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
require_relative './user'
|
||||
|
||||
class UserService
|
||||
# @return [User]
|
||||
def get_user
|
||||
User.new
|
||||
end
|
||||
end
|
||||
7
gitnexus/test/fixtures/lang-resolution/rust-err-unwrap/src/error.rs
vendored
Normal file
7
gitnexus/test/fixtures/lang-resolution/rust-err-unwrap/src/error.rs
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
pub struct AppError {
|
||||
pub code: i32,
|
||||
}
|
||||
|
||||
impl AppError {
|
||||
pub fn report(&self) {}
|
||||
}
|
||||
18
gitnexus/test/fixtures/lang-resolution/rust-err-unwrap/src/main.rs
vendored
Normal file
18
gitnexus/test/fixtures/lang-resolution/rust-err-unwrap/src/main.rs
vendored
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
mod user;
|
||||
mod error;
|
||||
use crate::user::User;
|
||||
use crate::error::AppError;
|
||||
|
||||
fn handle_err(res: Result<User, AppError>) {
|
||||
if let Err(e) = res {
|
||||
e.report();
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_ok(res: Result<User, AppError>) {
|
||||
if let Ok(user) = res {
|
||||
user.save();
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {}
|
||||
7
gitnexus/test/fixtures/lang-resolution/rust-err-unwrap/src/repo.rs
vendored
Normal file
7
gitnexus/test/fixtures/lang-resolution/rust-err-unwrap/src/repo.rs
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
pub struct Repo {
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
impl Repo {
|
||||
pub fn save(&self) {}
|
||||
}
|
||||
7
gitnexus/test/fixtures/lang-resolution/rust-err-unwrap/src/user.rs
vendored
Normal file
7
gitnexus/test/fixtures/lang-resolution/rust-err-unwrap/src/user.rs
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
pub struct User {
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
impl User {
|
||||
pub fn save(&self) {}
|
||||
}
|
||||
1
gitnexus/test/fixtures/lang-resolution/rust-if-let-unwrap/models/mod.rs
vendored
Normal file
1
gitnexus/test/fixtures/lang-resolution/rust-if-let-unwrap/models/mod.rs
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
// Placeholder — actual module structure is in src/
|
||||
1
gitnexus/test/fixtures/lang-resolution/rust-if-let-unwrap/models/repo.rs
vendored
Normal file
1
gitnexus/test/fixtures/lang-resolution/rust-if-let-unwrap/models/repo.rs
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
// Placeholder — actual Repo definition is in src/repo.rs
|
||||
1
gitnexus/test/fixtures/lang-resolution/rust-if-let-unwrap/models/user.rs
vendored
Normal file
1
gitnexus/test/fixtures/lang-resolution/rust-if-let-unwrap/models/user.rs
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
// Placeholder — actual User definition is in src/user.rs
|
||||
11
gitnexus/test/fixtures/lang-resolution/rust-if-let-unwrap/src/main.rs
vendored
Normal file
11
gitnexus/test/fixtures/lang-resolution/rust-if-let-unwrap/src/main.rs
vendored
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
mod user;
|
||||
mod repo;
|
||||
use crate::user::User;
|
||||
|
||||
fn process(opt: Option<User>) {
|
||||
if let Some(user) = opt {
|
||||
user.save();
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {}
|
||||
7
gitnexus/test/fixtures/lang-resolution/rust-if-let-unwrap/src/repo.rs
vendored
Normal file
7
gitnexus/test/fixtures/lang-resolution/rust-if-let-unwrap/src/repo.rs
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
pub struct Repo {
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
impl Repo {
|
||||
pub fn save(&self) {}
|
||||
}
|
||||
7
gitnexus/test/fixtures/lang-resolution/rust-if-let-unwrap/src/user.rs
vendored
Normal file
7
gitnexus/test/fixtures/lang-resolution/rust-if-let-unwrap/src/user.rs
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
pub struct User {
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
impl User {
|
||||
pub fn save(&self) {}
|
||||
}
|
||||
6
gitnexus/test/fixtures/lang-resolution/typescript-chain-call/app.ts
vendored
Normal file
6
gitnexus/test/fixtures/lang-resolution/typescript-chain-call/app.ts
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { UserService } from './services/UserService';
|
||||
|
||||
export function processUser(): void {
|
||||
const svc = new UserService();
|
||||
svc.getUser().save();
|
||||
}
|
||||
3
gitnexus/test/fixtures/lang-resolution/typescript-chain-call/models/Repo.ts
vendored
Normal file
3
gitnexus/test/fixtures/lang-resolution/typescript-chain-call/models/Repo.ts
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export class Repo {
|
||||
save(): void {}
|
||||
}
|
||||
3
gitnexus/test/fixtures/lang-resolution/typescript-chain-call/models/User.ts
vendored
Normal file
3
gitnexus/test/fixtures/lang-resolution/typescript-chain-call/models/User.ts
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export class User {
|
||||
save(): void {}
|
||||
}
|
||||
7
gitnexus/test/fixtures/lang-resolution/typescript-chain-call/services/UserService.ts
vendored
Normal file
7
gitnexus/test/fixtures/lang-resolution/typescript-chain-call/services/UserService.ts
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { User } from '../models/User';
|
||||
|
||||
export class UserService {
|
||||
getUser(): User {
|
||||
return new User();
|
||||
}
|
||||
}
|
||||
12
gitnexus/test/fixtures/lang-resolution/typescript-overloaded-receiver/app.ts
vendored
Normal file
12
gitnexus/test/fixtures/lang-resolution/typescript-overloaded-receiver/app.ts
vendored
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { User } from './models/User';
|
||||
import { Repo } from './models/Repo';
|
||||
|
||||
// Both 'user' and 'repo' are created via constructor inference (no type annotation).
|
||||
// The enclosing scope is 'run@0', with varNames 'user' and 'repo'.
|
||||
// user.save() must resolve to User#save and repo.save() must resolve to Repo#save.
|
||||
export function run(): void {
|
||||
const user = new User();
|
||||
const repo = new Repo();
|
||||
user.save();
|
||||
repo.save();
|
||||
}
|
||||
3
gitnexus/test/fixtures/lang-resolution/typescript-overloaded-receiver/db/Cache.ts
vendored
Normal file
3
gitnexus/test/fixtures/lang-resolution/typescript-overloaded-receiver/db/Cache.ts
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export class Cache {
|
||||
store(): void {}
|
||||
}
|
||||
3
gitnexus/test/fixtures/lang-resolution/typescript-overloaded-receiver/db/Database.ts
vendored
Normal file
3
gitnexus/test/fixtures/lang-resolution/typescript-overloaded-receiver/db/Database.ts
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export class Database {
|
||||
persist(): void {}
|
||||
}
|
||||
5
gitnexus/test/fixtures/lang-resolution/typescript-overloaded-receiver/models/Repo.ts
vendored
Normal file
5
gitnexus/test/fixtures/lang-resolution/typescript-overloaded-receiver/models/Repo.ts
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export class Repo {
|
||||
save(): boolean {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
5
gitnexus/test/fixtures/lang-resolution/typescript-overloaded-receiver/models/User.ts
vendored
Normal file
5
gitnexus/test/fixtures/lang-resolution/typescript-overloaded-receiver/models/User.ts
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export class User {
|
||||
save(): boolean {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
7
gitnexus/test/fixtures/lang-resolution/typescript-static-chain/app.ts
vendored
Normal file
7
gitnexus/test/fixtures/lang-resolution/typescript-static-chain/app.ts
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { UserService } from './services/UserService';
|
||||
|
||||
// Chain base is a class name, not a variable.
|
||||
// Requires class-as-receiver fallback on the chain base resolution.
|
||||
export function processUser(): void {
|
||||
UserService.findUser().save();
|
||||
}
|
||||
3
gitnexus/test/fixtures/lang-resolution/typescript-static-chain/models/Repo.ts
vendored
Normal file
3
gitnexus/test/fixtures/lang-resolution/typescript-static-chain/models/Repo.ts
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export class Repo {
|
||||
save(): void {}
|
||||
}
|
||||
3
gitnexus/test/fixtures/lang-resolution/typescript-static-chain/models/User.ts
vendored
Normal file
3
gitnexus/test/fixtures/lang-resolution/typescript-static-chain/models/User.ts
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export class User {
|
||||
save(): void {}
|
||||
}
|
||||
7
gitnexus/test/fixtures/lang-resolution/typescript-static-chain/services/UserService.ts
vendored
Normal file
7
gitnexus/test/fixtures/lang-resolution/typescript-static-chain/services/UserService.ts
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { User } from '../models/User';
|
||||
|
||||
export class UserService {
|
||||
static findUser(): User {
|
||||
return new User();
|
||||
}
|
||||
}
|
||||
7
gitnexus/test/fixtures/lang-resolution/typescript-typed-param-chain/app.ts
vendored
Normal file
7
gitnexus/test/fixtures/lang-resolution/typescript-typed-param-chain/app.ts
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { UserService } from './services/UserService';
|
||||
|
||||
// svc is typed via parameter annotation, NOT constructor binding.
|
||||
// The chain base type must come from typeEnv, not receiverMap.
|
||||
export function processUser(svc: UserService): void {
|
||||
svc.getUser().save();
|
||||
}
|
||||
3
gitnexus/test/fixtures/lang-resolution/typescript-typed-param-chain/models/Repo.ts
vendored
Normal file
3
gitnexus/test/fixtures/lang-resolution/typescript-typed-param-chain/models/Repo.ts
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export class Repo {
|
||||
save(): void {}
|
||||
}
|
||||
3
gitnexus/test/fixtures/lang-resolution/typescript-typed-param-chain/models/User.ts
vendored
Normal file
3
gitnexus/test/fixtures/lang-resolution/typescript-typed-param-chain/models/User.ts
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export class User {
|
||||
save(): void {}
|
||||
}
|
||||
7
gitnexus/test/fixtures/lang-resolution/typescript-typed-param-chain/services/UserService.ts
vendored
Normal file
7
gitnexus/test/fixtures/lang-resolution/typescript-typed-param-chain/services/UserService.ts
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { User } from '../models/User';
|
||||
|
||||
export class UserService {
|
||||
getUser(): User {
|
||||
return new User();
|
||||
}
|
||||
}
|
||||
|
|
@ -888,3 +888,47 @@ describe('C# assignment chain + is-pattern coexistence', () => {
|
|||
expect(wrongCall).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// C# is-pattern disambiguation: `if (obj is User user)` should bind user → User
|
||||
// and resolve user.Save() to User#Save, NOT Repo#Save.
|
||||
// Validates the Phase 5.2 is_pattern_expression extraction in extractDeclaration.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('C# is-pattern type binding disambiguation (Phase 5.2)', () => {
|
||||
let result: PipelineResult;
|
||||
|
||||
beforeAll(async () => {
|
||||
result = await runPipelineFromRepo(
|
||||
path.join(FIXTURES, 'csharp-is-pattern'),
|
||||
() => {},
|
||||
);
|
||||
}, 60000);
|
||||
|
||||
it('detects User and Repo classes each with a Save method', () => {
|
||||
expect(getNodesByLabel(result, 'Class')).toContain('User');
|
||||
expect(getNodesByLabel(result, 'Class')).toContain('Repo');
|
||||
const saveMethods = getNodesByLabel(result, 'Method').filter(m => m === 'Save');
|
||||
expect(saveMethods.length).toBe(2);
|
||||
});
|
||||
|
||||
it('resolves user.Save() inside if (obj is User user) to User#Save', () => {
|
||||
const calls = getRelationships(result, 'CALLS');
|
||||
const userSave = calls.find(c =>
|
||||
c.target === 'Save' &&
|
||||
c.source === 'Process' &&
|
||||
c.targetFilePath?.includes('User.cs'),
|
||||
);
|
||||
expect(userSave).toBeDefined();
|
||||
});
|
||||
|
||||
it('does NOT resolve user.Save() to Repo#Save', () => {
|
||||
const calls = getRelationships(result, 'CALLS');
|
||||
const repoSave = calls.find(c =>
|
||||
c.target === 'Save' &&
|
||||
c.source === 'Process' &&
|
||||
c.targetFilePath?.includes('Repo.cs'),
|
||||
);
|
||||
expect(repoSave).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -777,3 +777,144 @@ describe('Java Optional<User> receiver resolution via wrapper unwrapping', () =>
|
|||
expect(userSave!.targetFilePath).not.toBe(repoSave!.targetFilePath);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Chained method call resolution: svc.getUser().save()
|
||||
// The receiver of save() is a method_invocation (getUser()), not a simple identifier.
|
||||
// Resolution must walk the chain: getUser() returns User, so save() → User#save.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Java chained method call resolution', () => {
|
||||
let result: PipelineResult;
|
||||
|
||||
beforeAll(async () => {
|
||||
result = await runPipelineFromRepo(
|
||||
path.join(FIXTURES, 'java-chain-call'),
|
||||
() => {},
|
||||
);
|
||||
}, 60000);
|
||||
|
||||
it('detects User, Repo and UserService classes', () => {
|
||||
const classes = getNodesByLabel(result, 'Class');
|
||||
expect(classes).toContain('User');
|
||||
expect(classes).toContain('Repo');
|
||||
expect(classes).toContain('UserService');
|
||||
});
|
||||
|
||||
it('detects save methods on both User and Repo', () => {
|
||||
const saveMethods = getNodesByLabel(result, 'Method').filter(m => m === 'save');
|
||||
expect(saveMethods.length).toBe(2);
|
||||
});
|
||||
|
||||
it('detects getUser method on UserService', () => {
|
||||
const methods = getNodesByLabel(result, 'Method');
|
||||
expect(methods).toContain('getUser');
|
||||
});
|
||||
|
||||
it('resolves svc.getUser().save() to User#save, NOT Repo#save', () => {
|
||||
const calls = getRelationships(result, 'CALLS');
|
||||
const userSave = calls.find(c =>
|
||||
c.target === 'save' &&
|
||||
c.source === 'processUser' &&
|
||||
c.targetFilePath.includes('User.java'),
|
||||
);
|
||||
const repoSave = calls.find(c =>
|
||||
c.target === 'save' &&
|
||||
c.source === 'processUser' &&
|
||||
c.targetFilePath.includes('Repo.java'),
|
||||
);
|
||||
expect(userSave).toBeDefined();
|
||||
expect(repoSave).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Java 16+ instanceof pattern variable: `if (obj instanceof User user)`
|
||||
// Phase 5.2: extractPatternBinding on instanceof_expression binds user → User.
|
||||
// Disambiguation: User.save vs Repo.save — only User.save should be called.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Java instanceof pattern variable resolution (Phase 5.2)', () => {
|
||||
let result: PipelineResult;
|
||||
|
||||
beforeAll(async () => {
|
||||
result = await runPipelineFromRepo(
|
||||
path.join(FIXTURES, 'java-instanceof-pattern'),
|
||||
() => {},
|
||||
);
|
||||
}, 60000);
|
||||
|
||||
it('detects User and Repo classes each with a save method', () => {
|
||||
expect(getNodesByLabel(result, 'Class')).toContain('User');
|
||||
expect(getNodesByLabel(result, 'Class')).toContain('Repo');
|
||||
const saveMethods = getNodesByLabel(result, 'Method').filter(m => m === 'save');
|
||||
expect(saveMethods.length).toBe(2);
|
||||
});
|
||||
|
||||
it('resolves user.save() inside if (obj instanceof User user) to User#save', () => {
|
||||
const calls = getRelationships(result, 'CALLS');
|
||||
const userSave = calls.find(c =>
|
||||
c.target === 'save' &&
|
||||
c.source === 'process' &&
|
||||
c.targetFilePath.includes('User.java'),
|
||||
);
|
||||
expect(userSave).toBeDefined();
|
||||
});
|
||||
|
||||
it('does NOT resolve user.save() to Repo#save', () => {
|
||||
const calls = getRelationships(result, 'CALLS');
|
||||
const repoSave = calls.find(c =>
|
||||
c.target === 'save' &&
|
||||
c.source === 'process' &&
|
||||
c.targetFilePath.includes('Repo.java'),
|
||||
);
|
||||
expect(repoSave).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Enum static method calls: Status.fromCode(200) should resolve via
|
||||
// class-as-receiver with Enum type included in the filter.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Java enum static method call resolution (Phase 5 review fix)', () => {
|
||||
let result: PipelineResult;
|
||||
|
||||
beforeAll(async () => {
|
||||
result = await runPipelineFromRepo(
|
||||
path.join(FIXTURES, 'java-enum-static-call'),
|
||||
() => {},
|
||||
);
|
||||
}, 60000);
|
||||
|
||||
it('detects Status as an Enum and App as a Class', () => {
|
||||
expect(getNodesByLabel(result, 'Enum')).toContain('Status');
|
||||
expect(getNodesByLabel(result, 'Class')).toContain('App');
|
||||
});
|
||||
|
||||
it('detects fromCode and label methods on Status', () => {
|
||||
const methods = getNodesByLabel(result, 'Method');
|
||||
expect(methods).toContain('fromCode');
|
||||
expect(methods).toContain('label');
|
||||
});
|
||||
|
||||
it('resolves Status.fromCode(200) to Status#fromCode via class-as-receiver', () => {
|
||||
const calls = getRelationships(result, 'CALLS');
|
||||
const fromCodeCall = calls.find(c =>
|
||||
c.target === 'fromCode' &&
|
||||
c.source === 'process' &&
|
||||
c.targetFilePath?.includes('Status.java'),
|
||||
);
|
||||
expect(fromCodeCall).toBeDefined();
|
||||
});
|
||||
|
||||
it('resolves s.label() to Status#label', () => {
|
||||
const calls = getRelationships(result, 'CALLS');
|
||||
const labelCall = calls.find(c =>
|
||||
c.target === 'label' &&
|
||||
c.source === 'process' &&
|
||||
c.targetFilePath?.includes('Status.java'),
|
||||
);
|
||||
expect(labelCall).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -819,3 +819,53 @@ describe('Kotlin assignment chain inside class method', () => {
|
|||
expect(wrongCall).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Chained method calls: svc.getUser().save()
|
||||
// Tests that Kotlin's navigation_expression → navigation_suffix AST structure
|
||||
// is correctly handled by extractCallChain (Phase 5 review Finding 1, Round 3).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Kotlin chained method call resolution (Phase 5 review fix)', () => {
|
||||
let result: PipelineResult;
|
||||
|
||||
beforeAll(async () => {
|
||||
result = await runPipelineFromRepo(
|
||||
path.join(FIXTURES, 'kotlin-chain-call'),
|
||||
() => {},
|
||||
);
|
||||
}, 60000);
|
||||
|
||||
it('detects User, Repo, and UserService classes', () => {
|
||||
const classes = getNodesByLabel(result, 'Class');
|
||||
expect(classes).toContain('User');
|
||||
expect(classes).toContain('Repo');
|
||||
expect(classes).toContain('UserService');
|
||||
});
|
||||
|
||||
it('detects getUser and save functions', () => {
|
||||
const fns = getNodesByLabel(result, 'Function');
|
||||
expect(fns).toContain('getUser');
|
||||
expect(fns).toContain('save');
|
||||
});
|
||||
|
||||
it('resolves svc.getUser().save() to User#save via chain resolution', () => {
|
||||
const calls = getRelationships(result, 'CALLS');
|
||||
const userSave = calls.find(c =>
|
||||
c.target === 'save' &&
|
||||
c.source === 'processUser' &&
|
||||
c.targetFilePath?.includes('User.kt'),
|
||||
);
|
||||
expect(userSave).toBeDefined();
|
||||
});
|
||||
|
||||
it('does NOT resolve svc.getUser().save() to Repo#save', () => {
|
||||
const calls = getRelationships(result, 'CALLS');
|
||||
const repoSave = calls.find(c =>
|
||||
c.target === 'save' &&
|
||||
c.source === 'processUser' &&
|
||||
c.targetFilePath?.includes('Repo.kt'),
|
||||
);
|
||||
expect(repoSave).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -707,17 +707,17 @@ describe('Python static/classmethod class resolution (issue #289)', () => {
|
|||
expect(createCall).toBeDefined();
|
||||
});
|
||||
|
||||
it('does not emit ambiguous find_user() when both classes define it (known limitation)', () => {
|
||||
// UserService.find_user() and AdminService.find_user() are ambiguous — the pipeline
|
||||
// refuses to guess. Static method calls like ClassName.method() don't have a typed
|
||||
// receiver variable, so receiver-constrained disambiguation doesn't apply.
|
||||
// This is expected: no false edges is better than wrong edges.
|
||||
it('resolves find_user() via class-as-receiver for static method calls', () => {
|
||||
// UserService.find_user() and AdminService.find_user() are both resolved because
|
||||
// the class name (UserService / AdminService) is used as the receiver type for
|
||||
// disambiguation. Both find_user methods share the same nodeId (same file, same name)
|
||||
// so exactly 1 CALLS edge is emitted — which is correct (not ambiguous, not missing).
|
||||
const calls = getRelationships(result, 'CALLS');
|
||||
const findCalls = calls.filter(c =>
|
||||
c.target === 'find_user' && c.source === 'process',
|
||||
);
|
||||
// Either 0 (refused ambiguous) or 2 (both resolved) — not 1 (wrong guess)
|
||||
expect(findCalls.length === 0 || findCalls.length === 2).toBe(true);
|
||||
expect(findCalls.length).toBe(1);
|
||||
expect(findCalls[0].targetFilePath).toContain('service.py');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -755,3 +755,59 @@ describe('Ruby YARD generic type annotations (Hash<Symbol, User>)', () => {
|
|||
expect(findCall).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Chained method calls: svc.get_user.save
|
||||
// Tests that Ruby's `call` node uses `method` and `receiver` fields correctly
|
||||
// for chain extraction — the tree-sitter-ruby grammar differs from other languages.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Ruby chained method call resolution (Phase 5 review fix)', () => {
|
||||
let result: PipelineResult;
|
||||
|
||||
beforeAll(async () => {
|
||||
result = await runPipelineFromRepo(
|
||||
path.join(FIXTURES, 'ruby-chain-call'),
|
||||
() => {},
|
||||
);
|
||||
}, 60000);
|
||||
|
||||
it('detects User, Repo, UserService and App classes', () => {
|
||||
const classes = getNodesByLabel(result, 'Class');
|
||||
expect(classes).toContain('User');
|
||||
expect(classes).toContain('Repo');
|
||||
expect(classes).toContain('UserService');
|
||||
expect(classes).toContain('App');
|
||||
});
|
||||
|
||||
it('detects save methods on both User and Repo', () => {
|
||||
const methods = getNodesByLabel(result, 'Method');
|
||||
const saveMethods = methods.filter(m => m === 'save');
|
||||
expect(saveMethods.length).toBe(2);
|
||||
});
|
||||
|
||||
it('detects get_user method on UserService', () => {
|
||||
const methods = getNodesByLabel(result, 'Method');
|
||||
expect(methods).toContain('get_user');
|
||||
});
|
||||
|
||||
it('resolves svc.get_user.save to User#save via chain resolution', () => {
|
||||
const calls = getRelationships(result, 'CALLS');
|
||||
const userSave = calls.find(c =>
|
||||
c.target === 'save' &&
|
||||
c.source === 'process' &&
|
||||
c.targetFilePath?.includes('user.rb'),
|
||||
);
|
||||
expect(userSave).toBeDefined();
|
||||
});
|
||||
|
||||
it('does NOT resolve svc.get_user.save to Repo#save', () => {
|
||||
const calls = getRelationships(result, 'CALLS');
|
||||
const repoSave = calls.find(c =>
|
||||
c.target === 'save' &&
|
||||
c.source === 'process' &&
|
||||
c.targetFilePath?.includes('repo.rb'),
|
||||
);
|
||||
expect(repoSave).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -923,3 +923,100 @@ describe('Rust Option<User> receiver resolution via wrapper unwrapping', () => {
|
|||
expect(repoSave).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// if let Some(user) = opt — Phase 5.2 pattern binding: unwrap Option<T>
|
||||
// `opt: Option<User>` → Option<User> is stored as "User" in TypeEnv via
|
||||
// NULLABLE_WRAPPER_TYPES. extractPatternBinding maps `user` → "User".
|
||||
// Disambiguation: User.save vs Repo.save — only User.save should be called.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Rust if-let Some(x) = opt pattern binding (Phase 5.2)', () => {
|
||||
let result: PipelineResult;
|
||||
|
||||
beforeAll(async () => {
|
||||
result = await runPipelineFromRepo(
|
||||
path.join(FIXTURES, 'rust-if-let-unwrap'),
|
||||
() => {},
|
||||
);
|
||||
}, 60000);
|
||||
|
||||
it('detects User and Repo structs each with a save function', () => {
|
||||
expect(getNodesByLabel(result, 'Struct')).toContain('User');
|
||||
expect(getNodesByLabel(result, 'Struct')).toContain('Repo');
|
||||
const saveFns = getNodesByLabel(result, 'Function').filter(f => f === 'save');
|
||||
expect(saveFns.length).toBe(2);
|
||||
});
|
||||
|
||||
it('resolves user.save() inside if-let Some(user) = opt to User#save', () => {
|
||||
const calls = getRelationships(result, 'CALLS');
|
||||
const userSave = calls.find(c =>
|
||||
c.target === 'save' &&
|
||||
c.source === 'process' &&
|
||||
c.targetFilePath?.includes('user.rs'),
|
||||
);
|
||||
expect(userSave).toBeDefined();
|
||||
});
|
||||
|
||||
it('does NOT resolve user.save() to Repo#save', () => {
|
||||
const calls = getRelationships(result, 'CALLS');
|
||||
const repoSave = calls.find(c =>
|
||||
c.target === 'save' &&
|
||||
c.source === 'process' &&
|
||||
c.targetFilePath?.includes('repo.rs'),
|
||||
);
|
||||
expect(repoSave).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Rust if-let Err(e) = res pattern binding (Phase 5 review fix)
|
||||
// Result<User, AppError> → Err(e) should type e as AppError (typeArgs[1]).
|
||||
// Also tests Ok(user) in the same fixture to verify both arms work.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Rust if-let Err(e) pattern binding (Phase 5 review fix)', () => {
|
||||
let result: PipelineResult;
|
||||
|
||||
beforeAll(async () => {
|
||||
result = await runPipelineFromRepo(
|
||||
path.join(FIXTURES, 'rust-err-unwrap'),
|
||||
() => {},
|
||||
);
|
||||
}, 60000);
|
||||
|
||||
it('detects User and AppError structs', () => {
|
||||
const structs = getNodesByLabel(result, 'Struct');
|
||||
expect(structs).toContain('User');
|
||||
expect(structs).toContain('AppError');
|
||||
});
|
||||
|
||||
it('resolves e.report() inside if-let Err(e) to AppError#report', () => {
|
||||
const calls = getRelationships(result, 'CALLS');
|
||||
const reportCall = calls.find(c =>
|
||||
c.target === 'report' &&
|
||||
c.source === 'handle_err' &&
|
||||
c.targetFilePath?.includes('error.rs'),
|
||||
);
|
||||
expect(reportCall).toBeDefined();
|
||||
});
|
||||
|
||||
it('resolves user.save() inside if-let Ok(user) to User#save', () => {
|
||||
const calls = getRelationships(result, 'CALLS');
|
||||
const saveCall = calls.find(c =>
|
||||
c.target === 'save' &&
|
||||
c.source === 'handle_ok' &&
|
||||
c.targetFilePath?.includes('user.rs'),
|
||||
);
|
||||
expect(saveCall).toBeDefined();
|
||||
});
|
||||
|
||||
it('does NOT resolve e.report() to User#save (no cross-contamination)', () => {
|
||||
const calls = getRelationships(result, 'CALLS');
|
||||
const wrongCall = calls.find(c =>
|
||||
c.target === 'save' &&
|
||||
c.source === 'handle_err',
|
||||
);
|
||||
expect(wrongCall).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1211,3 +1211,219 @@ describe('TypeScript nullable + assignment chain combined', () => {
|
|||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Chained method call resolution: svc.getUser().save()
|
||||
// The receiver of save() is a call_expression (getUser()), not a simple identifier.
|
||||
// Resolution must walk the chain: getUser() returns User, so save() → User#save.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('TypeScript chained method call resolution', () => {
|
||||
let result: PipelineResult;
|
||||
|
||||
beforeAll(async () => {
|
||||
result = await runPipelineFromRepo(
|
||||
path.join(FIXTURES, 'typescript-chain-call'),
|
||||
() => {},
|
||||
);
|
||||
}, 60000);
|
||||
|
||||
it('detects User, Repo and UserService classes', () => {
|
||||
const classes = getNodesByLabel(result, 'Class');
|
||||
expect(classes).toContain('User');
|
||||
expect(classes).toContain('Repo');
|
||||
expect(classes).toContain('UserService');
|
||||
});
|
||||
|
||||
it('detects save methods on both User and Repo', () => {
|
||||
const saveMethods = getNodesByLabel(result, 'Method').filter(m => m === 'save');
|
||||
expect(saveMethods.length).toBe(2);
|
||||
});
|
||||
|
||||
it('detects getUser method on UserService', () => {
|
||||
const methods = getNodesByLabel(result, 'Method');
|
||||
expect(methods).toContain('getUser');
|
||||
});
|
||||
|
||||
it('resolves svc.getUser().save() to User#save, NOT Repo#save', () => {
|
||||
const calls = getRelationships(result, 'CALLS');
|
||||
const userSave = calls.find(c =>
|
||||
c.target === 'save' &&
|
||||
c.source === 'processUser' &&
|
||||
c.targetFilePath.includes('User'),
|
||||
);
|
||||
const repoSave = calls.find(c =>
|
||||
c.target === 'save' &&
|
||||
c.source === 'processUser' &&
|
||||
c.targetFilePath.includes('Repo'),
|
||||
);
|
||||
expect(userSave).toBeDefined();
|
||||
expect(repoSave).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Overloaded receiver: two classes with the same method name (save) must not
|
||||
// collide in the receiverKey map. The fix preserves @startIndex in the key so
|
||||
// User.save@idx1 and Repo.save@idx2 remain distinct even when the enclosing
|
||||
// scope funcName is the same.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('TypeScript overloaded-receiver resolution (receiverKey collision fix)', () => {
|
||||
let result: PipelineResult;
|
||||
|
||||
beforeAll(async () => {
|
||||
result = await runPipelineFromRepo(
|
||||
path.join(FIXTURES, 'typescript-overloaded-receiver'),
|
||||
() => {},
|
||||
);
|
||||
}, 60000);
|
||||
|
||||
it('detects User and Repo classes, both with a save method', () => {
|
||||
expect(getNodesByLabel(result, 'Class')).toContain('User');
|
||||
expect(getNodesByLabel(result, 'Class')).toContain('Repo');
|
||||
const saveMethods = getNodesByLabel(result, 'Method').filter(m => m === 'save');
|
||||
expect(saveMethods.length).toBe(2);
|
||||
});
|
||||
|
||||
it('resolves user.save() to User#save (models/User.ts), not Repo#save', () => {
|
||||
const calls = getRelationships(result, 'CALLS');
|
||||
const userSave = calls.find(c =>
|
||||
c.target === 'save' && c.targetFilePath.includes('User'),
|
||||
);
|
||||
expect(userSave).toBeDefined();
|
||||
expect(userSave!.source).toBe('run');
|
||||
// Negative: must not resolve to Repo#save
|
||||
const wrongSave = calls.find(c =>
|
||||
c.target === 'save' && c.source === 'run' && c.targetFilePath.includes('Repo'),
|
||||
);
|
||||
// If only one save target resolves to User (not Repo), we correctly exclude Repo
|
||||
expect(userSave!.targetFilePath).toContain('User');
|
||||
});
|
||||
|
||||
it('resolves repo.save() to Repo#save (models/Repo.ts), not User#save', () => {
|
||||
const calls = getRelationships(result, 'CALLS');
|
||||
const repoSave = calls.find(c =>
|
||||
c.target === 'save' && c.targetFilePath.includes('Repo'),
|
||||
);
|
||||
expect(repoSave).toBeDefined();
|
||||
expect(repoSave!.source).toBe('run');
|
||||
expect(repoSave!.targetFilePath).toContain('Repo');
|
||||
});
|
||||
|
||||
it('emits exactly 2 save() CALLS edges — one per class', () => {
|
||||
const calls = getRelationships(result, 'CALLS');
|
||||
const saveCalls = calls.filter(c => c.target === 'save');
|
||||
expect(saveCalls.length).toBe(2);
|
||||
const targets = saveCalls.map(c => c.targetFilePath).sort();
|
||||
expect(targets[0]).toContain('Repo');
|
||||
expect(targets[1]).toContain('User');
|
||||
});
|
||||
|
||||
it('resolves constructor calls for both User and Repo', () => {
|
||||
const calls = getRelationships(result, 'CALLS');
|
||||
const userCtor = calls.find(c => c.target === 'User' && c.targetLabel === 'Class');
|
||||
const repoCtor = calls.find(c => c.target === 'Repo' && c.targetLabel === 'Class');
|
||||
expect(userCtor).toBeDefined();
|
||||
expect(repoCtor).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Typed parameter chain: svc.getUser().save() where svc is a parameter with
|
||||
// a type annotation (not a constructor binding). Tests that the worker path
|
||||
// consults typeEnv for chain base receivers (Phase 5 review Finding 1).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('TypeScript typed-parameter chain call resolution (Phase 5 review fix)', () => {
|
||||
let result: PipelineResult;
|
||||
|
||||
beforeAll(async () => {
|
||||
result = await runPipelineFromRepo(
|
||||
path.join(FIXTURES, 'typescript-typed-param-chain'),
|
||||
() => {},
|
||||
);
|
||||
}, 60000);
|
||||
|
||||
it('detects User, Repo, and UserService classes', () => {
|
||||
const classes = getNodesByLabel(result, 'Class');
|
||||
expect(classes).toContain('User');
|
||||
expect(classes).toContain('Repo');
|
||||
expect(classes).toContain('UserService');
|
||||
});
|
||||
|
||||
it('detects getUser and save methods', () => {
|
||||
const methods = getNodesByLabel(result, 'Method');
|
||||
expect(methods).toContain('getUser');
|
||||
expect(methods).toContain('save');
|
||||
});
|
||||
|
||||
it('resolves svc.getUser().save() to User#save via parameter type annotation', () => {
|
||||
const calls = getRelationships(result, 'CALLS');
|
||||
const userSave = calls.find(c =>
|
||||
c.target === 'save' &&
|
||||
c.source === 'processUser' &&
|
||||
c.targetFilePath.includes('User'),
|
||||
);
|
||||
expect(userSave).toBeDefined();
|
||||
});
|
||||
|
||||
it('does NOT resolve svc.getUser().save() to Repo#save', () => {
|
||||
const calls = getRelationships(result, 'CALLS');
|
||||
const repoSave = calls.find(c =>
|
||||
c.target === 'save' &&
|
||||
c.source === 'processUser' &&
|
||||
c.targetFilePath.includes('Repo'),
|
||||
);
|
||||
expect(repoSave).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Static chain: UserService.findUser().save() where the chain base is a class
|
||||
// name (not a variable). Tests that the serial path applies class-as-receiver
|
||||
// to chain base resolution (Phase 5 review Finding 2).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('TypeScript static class-name chain call resolution (Phase 5 review fix)', () => {
|
||||
let result: PipelineResult;
|
||||
|
||||
beforeAll(async () => {
|
||||
result = await runPipelineFromRepo(
|
||||
path.join(FIXTURES, 'typescript-static-chain'),
|
||||
() => {},
|
||||
);
|
||||
}, 60000);
|
||||
|
||||
it('detects User, Repo, and UserService classes', () => {
|
||||
const classes = getNodesByLabel(result, 'Class');
|
||||
expect(classes).toContain('User');
|
||||
expect(classes).toContain('Repo');
|
||||
expect(classes).toContain('UserService');
|
||||
});
|
||||
|
||||
it('detects static findUser and instance save methods', () => {
|
||||
const methods = getNodesByLabel(result, 'Method');
|
||||
expect(methods).toContain('findUser');
|
||||
expect(methods).toContain('save');
|
||||
});
|
||||
|
||||
it('resolves UserService.findUser().save() to User#save via class-name chain base', () => {
|
||||
const calls = getRelationships(result, 'CALLS');
|
||||
const userSave = calls.find(c =>
|
||||
c.target === 'save' &&
|
||||
c.source === 'processUser' &&
|
||||
c.targetFilePath.includes('User'),
|
||||
);
|
||||
expect(userSave).toBeDefined();
|
||||
});
|
||||
|
||||
it('does NOT resolve UserService.findUser().save() to Repo#save', () => {
|
||||
const calls = getRelationships(result, 'CALLS');
|
||||
const repoSave = calls.find(c =>
|
||||
c.target === 'save' &&
|
||||
c.source === 'processUser' &&
|
||||
c.targetFilePath.includes('Repo'),
|
||||
);
|
||||
expect(repoSave).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -476,6 +476,122 @@ describe('processCallsFromExtracted', () => {
|
|||
|
||||
// ---- Scope-aware constructor bindings (Phase 3) ----
|
||||
|
||||
it('receiverKey collision: same method name in different classes does not collide', async () => {
|
||||
// User.save@100 and Repo.save@200 are two methods named "save" in different classes.
|
||||
// Each has a local variable "db" pointing to a different type.
|
||||
// Without @startIndex in the key, the second binding would overwrite the first.
|
||||
ctx.symbols.add('src/db/Database.ts', 'Database', 'Class:src/db/Database.ts:Database', 'Class');
|
||||
ctx.symbols.add('src/db/Cache.ts', 'Cache', 'Class:src/db/Cache.ts:Cache', 'Class');
|
||||
ctx.symbols.add('src/db/Database.ts', 'query', 'Method:src/db/Database.ts:query', 'Method', { ownerId: 'Class:src/db/Database.ts:Database' });
|
||||
ctx.symbols.add('src/db/Cache.ts', 'query', 'Method:src/db/Cache.ts:query', 'Method', { ownerId: 'Class:src/db/Cache.ts:Cache' });
|
||||
ctx.importMap.set('src/models/User.ts', new Set(['src/db/Database.ts']));
|
||||
ctx.importMap.set('src/models/Repo.ts', new Set(['src/db/Cache.ts']));
|
||||
|
||||
// Two bindings: both enclosing scope is named "save" but at different startIndexes
|
||||
const constructorBindings: FileConstructorBindings[] = [
|
||||
{
|
||||
filePath: 'src/models/User.ts',
|
||||
bindings: [
|
||||
// save@100: inside User.save(), db = new Database()
|
||||
{ scope: 'save@100', varName: 'db', calleeName: 'Database' },
|
||||
],
|
||||
},
|
||||
{
|
||||
filePath: 'src/models/Repo.ts',
|
||||
bindings: [
|
||||
// save@200: inside Repo.save(), db = new Cache()
|
||||
{ scope: 'save@200', varName: 'db', calleeName: 'Cache' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const calls: ExtractedCall[] = [
|
||||
{
|
||||
filePath: 'src/models/User.ts',
|
||||
calledName: 'query',
|
||||
sourceId: 'Method:src/models/User.ts:save',
|
||||
receiverName: 'db',
|
||||
callForm: 'member',
|
||||
},
|
||||
{
|
||||
filePath: 'src/models/Repo.ts',
|
||||
calledName: 'query',
|
||||
sourceId: 'Method:src/models/Repo.ts:save',
|
||||
receiverName: 'db',
|
||||
callForm: 'member',
|
||||
},
|
||||
];
|
||||
|
||||
await processCallsFromExtracted(graph, calls, ctx, undefined, constructorBindings);
|
||||
|
||||
const rels = graph.relationships.filter(r => r.type === 'CALLS');
|
||||
expect(rels).toHaveLength(2);
|
||||
const userQueryRel = rels.find(r => r.sourceId === 'Method:src/models/User.ts:save');
|
||||
const repoQueryRel = rels.find(r => r.sourceId === 'Method:src/models/Repo.ts:save');
|
||||
expect(userQueryRel?.targetId).toBe('Method:src/db/Database.ts:query');
|
||||
expect(repoQueryRel?.targetId).toBe('Method:src/db/Cache.ts:query');
|
||||
});
|
||||
|
||||
it('receiverKey collision: same scope funcName + same varName + same type resolves (non-ambiguous)', async () => {
|
||||
// Two save@* scopes both bind "db" to the same type — not ambiguous, should resolve.
|
||||
ctx.symbols.add('src/db/Database.ts', 'Database', 'Class:src/db/Database.ts:Database', 'Class');
|
||||
ctx.symbols.add('src/db/Database.ts', 'query', 'Method:src/db/Database.ts:query', 'Method', { ownerId: 'Class:src/db/Database.ts:Database' });
|
||||
ctx.importMap.set('src/service.ts', new Set(['src/db/Database.ts']));
|
||||
|
||||
const constructorBindings: FileConstructorBindings[] = [{
|
||||
filePath: 'src/service.ts',
|
||||
bindings: [
|
||||
{ scope: 'save@10', varName: 'db', calleeName: 'Database' },
|
||||
{ scope: 'save@50', varName: 'db', calleeName: 'Database' },
|
||||
],
|
||||
}];
|
||||
|
||||
const calls: ExtractedCall[] = [{
|
||||
filePath: 'src/service.ts',
|
||||
calledName: 'query',
|
||||
sourceId: 'Method:src/service.ts:save',
|
||||
receiverName: 'db',
|
||||
callForm: 'member',
|
||||
}];
|
||||
|
||||
await processCallsFromExtracted(graph, calls, ctx, undefined, constructorBindings);
|
||||
|
||||
const rels = graph.relationships.filter(r => r.type === 'CALLS');
|
||||
expect(rels).toHaveLength(1);
|
||||
expect(rels[0].targetId).toBe('Method:src/db/Database.ts:query');
|
||||
});
|
||||
|
||||
it('receiverKey collision: same scope funcName + same varName + different types → ambiguous, no CALLS edge', async () => {
|
||||
// Two save@* scopes in the same file bind "db" to different types — truly ambiguous.
|
||||
ctx.symbols.add('src/db/Database.ts', 'Database', 'Class:src/db/Database.ts:Database', 'Class');
|
||||
ctx.symbols.add('src/db/Cache.ts', 'Cache', 'Class:src/db/Cache.ts:Cache', 'Class');
|
||||
ctx.symbols.add('src/db/Database.ts', 'query', 'Method:src/db/Database.ts:query', 'Method', { ownerId: 'Class:src/db/Database.ts:Database' });
|
||||
ctx.symbols.add('src/db/Cache.ts', 'query', 'Method:src/db/Cache.ts:query', 'Method', { ownerId: 'Class:src/db/Cache.ts:Cache' });
|
||||
ctx.importMap.set('src/service.ts', new Set(['src/db/Database.ts', 'src/db/Cache.ts']));
|
||||
|
||||
const constructorBindings: FileConstructorBindings[] = [{
|
||||
filePath: 'src/service.ts',
|
||||
bindings: [
|
||||
{ scope: 'save@10', varName: 'db', calleeName: 'Database' },
|
||||
{ scope: 'save@50', varName: 'db', calleeName: 'Cache' },
|
||||
],
|
||||
}];
|
||||
|
||||
const calls: ExtractedCall[] = [{
|
||||
filePath: 'src/service.ts',
|
||||
calledName: 'query',
|
||||
sourceId: 'Method:src/service.ts:save',
|
||||
receiverName: 'db',
|
||||
callForm: 'member',
|
||||
}];
|
||||
|
||||
await processCallsFromExtracted(graph, calls, ctx, undefined, constructorBindings);
|
||||
|
||||
// Ambiguous — different types for same funcName+varName, should not emit a CALLS edge
|
||||
const rels = graph.relationships.filter(r => r.type === 'CALLS');
|
||||
expect(rels).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('scope-aware bindings: same varName in different functions resolves to correct type', async () => {
|
||||
ctx.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class');
|
||||
ctx.symbols.add('src/models.ts', 'Repo', 'Class:src/models.ts:Repo', 'Class');
|
||||
|
|
|
|||
|
|
@ -346,6 +346,12 @@ describe('buildTypeEnv', () => {
|
|||
expect(flatGet(env, 'user')).toBe('User');
|
||||
});
|
||||
|
||||
it('extracts type from standalone annotation without value (file scope)', () => {
|
||||
const tree = parse('active_user: User', Python);
|
||||
const { env } = buildTypeEnv(tree, 'python');
|
||||
expect(flatGet(env, 'active_user')).toBe('User');
|
||||
});
|
||||
|
||||
it('extracts type from function parameters', () => {
|
||||
const tree = parse('def process(user: User, repo: Repository): pass', Python);
|
||||
const { env } = buildTypeEnv(tree, 'python');
|
||||
|
|
@ -1191,7 +1197,7 @@ class RepoService {
|
|||
expect(flatGet(env, 'item')).toBe('Config');
|
||||
});
|
||||
|
||||
it('does NOT extract binding from if let Some(x) = opt (requires generic unwrapping)', () => {
|
||||
it('extracts binding from if let Some(x) = opt via Phase 5.2 pattern binding', () => {
|
||||
const tree = parse(`
|
||||
fn process(opt: Option<User>) {
|
||||
if let Some(user) = opt {
|
||||
|
|
@ -1200,8 +1206,9 @@ class RepoService {
|
|||
}
|
||||
`, Rust);
|
||||
const { env } = buildTypeEnv(tree, 'rust');
|
||||
// user's type is Option's inner type — requires generic unwrapping (Phase 3)
|
||||
expect(flatGet(env, 'user')).toBeUndefined();
|
||||
// Option<User> is unwrapped to "User" in TypeEnv via NULLABLE_WRAPPER_TYPES.
|
||||
// extractPatternBinding maps `user` → "User" from the scopeEnv lookup for `opt`.
|
||||
expect(flatGet(env, 'user')).toBe('User');
|
||||
});
|
||||
|
||||
it('does NOT extract field bindings from struct pattern destructuring', () => {
|
||||
|
|
@ -1245,6 +1252,93 @@ class RepoService {
|
|||
expect(flatGet(env, 'opt')).toBe('User');
|
||||
expect(flatGet(env, 'user')).toBe('User');
|
||||
});
|
||||
|
||||
it('Phase 5.2: extracts binding from if let Some(x) = opt where opt: Option<User>', () => {
|
||||
const tree = parse(`
|
||||
fn process(opt: Option<User>) {
|
||||
if let Some(user) = opt {
|
||||
user.save();
|
||||
}
|
||||
}
|
||||
`, Rust);
|
||||
const { env } = buildTypeEnv(tree, 'rust');
|
||||
// opt: Option<User> → scopeEnv stores "User" (NULLABLE_WRAPPER_TYPES unwrapping)
|
||||
// extractPatternBinding maps user → opt's type → "User"
|
||||
expect(flatGet(env, 'user')).toBe('User');
|
||||
});
|
||||
|
||||
it('Phase 5.2: does NOT extract binding when source variable is unknown', () => {
|
||||
const tree = parse(`
|
||||
fn process() {
|
||||
if let Some(x) = unknown_var {
|
||||
x.foo();
|
||||
}
|
||||
}
|
||||
`, Rust);
|
||||
const { env } = buildTypeEnv(tree, 'rust');
|
||||
// unknown_var is not in scopeEnv — conservative, produces no binding
|
||||
expect(flatGet(env, 'x')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('Phase 5.2: does NOT extract binding for non-Option/Result wrappers', () => {
|
||||
const tree = parse(`
|
||||
fn process(vec: Vec<User>) {
|
||||
if let SomeOtherVariant(x) = vec {
|
||||
x.save();
|
||||
}
|
||||
}
|
||||
`, Rust);
|
||||
const { env } = buildTypeEnv(tree, 'rust');
|
||||
// SomeOtherVariant is not a known unwrap wrapper — no binding
|
||||
expect(flatGet(env, 'x')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Java instanceof pattern variable (Phase 5.2)', () => {
|
||||
it('extracts binding from x instanceof User user', () => {
|
||||
const tree = parse(`
|
||||
class App {
|
||||
void process(Object obj) {
|
||||
if (obj instanceof User user) {
|
||||
user.save();
|
||||
}
|
||||
}
|
||||
}
|
||||
`, Java);
|
||||
const { env } = buildTypeEnv(tree, 'java');
|
||||
expect(flatGet(env, 'user')).toBe('User');
|
||||
});
|
||||
|
||||
it('does NOT extract binding from plain instanceof without variable', () => {
|
||||
const tree = parse(`
|
||||
class App {
|
||||
void process(Object obj) {
|
||||
boolean b = obj instanceof User;
|
||||
}
|
||||
}
|
||||
`, Java);
|
||||
const { env } = buildTypeEnv(tree, 'java');
|
||||
// No pattern variable declared — no binding
|
||||
expect(flatGet(env, 'b')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('extracts correct type when multiple instanceof patterns exist', () => {
|
||||
const tree = parse(`
|
||||
class App {
|
||||
void process(Object obj) {
|
||||
if (obj instanceof User user) {
|
||||
user.save();
|
||||
}
|
||||
if (obj instanceof Repo repo) {
|
||||
repo.save();
|
||||
}
|
||||
}
|
||||
}
|
||||
`, Java);
|
||||
const { env } = buildTypeEnv(tree, 'java');
|
||||
expect(flatGet(env, 'user')).toBe('User');
|
||||
expect(flatGet(env, 'repo')).toBe('Repo');
|
||||
});
|
||||
});
|
||||
|
||||
describe('PHP', () => {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue