feat: resolve 4 known limitation skip tests + method-aware type arg selection

Unskip 4 of 5 type-env known limitations with full integration test coverage:

1. TS destructured for-of: handle array_pattern by binding last named child
   to element type. Fix Map<K,V> to return last generic arg (value type).
2. Python dict.items() loop: handle `call` iterables + `pattern_list` left
   side. Fix dict[K,V] extraction via type_parameter with last-arg heuristic.
   Unwrap `type` wrapper in extractPyElementTypeFromAnnotation.
3. TS instanceof narrowing: add extractPatternBinding for binary_expression
   with positional child access. First-writer-wins (not block-scoped).
4. Rust .iter() for-loops: handle call_expression in for_expression value
   node by extracting receiver from field_expression.

Method-aware type arg resolution:
- Add TypeArgPosition ('first'|'last') to resolveIterableElementType
- .keys()/.keySet()/.Keys → first type arg (key); all else → last (value)
- Thread position through all 3 strategy callbacks in TS/Rust/Python
- Add predefined_type to extractSimpleTypeName for TS primitives (string etc)

New fixtures: rust-iter-for-loop, typescript-destructured-for-of,
typescript-instanceof-narrowing, python-dict-items-loop.
248 unit tests pass (6 new), 1 skip (Ruby block params).
This commit is contained in:
Gergo Magyar 2026-03-17 06:24:09 +00:00
parent 5f12ffccf2
commit 656af32e52
20 changed files with 500 additions and 77 deletions

View file

