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:
Gergő Magyar 2026-03-16 19:38:09 +00:00 • committed by GitHub
parent cb79a6ab9d
commit ce45f59f96
68 changed files with 1743 additions and 34 deletions

View file

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

View file

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

View file

@ -40,6 +40,7 @@ export type {
ConstructorBindingScanner,
ForLoopExtractor,
PendingAssignmentExtractor,
PatternBindingExtractor,
} from './types.js';
export {
TYPED_PARAMETER_TYPES,

View file

@ -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 ────────────────────────────────────────────────────────────────

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,5 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
</PropertyGroup>
</Project>

View file

@ -0,0 +1,6 @@
namespace IsPattern.Models;
public class Repo
{
public void Save() {}
}

View file

@ -0,0 +1,6 @@
namespace IsPattern.Models;
public class User
{
public void Save() {}
}

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

View file

@ -0,0 +1,8 @@
import services.UserService;
public class App {
public static void processUser() {
UserService svc = new UserService();
svc.getUser().save();
}
}

View file

@ -0,0 +1,7 @@
package models;
public class Repo {
public boolean save() {
return true;
}
}

View file

@ -0,0 +1,7 @@
package models;
public class User {
public boolean save() {
return true;
}
}

View file

@ -0,0 +1,9 @@
package services;
import models.User;
public class UserService {
public User getUser() {
return new User();
}
}

View file

@ -0,0 +1,6 @@
public class App {
public void process() {
Status s = Status.fromCode(200);
s.label();
}
}

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

View file

@ -0,0 +1,5 @@
package models;
public class Repo {
public void save() {}
}

View file

@ -0,0 +1,5 @@
package models;
public class User {
public void save() {}
}

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

View file

@ -0,0 +1,7 @@
import models.User
import services.UserService
fun processUser() {
val svc = UserService()
svc.getUser().save()
}

View file

@ -0,0 +1,5 @@
package models
class Repo {
fun save() {}
}

View file

@ -0,0 +1,5 @@
package models
class User {
fun save() {}
}

View file

@ -0,0 +1,9 @@
package services
import models.User
class UserService {
fun getUser(): User {
return User()
}
}

View file

@ -1,3 +1,5 @@
class Repo:
name: str = ""
def save(self):
return False

View file

@ -1,3 +1,5 @@
class User:
name: str = ""
def save(self):
return True

View file

@ -0,0 +1,8 @@
require_relative './user_service'
class App
def process
svc = UserService.new
svc.get_user.save
end
end

View file

@ -0,0 +1,5 @@
class Repo
def save
true
end
end

View file

@ -0,0 +1,5 @@
class User
def save
true
end
end

View file

@ -0,0 +1,8 @@
require_relative './user'
class UserService
# @return [User]
def get_user
User.new
end
end

View file

@ -0,0 +1,7 @@
pub struct AppError {
pub code: i32,
}
impl AppError {
pub fn report(&self) {}
}

View 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() {}

View file

@ -0,0 +1,7 @@
pub struct Repo {
pub name: String,
}
impl Repo {
pub fn save(&self) {}
}

View file

@ -0,0 +1,7 @@
pub struct User {
pub name: String,
}
impl User {
pub fn save(&self) {}
}

View file

@ -0,0 +1 @@
// Placeholder — actual module structure is in src/

View file

@ -0,0 +1 @@
// Placeholder — actual Repo definition is in src/repo.rs

View file

@ -0,0 +1 @@
// Placeholder — actual User definition is in src/user.rs

View 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() {}

View file

@ -0,0 +1,7 @@
pub struct Repo {
pub name: String,
}
impl Repo {
pub fn save(&self) {}
}

View file

@ -0,0 +1,7 @@
pub struct User {
pub name: String,
}
impl User {
pub fn save(&self) {}
}

View file

@ -0,0 +1,6 @@
import { UserService } from './services/UserService';
export function processUser(): void {
const svc = new UserService();
svc.getUser().save();
}

View file

@ -0,0 +1,3 @@
export class Repo {
save(): void {}
}

View file

@ -0,0 +1,3 @@
export class User {
save(): void {}
}

View file

@ -0,0 +1,7 @@
import { User } from '../models/User';
export class UserService {
getUser(): User {
return new User();
}
}

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

View file

@ -0,0 +1,3 @@
export class Cache {
store(): void {}
}

View file

@ -0,0 +1,3 @@
export class Database {
persist(): void {}
}

View file

@ -0,0 +1,5 @@
export class Repo {
save(): boolean {
return false;
}
}

View file

@ -0,0 +1,5 @@
export class User {
save(): boolean {
return true;
}
}

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

View file

@ -0,0 +1,3 @@
export class Repo {
save(): void {}
}

View file

@ -0,0 +1,3 @@
export class User {
save(): void {}
}

View file

@ -0,0 +1,7 @@
import { User } from '../models/User';
export class UserService {
static findUser(): User {
return new User();
}
}

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

View file

@ -0,0 +1,3 @@
export class Repo {
save(): void {}
}

View file

@ -0,0 +1,3 @@
export class User {
save(): void {}
}

View file

@ -0,0 +1,7 @@
import { User } from '../models/User';
export class UserService {
getUser(): User {
return new User();
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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', () => {