From d05aa9ef6f8974957631b8b7242ac932276216cd Mon Sep 17 00:00:00 2001 From: Gergo Magyar Date: Tue, 17 Mar 2026 06:52:26 +0000 Subject: [PATCH] feat: method-aware for-loop extractors + integration tests for all languages Upgrade 4 existing extractors + create 3 new ones for full cross-language coverage of call_expression iterables and container descriptor resolution: Upgraded (add call expr iterable + methodToTypeArgPosition): - Java: method_invocation (data.keySet(), data.values()) - Kotlin: navigation_expression + call_expression (data.keys, data.values()) - C#: member_access_expression + invocation_expression (data.Keys, data.Values) - Go: TypeArgPosition threading for Go 1.18+ generics New for-loop extractors: - C++: for_range_loop with auto& unwrapping, template_type + qualified_identifier (std::vector) extraction, explicit vs auto type handling - PHP: foreach_statement with simple/key-value/by-reference forms, PHPDoc @param priority over AST array type - Ruby: for-in with YARD @param type resolution via comment parsing Integration test fixtures + tests for all 6 languages: - java-map-keys-values (Map.values() + List iteration) - kotlin-map-keys-values (HashMap.values + List iteration) - csharp-dictionary-keys-values (Dictionary.Values foreach) - cpp-range-for (auto& + const auto& range-based for) - php-foreach-loop (foreach with PHPDoc @param User[]) - ruby-for-in-loop (for-in with YARD @param Array) Bugs fixed during integration testing: - C++: qualified_identifier (std::vector) not unwrapped to template_type - PHP: extractParameter overwrote PHPDoc-derived types with bare 'array' 252 unit tests pass, 201 integration tests pass across 6 languages. --- .../core/ingestion/type-extractors/c-cpp.ts | 152 +++++++++++++++++- .../core/ingestion/type-extractors/csharp.ts | 52 ++++-- .../src/core/ingestion/type-extractors/go.ts | 19 ++- .../src/core/ingestion/type-extractors/jvm.ts | 81 ++++++++-- .../src/core/ingestion/type-extractors/php.ts | 122 +++++++++++++- .../core/ingestion/type-extractors/ruby.ts | 120 +++++++++++++- .../lang-resolution/cpp-range-for/Repo.h | 10 ++ .../lang-resolution/cpp-range-for/app.cpp | 13 +- .../lang-resolution/cpp-range-for/user.h | 6 +- .../csharp-dictionary-keys-values/App.cs | 15 ++ .../csharp-dictionary-keys-values/Repo.cs | 4 + .../csharp-dictionary-keys-values/User.cs | 4 + .../java-map-keys-values/src/App.java | 18 +++ .../java-map-keys-values/src/Repo.java | 7 + .../java-map-keys-values/src/User.java | 7 + .../kotlin-map-keys-values/src/App.kt | 11 ++ .../kotlin-map-keys-values/src/Repo.kt | 3 + .../kotlin-map-keys-values/src/User.kt | 3 + .../lang-resolution/php-foreach-loop/App.php | 12 ++ .../lang-resolution/php-foreach-loop/Repo.php | 11 ++ .../lang-resolution/php-foreach-loop/User.php | 11 ++ .../lang-resolution/ruby-for-in-loop/app.rb | 8 + .../lang-resolution/ruby-for-in-loop/repo.rb | 8 + .../lang-resolution/ruby-for-in-loop/user.rb | 8 + .../test/integration/resolvers/cpp.test.ts | 35 ++-- .../test/integration/resolvers/csharp.test.ts | 35 ++++ .../test/integration/resolvers/java.test.ts | 43 +++++ .../test/integration/resolvers/kotlin.test.ts | 43 +++++ .../test/integration/resolvers/php.test.ts | 35 ++++ .../test/integration/resolvers/ruby.test.ts | 35 ++++ 30 files changed, 874 insertions(+), 57 deletions(-) create mode 100644 gitnexus/test/fixtures/lang-resolution/cpp-range-for/Repo.h create mode 100644 gitnexus/test/fixtures/lang-resolution/csharp-dictionary-keys-values/App.cs create mode 100644 gitnexus/test/fixtures/lang-resolution/csharp-dictionary-keys-values/Repo.cs create mode 100644 gitnexus/test/fixtures/lang-resolution/csharp-dictionary-keys-values/User.cs create mode 100644 gitnexus/test/fixtures/lang-resolution/java-map-keys-values/src/App.java create mode 100644 gitnexus/test/fixtures/lang-resolution/java-map-keys-values/src/Repo.java create mode 100644 gitnexus/test/fixtures/lang-resolution/java-map-keys-values/src/User.java create mode 100644 gitnexus/test/fixtures/lang-resolution/kotlin-map-keys-values/src/App.kt create mode 100644 gitnexus/test/fixtures/lang-resolution/kotlin-map-keys-values/src/Repo.kt create mode 100644 gitnexus/test/fixtures/lang-resolution/kotlin-map-keys-values/src/User.kt create mode 100644 gitnexus/test/fixtures/lang-resolution/php-foreach-loop/App.php create mode 100644 gitnexus/test/fixtures/lang-resolution/php-foreach-loop/Repo.php create mode 100644 gitnexus/test/fixtures/lang-resolution/php-foreach-loop/User.php create mode 100644 gitnexus/test/fixtures/lang-resolution/ruby-for-in-loop/app.rb create mode 100644 gitnexus/test/fixtures/lang-resolution/ruby-for-in-loop/repo.rb create mode 100644 gitnexus/test/fixtures/lang-resolution/ruby-for-in-loop/user.rb diff --git a/gitnexus/src/core/ingestion/type-extractors/c-cpp.ts b/gitnexus/src/core/ingestion/type-extractors/c-cpp.ts index c4c8e535f..666fb34cd 100644 --- a/gitnexus/src/core/ingestion/type-extractors/c-cpp.ts +++ b/gitnexus/src/core/ingestion/type-extractors/c-cpp.ts @@ -1,10 +1,9 @@ import type { SyntaxNode } from '../utils.js'; -import type { LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor, InitializerExtractor, ClassNameLookup, ConstructorBindingScanner, PendingAssignmentExtractor } from './types.js'; -import { extractSimpleTypeName, extractVarName } from './shared.js'; +import type { LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor, InitializerExtractor, ClassNameLookup, ConstructorBindingScanner, PendingAssignmentExtractor, ForLoopExtractor } from './types.js'; +import { extractSimpleTypeName, extractVarName, resolveIterableElementType, methodToTypeArgPosition, type TypeArgPosition } from './shared.js'; const DECLARATION_NODE_TYPES: ReadonlySet = new Set([ 'declaration', - 'for_range_loop', ]); /** C++: Type x = ...; Type* x; Type& x; */ @@ -183,11 +182,158 @@ const extractPendingAssignment: PendingAssignmentExtractor = (node, scopeEnv) => return { lhs, rhs: value.text }; }; +// --- For-loop Tier 1c --- + +const FOR_LOOP_NODE_TYPES: ReadonlySet = new Set(['for_range_loop']); + +/** Extract template type arguments from a C++ template_type node. + * C++ template_type uses template_argument_list (not type_arguments), and each + * argument is a type_descriptor with a 'type' field containing the type_specifier. */ +const extractCppTemplateTypeArgs = (templateTypeNode: SyntaxNode): string[] => { + const argsNode = templateTypeNode.childForFieldName('arguments'); + if (!argsNode || argsNode.type !== 'template_argument_list') return []; + const result: string[] = []; + for (let i = 0; i < argsNode.namedChildCount; i++) { + let argNode = argsNode.namedChild(i); + if (!argNode) continue; + // type_descriptor wraps the actual type specifier in a 'type' field + if (argNode.type === 'type_descriptor') { + const inner = argNode.childForFieldName('type'); + if (inner) argNode = inner; + } + const name = extractSimpleTypeName(argNode); + if (name) result.push(name); + } + return result; +}; + +/** Extract element type from a C++ type annotation AST node. + * Handles: template_type (vector, map), + * pointer/reference types (User*, User&). */ +const extractCppElementTypeFromTypeNode = (typeNode: SyntaxNode, pos: TypeArgPosition = 'last'): string | undefined => { + // template_type: vector, map — extract type arg based on position + if (typeNode.type === 'template_type') { + const args = extractCppTemplateTypeArgs(typeNode); + if (args.length >= 1) return pos === 'first' ? args[0] : args[args.length - 1]; + } + // reference/pointer types: unwrap and recurse (vector& → vector) + if (typeNode.type === 'reference_type' || typeNode.type === 'pointer_type' + || typeNode.type === 'type_descriptor') { + const inner = typeNode.lastNamedChild; + if (inner) return extractCppElementTypeFromTypeNode(inner, pos); + } + // qualified/scoped types: std::vector → unwrap to template_type child + if (typeNode.type === 'qualified_identifier' || typeNode.type === 'scoped_type_identifier') { + const inner = typeNode.lastNamedChild; + if (inner) return extractCppElementTypeFromTypeNode(inner, pos); + } + return undefined; +}; + +/** Walk up from a for-range-loop to the enclosing function_definition and search parameters + * for one named `iterableName`. Returns the element type from its annotation. */ +const findCppParamElementType = (iterableName: string, startNode: SyntaxNode, pos: TypeArgPosition = 'last'): string | undefined => { + let current: SyntaxNode | null = startNode.parent; + while (current) { + if (current.type === 'function_definition') { + const declarator = current.childForFieldName('declarator'); + // function_definition > declarator (function_declarator) > parameters (parameter_list) + const paramsNode = declarator?.childForFieldName('parameters'); + if (paramsNode) { + for (let i = 0; i < paramsNode.namedChildCount; i++) { + const param = paramsNode.namedChild(i); + if (!param || param.type !== 'parameter_declaration') continue; + const paramDeclarator = param.childForFieldName('declarator'); + if (!paramDeclarator) continue; + // Unwrap reference/pointer declarators: vector& users → &users + let identNode = paramDeclarator; + if (identNode.type === 'reference_declarator' || identNode.type === 'pointer_declarator') { + identNode = identNode.firstNamedChild ?? identNode; + } + if (identNode.text !== iterableName) continue; + const typeNode = param.childForFieldName('type'); + if (typeNode) return extractCppElementTypeFromTypeNode(typeNode, pos); + } + } + break; + } + current = current.parent; + } + return undefined; +}; + +/** C++: for (auto& user : users) — extract loop variable binding. + * Handles explicit types (for (User& user : users)) and auto (for (auto& user : users)). + * For auto, resolves element type from the iterable's container type. */ +const extractForLoopBinding: ForLoopExtractor = ( + node: SyntaxNode, + scopeEnv: Map, + declarationTypeNodes: ReadonlyMap, + scope: string, +): void => { + if (node.type !== 'for_range_loop') return; + + const typeNode = node.childForFieldName('type'); + const declaratorNode = node.childForFieldName('declarator'); + const rightNode = node.childForFieldName('right'); + if (!typeNode || !declaratorNode || !rightNode) return; + + // Unwrap reference/pointer declarator to get the loop variable name + let nameNode = declaratorNode; + if (nameNode.type === 'reference_declarator' || nameNode.type === 'pointer_declarator') { + nameNode = nameNode.firstNamedChild ?? nameNode; + } + const varName = extractVarName(nameNode); + if (!varName) return; + + // Check if the type is auto/placeholder — if not, use the explicit type directly + const isAuto = typeNode.type === 'placeholder_type_specifier' + || typeNode.text === 'auto' + || typeNode.text === 'const auto' + || typeNode.text === 'decltype(auto)'; + + if (!isAuto) { + // Explicit type: for (User& user : users) — extract directly + const typeName = extractSimpleTypeName(typeNode); + if (typeName) scopeEnv.set(varName, typeName); + return; + } + + // auto/const auto/auto& — resolve from the iterable's container type + // Extract iterable name + optional method + let iterableName: string | undefined; + let methodName: string | undefined; + if (rightNode.type === 'identifier') { + iterableName = rightNode.text; + } else if (rightNode.type === 'call_expression') { + // users.begin() is NOT used in range-for, but container.items() etc. might be + const fieldExpr = rightNode.childForFieldName('function'); + if (fieldExpr?.type === 'field_expression') { + const obj = fieldExpr.firstNamedChild; + if (obj?.type === 'identifier') iterableName = obj.text; + const field = fieldExpr.lastNamedChild; + if (field?.type === 'field_identifier') methodName = field.text; + } + } + if (!iterableName) return; + + const containerTypeName = scopeEnv.get(iterableName); + const typeArgPos = methodToTypeArgPosition(methodName, containerTypeName); + const elementType = resolveIterableElementType( + iterableName, node, scopeEnv, declarationTypeNodes, scope, + extractCppElementTypeFromTypeNode, findCppParamElementType, + typeArgPos, + ); + if (elementType) scopeEnv.set(varName, elementType); +}; + export const typeConfig: LanguageTypeConfig = { declarationNodeTypes: DECLARATION_NODE_TYPES, + forLoopNodeTypes: FOR_LOOP_NODE_TYPES, extractDeclaration, extractParameter, extractInitializer, scanConstructorBinding, + extractForLoopBinding, extractPendingAssignment, }; diff --git a/gitnexus/src/core/ingestion/type-extractors/csharp.ts b/gitnexus/src/core/ingestion/type-extractors/csharp.ts index afe2431dd..a52ec4f54 100644 --- a/gitnexus/src/core/ingestion/type-extractors/csharp.ts +++ b/gitnexus/src/core/ingestion/type-extractors/csharp.ts @@ -1,6 +1,6 @@ import type { SyntaxNode } from '../utils.js'; import type { ConstructorBindingScanner, ForLoopExtractor, LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor, PendingAssignmentExtractor, PatternBindingExtractor } from './types.js'; -import { extractSimpleTypeName, extractVarName, findChildByType, unwrapAwait, extractGenericTypeArgs, resolveIterableElementType } from './shared.js'; +import { extractSimpleTypeName, extractVarName, findChildByType, unwrapAwait, extractGenericTypeArgs, resolveIterableElementType, methodToTypeArgPosition, type TypeArgPosition } from './shared.js'; const DECLARATION_NODE_TYPES: ReadonlySet = new Set([ 'local_declaration_statement', @@ -132,14 +132,21 @@ const FOR_LOOP_NODE_TYPES: ReadonlySet = new Set([ ]); /** Extract element type from a C# type annotation AST node. - * Handles generic_name (List), array_type (User[]), nullable_type (?). */ -const extractCSharpElementTypeFromTypeNode = (typeNode: SyntaxNode): string | undefined => { - // generic_name: List, IEnumerable — C# uses generic_name (not generic_type) + * Handles generic_name (List), array_type (User[]), nullable_type (?). + * `pos` selects which type arg: 'first' for keys, 'last' for values (default). */ +const extractCSharpElementTypeFromTypeNode = (typeNode: SyntaxNode, pos: TypeArgPosition = 'last'): string | undefined => { + // generic_name: List, IEnumerable, Dictionary + // C# uses generic_name (not generic_type) if (typeNode.type === 'generic_name') { const argList = findChildByType(typeNode, 'type_argument_list'); if (argList && argList.namedChildCount >= 1) { - const firstArg = argList.namedChild(0); - if (firstArg) return extractSimpleTypeName(firstArg); + if (pos === 'first') { + const firstArg = argList.namedChild(0); + if (firstArg) return extractSimpleTypeName(firstArg); + } else { + const lastArg = argList.namedChild(argList.namedChildCount - 1); + if (lastArg) return extractSimpleTypeName(lastArg); + } } } // array_type: User[] @@ -150,13 +157,13 @@ const extractCSharpElementTypeFromTypeNode = (typeNode: SyntaxNode): string | un // nullable_type: unwrap and recurse (List? → List → User) if (typeNode.type === 'nullable_type') { const inner = typeNode.firstNamedChild; - if (inner) return extractCSharpElementTypeFromTypeNode(inner); + if (inner) return extractCSharpElementTypeFromTypeNode(inner, pos); } return undefined; }; /** Walk up from a foreach to the enclosing method and search parameters. */ -const findCSharpParamElementType = (iterableName: string, startNode: SyntaxNode): string | undefined => { +const findCSharpParamElementType = (iterableName: string, startNode: SyntaxNode, pos: TypeArgPosition = 'last'): string | undefined => { let current: SyntaxNode | null = startNode.parent; while (current) { if (current.type === 'method_declaration' || current.type === 'local_function_statement') { @@ -168,7 +175,7 @@ const findCSharpParamElementType = (iterableName: string, startNode: SyntaxNode) const nameNode = param.childForFieldName('name'); if (nameNode?.text !== iterableName) continue; const typeNode = param.childForFieldName('type'); - if (typeNode) return extractCSharpElementTypeFromTypeNode(typeNode); + if (typeNode) return extractCSharpElementTypeFromTypeNode(typeNode, pos); } } break; @@ -201,12 +208,35 @@ const extractForLoopBinding: ForLoopExtractor = ( // Tier 1c: implicit type (var) — resolve from iterable's container type 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 === 'member_access_expression') { + // C# property access: data.Keys, data.Values → member_access_expression + const obj = rightNode.childForFieldName('expression'); + const prop = rightNode.childForFieldName('name'); + if (obj?.type === 'identifier') iterableName = obj.text; + if (prop?.type === 'identifier') methodName = prop.text; + } else if (rightNode?.type === 'invocation_expression') { + // C# method call: data.Select(...) → invocation_expression > member_access_expression + const fn = rightNode.firstNamedChild; + if (fn?.type === 'member_access_expression') { + const obj = fn.childForFieldName('expression'); + const prop = fn.childForFieldName('name'); + if (obj?.type === 'identifier') iterableName = obj.text; + if (prop?.type === 'identifier') methodName = prop.text; + } + } + if (!iterableName) return; + + const containerTypeName = scopeEnv.get(iterableName); + const typeArgPos = methodToTypeArgPosition(methodName, containerTypeName); const elementType = resolveIterableElementType( iterableName, node, scopeEnv, declarationTypeNodes, scope, extractCSharpElementTypeFromTypeNode, findCSharpParamElementType, + typeArgPos, ); if (elementType) scopeEnv.set(varName, elementType); }; diff --git a/gitnexus/src/core/ingestion/type-extractors/go.ts b/gitnexus/src/core/ingestion/type-extractors/go.ts index cccc04ac9..321638616 100644 --- a/gitnexus/src/core/ingestion/type-extractors/go.ts +++ b/gitnexus/src/core/ingestion/type-extractors/go.ts @@ -1,6 +1,6 @@ import type { SyntaxNode } from '../utils.js'; import type { ConstructorBindingScanner, ForLoopExtractor, LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor, PendingAssignmentExtractor } from './types.js'; -import { extractSimpleTypeName, extractVarName, extractElementTypeFromString, findChildByType, resolveIterableElementType } from './shared.js'; +import { extractSimpleTypeName, extractVarName, extractElementTypeFromString, extractGenericTypeArgs, findChildByType, resolveIterableElementType, methodToTypeArgPosition, type TypeArgPosition } from './shared.js'; const DECLARATION_NODE_TYPES: ReadonlySet = new Set([ 'var_declaration', @@ -197,7 +197,7 @@ const GO_FUNCTION_NODE_TYPES = new Set([ * array_type "[10]User" → element field → type_identifier "User" * Falls back to text-based extraction via extractElementTypeFromString. */ -const extractGoElementTypeFromTypeNode = (typeNode: SyntaxNode): string | undefined => { +const extractGoElementTypeFromTypeNode = (typeNode: SyntaxNode, pos: TypeArgPosition = 'last'): string | undefined => { // slice_type: []User — element field is the element type if (typeNode.type === 'slice_type' || typeNode.type === 'array_type') { const elemNode = typeNode.childForFieldName('element'); @@ -213,8 +213,14 @@ const extractGoElementTypeFromTypeNode = (typeNode: SyntaxNode): string | undefi const valueNode = typeNode.childForFieldName('value') ?? typeNode.lastNamedChild; if (valueNode) return extractSimpleTypeName(valueNode); } + // generic_type: Go 1.18+ generics (e.g., MySlice[User], Cache[string, User]) + // Use position-aware arg selection: 'first' for keys, 'last' for values. + if (typeNode.type === 'generic_type') { + const args = extractGenericTypeArgs(typeNode); + if (args.length >= 1) return pos === 'first' ? args[0] : args[args.length - 1]; + } // Fallback: text-based extraction ([]User → User, User[] → User) - return extractElementTypeFromString(typeNode.text); + return extractElementTypeFromString(typeNode.text, pos); }; /** Check if a Go type node represents a channel type. Used to determine @@ -242,7 +248,7 @@ const isChannelType = ( * name field: identifier (the parameter name) * type field: the type node (slice_type for []User) */ -const findGoParamElementType = (iterableName: string, startNode: SyntaxNode): string | undefined => { +const findGoParamElementType = (iterableName: string, startNode: SyntaxNode, pos: TypeArgPosition = 'last'): string | undefined => { let current: SyntaxNode | null = startNode.parent; while (current) { if (GO_FUNCTION_NODE_TYPES.has(current.type)) { @@ -255,7 +261,7 @@ const findGoParamElementType = (iterableName: string, startNode: SyntaxNode): st const nameNode = paramDecl.childForFieldName('name'); if (nameNode?.text === iterableName) { const typeNode = paramDecl.childForFieldName('type'); - if (typeNode) return extractGoElementTypeFromTypeNode(typeNode); + if (typeNode) return extractGoElementTypeFromTypeNode(typeNode, pos); } } } @@ -304,9 +310,12 @@ const extractForLoopBinding: ForLoopExtractor = ( if (!rightNode || rightNode.type !== 'identifier') return; const iterableName = rightNode.text; + const containerTypeName = scopeEnv.get(iterableName); + const typeArgPos = methodToTypeArgPosition(undefined, containerTypeName); const elementType = resolveIterableElementType( iterableName, node, scopeEnv, declarationTypeNodes, scope, extractGoElementTypeFromTypeNode, findGoParamElementType, + typeArgPos, ); if (!elementType) return; diff --git a/gitnexus/src/core/ingestion/type-extractors/jvm.ts b/gitnexus/src/core/ingestion/type-extractors/jvm.ts index 9ee3aa19d..36e190bc4 100644 --- a/gitnexus/src/core/ingestion/type-extractors/jvm.ts +++ b/gitnexus/src/core/ingestion/type-extractors/jvm.ts @@ -1,6 +1,6 @@ import type { SyntaxNode } from '../utils.js'; import type { LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor, InitializerExtractor, ClassNameLookup, ConstructorBindingScanner, ForLoopExtractor, PendingAssignmentExtractor, PatternBindingExtractor } from './types.js'; -import { extractSimpleTypeName, extractVarName, findChildByType, extractGenericTypeArgs, resolveIterableElementType } from './shared.js'; +import { extractSimpleTypeName, extractVarName, findChildByType, extractGenericTypeArgs, resolveIterableElementType, methodToTypeArgPosition, type TypeArgPosition } from './shared.js'; // ── Java ────────────────────────────────────────────────────────────────── @@ -91,10 +91,10 @@ const JAVA_FOR_LOOP_NODE_TYPES: ReadonlySet = new Set([ /** Extract element type from a Java type annotation AST node. * Handles generic_type (List), array_type (User[]). */ -const extractJavaElementTypeFromTypeNode = (typeNode: SyntaxNode): string | undefined => { +const extractJavaElementTypeFromTypeNode = (typeNode: SyntaxNode, pos: TypeArgPosition = 'last'): string | undefined => { 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]; } if (typeNode.type === 'array_type') { const elemNode = typeNode.firstNamedChild; @@ -104,7 +104,7 @@ const extractJavaElementTypeFromTypeNode = (typeNode: SyntaxNode): string | unde }; /** Walk up from a for-each to the enclosing method_declaration and search parameters. */ -const findJavaParamElementType = (iterableName: string, startNode: SyntaxNode): string | undefined => { +const findJavaParamElementType = (iterableName: string, startNode: SyntaxNode, pos: TypeArgPosition = 'last'): string | undefined => { let current: SyntaxNode | null = startNode.parent; while (current) { if (current.type === 'method_declaration' || current.type === 'constructor_declaration') { @@ -116,7 +116,7 @@ const findJavaParamElementType = (iterableName: string, startNode: SyntaxNode): const nameNode = param.childForFieldName('name'); if (nameNode?.text !== iterableName) continue; const typeNode = param.childForFieldName('type'); - if (typeNode) return extractJavaElementTypeFromTypeNode(typeNode); + if (typeNode) return extractJavaElementTypeFromTypeNode(typeNode, pos); } } break; @@ -149,12 +149,27 @@ const extractJavaForLoopBinding: ForLoopExtractor = ( // Tier 1c: var — resolve from iterable's container type const iterableNode = node.childForFieldName('value'); - if (!iterableNode || iterableNode.type !== 'identifier') return; - const iterableName = iterableNode.text; + if (!iterableNode) return; + let iterableName: string | undefined; + let methodName: string | undefined; + if (iterableNode.type === 'identifier') { + iterableName = iterableNode.text; + } else if (iterableNode.type === 'method_invocation') { + // data.keySet() → method_invocation > object: identifier + name: identifier + const obj = iterableNode.childForFieldName('object'); + const name = iterableNode.childForFieldName('name'); + if (obj?.type === 'identifier') iterableName = obj.text; + if (name) methodName = name.text; + } + if (!iterableName) return; + + const containerTypeName = scopeEnv.get(iterableName); + const typeArgPos = methodToTypeArgPosition(methodName, containerTypeName); const elementType = resolveIterableElementType( iterableName, node, scopeEnv, declarationTypeNodes, scope, extractJavaElementTypeFromTypeNode, findJavaParamElementType, + typeArgPos, ); if (elementType) scopeEnv.set(varName, elementType); }; @@ -343,16 +358,18 @@ const KOTLIN_FOR_LOOP_NODE_TYPES: ReadonlySet = new Set([ /** Extract element type from a Kotlin type annotation AST node (user_type wrapping generic). * Kotlin: user_type → [type_identifier, type_arguments → [type_projection → user_type]] * Handles the type_projection wrapper that Kotlin uses for generic type arguments. */ -const extractKotlinElementTypeFromTypeNode = (typeNode: SyntaxNode): string | undefined => { +const extractKotlinElementTypeFromTypeNode = (typeNode: SyntaxNode, pos: TypeArgPosition = 'last'): string | undefined => { if (typeNode.type === 'user_type') { const argsNode = findChildByType(typeNode, 'type_arguments'); if (argsNode && argsNode.namedChildCount >= 1) { - const firstArg = argsNode.namedChild(0); - if (!firstArg) return undefined; + const targetArg = pos === 'first' + ? argsNode.namedChild(0) + : argsNode.namedChild(argsNode.namedChildCount - 1); + if (!targetArg) return undefined; // Kotlin wraps type args in type_projection — unwrap to get the inner type - const inner = firstArg.type === 'type_projection' - ? firstArg.firstNamedChild - : firstArg; + const inner = targetArg.type === 'type_projection' + ? targetArg.firstNamedChild + : targetArg; if (inner) return extractSimpleTypeName(inner); } } @@ -361,7 +378,7 @@ const extractKotlinElementTypeFromTypeNode = (typeNode: SyntaxNode): string | un /** Walk up from a for-loop to the enclosing function_declaration and search parameters. * Kotlin parameters use positional children (simple_identifier, user_type), not named fields. */ -const findKotlinParamElementType = (iterableName: string, startNode: SyntaxNode): string | undefined => { +const findKotlinParamElementType = (iterableName: string, startNode: SyntaxNode, pos: TypeArgPosition = 'last'): string | undefined => { let current: SyntaxNode | null = startNode.parent; while (current) { if (current.type === 'function_declaration') { @@ -373,7 +390,7 @@ const findKotlinParamElementType = (iterableName: string, startNode: SyntaxNode) const nameNode = findChildByType(param, 'simple_identifier'); if (nameNode?.text !== iterableName) continue; const typeNode = findChildByType(param, 'user_type'); - if (typeNode) return extractKotlinElementTypeFromTypeNode(typeNode); + if (typeNode) return extractKotlinElementTypeFromTypeNode(typeNode, pos); } } break; @@ -407,23 +424,53 @@ const extractKotlinForLoopBinding: ForLoopExtractor = ( } // Tier 1c: no annotation — resolve from iterable's container type - // Kotlin for-loop children: [variable_declaration, simple_identifier(iterable), control_structure_body] + // Kotlin for-loop children: [variable_declaration, iterable_expr, control_structure_body] // The iterable is the second named child of the for_statement (after variable_declaration) let iterableName: string | undefined; + let methodName: string | undefined; let foundVarDecl = false; for (let i = 0; i < node.namedChildCount; i++) { const child = node.namedChild(i); if (child === varDecl) { foundVarDecl = true; continue; } - if (foundVarDecl && child?.type === 'simple_identifier') { + if (!foundVarDecl || !child) continue; + if (child.type === 'simple_identifier') { iterableName = child.text; break; } + if (child.type === 'navigation_expression') { + // data.keys → navigation_expression > simple_identifier(data) + navigation_suffix > simple_identifier(keys) + const obj = child.firstNamedChild; + if (obj?.type === 'simple_identifier') iterableName = obj.text; + const suffix = findChildByType(child, 'navigation_suffix'); + if (suffix) { + const prop = findChildByType(suffix, 'simple_identifier'); + if (prop) methodName = prop.text; + } + break; + } + if (child.type === 'call_expression') { + // data.values() → call_expression > navigation_expression > simple_identifier + navigation_suffix + const callee = child.firstNamedChild; + if (callee?.type === 'navigation_expression') { + const obj = callee.firstNamedChild; + if (obj?.type === 'simple_identifier') iterableName = obj.text; + const suffix = findChildByType(callee, 'navigation_suffix'); + if (suffix) { + const prop = findChildByType(suffix, 'simple_identifier'); + if (prop) methodName = prop.text; + } + } + break; + } } if (!iterableName) return; + const containerTypeName = scopeEnv.get(iterableName); + const typeArgPos = methodToTypeArgPosition(methodName, containerTypeName); const elementType = resolveIterableElementType( iterableName, node, scopeEnv, declarationTypeNodes, scope, extractKotlinElementTypeFromTypeNode, findKotlinParamElementType, + typeArgPos, ); if (elementType) scopeEnv.set(varName, elementType); }; diff --git a/gitnexus/src/core/ingestion/type-extractors/php.ts b/gitnexus/src/core/ingestion/type-extractors/php.ts index be561a12e..99cfa105d 100644 --- a/gitnexus/src/core/ingestion/type-extractors/php.ts +++ b/gitnexus/src/core/ingestion/type-extractors/php.ts @@ -1,6 +1,6 @@ import type { SyntaxNode } from '../utils.js'; -import type { LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor, InitializerExtractor, ClassNameLookup, ConstructorBindingScanner, ReturnTypeExtractor, PendingAssignmentExtractor } from './types.js'; -import { extractSimpleTypeName, extractVarName, extractCalleeName } from './shared.js'; +import type { LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor, InitializerExtractor, ClassNameLookup, ConstructorBindingScanner, ReturnTypeExtractor, PendingAssignmentExtractor, ForLoopExtractor } from './types.js'; +import { extractSimpleTypeName, extractVarName, extractCalleeName, resolveIterableElementType } from './shared.js'; const DECLARATION_NODE_TYPES: ReadonlySet = new Set([ 'assignment_expression', // For constructor inference: $x = new User() @@ -190,8 +190,12 @@ const extractParameter: ParameterExtractor = (node: SyntaxNode, env: MapgetUser() — bind variable to call return type */ @@ -259,12 +263,124 @@ const extractPendingAssignment: PendingAssignmentExtractor = (node, scopeEnv) => return { lhs, rhs }; }; +const FOR_LOOP_NODE_TYPES: ReadonlySet = new Set([ + 'foreach_statement', +]); + +/** Extract element type from a PHP type annotation AST node. + * PHP has limited AST-level container types — `array` is a primitive_type with no generic args. + * Named types (e.g., `Collection`) are returned as-is (container descriptor lookup handles them). */ +const extractPhpElementTypeFromTypeNode = (_typeNode: SyntaxNode): string | undefined => { + // PHP AST type nodes don't carry generic parameters (array is PHPDoc-only). + // primitive_type 'array' and named_type 'Collection' don't encode element types. + return undefined; +}; + +/** Walk up from a foreach to the enclosing function and search parameter type annotations. + * PHP parameter type hints are limited (array, ClassName) — this extracts element type when possible. */ +const findPhpParamElementType = (iterableName: string, startNode: SyntaxNode): string | undefined => { + let current: SyntaxNode | null = startNode.parent; + while (current) { + if (current.type === 'method_declaration' || current.type === 'function_definition') { + const paramsNode = current.childForFieldName('parameters'); + if (paramsNode) { + for (let i = 0; i < paramsNode.namedChildCount; i++) { + const param = paramsNode.namedChild(i); + if (!param || param.type !== 'simple_parameter') continue; + const nameNode = param.childForFieldName('name'); + if (nameNode?.text !== iterableName) continue; + const typeNode = param.childForFieldName('type'); + if (typeNode) return extractPhpElementTypeFromTypeNode(typeNode); + } + } + break; + } + current = current.parent; + } + return undefined; +}; + +/** + * PHP: foreach ($users as $user) — extract loop variable binding. + * + * AST structure (from tree-sitter-php grammar): + * foreach_statement — no named fields for iterable/value (only 'body') + * children[0]: expression (iterable, e.g. $users) + * children[1]: expression (simple value) OR pair ($key => $value) + * pair children: expression (key), expression (value) + * + * PHP's PHPDoc @param normalizes `User[]` → `User` in the env, so the iterable's + * stored type IS the element type. We first try resolveIterableElementType (for + * constructor-binding cases that retain container types), then fall back to direct + * scopeEnv lookup (for PHPDoc-normalized types). + */ +const extractForLoopBinding: ForLoopExtractor = ( + node: SyntaxNode, + scopeEnv: Map, + declarationTypeNodes: ReadonlyMap, + scope: string, +): void => { + if (node.type !== 'foreach_statement') return; + + // Collect non-body named children: first is the iterable, second is value or pair + const children: SyntaxNode[] = []; + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child && child !== node.childForFieldName('body')) { + children.push(child); + } + } + if (children.length < 2) return; + + const iterableNode = children[0]; + const valueOrPair = children[1]; + + // Determine the loop variable node + let loopVarNode: SyntaxNode; + if (valueOrPair.type === 'pair') { + // $key => $value — the value is the last named child of the pair + const lastChild = valueOrPair.namedChild(valueOrPair.namedChildCount - 1); + if (!lastChild) return; + // Handle by_ref: foreach ($arr as $k => &$v) + loopVarNode = lastChild.type === 'by_ref' ? (lastChild.firstNamedChild ?? lastChild) : lastChild; + } else { + // Simple: foreach ($users as $user) or foreach ($users as &$user) + loopVarNode = valueOrPair.type === 'by_ref' ? (valueOrPair.firstNamedChild ?? valueOrPair) : valueOrPair; + } + + const varName = extractVarName(loopVarNode); + if (!varName) return; + + // Get iterable variable name (PHP vars include $ prefix) + const iterableName = iterableNode.type === 'variable_name' ? iterableNode.text : undefined; + if (!iterableName) return; + + // Strategy A: try resolveIterableElementType (handles constructor-binding container types) + const elementType = resolveIterableElementType( + iterableName, node, scopeEnv, declarationTypeNodes, scope, + extractPhpElementTypeFromTypeNode, findPhpParamElementType, + ); + if (elementType) { + scopeEnv.set(varName, elementType); + return; + } + + // Strategy B: direct scopeEnv lookup — PHP normalizePhpType strips User[] → User, + // so the iterable's stored type is already the element type from PHPDoc annotations. + const iterableType = scopeEnv.get(iterableName); + if (iterableType) { + scopeEnv.set(varName, iterableType); + } +}; + export const typeConfig: LanguageTypeConfig = { declarationNodeTypes: DECLARATION_NODE_TYPES, + forLoopNodeTypes: FOR_LOOP_NODE_TYPES, extractDeclaration, extractParameter, extractInitializer, scanConstructorBinding, extractReturnType, + extractForLoopBinding, extractPendingAssignment, }; diff --git a/gitnexus/src/core/ingestion/type-extractors/ruby.ts b/gitnexus/src/core/ingestion/type-extractors/ruby.ts index 9f735952c..6d179e748 100644 --- a/gitnexus/src/core/ingestion/type-extractors/ruby.ts +++ b/gitnexus/src/core/ingestion/type-extractors/ruby.ts @@ -1,5 +1,5 @@ -import type { LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor, InitializerExtractor, ClassNameLookup, ConstructorBindingScanner, ReturnTypeExtractor, PendingAssignmentExtractor } from './types.js'; -import { extractRubyConstructorAssignment, extractSimpleTypeName } from './shared.js'; +import type { LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor, InitializerExtractor, ClassNameLookup, ConstructorBindingScanner, ReturnTypeExtractor, PendingAssignmentExtractor, ForLoopExtractor } from './types.js'; +import { extractRubyConstructorAssignment, extractSimpleTypeName, extractElementTypeFromString, extractVarName, resolveIterableElementType } from './shared.js'; import type { SyntaxNode } from '../utils.js'; /** @@ -261,6 +261,120 @@ const scanConstructorBinding: ConstructorBindingScanner = (node) => { return { varName: left.text, calleeName }; }; +/** Ruby method node types that carry a parameter list. */ +const RUBY_METHOD_NODE_TYPES = new Set(['method', 'singleton_method']); + +const FOR_LOOP_NODE_TYPES: ReadonlySet = new Set(['for']); + +/** + * Collect raw YARD @param type strings from comment nodes preceding a method. + * Unlike collectYardParams which returns simplified type names, this returns the + * raw bracket content (e.g., "Array" not "Array") for element type extraction. + */ +const collectYardRawParams = (methodNode: SyntaxNode): Map => { + const params = new Map(); + const commentTexts: string[] = []; + + const collectComments = (startNode: SyntaxNode): void => { + let sibling = startNode.previousSibling; + while (sibling) { + if (sibling.type === 'comment') { + commentTexts.unshift(sibling.text); + } else if (sibling.isNamed) { + break; + } + sibling = sibling.previousSibling; + } + }; + + collectComments(methodNode); + if (commentTexts.length === 0 && methodNode.parent?.type === 'body_statement') { + collectComments(methodNode.parent); + } + + const commentBlock = commentTexts.join('\n'); + let match: RegExpExecArray | null; + + YARD_PARAM_RE.lastIndex = 0; + while ((match = YARD_PARAM_RE.exec(commentBlock)) !== null) { + params.set(match[1], match[2]); + } + YARD_PARAM_ALT_RE.lastIndex = 0; + while ((match = YARD_PARAM_ALT_RE.exec(commentBlock)) !== null) { + if (!params.has(match[2])) params.set(match[2], match[1]); + } + + return params; +}; + +/** + * Walk up the AST from a for-statement to find the enclosing method, + * then search its YARD @param annotations for one named `iterableName`. + * Returns the element type extracted from the raw YARD type string. + * + * Example: `@param users [Array]` → extracts "User" from "Array". + */ +const findRubyParamElementType = (iterableName: string, startNode: SyntaxNode): string | undefined => { + let current: SyntaxNode | null = startNode.parent; + while (current) { + if (RUBY_METHOD_NODE_TYPES.has(current.type)) { + const rawParams = collectYardRawParams(current); + const rawType = rawParams.get(iterableName); + if (rawType) return extractElementTypeFromString(rawType); + break; + } + current = current.parent; + } + return undefined; +}; + +/** + * Ruby: for user in users ... end + * + * tree-sitter-ruby `for` node structure: + * pattern field: the loop variable (identifier) + * value field: `in` node whose child is the iterable expression + * + * Tier 1c: resolves the element type via: + * 1. scopeEnv string — extractElementTypeFromString on the stored type + * 2. AST walk — walks up to the enclosing method's YARD @param to read Array directly + * + * Ruby has no static types on loop variables, so this mainly works when the + * iterable has a YARD-annotated container type (e.g., `@param users [Array]`). + */ +const extractForLoopBinding: ForLoopExtractor = ( + node: SyntaxNode, + scopeEnv: Map, + declarationTypeNodes: ReadonlyMap, + scope: string, +): void => { + if (node.type !== 'for') return; + + // The loop variable is the `pattern` field (identifier). + const patternNode = node.childForFieldName('pattern'); + if (!patternNode) return; + const loopVarName = extractVarName(patternNode); + if (!loopVarName) return; + + // The iterable is inside the `value` field which is an `in` node wrapping the expression. + const inNode = node.childForFieldName('value'); + if (!inNode) return; + const iterableNode = inNode.firstNamedChild; + if (!iterableNode || iterableNode.type !== 'identifier') return; + const iterableName = iterableNode.text; + + // Ruby has no extractFromTypeNode (no AST type annotations), pass a no-op. + const noopExtractFromTypeNode = (): string | undefined => undefined; + + const elementType = resolveIterableElementType( + iterableName, node, scopeEnv, declarationTypeNodes, scope, + noopExtractFromTypeNode, findRubyParamElementType, + ); + if (!elementType) return; + + scopeEnv.set(loopVarName, elementType); +}; + /** * Ruby: alias_user = user → assignment with left/right identifier fields. * Only handles plain identifier RHS (not calls, not literals). @@ -279,10 +393,12 @@ const extractPendingAssignment: PendingAssignmentExtractor = (node, scopeEnv) => export const typeConfig: LanguageTypeConfig = { declarationNodeTypes: DECLARATION_NODE_TYPES, + forLoopNodeTypes: FOR_LOOP_NODE_TYPES, extractDeclaration, extractParameter, extractInitializer, scanConstructorBinding, extractReturnType, + extractForLoopBinding, extractPendingAssignment, }; diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-range-for/Repo.h b/gitnexus/test/fixtures/lang-resolution/cpp-range-for/Repo.h new file mode 100644 index 000000000..b3858517c --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-range-for/Repo.h @@ -0,0 +1,10 @@ +#pragma once +#include + +class Repo { +public: + Repo(const std::string& name) : name_(name) {} + void save() {} +private: + std::string name_; +}; diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-range-for/app.cpp b/gitnexus/test/fixtures/lang-resolution/cpp-range-for/app.cpp index e37a6e932..79fb20095 100644 --- a/gitnexus/test/fixtures/lang-resolution/cpp-range-for/app.cpp +++ b/gitnexus/test/fixtures/lang-resolution/cpp-range-for/app.cpp @@ -1,8 +1,15 @@ -#include "user.h" +#include "User.h" +#include "Repo.h" #include -void processUsers(std::vector users) { - for (User& user : users) { +void processUsers(const std::vector& users) { + for (auto& user : users) { user.save(); } } + +void processRepos(const std::vector& repos) { + for (const auto& repo : repos) { + repo.save(); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-range-for/user.h b/gitnexus/test/fixtures/lang-resolution/cpp-range-for/user.h index 489f99f92..fdb227354 100644 --- a/gitnexus/test/fixtures/lang-resolution/cpp-range-for/user.h +++ b/gitnexus/test/fixtures/lang-resolution/cpp-range-for/user.h @@ -1,6 +1,10 @@ #pragma once +#include class User { public: - bool save() { return true; } + User(const std::string& name) : name_(name) {} + void save() {} +private: + std::string name_; }; diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-dictionary-keys-values/App.cs b/gitnexus/test/fixtures/lang-resolution/csharp-dictionary-keys-values/App.cs new file mode 100644 index 000000000..cd94c3653 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-dictionary-keys-values/App.cs @@ -0,0 +1,15 @@ +using System.Collections.Generic; + +public class App { + public void ProcessValues(Dictionary data) { + foreach (var user in data.Values) { + user.Save(); + } + } + + public void ProcessList(List users) { + foreach (var user in users) { + user.Save(); + } + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-dictionary-keys-values/Repo.cs b/gitnexus/test/fixtures/lang-resolution/csharp-dictionary-keys-values/Repo.cs new file mode 100644 index 000000000..0e8416a76 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-dictionary-keys-values/Repo.cs @@ -0,0 +1,4 @@ +public class Repo { + public string Name { get; set; } + public void Save() {} +} diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-dictionary-keys-values/User.cs b/gitnexus/test/fixtures/lang-resolution/csharp-dictionary-keys-values/User.cs new file mode 100644 index 000000000..57c04e93b --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-dictionary-keys-values/User.cs @@ -0,0 +1,4 @@ +public class User { + public string Name { get; set; } + public void Save() {} +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-map-keys-values/src/App.java b/gitnexus/test/fixtures/lang-resolution/java-map-keys-values/src/App.java new file mode 100644 index 000000000..2641fc4a4 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-map-keys-values/src/App.java @@ -0,0 +1,18 @@ +package src; + +import java.util.Map; +import java.util.List; + +public class App { + public void processValues(Map data) { + for (var user : data.values()) { + user.save(); + } + } + + public void processList(List users) { + for (var user : users) { + user.save(); + } + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-map-keys-values/src/Repo.java b/gitnexus/test/fixtures/lang-resolution/java-map-keys-values/src/Repo.java new file mode 100644 index 000000000..2ec27c97a --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-map-keys-values/src/Repo.java @@ -0,0 +1,7 @@ +package src; + +public class Repo { + private String name; + public Repo(String name) { this.name = name; } + public void save() {} +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-map-keys-values/src/User.java b/gitnexus/test/fixtures/lang-resolution/java-map-keys-values/src/User.java new file mode 100644 index 000000000..6fe76df2a --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-map-keys-values/src/User.java @@ -0,0 +1,7 @@ +package src; + +public class User { + private String name; + public User(String name) { this.name = name; } + public void save() {} +} diff --git a/gitnexus/test/fixtures/lang-resolution/kotlin-map-keys-values/src/App.kt b/gitnexus/test/fixtures/lang-resolution/kotlin-map-keys-values/src/App.kt new file mode 100644 index 000000000..1ae9e461d --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/kotlin-map-keys-values/src/App.kt @@ -0,0 +1,11 @@ +fun processValues(data: HashMap) { + for (user in data.values) { + user.save() + } +} + +fun processList(users: List) { + for (user in users) { + user.save() + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/kotlin-map-keys-values/src/Repo.kt b/gitnexus/test/fixtures/lang-resolution/kotlin-map-keys-values/src/Repo.kt new file mode 100644 index 000000000..0c3c2725f --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/kotlin-map-keys-values/src/Repo.kt @@ -0,0 +1,3 @@ +class Repo(val name: String) { + fun save() {} +} diff --git a/gitnexus/test/fixtures/lang-resolution/kotlin-map-keys-values/src/User.kt b/gitnexus/test/fixtures/lang-resolution/kotlin-map-keys-values/src/User.kt new file mode 100644 index 000000000..414952708 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/kotlin-map-keys-values/src/User.kt @@ -0,0 +1,3 @@ +class User(val name: String) { + fun save() {} +} diff --git a/gitnexus/test/fixtures/lang-resolution/php-foreach-loop/App.php b/gitnexus/test/fixtures/lang-resolution/php-foreach-loop/App.php new file mode 100644 index 000000000..cea2c74e1 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/php-foreach-loop/App.php @@ -0,0 +1,12 @@ +save(); + } + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/php-foreach-loop/Repo.php b/gitnexus/test/fixtures/lang-resolution/php-foreach-loop/Repo.php new file mode 100644 index 000000000..2a64b11be --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/php-foreach-loop/Repo.php @@ -0,0 +1,11 @@ +name = $name; + } + + public function save(): void {} +} diff --git a/gitnexus/test/fixtures/lang-resolution/php-foreach-loop/User.php b/gitnexus/test/fixtures/lang-resolution/php-foreach-loop/User.php new file mode 100644 index 000000000..437c13028 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/php-foreach-loop/User.php @@ -0,0 +1,11 @@ +name = $name; + } + + public function save(): void {} +} diff --git a/gitnexus/test/fixtures/lang-resolution/ruby-for-in-loop/app.rb b/gitnexus/test/fixtures/lang-resolution/ruby-for-in-loop/app.rb new file mode 100644 index 000000000..9e326dd3d --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/ruby-for-in-loop/app.rb @@ -0,0 +1,8 @@ +require_relative 'user' + +# @param users [Array] +def process_users(users) + for user in users + user.save + end +end diff --git a/gitnexus/test/fixtures/lang-resolution/ruby-for-in-loop/repo.rb b/gitnexus/test/fixtures/lang-resolution/ruby-for-in-loop/repo.rb new file mode 100644 index 000000000..9cf9c87f7 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/ruby-for-in-loop/repo.rb @@ -0,0 +1,8 @@ +class Repo + def initialize(name) + @name = name + end + + def save + end +end diff --git a/gitnexus/test/fixtures/lang-resolution/ruby-for-in-loop/user.rb b/gitnexus/test/fixtures/lang-resolution/ruby-for-in-loop/user.rb new file mode 100644 index 000000000..6c93f5b8b --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/ruby-for-in-loop/user.rb @@ -0,0 +1,8 @@ +class User + def initialize(name) + @name = name + end + + def save + end +end diff --git a/gitnexus/test/integration/resolvers/cpp.test.ts b/gitnexus/test/integration/resolvers/cpp.test.ts index 0cea9e55b..b6f43d52e 100644 --- a/gitnexus/test/integration/resolvers/cpp.test.ts +++ b/gitnexus/test/integration/resolvers/cpp.test.ts @@ -446,10 +446,10 @@ describe('C++ scoped brace-init resolution (ns::Type{})', () => { }); // --------------------------------------------------------------------------- -// Range-for with explicit type: for (User& user : users) { user.save(); } +// C++ range-based for: for (auto& user : users) — Tier 1c // --------------------------------------------------------------------------- -describe('C++ range-for explicit type resolution', () => { +describe('C++ range-based for loop resolution', () => { let result: PipelineResult; beforeAll(async () => { @@ -459,22 +459,33 @@ describe('C++ range-for explicit type resolution', () => { ); }, 60000); - it('detects User class and save method', () => { + it('detects User and Repo classes with save methods', () => { expect(getNodesByLabel(result, 'Class')).toContain('User'); - expect(getNodesByLabel(result, 'Method')).toContain('save'); + expect(getNodesByLabel(result, 'Class')).toContain('Repo'); }); - it('resolves user.save() inside range-for to User.save via explicit type', () => { + it('resolves user.save() in range-for to User#save', () => { const calls = getRelationships(result, 'CALLS'); - const saveCall = calls.find(c => c.target === 'save' && c.targetFilePath === 'user.h'); - expect(saveCall).toBeDefined(); - expect(saveCall!.source).toBe('processUsers'); + const userSave = calls.find(c => + c.target === 'save' && c.source === 'processUsers' && c.targetFilePath?.includes('User'), + ); + expect(userSave).toBeDefined(); }); - it('emits HAS_METHOD edge from User to save', () => { - const hasMethod = getRelationships(result, 'HAS_METHOD'); - const edge = hasMethod.find(e => e.source === 'User' && e.target === 'save'); - expect(edge).toBeDefined(); + it('resolves repo.save() in const auto& range-for to Repo#save', () => { + const calls = getRelationships(result, 'CALLS'); + const repoSave = calls.find(c => + c.target === 'save' && c.source === 'processRepos' && c.targetFilePath?.includes('Repo'), + ); + 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 === 'processUsers' && c.targetFilePath?.includes('Repo'), + ); + expect(wrongSave).toBeUndefined(); }); }); diff --git a/gitnexus/test/integration/resolvers/csharp.test.ts b/gitnexus/test/integration/resolvers/csharp.test.ts index 6e9f79e5d..2a837873d 100644 --- a/gitnexus/test/integration/resolvers/csharp.test.ts +++ b/gitnexus/test/integration/resolvers/csharp.test.ts @@ -1077,3 +1077,38 @@ describe('C# switch pattern type resolution', () => { expect(repoSave).toBeDefined(); }); }); + +// --------------------------------------------------------------------------- +// C# Dictionary .Values foreach — member_access_expression resolution +// --------------------------------------------------------------------------- + +describe('C# Dictionary .Values foreach resolution', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'csharp-dictionary-keys-values'), + () => {}, + ); + }, 60000); + + it('detects User class with Save method', () => { + expect(getNodesByLabel(result, 'Class')).toContain('User'); + }); + + it('resolves user.Save() via Dictionary.Values to User#Save', () => { + const calls = getRelationships(result, 'CALLS'); + const userSave = calls.find(c => + c.target === 'Save' && c.source === 'ProcessValues' && 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 === 'ProcessValues' && c.targetFilePath?.includes('Repo'), + ); + expect(wrongSave).toBeUndefined(); + }); +}); diff --git a/gitnexus/test/integration/resolvers/java.test.ts b/gitnexus/test/integration/resolvers/java.test.ts index 0d6ee1c84..f98dd4627 100644 --- a/gitnexus/test/integration/resolvers/java.test.ts +++ b/gitnexus/test/integration/resolvers/java.test.ts @@ -918,3 +918,46 @@ describe('Java enum static method call resolution (Phase 5 review fix)', () => { expect(labelCall).toBeDefined(); }); }); + +// --------------------------------------------------------------------------- +// Java Map .values() for-loop — method-aware type arg resolution +// --------------------------------------------------------------------------- + +describe('Java Map .values() for-loop resolution', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'java-map-keys-values'), + () => {}, + ); + }, 60000); + + it('detects User class with save method', () => { + expect(getNodesByLabel(result, 'Class')).toContain('User'); + }); + + it('resolves user.save() via Map.values() to User#save', () => { + const calls = getRelationships(result, 'CALLS'); + const userSave = calls.find(c => + c.target === 'save' && c.source === 'processValues' && 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 === 'processValues' && c.targetFilePath?.includes('Repo'), + ); + expect(wrongSave).toBeUndefined(); + }); + + it('resolves user.save() via List iteration to User#save', () => { + const calls = getRelationships(result, 'CALLS'); + const userSave = calls.find(c => + c.target === 'save' && c.source === 'processList' && c.targetFilePath?.includes('User'), + ); + expect(userSave).toBeDefined(); + }); +}); diff --git a/gitnexus/test/integration/resolvers/kotlin.test.ts b/gitnexus/test/integration/resolvers/kotlin.test.ts index 8fd6ee0ea..01e977e5e 100644 --- a/gitnexus/test/integration/resolvers/kotlin.test.ts +++ b/gitnexus/test/integration/resolvers/kotlin.test.ts @@ -913,3 +913,46 @@ describe('Kotlin unannotated for-loop type resolution (Tier 1c)', () => { expect(wrong).toBeUndefined(); }); }); + +// --------------------------------------------------------------------------- +// Kotlin HashMap .values navigation_expression resolution +// --------------------------------------------------------------------------- + +describe('Kotlin HashMap .values for-loop resolution', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'kotlin-map-keys-values'), + () => {}, + ); + }, 60000); + + it('detects User class with save function', () => { + expect(getNodesByLabel(result, 'Class')).toContain('User'); + }); + + it('resolves user.save() via HashMap.values to User#save', () => { + const calls = getRelationships(result, 'CALLS'); + const userSave = calls.find(c => + c.target === 'save' && c.source === 'processValues' && 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 === 'processValues' && c.targetFilePath?.includes('Repo'), + ); + expect(wrongSave).toBeUndefined(); + }); + + it('resolves user.save() via List iteration to User#save', () => { + const calls = getRelationships(result, 'CALLS'); + const userSave = calls.find(c => + c.target === 'save' && c.source === 'processList' && c.targetFilePath?.includes('User'), + ); + expect(userSave).toBeDefined(); + }); +}); diff --git a/gitnexus/test/integration/resolvers/php.test.ts b/gitnexus/test/integration/resolvers/php.test.ts index 5b44b5b2a..911939d44 100644 --- a/gitnexus/test/integration/resolvers/php.test.ts +++ b/gitnexus/test/integration/resolvers/php.test.ts @@ -989,3 +989,38 @@ describe('PHP assignment chain propagation', () => { expect(userSave!.targetFilePath).not.toBe(repoSave!.targetFilePath); }); }); + +// --------------------------------------------------------------------------- +// PHP foreach ($users as $user) — Tier 1c +// --------------------------------------------------------------------------- + +describe('PHP foreach loop resolution', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'php-foreach-loop'), + () => {}, + ); + }, 60000); + + it('detects User class with save method', () => { + expect(getNodesByLabel(result, 'Class')).toContain('User'); + }); + + it('resolves $user->save() in foreach to User#save', () => { + const calls = getRelationships(result, 'CALLS'); + const userSave = calls.find(c => + c.target === 'save' && c.source === 'processUsers' && 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 === 'processUsers' && c.targetFilePath?.includes('Repo'), + ); + expect(wrongSave).toBeUndefined(); + }); +}); diff --git a/gitnexus/test/integration/resolvers/ruby.test.ts b/gitnexus/test/integration/resolvers/ruby.test.ts index 86294fd4d..f153dedf0 100644 --- a/gitnexus/test/integration/resolvers/ruby.test.ts +++ b/gitnexus/test/integration/resolvers/ruby.test.ts @@ -811,3 +811,38 @@ describe('Ruby chained method call resolution (Phase 5 review fix)', () => { expect(repoSave).toBeUndefined(); }); }); + +// --------------------------------------------------------------------------- +// Ruby for-in loop: for user in users — YARD @param resolution +// --------------------------------------------------------------------------- + +describe('Ruby for-in loop resolution', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'ruby-for-in-loop'), + () => {}, + ); + }, 60000); + + it('detects User class with save method', () => { + expect(getNodesByLabel(result, 'Class')).toContain('User'); + }); + + it('resolves user.save in for-in to User#save', () => { + const calls = getRelationships(result, 'CALLS'); + const userSave = calls.find(c => + c.target === 'save' && c.source === 'process_users' && 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 === 'process_users' && c.targetFilePath?.includes('repo'), + ); + expect(wrongSave).toBeUndefined(); + }); +});