From 656af32e527bcab718e9caede235305c2bb78ef8 Mon Sep 17 00:00:00 2001 From: Gergo Magyar Date: Tue, 17 Mar 2026 06:24:09 +0000 Subject: [PATCH] feat: resolve 4 known limitation skip tests + method-aware type arg selection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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). --- .../core/ingestion/type-extractors/python.ts | 73 ++++++++--- .../core/ingestion/type-extractors/rust.ts | 29 +++-- .../core/ingestion/type-extractors/shared.ts | 55 ++++++--- .../ingestion/type-extractors/typescript.ts | 75 +++++++++--- .../python-dict-items-loop/app.py | 5 + .../python-dict-items-loop/repo.py | 6 + .../python-dict-items-loop/user.py | 6 + .../rust-iter-for-loop/src/main.rs | 18 +++ .../rust-iter-for-loop/src/repo.rs | 7 ++ .../rust-iter-for-loop/src/user.rs | 7 ++ .../typescript-destructured-for-of/src/app.ts | 7 ++ .../src/repo.ts | 4 + .../src/user.ts | 4 + .../src/app.ts | 7 ++ .../src/repo.ts | 4 + .../src/user.ts | 4 + .../test/integration/resolvers/python.test.ts | 35 ++++++ .../test/integration/resolvers/rust.test.ts | 46 +++++++ .../integration/resolvers/typescript.test.ts | 70 +++++++++++ gitnexus/test/unit/type-env.test.ts | 115 ++++++++++++++---- 20 files changed, 500 insertions(+), 77 deletions(-) create mode 100644 gitnexus/test/fixtures/lang-resolution/python-dict-items-loop/app.py create mode 100644 gitnexus/test/fixtures/lang-resolution/python-dict-items-loop/repo.py create mode 100644 gitnexus/test/fixtures/lang-resolution/python-dict-items-loop/user.py create mode 100644 gitnexus/test/fixtures/lang-resolution/rust-iter-for-loop/src/main.rs create mode 100644 gitnexus/test/fixtures/lang-resolution/rust-iter-for-loop/src/repo.rs create mode 100644 gitnexus/test/fixtures/lang-resolution/rust-iter-for-loop/src/user.rs create mode 100644 gitnexus/test/fixtures/lang-resolution/typescript-destructured-for-of/src/app.ts create mode 100644 gitnexus/test/fixtures/lang-resolution/typescript-destructured-for-of/src/repo.ts create mode 100644 gitnexus/test/fixtures/lang-resolution/typescript-destructured-for-of/src/user.ts create mode 100644 gitnexus/test/fixtures/lang-resolution/typescript-instanceof-narrowing/src/app.ts create mode 100644 gitnexus/test/fixtures/lang-resolution/typescript-instanceof-narrowing/src/repo.ts create mode 100644 gitnexus/test/fixtures/lang-resolution/typescript-instanceof-narrowing/src/user.ts diff --git a/gitnexus/src/core/ingestion/type-extractors/python.ts b/gitnexus/src/core/ingestion/type-extractors/python.ts index f971f48cc..98ce18287 100644 --- a/gitnexus/src/core/ingestion/type-extractors/python.ts +++ b/gitnexus/src/core/ingestion/type-extractors/python.ts @@ -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 = 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); }; diff --git a/gitnexus/src/core/ingestion/type-extractors/rust.ts b/gitnexus/src/core/ingestion/type-extractors/rust.ts index 067bcc750..19415b1b5 100644 --- a/gitnexus/src/core/ingestion/type-extractors/rust.ts +++ b/gitnexus/src/core/ingestion/type-extractors/rust.ts @@ -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 = new Set([ 'let_declaration', @@ -291,16 +291,16 @@ const FOR_LOOP_NODE_TYPES: ReadonlySet = new Set(['for_expression']); /** Extract element type from a Rust type annotation AST node. * Handles: generic_type (Vec), 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, HashSet — extract first type argument +const extractRustElementTypeFromTypeNode = (typeNode: SyntaxNode, pos: TypeArgPosition = 'last'): string | undefined => { + // generic_type: Vec, HashMap — 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 — 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; diff --git a/gitnexus/src/core/ingestion/type-extractors/shared.ts b/gitnexus/src/core/ingestion/type-extractors/shared.ts index b01d001e5..808b0db55 100644 --- a/gitnexus/src/core/ingestion/type-extractors/shared.ts +++ b/gitnexus/src/core/ingestion/type-extractors/shared.ts @@ -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) — used for .keys(), .keySet() + * - 'last': value type (e.g., V from Map) — 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, declarationTypeNodes: ReadonlyMap, 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 (C++ container) * - Vec → User (Rust container) * - * For multi-argument generics (Map), 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), 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; } } diff --git a/gitnexus/src/core/ingestion/type-extractors/typescript.ts b/gitnexus/src/core/ingestion/type-extractors/typescript.ts index b3e7706e4..6fd184fd5 100644 --- a/gitnexus/src/core/ingestion/type-extractors/typescript.ts +++ b/gitnexus/src/core/ingestion/type-extractors/typescript.ts @@ -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 = new Set([ 'lexical_declaration', @@ -208,7 +208,7 @@ const TS_FUNCTION_NODE_TYPES = new Set([ * type_annotation ": Array" → 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, ReadonlyArray — generic_type + // Array, Map — 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, }; diff --git a/gitnexus/test/fixtures/lang-resolution/python-dict-items-loop/app.py b/gitnexus/test/fixtures/lang-resolution/python-dict-items-loop/app.py new file mode 100644 index 000000000..2d78849a7 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/python-dict-items-loop/app.py @@ -0,0 +1,5 @@ +from user import User + +def process(data: dict[str, User]): + for key, user in data.items(): + user.save() diff --git a/gitnexus/test/fixtures/lang-resolution/python-dict-items-loop/repo.py b/gitnexus/test/fixtures/lang-resolution/python-dict-items-loop/repo.py new file mode 100644 index 000000000..2ab5d4e66 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/python-dict-items-loop/repo.py @@ -0,0 +1,6 @@ +class Repo: + def __init__(self, name: str): + self.name = name + + def save(self): + pass diff --git a/gitnexus/test/fixtures/lang-resolution/python-dict-items-loop/user.py b/gitnexus/test/fixtures/lang-resolution/python-dict-items-loop/user.py new file mode 100644 index 000000000..ce4796bd0 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/python-dict-items-loop/user.py @@ -0,0 +1,6 @@ +class User: + def __init__(self, name: str): + self.name = name + + def save(self): + pass diff --git a/gitnexus/test/fixtures/lang-resolution/rust-iter-for-loop/src/main.rs b/gitnexus/test/fixtures/lang-resolution/rust-iter-for-loop/src/main.rs new file mode 100644 index 000000000..e40a04da5 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-iter-for-loop/src/main.rs @@ -0,0 +1,18 @@ +mod user; +mod repo; +use crate::user::User; +use crate::repo::Repo; + +fn process_users(users: Vec) { + for user in users.iter() { + user.save(); + } +} + +fn process_repos(repos: Vec) { + for repo in repos.into_iter() { + repo.save(); + } +} + +fn main() {} diff --git a/gitnexus/test/fixtures/lang-resolution/rust-iter-for-loop/src/repo.rs b/gitnexus/test/fixtures/lang-resolution/rust-iter-for-loop/src/repo.rs new file mode 100644 index 000000000..bc1ad22b0 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-iter-for-loop/src/repo.rs @@ -0,0 +1,7 @@ +pub struct Repo { + pub name: String, +} + +impl Repo { + pub fn save(&self) {} +} diff --git a/gitnexus/test/fixtures/lang-resolution/rust-iter-for-loop/src/user.rs b/gitnexus/test/fixtures/lang-resolution/rust-iter-for-loop/src/user.rs new file mode 100644 index 000000000..706b24558 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-iter-for-loop/src/user.rs @@ -0,0 +1,7 @@ +pub struct User { + pub name: String, +} + +impl User { + pub fn save(&self) {} +} diff --git a/gitnexus/test/fixtures/lang-resolution/typescript-destructured-for-of/src/app.ts b/gitnexus/test/fixtures/lang-resolution/typescript-destructured-for-of/src/app.ts new file mode 100644 index 000000000..d16ea64bb --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/typescript-destructured-for-of/src/app.ts @@ -0,0 +1,7 @@ +import { User } from './user'; + +function processEntries(entries: Map) { + for (const [key, user] of entries) { + user.save(); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/typescript-destructured-for-of/src/repo.ts b/gitnexus/test/fixtures/lang-resolution/typescript-destructured-for-of/src/repo.ts new file mode 100644 index 000000000..653bd2c51 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/typescript-destructured-for-of/src/repo.ts @@ -0,0 +1,4 @@ +export class Repo { + constructor(public name: string) {} + save() {} +} diff --git a/gitnexus/test/fixtures/lang-resolution/typescript-destructured-for-of/src/user.ts b/gitnexus/test/fixtures/lang-resolution/typescript-destructured-for-of/src/user.ts new file mode 100644 index 000000000..a4f084b28 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/typescript-destructured-for-of/src/user.ts @@ -0,0 +1,4 @@ +export class User { + constructor(public name: string) {} + save() {} +} diff --git a/gitnexus/test/fixtures/lang-resolution/typescript-instanceof-narrowing/src/app.ts b/gitnexus/test/fixtures/lang-resolution/typescript-instanceof-narrowing/src/app.ts new file mode 100644 index 000000000..5b94bce5f --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/typescript-instanceof-narrowing/src/app.ts @@ -0,0 +1,7 @@ +import { User } from './user'; + +function process(x) { + if (x instanceof User) { + x.save(); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/typescript-instanceof-narrowing/src/repo.ts b/gitnexus/test/fixtures/lang-resolution/typescript-instanceof-narrowing/src/repo.ts new file mode 100644 index 000000000..653bd2c51 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/typescript-instanceof-narrowing/src/repo.ts @@ -0,0 +1,4 @@ +export class Repo { + constructor(public name: string) {} + save() {} +} diff --git a/gitnexus/test/fixtures/lang-resolution/typescript-instanceof-narrowing/src/user.ts b/gitnexus/test/fixtures/lang-resolution/typescript-instanceof-narrowing/src/user.ts new file mode 100644 index 000000000..a4f084b28 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/typescript-instanceof-narrowing/src/user.ts @@ -0,0 +1,4 @@ +export class User { + constructor(public name: string) {} + save() {} +} diff --git a/gitnexus/test/integration/resolvers/python.test.ts b/gitnexus/test/integration/resolvers/python.test.ts index 77186edd1..3bb5f52f0 100644 --- a/gitnexus/test/integration/resolvers/python.test.ts +++ b/gitnexus/test/integration/resolvers/python.test.ts @@ -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(); + }); +}); diff --git a/gitnexus/test/integration/resolvers/rust.test.ts b/gitnexus/test/integration/resolvers/rust.test.ts index 354e16361..670bb6092 100644 --- a/gitnexus/test/integration/resolvers/rust.test.ts +++ b/gitnexus/test/integration/resolvers/rust.test.ts @@ -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(); + }); +}); diff --git a/gitnexus/test/integration/resolvers/typescript.test.ts b/gitnexus/test/integration/resolvers/typescript.test.ts index 7da8690d1..15e3a1820 100644 --- a/gitnexus/test/integration/resolvers/typescript.test.ts +++ b/gitnexus/test/integration/resolvers/typescript.test.ts @@ -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(); + }); +}); diff --git a/gitnexus/test/unit/type-env.test.ts b/gitnexus/test/unit/type-env.test.ts index 40a86e9dc..e1325eb88 100644 --- a/gitnexus/test/unit/type-env.test.ts +++ b/gitnexus/test/unit/type-env.test.ts @@ -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 resolves to 'User' via last generic type arg. const tree = parse(` function process(entries: Map) { 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) { for user in users.iter() { @@ -2990,12 +2984,83 @@ fn process(users: Vec) { 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) { + 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) { + 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) { + 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) { + 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 }