@ -1,6 +1,6 @@
import type { SyntaxNode } from '../utils.js';
import type { LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor, InitializerExtractor, ClassNameLookup, ConstructorBindingScanner, PendingAssignmentExtractor, PatternBindingExtractor, ForLoopExtractor } from './types.js';
import { extractSimpleTypeName, extractVarName, extractElementTypeFromString, extractGenericTypeArgs, resolveIterableElementType } from './shared.js';
import { extractSimpleTypeName, extractVarName, extractElementTypeFromString, extractGenericTypeArgs, resolveIterableElementType, methodToTypeArgPosition, type TypeArgPosition } from './shared.js';
const DECLARATION_NODE_TYPES: ReadonlySet<string> = new Set([
'assignment',
@ -150,18 +150,35 @@ const PY_FUNCTION_NODE_TYPES = new Set([
* generic_type → extractGenericTypeArgs → first arg
* Falls back to text-based extraction.
*/
const extractPyElementTypeFromAnnotation = (typeNode: SyntaxNode): string | undefined => {
const extractPyElementTypeFromAnnotation = (typeNode: SyntaxNode, pos: TypeArgPosition = 'last'): string | undefined => {
// Unwrap 'type' wrapper node to get to the actual type (e.g., type > generic_type)
const inner = typeNode.type === 'type' ? (typeNode.firstNamedChild ?? typeNode) : typeNode;
// Python subscript: List[User], Sequence[User] — use raw text
if (typeNode.type === 'subscript') {
return extractElementTypeFromString(typeNode.text);
if (inner.type === 'subscript') {
return extractElementTypeFromString(inner.text, pos);
}
// generic_type (less common in Python but present in some grammars)
if (typeNode.type === 'generic_type') {
const args = extractGenericTypeArgs(typeNode);
if (args.length >= 1) return args[0];
// generic_type: dict[str, User] — tree-sitter-python uses type_parameter child
if (inner.type === 'generic_type') {
// Try standard extractGenericTypeArgs first (handles type_arguments)
const args = extractGenericTypeArgs(inner);
if (args.length >= 1) return pos === 'first' ? args[0] : args[args.length - 1];
// Fallback: look for type_parameter child (tree-sitter-python specific)
for (let i = 0; i < inner.namedChildCount; i++) {
const child = inner.namedChild(i);
if (child?.type === 'type_parameter') {
if (pos === 'first') {
const firstArg = child.firstNamedChild;
if (firstArg) return extractSimpleTypeName(firstArg);
} else {
const lastArg = child.lastNamedChild;
if (lastArg) return extractSimpleTypeName(lastArg);
}
}
}
}
// Fallback: raw text extraction (handles User[], [User], etc.)
return extractElementTypeFromString(typeNode.text);
return extractElementTypeFromString(inner.text, pos);
};
/**
@ -173,7 +190,7 @@ const extractPyElementTypeFromAnnotation = (typeNode: SyntaxNode): string | unde
* `typed_parameter` may not expose the name as a `name` field — falls back to
* checking the first identifier-type named child.
*/
const findPyParamElementType = (iterableName: string, startNode: SyntaxNode): string | undefined => {
const findPyParamElementType = (iterableName: string, startNode: SyntaxNode, pos: TypeArgPosition = 'last'): string | undefined => {
let current: SyntaxNode | null = startNode.parent;
while (current) {
if (current.type === 'function_definition') {
@ -191,7 +208,7 @@ const findPyParamElementType = (iterableName: string, startNode: SyntaxNode): st
const typeAnnotation = param.childForFieldName('type')
?? (param.namedChildCount >= 2 ? param.namedChild(param.namedChildCount - 1) : null);
if (typeAnnotation && typeAnnotation !== nameNode) {
return extractPyElementTypeFromAnnotation(typeAnnotation);
return extractPyElementTypeFromAnnotation(typeAnnotation, pos);
}
}
}
@ -220,20 +237,46 @@ const extractForLoopBinding: ForLoopExtractor = (
): void => {
if (node.type !== 'for_statement') return;
// The iterable is the `right` field of the for_statement.
// The iterable is the `right` field — may be identifier or call (data.items()/keys()/values()).
const rightNode = node.childForFieldName('right');
if (!rightNode || rightNode.type !== 'identifier') return;
const iterableName = rightNode.text;
let iterableName: string | undefined;
let methodName: string | undefined;
if (rightNode?.type === 'identifier') {
iterableName = rightNode.text;
} else if (rightNode?.type === 'call') {
// data.items() → call > function: attribute > identifier('data') + identifier('items')
const fn = rightNode.childForFieldName('function');
if (fn?.type === 'attribute') {
const obj = fn.firstNamedChild;
if (obj?.type === 'identifier') iterableName = obj.text;
// Extract method name: items, keys, values
const method = fn.lastNamedChild;
if (method?.type === 'identifier' && method !== obj) methodName = method.text;
}
}
if (!iterableName) return;
const typeArgPos = methodToTypeArgPosition(methodName);
const elementType = resolveIterableElementType(
iterableName, node, scopeEnv, declarationTypeNodes, scope,
extractPyElementTypeFromAnnotation, findPyParamElementType,
typeArgPos,
);
if (!elementType) return;
// The loop variable is the `left` field — a plain identifier.
// The loop variable is the `left` field — identifier or pattern_list.
const leftNode = node.childForFieldName('left');
if (!leftNode) return;
// Handle tuple unpacking: for key, value in data.items()
if (leftNode.type === 'pattern_list') {
const lastChild = leftNode.lastNamedChild;
if (lastChild?.type === 'identifier') {
scopeEnv.set(lastChild.text, elementType);
}
return;
}
const loopVarName = extractVarName(leftNode);
if (loopVarName) scopeEnv.set(loopVarName, elementType);
};

View file

@ -1,6 +1,6 @@
import type { SyntaxNode } from '../utils.js';
import type { LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor, InitializerExtractor, ClassNameLookup, ConstructorBindingScanner, PendingAssignmentExtractor, PatternBindingExtractor, ForLoopExtractor } from './types.js';
import { extractSimpleTypeName, extractVarName, hasTypeAnnotation, unwrapAwait, extractGenericTypeArgs, resolveIterableElementType } from './shared.js';
import { extractSimpleTypeName, extractVarName, hasTypeAnnotation, unwrapAwait, extractGenericTypeArgs, resolveIterableElementType, methodToTypeArgPosition, type TypeArgPosition } from './shared.js';
const DECLARATION_NODE_TYPES: ReadonlySet<string> = new Set([
'let_declaration',
@ -291,16 +291,16 @@ const FOR_LOOP_NODE_TYPES: ReadonlySet<string> = new Set(['for_expression']);
/** Extract element type from a Rust type annotation AST node.
* Handles: generic_type (Vec<User>), reference_type (&[User]), array_type ([User; N]),
* slice_type ([User]). For call-graph purposes, strips references (&User → User). */
const extractRustElementTypeFromTypeNode = (typeNode: SyntaxNode): string | undefined => {
// generic_type: Vec<User>, HashSet<User> — extract first type argument
const extractRustElementTypeFromTypeNode = (typeNode: SyntaxNode, pos: TypeArgPosition = 'last'): string | undefined => {
// generic_type: Vec<User>, HashMap<K, V> — extract type arg based on position
if (typeNode.type === 'generic_type') {
const args = extractGenericTypeArgs(typeNode);
if (args.length >= 1) return args[0];
if (args.length >= 1) return pos === 'first' ? args[0] : args[args.length - 1];
}
// reference_type: &[User] or &Vec<User> — unwrap the reference and recurse
if (typeNode.type === 'reference_type') {
const inner = typeNode.lastNamedChild;
if (inner) return extractRustElementTypeFromTypeNode(inner);
if (inner) return extractRustElementTypeFromTypeNode(inner, pos);
}
// array_type: [User; N] — element is the first child
if (typeNode.type === 'array_type') {
@ -317,7 +317,7 @@ const extractRustElementTypeFromTypeNode = (typeNode: SyntaxNode): string | unde
/** Walk up from a for-loop to the enclosing function_item and search parameters
* for one named `iterableName`. Returns the element type from its annotation. */
const findRustParamElementType = (iterableName: string, startNode: SyntaxNode): string | undefined => {
const findRustParamElementType = (iterableName: string, startNode: SyntaxNode, pos: TypeArgPosition = 'last'): string | undefined => {
let current: SyntaxNode | null = startNode.parent;
while (current) {
if (current.type === 'function_item') {
@ -338,7 +338,7 @@ const findRustParamElementType = (iterableName: string, startNode: SyntaxNode):
}
if (identNode.text !== iterableName) continue;
const typeNode = param.childForFieldName('type');
if (typeNode) return extractRustElementTypeFromTypeNode(typeNode);
if (typeNode) return extractRustElementTypeFromTypeNode(typeNode, pos);
}
}
break;
@ -362,19 +362,32 @@ const extractForLoopBinding: ForLoopExtractor = (
const valueNode = node.childForFieldName('value');
if (!patternNode || !valueNode) return;
// Extract iterable name — may be &users, &mut users, or plain users
// Extract iterable name + method — may be &users, users, or users.iter()/keys()/values()
let iterableName: string | undefined;
let methodName: string | undefined;
if (valueNode.type === 'reference_expression') {
const inner = valueNode.lastNamedChild;
if (inner?.type === 'identifier') iterableName = inner.text;
} else if (valueNode.type === 'identifier') {
iterableName = valueNode.text;
} else if (valueNode.type === 'call_expression') {
// users.iter() → call_expression > function: field_expression > identifier + field_identifier
const fieldExpr = valueNode.childForFieldName('function');
if (fieldExpr?.type === 'field_expression') {
const obj = fieldExpr.firstNamedChild;
if (obj?.type === 'identifier') iterableName = obj.text;
// Extract method name: iter, keys, values, into_iter, etc.
const field = fieldExpr.lastNamedChild;
if (field?.type === 'field_identifier') methodName = field.text;
}
}
if (!iterableName) return;
const typeArgPos = methodToTypeArgPosition(methodName);
const elementType = resolveIterableElementType(
iterableName, node, scopeEnv, declarationTypeNodes, scope,
extractRustElementTypeFromTypeNode, findRustParamElementType,
typeArgPos,
);
if (!elementType) return;

View file

@ -1,5 +1,20 @@
import type { SyntaxNode } from '../utils.js';
/** Which type argument to extract from a multi-arg generic container.
* - 'first': key type (e.g., K from Map<K,V>) — used for .keys(), .keySet()
* - 'last': value type (e.g., V from Map<K,V>) — used for .values(), .items(), .iter() */
export type TypeArgPosition = 'first' | 'last';
/** Map method names to which type argument they yield.
* Methods that iterate/return keys → 'first'; everything else → 'last'. */
const KEY_METHODS = new Set(['keys', 'keySet', 'Keys']);
/** Determine which type arg to use based on the iterator method name.
* .keys() / .keySet() → 'first' (key type); everything else → 'last' (value type). */
export function methodToTypeArgPosition(methodName: string | undefined): TypeArgPosition {
return methodName && KEY_METHODS.has(methodName) ? 'first' : 'last';
}
/**
* Shared 3-strategy fallback for resolving the element type of a container variable.
* Used by all for-loop extractors to resolve the loop variable's type from the iterable.
@ -11,6 +26,7 @@ import type { SyntaxNode } from '../utils.js';
*
* @param extractFromTypeNode Language-specific function to extract element type from AST node
* @param findParamElementType Optional language-specific AST walk to find parameter type
* @param typeArgPos Which generic type arg to extract: 'first' for keys, 'last' for values (default)
*/
export function resolveIterableElementType(
iterableName: string,
@ -18,23 +34,24 @@ export function resolveIterableElementType(
scopeEnv: ReadonlyMap<string, string>,
declarationTypeNodes: ReadonlyMap<string, SyntaxNode>,
scope: string,
extractFromTypeNode: (typeNode: SyntaxNode) => string | undefined,
findParamElementType?: (name: string, startNode: SyntaxNode) => string | undefined,
extractFromTypeNode: (typeNode: SyntaxNode, pos?: TypeArgPosition) => string | undefined,
findParamElementType?: (name: string, startNode: SyntaxNode, pos?: TypeArgPosition) => string | undefined,
typeArgPos: TypeArgPosition = 'last',
): string | undefined {
// Strategy 1: declarationTypeNodes AST node
const typeNode = declarationTypeNodes.get(`${scope}\0${iterableName}`);
if (typeNode) {
const t = extractFromTypeNode(typeNode);
const t = extractFromTypeNode(typeNode, typeArgPos);
if (t) return t;
}
// Strategy 2: scopeEnv string → extractElementTypeFromString
const iterableType = scopeEnv.get(iterableName);
if (iterableType) {
const el = extractElementTypeFromString(iterableType);
const el = extractElementTypeFromString(iterableType, typeArgPos);
if (el) return el;
}
// Strategy 3: AST walk to function parameters
if (findParamElementType) return findParamElementType(iterableName, node);
if (findParamElementType) return findParamElementType(iterableName, node, typeArgPos);
return undefined;
}
@ -131,8 +148,9 @@ export const extractSimpleTypeName = (typeNode: SyntaxNode): string | undefined
if (inner) return extractSimpleTypeName(inner);
}
// PHP primitive_type (string, int, float, bool)
if (typeNode.type === 'primitive_type') {
// Primitive/predefined types: string, int, float, bool, number, unknown, any
// PHP: primitive_type; TS/JS: predefined_type
if (typeNode.type === 'primitive_type' || typeNode.type === 'predefined_type') {
return typeNode.text;
}
@ -383,11 +401,11 @@ function extractFirstArg(args: string): string {
* - vector<User> → User (C++ container)
* - Vec<User> → User (Rust container)
*
* For multi-argument generics (Map<K, V>), only the first type argument is
* returned. Returns undefined when the extracted type is not a simple word
* (e.g., nested generics as element types).
* For multi-argument generics (Map<K, V>), returns the first or last type arg
* based on `pos` ('first' for keys, 'last' for values — default 'last').
* Returns undefined when the extracted type is not a simple word.
*/
export function extractElementTypeFromString(typeStr: string): string | undefined {
export function extractElementTypeFromString(typeStr: string, pos: TypeArgPosition = 'last'): string | undefined {
if (!typeStr || typeStr.length === 0 || typeStr.length > 2048) return undefined;
// 1. Array suffix: User[] → User
@ -437,6 +455,7 @@ export function extractElementTypeFromString(typeStr: string): string | undefine
// selected closeChar can match at depth 0 (prevents cross-bracket miscounting).
let depth = 0;
const start = openIdx + 1;
let lastCommaIdx = -1; // Track last top-level comma for 'last' position
for (let i = start; i < typeStr.length; i++) {
const ch = typeStr[i];
if (ch === '<' || ch === '[') {
@ -445,15 +464,23 @@ export function extractElementTypeFromString(typeStr: string): string | undefine
if (depth === 0) {
// At depth 0 — only match if it is our selected close bracket.
if (ch !== closeChar) return undefined; // mismatched bracket = malformed
if (pos === 'last' && lastCommaIdx >= 0) {
// Return last arg (text after last comma)
const lastArg = typeStr.slice(lastCommaIdx + 1, i).trim();
return lastArg && /^\w+$/.test(lastArg) ? lastArg : undefined;
}
const inner = typeStr.slice(start, i).trim();
const firstArg = extractFirstArg(inner);
return firstArg && /^\w+$/.test(firstArg) ? firstArg : undefined;
}
depth--;
} else if (ch === ',' && depth === 0) {
// Top-level comma before the matching close bracket — take the text before it.
const arg = typeStr.slice(start, i).trim();
return arg && /^\w+$/.test(arg) ? arg : undefined;
if (pos === 'first') {
// Return first arg (text before first comma)
const arg = typeStr.slice(start, i).trim();
return arg && /^\w+$/.test(arg) ? arg : undefined;
}
lastCommaIdx = i;
}
}

View file

@ -1,6 +1,6 @@
import type { SyntaxNode } from '../utils.js';
import type { LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor, InitializerExtractor, ClassNameLookup, ConstructorBindingScanner, ReturnTypeExtractor, PendingAssignmentExtractor, ForLoopExtractor } from './types.js';
import { extractSimpleTypeName, extractVarName, hasTypeAnnotation, unwrapAwait, extractCalleeName, extractElementTypeFromString, extractGenericTypeArgs, resolveIterableElementType } from './shared.js';
import type { LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor, InitializerExtractor, ClassNameLookup, ConstructorBindingScanner, ReturnTypeExtractor, PendingAssignmentExtractor, ForLoopExtractor, PatternBindingExtractor } from './types.js';
import { extractSimpleTypeName, extractVarName, hasTypeAnnotation, unwrapAwait, extractCalleeName, extractElementTypeFromString, extractGenericTypeArgs, resolveIterableElementType, methodToTypeArgPosition, type TypeArgPosition } from './shared.js';
const DECLARATION_NODE_TYPES: ReadonlySet<string> = new Set([
'lexical_declaration',
@ -208,7 +208,7 @@ const TS_FUNCTION_NODE_TYPES = new Set([
* type_annotation ": Array<User>" → generic_type → extractGenericTypeArgs → "User"
* Falls back to text-based extraction via extractElementTypeFromString.
*/
const extractTsElementTypeFromAnnotation = (typeAnnotation: SyntaxNode): string | undefined => {
const extractTsElementTypeFromAnnotation = (typeAnnotation: SyntaxNode, pos: TypeArgPosition = 'last'): string | undefined => {
// Unwrap type_annotation (the node text includes ': ' prefix)
const inner = typeAnnotation.type === 'type_annotation'
? (typeAnnotation.firstNamedChild ?? typeAnnotation)
@ -217,7 +217,7 @@ const extractTsElementTypeFromAnnotation = (typeAnnotation: SyntaxNode): string
// readonly User[] — readonly_type wraps array_type: unwrap and recurse
if (inner.type === 'readonly_type') {
const wrapped = inner.firstNamedChild;
if (wrapped) return extractTsElementTypeFromAnnotation(wrapped);
if (wrapped) return extractTsElementTypeFromAnnotation(wrapped, pos);
}
// User[] — array_type: first named child is the element type
@ -226,15 +226,16 @@ const extractTsElementTypeFromAnnotation = (typeAnnotation: SyntaxNode): string
if (elem) return extractSimpleTypeName(elem);
}
// Array<User>, ReadonlyArray<User> — generic_type
// Array<User>, Map<string, User> — generic_type
// pos determines which type arg: 'first' for keys, 'last' for values
if (inner.type === 'generic_type') {
const args = extractGenericTypeArgs(inner);
if (args.length >= 1) return args[0];
if (args.length >= 1) return pos === 'first' ? args[0] : args[args.length - 1];
}
// Fallback: strip ': ' prefix from type_annotation text and use string extraction
const rawText = inner.text;
return extractElementTypeFromString(rawText);
return extractElementTypeFromString(rawText, pos);
};
/**
@ -246,6 +247,7 @@ const findTsLocalDeclElementType = (
iterableName: string,
blockNode: SyntaxNode,
beforeNode: SyntaxNode,
pos: TypeArgPosition = 'last',
): string | undefined => {
for (let i = 0; i < blockNode.namedChildCount; i++) {
const stmt = blockNode.namedChild(i);
@ -260,7 +262,7 @@ const findTsLocalDeclElementType = (
const nameNode = decl.childForFieldName('name');
if (nameNode?.text !== iterableName) continue;
const typeAnnotation = decl.childForFieldName('type');
if (typeAnnotation) return extractTsElementTypeFromAnnotation(typeAnnotation);
if (typeAnnotation) return extractTsElementTypeFromAnnotation(typeAnnotation, pos);
}
}
return undefined;
@ -272,7 +274,7 @@ const findTsLocalDeclElementType = (
* for a variable named `iterableName` with a container type annotation.
* Returns the element type extracted from the annotation, or undefined.
*/
const findTsIterableElementType = (iterableName: string, startNode: SyntaxNode): string | undefined => {
const findTsIterableElementType = (iterableName: string, startNode: SyntaxNode, pos: TypeArgPosition = 'last'): string | undefined => {
let current: SyntaxNode | null = startNode.parent;
// Capture the immediate statement_block parent to search local declarations
const blockNode = current?.type === 'statement_block' ? current : null;
@ -289,13 +291,13 @@ const findTsIterableElementType = (iterableName: string, startNode: SyntaxNode):
const patternNode = param.childForFieldName('pattern') ?? param.childForFieldName('name');
if (patternNode?.text === iterableName) {
const typeAnnotation = param.childForFieldName('type');
if (typeAnnotation) return extractTsElementTypeFromAnnotation(typeAnnotation);
if (typeAnnotation) return extractTsElementTypeFromAnnotation(typeAnnotation, pos);
}
}
}
// Search local declarations in the function body (statement_block)
if (blockNode) {
const result = findTsLocalDeclElementType(iterableName, blockNode, startNode);
const result = findTsLocalDeclElementType(iterableName, blockNode, startNode, pos);
if (result) return result;
}
break; // stop at the nearest function boundary
@ -336,21 +338,47 @@ const extractForLoopBinding: ForLoopExtractor = (
}
if (!isForOf) return;
// The iterable is the `right` field of the for_in_statement.
// The iterable is the `right` field — may be identifier or call_expression.
const rightNode = node.childForFieldName('right');
if (!rightNode || rightNode.type !== 'identifier') return;
const iterableName = rightNode.text;
let iterableName: string | undefined;
let methodName: string | undefined;
if (rightNode?.type === 'identifier') {
iterableName = rightNode.text;
} else if (rightNode?.type === 'call_expression') {
// entries.values() → call_expression > function: member_expression > object + property
const fn = rightNode.childForFieldName('function');
if (fn?.type === 'member_expression') {
const obj = fn.childForFieldName('object');
const prop = fn.childForFieldName('property');
if (obj?.type === 'identifier') iterableName = obj.text;
if (prop?.type === 'property_identifier') methodName = prop.text;
}
}
if (!iterableName) return;
const typeArgPos = methodToTypeArgPosition(methodName);
const elementType = resolveIterableElementType(
iterableName, node, scopeEnv, declarationTypeNodes, scope,
extractTsElementTypeFromAnnotation, findTsIterableElementType,
typeArgPos,
);
if (!elementType) return;
// The loop variable is the `left` field. It may be wrapped in a variable_declarator.
// The loop variable is the `left` field.
const leftNode = node.childForFieldName('left');
if (!leftNode) return;
// Handle destructured for-of: for (const [k, v] of entries)
// AST: left = array_pattern directly (no variable_declarator wrapper)
// Bind the LAST identifier to the element type (value in [key, value] patterns)
if (leftNode.type === 'array_pattern') {
const lastChild = leftNode.lastNamedChild;
if (lastChild?.type === 'identifier') {
scopeEnv.set(lastChild.text, elementType);
}
return;
}
let loopVarNode: SyntaxNode | null = leftNode;
// `const user` parses as: left → variable_declarator containing an identifier named `user`
if (loopVarNode.type === 'variable_declarator') {
@ -377,9 +405,25 @@ const extractPendingAssignment: PendingAssignmentExtractor = (node, scopeEnv) =>
return undefined;
};
/** TS instanceof narrowing: `x instanceof User` → bind x to User.
* Only works when x has no prior type binding (e.g. x: unknown, untyped params).
* Typed params (x: Animal) are blocked by the !scopeEnv.has() guard in buildTypeEnv.
* Uses first-writer-wins, same as Rust match arm bindings. */
const extractPatternBinding: PatternBindingExtractor = (node) => {
if (node.type !== 'binary_expression') return undefined;
const op = node.children.find(c => !c.isNamed && c.text === 'instanceof');
if (!op) return undefined;
// binary_expression children are positional — no left/right fields
const left = node.namedChild(0);
const right = node.namedChild(1);
if (left?.type !== 'identifier' || right?.type !== 'identifier') return undefined;
return { varName: left.text, typeName: right.text };
};
export const typeConfig: LanguageTypeConfig = {
declarationNodeTypes: DECLARATION_NODE_TYPES,
forLoopNodeTypes: FOR_LOOP_NODE_TYPES,
patternBindingNodeTypes: new Set(['binary_expression']),
extractDeclaration,
extractParameter,
extractInitializer,
@ -387,4 +431,5 @@ export const typeConfig: LanguageTypeConfig = {
extractReturnType,
extractForLoopBinding,
extractPendingAssignment,
extractPatternBinding,
};

View file

@ -0,0 +1,5 @@
from user import User
def process(data: dict[str, User]):
for key, user in data.items():
user.save()

View file

@ -0,0 +1,6 @@
class Repo:
def __init__(self, name: str):
self.name = name
def save(self):
pass

View file

@ -0,0 +1,6 @@
class User:
def __init__(self, name: str):
self.name = name
def save(self):
pass

View file

@ -0,0 +1,18 @@
mod user;
mod repo;
use crate::user::User;
use crate::repo::Repo;
fn process_users(users: Vec<User>) {
for user in users.iter() {
user.save();
}
}
fn process_repos(repos: Vec<Repo>) {
for repo in repos.into_iter() {
repo.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,7 @@
import { User } from './user';
function processEntries(entries: Map<string, User>) {
for (const [key, user] of entries) {
user.save();
}
}

View file

@ -0,0 +1,4 @@
export class Repo {
constructor(public name: string) {}
save() {}
}

View file

@ -0,0 +1,4 @@
export class User {
constructor(public name: string) {}
save() {}
}

View file

@ -0,0 +1,7 @@
import { User } from './user';
function process(x) {
if (x instanceof User) {
x.save();
}
}

View file

@ -0,0 +1,4 @@
export class Repo {
constructor(public name: string) {}
save() {}
}

View file

@ -0,0 +1,4 @@
export class User {
constructor(public name: string) {}
save() {}
}

View file

@ -1056,3 +1056,38 @@ describe('Python chained method call resolution', () => {
expect(repoSave).toBeUndefined();
});
});
// ---------------------------------------------------------------------------
// for key, user in data.items() — dict.items() call iterable + tuple unpacking
// ---------------------------------------------------------------------------
describe('Python dict.items() for-loop resolution', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'python-dict-items-loop'),
() => {},
);
}, 60000);
it('detects User class with save method', () => {
expect(getNodesByLabel(result, 'Class')).toContain('User');
});
it('resolves user.save() via dict.items() loop to User#save', () => {
const calls = getRelationships(result, 'CALLS');
const userSave = calls.find(c =>
c.target === 'save' && c.source === 'process' && c.targetFilePath?.includes('user.py'),
);
expect(userSave).toBeDefined();
});
it('does NOT resolve user.save() to Repo#save (negative)', () => {
const calls = getRelationships(result, 'CALLS');
const wrongSave = calls.find(c =>
c.target === 'save' && c.source === 'process' && c.targetFilePath?.includes('repo.py'),
);
expect(wrongSave).toBeUndefined();
});
});

View file

@ -1177,3 +1177,49 @@ describe('Rust match arm type resolution', () => {
expect(wrongSave).toBeUndefined();
});
});
// ---------------------------------------------------------------------------
// for user in users.iter() — call_expression iterable resolution
// ---------------------------------------------------------------------------
describe('Rust .iter() for-loop call_expression resolution', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'rust-iter-for-loop'),
() => {},
);
}, 60000);
it('detects User and Repo structs with save functions', () => {
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() via users.iter() to User#save', () => {
const calls = getRelationships(result, 'CALLS');
const userSave = calls.find(c =>
c.target === 'save' && c.source === 'process_users' && c.targetFilePath?.includes('user.rs'),
);
expect(userSave).toBeDefined();
});
it('resolves repo.save() via repos.into_iter() to Repo#save', () => {
const calls = getRelationships(result, 'CALLS');
const repoSave = calls.find(c =>
c.target === 'save' && c.source === 'process_repos' && c.targetFilePath?.includes('repo.rs'),
);
expect(repoSave).toBeDefined();
});
it('does NOT cross-resolve user.save() to Repo#save (negative)', () => {
const calls = getRelationships(result, 'CALLS');
const wrongSave = calls.find(c =>
c.target === 'save' && c.source === 'process_users' && c.targetFilePath?.includes('repo.rs'),
);
expect(wrongSave).toBeUndefined();
});
});

View file

@ -1471,3 +1471,73 @@ describe('TypeScript readonly array for-loop resolution (Tier 1c)', () => {
expect(wrong).toBeUndefined();
});
});
// ---------------------------------------------------------------------------
// for (const [key, user] of entries) — destructured for-of resolution
// ---------------------------------------------------------------------------
describe('TS destructured for-of Map resolution', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'typescript-destructured-for-of'),
() => {},
);
}, 60000);
it('detects User class with save method', () => {
expect(getNodesByLabel(result, 'Class')).toContain('User');
});
it('resolves user.save() in destructured for-of to User#save', () => {
const calls = getRelationships(result, 'CALLS');
const userSave = calls.find(c =>
c.target === 'save' && c.source === 'processEntries' && c.targetFilePath?.includes('user'),
);
expect(userSave).toBeDefined();
});
it('does NOT resolve user.save() to Repo#save (negative)', () => {
const calls = getRelationships(result, 'CALLS');
const wrongSave = calls.find(c =>
c.target === 'save' && c.source === 'processEntries' && c.targetFilePath?.includes('repo'),
);
expect(wrongSave).toBeUndefined();
});
});
// ---------------------------------------------------------------------------
// if (x instanceof User) { x.save() } — instanceof narrowing resolution
// ---------------------------------------------------------------------------
describe('TS instanceof narrowing resolution', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'typescript-instanceof-narrowing'),
() => {},
);
}, 60000);
it('detects User class with save method', () => {
expect(getNodesByLabel(result, 'Class')).toContain('User');
});
it('resolves x.save() after instanceof to User#save', () => {
const calls = getRelationships(result, 'CALLS');
const userSave = calls.find(c =>
c.target === 'save' && c.source === 'process' && c.targetFilePath?.includes('user'),
);
expect(userSave).toBeDefined();
});
it('does NOT resolve x.save() to Repo#save (negative)', () => {
const calls = getRelationships(result, 'CALLS');
const wrongSave = calls.find(c =>
c.target === 'save' && c.source === 'process' && c.targetFilePath?.includes('repo'),
);
expect(wrongSave).toBeUndefined();
});
});

View file

@ -2928,28 +2928,25 @@ class Foo {
});
});
// These tests document capabilities we intentionally do not support yet.
// Each skip test has assertions that should pass once the feature is implemented.
// When a skip test starts passing, remove .skip and update the corresponding issue.
describe('known limitations (documented skip tests)', () => {
it.skip('TS destructured for-of: for (const [k, v] of entries) — requires tuple destructuring', () => {
// extractVarName returns undefined for array_pattern; also needs
// resolveIterableElementType to return tuple types and ForLoopExtractor
// type signature to support multi-variable binding
describe('previously-skipped limitations (now resolved)', () => {
it('TS destructured for-of: for (const [k, v] of entries) — last-child heuristic', () => {
// array_pattern handled by binding last named child to element type.
// Map<string, User> resolves to 'User' via last generic type arg.
const tree = parse(`
function process(entries: Map<string, User>) {
for (const [key, user] of entries) {
user.save();
}
}
`, TypeScript);
`, TypeScript.typescript);
const { env } = buildTypeEnv(tree, 'typescript');
expect(flatGet(env, 'user')).toBe('User');
});
it.skip('Python tuple unpacking: for key, value in dict.items() — requires pattern_list + method call iterable', () => {
// Left side is pattern_list, not identifier (needs Group A: tuple destructuring).
// Right side is call_expression .items() (needs Group C: method call iterable resolution).
it('Python tuple unpacking: for key, value in dict.items() — call iterable + pattern_list', () => {
// call iterable: data.items() → extract receiver 'data' for type lookup.
// pattern_list: bind last named child to element type.
// dict[str, User] resolves to 'User' via last generic type arg.
const tree = parse(`
def process(data: dict[str, User]):
for key, user in data.items():
@ -2959,27 +2956,24 @@ def process(data: dict[str, User]):
expect(flatGet(env, 'user')).toBe('User');
});
it.skip('TS instanceof narrowing: if (x instanceof User) { x.save() } — needs block-level scoping', () => {
// Narrows existing variable within a block, does not introduce a new one.
// Requires scope stack infrastructure (push/pop on block entry/exit) and
// type guard recognition. TS parses instanceof as binary_expression, not
// a dedicated node type.
it('TS instanceof narrowing: if (x instanceof User) — first-writer-wins, not block-scoped', () => {
// Binds x to User via extractPatternBinding on binary_expression.
// Only works when x has no prior type binding in scopeEnv.
// True block-level scoping (overwriting existing bindings) is Phase 5.
const tree = parse(`
function process(x: unknown) {
function process(x) {
if (x instanceof User) {
x.save();
}
}
`, TypeScript);
`, TypeScript.typescript);
const { env } = buildTypeEnv(tree, 'typescript');
// x should be narrowed to User inside the if-block
expect(flatGet(env, 'x')).toBe('User');
});
it.skip('Rust for with .iter(): for user in users.iter() — needs method call iterable resolution', () => {
// Iterable is a call_expression, not an identifier. In idiomatic Rust,
// .iter()/.into_iter()/.iter_mut() is the dominant iteration pattern —
// the plain identifier form (which IS supported) is less common.
it('Rust for with .iter(): for user in users.iter() — call_expression iterable', () => {
// Extracts receiver from call_expression > field_expression > identifier.
// .iter()/.into_iter()/.iter_mut() is the dominant Rust iteration pattern.
const tree = parse(`
fn process(users: Vec<User>) {
for user in users.iter() {
@ -2990,12 +2984,83 @@ fn process(users: Vec<User>) {
const { env } = buildTypeEnv(tree, 'rust');
expect(flatGet(env, 'user')).toBe('User');
});
});
describe('method-aware type arg selection (.keys() vs .values())', () => {
it('TS for-of map.values() resolves to value type (User)', () => {
const tree = parse(`
function process(data: Map<string, User>) {
for (const user of data.values()) {
user.save();
}
}
`, TypeScript.typescript);
const { env } = buildTypeEnv(tree, 'typescript');
expect(flatGet(env, 'user')).toBe('User');
});
it('TS for-of map.keys() resolves to key type (string)', () => {
const tree = parse(`
function process(data: Map<string, User>) {
for (const key of data.keys()) {
key.trim();
}
}
`, TypeScript.typescript);
const { env } = buildTypeEnv(tree, 'typescript');
expect(flatGet(env, 'key')).toBe('string');
});
it('Python for key in data.keys() resolves to key type (str)', () => {
const tree = parse(`
def process(data: dict[str, User]):
for key in data.keys():
key.strip()
`, Python);
const { env } = buildTypeEnv(tree, 'python');
expect(flatGet(env, 'key')).toBe('str');
});
it('Python for user in data.values() resolves to value type (User)', () => {
const tree = parse(`
def process(data: dict[str, User]):
for user in data.values():
user.save()
`, Python);
const { env } = buildTypeEnv(tree, 'python');
expect(flatGet(env, 'user')).toBe('User');
});
it('Rust for key in map.keys() resolves to key type (String)', () => {
const tree = parse(`
fn process(data: HashMap<String, User>) {
for key in data.keys() {
key.len();
}
}
`, Rust);
const { env } = buildTypeEnv(tree, 'rust');
expect(flatGet(env, 'key')).toBe('String');
});
it('Rust for user in map.values() resolves to value type (User)', () => {
const tree = parse(`
fn process(data: HashMap<String, User>) {
for user in data.values() {
user.save();
}
}
`, Rust);
const { env } = buildTypeEnv(tree, 'rust');
expect(flatGet(env, 'user')).toBe('User');
});
});
describe('known limitations (documented skip tests)', () => {
it.skip('Ruby block parameter: users.each { |user| } — closure param inference, different feature', () => {
// Not a for-loop; .each { |user| } is a method call with a block.
// Requires closure parameter inference — a different feature category
// applicable to Ruby, Swift closures, Kotlin lambdas, and Java lambdas.
// Depends on method call resolution (Group C) as prerequisite.
const tree = parse(`
def process(users)
users.each { |user| user.save }