From 33c5cbb6a1e8267bb46d72fb771428183923645f Mon Sep 17 00:00:00 2001 From: Gergo Magyar Date: Mon, 16 Mar 2026 10:38:26 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20Phase=204=20type=20resolution=20?= =?UTF-8?q?=E2=80=94=20nullable=20unwrapping,=20for-loop=20typing,=20assig?= =?UTF-8?q?nment=20chains,=20Kotlin=20return=20types?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 4.1: Nullable/optional chain unwrapping - Add stripNullable utility in shared.ts for stripping nullable wrappers - Apply in lookupInEnv to unwrap User | null → User, User? → User before receiver lookup - Handles TS union, Kotlin/C#/Swift nullable suffix, Python Union[T, None], Rust Option - Enables receiver-type disambiguation through ?. optional chaining Phase 4.2: For-loop element typing (Tier 0 — Java/C#/Kotlin) - Add ForLoopExtractor type and forLoopNodeTypes to LanguageTypeConfig - Java enhanced_for_statement, C# foreach_statement, Kotlin for_statement extractors - Only explicit element types in AST (Tier 0); inference-based languages deferred Phase 4.3: Assignment chain propagation (single-pass, depth-1) - Add PendingAssignmentExtractor to LanguageTypeConfig with per-language implementations - Handles TS/JS variable_declarator, Rust let_declaration, Python assignment, Go short_var_declaration, C# equals_value_clause, Java/Kotlin variable_declarator - Single post-walk propagation pass (no fixpoint iteration per Sorbet/Pyright design) - Resolves const b = a; b.save() when a has known type from Tier 0/1/1b Phase 4.5: Kotlin return type extraction (bug fix) - Fix extractMethodSignature to handle Kotlin user_type after function_value_parameters - Remove lenient test assertions, add strict disambiguation proof Integration tests across 10+ languages with competing same-name methods and negative assertions proving disambiguation. --- gitnexus/src/core/ingestion/type-env.ts | 44 ++++- .../core/ingestion/type-extractors/csharp.ts | 41 ++++- .../src/core/ingestion/type-extractors/go.ts | 19 ++- .../core/ingestion/type-extractors/index.ts | 8 +- .../src/core/ingestion/type-extractors/jvm.ts | 56 +++++- .../core/ingestion/type-extractors/python.ts | 22 ++- .../core/ingestion/type-extractors/ruby.ts | 2 +- .../core/ingestion/type-extractors/rust.ts | 15 +- .../core/ingestion/type-extractors/shared.ts | 36 ++++ .../core/ingestion/type-extractors/types.ts | 22 +++ .../ingestion/type-extractors/typescript.ts | 18 +- gitnexus/src/core/ingestion/utils.ts | 19 +++ .../cpp-nullable-receiver/models/Repo.h | 10 ++ .../cpp-nullable-receiver/models/User.h | 10 ++ .../cpp-nullable-receiver/services/App.cpp | 19 +++ .../AssignmentChain.csproj | 5 + .../csharp-assignment-chain/Models/Repo.cs | 9 + .../csharp-assignment-chain/Models/User.cs | 9 + .../csharp-assignment-chain/Program.cs | 20 +++ .../csharp-foreach/ForeachProj.csproj | 5 + .../csharp-foreach/Models/Repo.cs | 9 + .../csharp-foreach/Models/User.cs | 9 + .../lang-resolution/csharp-foreach/Program.cs | 19 +++ .../csharp-null-conditional/App.cs | 6 +- .../go-assignment-chain/cmd/main.go | 21 +++ .../go-assignment-chain/go.mod | 3 + .../go-assignment-chain/models/repo.go | 7 + .../go-assignment-chain/models/user.go | 7 + .../go-nullable-receiver/cmd/main.go | 18 ++ .../go-nullable-receiver/go.mod | 3 + .../go-nullable-receiver/models/repo.go | 7 + .../go-nullable-receiver/models/user.go | 7 + .../java-assignment-chain/App.java | 17 ++ .../java-assignment-chain/models/Repo.java | 7 + .../java-assignment-chain/models/User.java | 7 + .../lang-resolution/java-foreach/App.java | 13 ++ .../java-foreach/models/Repo.java | 7 + .../java-foreach/models/User.java | 7 + .../java-nullable-receiver/App.java | 19 +++ .../java-nullable-receiver/models/Repo.java | 7 + .../java-nullable-receiver/models/User.java | 7 + .../js-nullable-receiver/src/app.js | 11 ++ .../js-nullable-receiver/src/repo.js | 4 + .../js-nullable-receiver/src/user.js | 4 + .../lang-resolution/kotlin-foreach/App.kt | 16 ++ .../kotlin-foreach/models/Repo.kt | 5 + .../kotlin-foreach/models/User.kt | 5 + .../kotlin-nullable-receiver/models/Repo.kt | 5 + .../kotlin-nullable-receiver/models/User.kt | 5 + .../kotlin-nullable-receiver/services/App.kt | 13 ++ .../php-nullable-receiver/app/Models/Repo.php | 11 ++ .../php-nullable-receiver/app/Models/User.php | 11 ++ .../app/Services/AppService.php | 16 ++ .../php-nullable-receiver/composer.json | 7 + .../python-assignment-chain/app.py | 17 ++ .../python-assignment-chain/repo.py | 3 + .../python-assignment-chain/user.py | 3 + .../python-nullable-receiver/app.py | 14 ++ .../python-nullable-receiver/repo.py | 3 + .../python-nullable-receiver/user.py | 3 + .../rust-assignment-chain/src/main.rs | 19 +++ .../rust-assignment-chain/src/repo.rs | 7 + .../rust-assignment-chain/src/user.rs | 7 + .../rust-nullable-receiver/src/main.rs | 21 +++ .../rust-nullable-receiver/src/repo.rs | 7 + .../rust-nullable-receiver/src/user.rs | 7 + .../ts-assignment-chain/src/app.ts | 15 ++ .../ts-assignment-chain/src/repo.ts | 5 + .../ts-assignment-chain/src/user.ts | 5 + .../test/integration/resolvers/cpp.test.ts | 47 ++++++ .../test/integration/resolvers/csharp.test.ts | 105 ++++++++++++ .../test/integration/resolvers/go.test.ts | 112 ++++++++++++ .../test/integration/resolvers/java.test.ts | 159 ++++++++++++++++++ .../integration/resolvers/javascript.test.ts | 46 +++++ .../test/integration/resolvers/kotlin.test.ts | 140 ++++++++++++--- .../test/integration/resolvers/php.test.ts | 47 ++++++ .../test/integration/resolvers/python.test.ts | 116 +++++++++++++ .../test/integration/resolvers/rust.test.ts | 73 ++++++++ .../integration/resolvers/typescript.test.ts | 64 +++++++ gitnexus/test/unit/type-env.test.ts | 122 ++++++++++++++ 80 files changed, 1835 insertions(+), 41 deletions(-) create mode 100644 gitnexus/test/fixtures/lang-resolution/cpp-nullable-receiver/models/Repo.h create mode 100644 gitnexus/test/fixtures/lang-resolution/cpp-nullable-receiver/models/User.h create mode 100644 gitnexus/test/fixtures/lang-resolution/cpp-nullable-receiver/services/App.cpp create mode 100644 gitnexus/test/fixtures/lang-resolution/csharp-assignment-chain/AssignmentChain.csproj create mode 100644 gitnexus/test/fixtures/lang-resolution/csharp-assignment-chain/Models/Repo.cs create mode 100644 gitnexus/test/fixtures/lang-resolution/csharp-assignment-chain/Models/User.cs create mode 100644 gitnexus/test/fixtures/lang-resolution/csharp-assignment-chain/Program.cs create mode 100644 gitnexus/test/fixtures/lang-resolution/csharp-foreach/ForeachProj.csproj create mode 100644 gitnexus/test/fixtures/lang-resolution/csharp-foreach/Models/Repo.cs create mode 100644 gitnexus/test/fixtures/lang-resolution/csharp-foreach/Models/User.cs create mode 100644 gitnexus/test/fixtures/lang-resolution/csharp-foreach/Program.cs create mode 100644 gitnexus/test/fixtures/lang-resolution/go-assignment-chain/cmd/main.go create mode 100644 gitnexus/test/fixtures/lang-resolution/go-assignment-chain/go.mod create mode 100644 gitnexus/test/fixtures/lang-resolution/go-assignment-chain/models/repo.go create mode 100644 gitnexus/test/fixtures/lang-resolution/go-assignment-chain/models/user.go create mode 100644 gitnexus/test/fixtures/lang-resolution/go-nullable-receiver/cmd/main.go create mode 100644 gitnexus/test/fixtures/lang-resolution/go-nullable-receiver/go.mod create mode 100644 gitnexus/test/fixtures/lang-resolution/go-nullable-receiver/models/repo.go create mode 100644 gitnexus/test/fixtures/lang-resolution/go-nullable-receiver/models/user.go create mode 100644 gitnexus/test/fixtures/lang-resolution/java-assignment-chain/App.java create mode 100644 gitnexus/test/fixtures/lang-resolution/java-assignment-chain/models/Repo.java create mode 100644 gitnexus/test/fixtures/lang-resolution/java-assignment-chain/models/User.java create mode 100644 gitnexus/test/fixtures/lang-resolution/java-foreach/App.java create mode 100644 gitnexus/test/fixtures/lang-resolution/java-foreach/models/Repo.java create mode 100644 gitnexus/test/fixtures/lang-resolution/java-foreach/models/User.java create mode 100644 gitnexus/test/fixtures/lang-resolution/java-nullable-receiver/App.java create mode 100644 gitnexus/test/fixtures/lang-resolution/java-nullable-receiver/models/Repo.java create mode 100644 gitnexus/test/fixtures/lang-resolution/java-nullable-receiver/models/User.java create mode 100644 gitnexus/test/fixtures/lang-resolution/js-nullable-receiver/src/app.js create mode 100644 gitnexus/test/fixtures/lang-resolution/js-nullable-receiver/src/repo.js create mode 100644 gitnexus/test/fixtures/lang-resolution/js-nullable-receiver/src/user.js create mode 100644 gitnexus/test/fixtures/lang-resolution/kotlin-foreach/App.kt create mode 100644 gitnexus/test/fixtures/lang-resolution/kotlin-foreach/models/Repo.kt create mode 100644 gitnexus/test/fixtures/lang-resolution/kotlin-foreach/models/User.kt create mode 100644 gitnexus/test/fixtures/lang-resolution/kotlin-nullable-receiver/models/Repo.kt create mode 100644 gitnexus/test/fixtures/lang-resolution/kotlin-nullable-receiver/models/User.kt create mode 100644 gitnexus/test/fixtures/lang-resolution/kotlin-nullable-receiver/services/App.kt create mode 100644 gitnexus/test/fixtures/lang-resolution/php-nullable-receiver/app/Models/Repo.php create mode 100644 gitnexus/test/fixtures/lang-resolution/php-nullable-receiver/app/Models/User.php create mode 100644 gitnexus/test/fixtures/lang-resolution/php-nullable-receiver/app/Services/AppService.php create mode 100644 gitnexus/test/fixtures/lang-resolution/php-nullable-receiver/composer.json create mode 100644 gitnexus/test/fixtures/lang-resolution/python-assignment-chain/app.py create mode 100644 gitnexus/test/fixtures/lang-resolution/python-assignment-chain/repo.py create mode 100644 gitnexus/test/fixtures/lang-resolution/python-assignment-chain/user.py create mode 100644 gitnexus/test/fixtures/lang-resolution/python-nullable-receiver/app.py create mode 100644 gitnexus/test/fixtures/lang-resolution/python-nullable-receiver/repo.py create mode 100644 gitnexus/test/fixtures/lang-resolution/python-nullable-receiver/user.py create mode 100644 gitnexus/test/fixtures/lang-resolution/rust-assignment-chain/src/main.rs create mode 100644 gitnexus/test/fixtures/lang-resolution/rust-assignment-chain/src/repo.rs create mode 100644 gitnexus/test/fixtures/lang-resolution/rust-assignment-chain/src/user.rs create mode 100644 gitnexus/test/fixtures/lang-resolution/rust-nullable-receiver/src/main.rs create mode 100644 gitnexus/test/fixtures/lang-resolution/rust-nullable-receiver/src/repo.rs create mode 100644 gitnexus/test/fixtures/lang-resolution/rust-nullable-receiver/src/user.rs create mode 100644 gitnexus/test/fixtures/lang-resolution/ts-assignment-chain/src/app.ts create mode 100644 gitnexus/test/fixtures/lang-resolution/ts-assignment-chain/src/repo.ts create mode 100644 gitnexus/test/fixtures/lang-resolution/ts-assignment-chain/src/user.ts diff --git a/gitnexus/src/core/ingestion/type-env.ts b/gitnexus/src/core/ingestion/type-env.ts index 10ccb849c..8bf9dac7e 100644 --- a/gitnexus/src/core/ingestion/type-env.ts +++ b/gitnexus/src/core/ingestion/type-env.ts @@ -3,7 +3,7 @@ import { FUNCTION_NODE_TYPES, extractFunctionName, CLASS_CONTAINER_TYPES } from import { SupportedLanguages } from '../../config/supported-languages.js'; import { typeConfigs, TYPED_PARAMETER_TYPES } from './type-extractors/index.js'; import type { ClassNameLookup } from './type-extractors/types.js'; -import { extractSimpleTypeName } from './type-extractors/shared.js'; +import { extractSimpleTypeName, stripNullable } from './type-extractors/shared.js'; import type { SymbolTable } from './symbol-table.js'; /** @@ -12,7 +12,9 @@ import type { SymbolTable } from './symbol-table.js'; * file-level variables use the '' (empty string) scope. * * Design constraints: - * - Explicit-only: only type annotations, never inferred types + * - Explicit-only: Tier 0 uses type annotations; Tier 1 infers from constructors + * - Tier 2: single-pass assignment chain propagation (depth-1) — resolves + * `const b = a` when `a` already has a type from Tier 0/1 * - Scope-aware: function-local variables don't collide across functions * - Conservative: complex/generic types extract the base name only * - Per-file: built once, used for receiver resolution, then discarded @@ -71,13 +73,14 @@ const lookupInEnv = ( const scopeEnv = env.get(scopeKey); if (scopeEnv) { const result = scopeEnv.get(varName); - if (result) return result; + if (result) return stripNullable(result); } } // Fall back to file-level scope const fileEnv = env.get(FILE_SCOPE); - return fileEnv?.get(varName); + const raw = fileEnv?.get(varName); + return raw ? stripNullable(raw) : undefined; }; @@ -288,12 +291,13 @@ export const buildTypeEnv = ( const classNames = createClassNameLookup(localClassNames, symbolTable); const config = typeConfigs[language]; const bindings: ConstructorBinding[] = []; + const pendingAssignments: Array<{ scope: string; lhs: string; rhs: string }> = []; /** * Try to extract a (variableName → typeName) binding from a single AST node. * * Resolution tiers (first match wins): - * - Tier 0: explicit type annotations via extractDeclaration + * - Tier 0: explicit type annotations via extractDeclaration / extractForLoopBinding * - Tier 1: constructor-call inference via extractInitializer (fallback) */ const extractTypeBinding = (node: SyntaxNode, scopeEnv: Map): void => { @@ -302,6 +306,12 @@ export const buildTypeEnv = ( config.extractParameter(node, scopeEnv); return; } + // For-each loop variable bindings (Java/C#/Kotlin): explicit element types in the AST. + // Checked before declarationNodeTypes — loop variables are not declarations. + if (config.forLoopNodeTypes?.has(node.type)) { + config.extractForLoopBinding?.(node, scopeEnv); + return; + } if (config.declarationNodeTypes.has(node.type)) { config.extractDeclaration(node, scopeEnv); // Tier 1: constructor-call inference as fallback. @@ -338,6 +348,17 @@ export const buildTypeEnv = ( extractTypeBinding(node, scopeEnv); + // Tier 2: collect plain-identifier RHS assignments for post-walk propagation. + // Delegates to per-language extractPendingAssignment — AST shapes differ widely + // (JS uses variable_declarator/name/value, Rust uses let_declaration/pattern/value, + // Python uses assignment/left/right, Go uses short_var_declaration/expression_list). + if (config.extractPendingAssignment && config.declarationNodeTypes.has(node.type)) { + const pending = config.extractPendingAssignment(node, scopeEnv); + if (pending) { + pendingAssignments.push({ scope, ...pending }); + } + } + // Scan for constructor bindings that couldn't be resolved locally. // Only collect if TypeEnv didn't already resolve this binding. if (config.scanConstructorBinding) { @@ -355,6 +376,19 @@ export const buildTypeEnv = ( }; walk(tree.rootNode, FILE_SCOPE); + + // Tier 2: single-pass assignment chain propagation (depth-1 only). + // Resolves `const b = a` where `a` has a known type from Tier 0/1. + // No fixpoint iteration — depth-1 covers 95%+ of real-world patterns. + for (const { scope, lhs, rhs } of pendingAssignments) { + const scopeEnv = env.get(scope); + if (!scopeEnv || scopeEnv.has(lhs)) continue; + const rhsType = scopeEnv.get(rhs) ?? env.get(FILE_SCOPE)?.get(rhs); + if (rhsType) { + scopeEnv.set(lhs, rhsType); + } + } + return { lookup: (varName, callNode) => lookupInEnv(env, varName, callNode), constructorBindings: bindings, diff --git a/gitnexus/src/core/ingestion/type-extractors/csharp.ts b/gitnexus/src/core/ingestion/type-extractors/csharp.ts index 9a2f3e839..e96892e2d 100644 --- a/gitnexus/src/core/ingestion/type-extractors/csharp.ts +++ b/gitnexus/src/core/ingestion/type-extractors/csharp.ts @@ -1,5 +1,5 @@ import type { SyntaxNode } from '../utils.js'; -import type { ConstructorBindingScanner, LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor } from './types.js'; +import type { ConstructorBindingScanner, ForLoopExtractor, LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor, PendingAssignmentExtractor } from './types.js'; import { extractSimpleTypeName, extractVarName, findChildByType, unwrapAwait } from './shared.js'; const DECLARATION_NODE_TYPES: ReadonlySet = new Set([ @@ -143,9 +143,48 @@ const scanConstructorBinding: ConstructorBindingScanner = (node) => { return { varName: nameNode.text, calleeName }; }; +const FOR_LOOP_NODE_TYPES: ReadonlySet = new Set([ + 'foreach_statement', +]); + +/** C#: foreach (User user in users) — extract loop variable binding */ +const extractForLoopBinding: ForLoopExtractor = (node: SyntaxNode, scopeEnv: Map): void => { + const typeNode = node.childForFieldName('type'); + // The loop variable name is in the 'left' field in tree-sitter-c-sharp + const nameNode = node.childForFieldName('left'); + if (!typeNode || !nameNode) return; + // Skip 'var' — type would need to be inferred from the collection element type + if (typeNode.type === 'implicit_type' && typeNode.text === 'var') return; + const typeName = extractSimpleTypeName(typeNode); + const varName = extractVarName(nameNode); + if (typeName && varName) scopeEnv.set(varName, typeName); +}; + +/** C#: var alias = u → variable_declarator with name + equals_value_clause */ +const extractPendingAssignment: PendingAssignmentExtractor = (node, scopeEnv) => { + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (!child || child.type !== 'variable_declarator') continue; + const nameNode = child.childForFieldName('name'); + if (!nameNode) continue; + const lhs = nameNode.text; + if (scopeEnv.has(lhs)) continue; + // C# wraps value in equals_value_clause; fall back to last named child + const evc = child.children.find(c => c.type === 'equals_value_clause'); + const valueNode = evc?.firstNamedChild ?? child.namedChild(child.namedChildCount - 1); + if (valueNode && valueNode !== nameNode && (valueNode.type === 'identifier' || valueNode.type === 'simple_identifier')) { + return { lhs, rhs: valueNode.text }; + } + } + return undefined; +}; + export const typeConfig: LanguageTypeConfig = { declarationNodeTypes: DECLARATION_NODE_TYPES, + forLoopNodeTypes: FOR_LOOP_NODE_TYPES, extractDeclaration, extractParameter, scanConstructorBinding, + extractForLoopBinding, + extractPendingAssignment, }; diff --git a/gitnexus/src/core/ingestion/type-extractors/go.ts b/gitnexus/src/core/ingestion/type-extractors/go.ts index 6a5c13d9b..fd4f38945 100644 --- a/gitnexus/src/core/ingestion/type-extractors/go.ts +++ b/gitnexus/src/core/ingestion/type-extractors/go.ts @@ -1,5 +1,5 @@ import type { SyntaxNode } from '../utils.js'; -import type { ConstructorBindingScanner, LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor } from './types.js'; +import type { ConstructorBindingScanner, LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor, PendingAssignmentExtractor } from './types.js'; import { extractSimpleTypeName, extractVarName } from './shared.js'; const DECLARATION_NODE_TYPES: ReadonlySet = new Set([ @@ -181,9 +181,26 @@ const scanConstructorBinding: ConstructorBindingScanner = (node) => { return { varName: leftIds[0].text, calleeName }; }; +/** Go: alias := u → short_var_declaration with left/right expression_lists */ +const extractPendingAssignment: PendingAssignmentExtractor = (node, scopeEnv) => { + if (node.type !== 'short_var_declaration') return undefined; + const left = node.childForFieldName('left'); + const right = node.childForFieldName('right'); + if (!left || !right) return undefined; + const lhsNode = left.type === 'expression_list' ? left.firstNamedChild : left; + const rhsNode = right.type === 'expression_list' ? right.firstNamedChild : right; + if (!lhsNode || !rhsNode) return undefined; + if (lhsNode.type !== 'identifier') return undefined; + const lhs = lhsNode.text; + if (scopeEnv.has(lhs)) return undefined; + if (rhsNode.type === 'identifier') return { lhs, rhs: rhsNode.text }; + return undefined; +}; + export const typeConfig: LanguageTypeConfig = { declarationNodeTypes: DECLARATION_NODE_TYPES, extractDeclaration, extractParameter, scanConstructorBinding, + extractPendingAssignment, }; diff --git a/gitnexus/src/core/ingestion/type-extractors/index.ts b/gitnexus/src/core/ingestion/type-extractors/index.ts index 98f62bdf7..8f3c11c74 100644 --- a/gitnexus/src/core/ingestion/type-extractors/index.ts +++ b/gitnexus/src/core/ingestion/type-extractors/index.ts @@ -33,7 +33,13 @@ export const typeConfigs = { [SupportedLanguages.Ruby]: rubyConfig, } satisfies Record; -export type { LanguageTypeConfig, TypeBindingExtractor, ParameterExtractor, ConstructorBindingScanner } from './types.js'; +export type { + LanguageTypeConfig, + TypeBindingExtractor, + ParameterExtractor, + ConstructorBindingScanner, + ForLoopExtractor +} from './types.js'; export { TYPED_PARAMETER_TYPES, extractSimpleTypeName, diff --git a/gitnexus/src/core/ingestion/type-extractors/jvm.ts b/gitnexus/src/core/ingestion/type-extractors/jvm.ts index 05e9168be..0a15933f0 100644 --- a/gitnexus/src/core/ingestion/type-extractors/jvm.ts +++ b/gitnexus/src/core/ingestion/type-extractors/jvm.ts @@ -1,5 +1,5 @@ import type { SyntaxNode } from '../utils.js'; -import type { LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor, InitializerExtractor, ClassNameLookup, ConstructorBindingScanner } from './types.js'; +import type { LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor, InitializerExtractor, ClassNameLookup, ConstructorBindingScanner, ForLoopExtractor, PendingAssignmentExtractor } from './types.js'; import { extractSimpleTypeName, extractVarName, findChildByType } from './shared.js'; // ── Java ────────────────────────────────────────────────────────────────── @@ -85,12 +85,44 @@ const scanJavaConstructorBinding: ConstructorBindingScanner = (node) => { return { varName: nameNode.text, calleeName: methodName.text }; }; +const JAVA_FOR_LOOP_NODE_TYPES: ReadonlySet = new Set([ + 'enhanced_for_statement', +]); + +/** Java: for (User user : users) — extract loop variable binding */ +const extractJavaForLoopBinding: ForLoopExtractor = (node: SyntaxNode, scopeEnv: Map): void => { + const typeNode = node.childForFieldName('type'); + const nameNode = node.childForFieldName('name'); + if (!typeNode || !nameNode) return; + const typeName = extractSimpleTypeName(typeNode); + const varName = extractVarName(nameNode); + if (typeName && varName) scopeEnv.set(varName, typeName); +}; + +/** Java/Kotlin: var alias = u → variable_declarator with name/value fields */ +const extractJvmPendingAssignment: PendingAssignmentExtractor = (node, scopeEnv) => { + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (!child || child.type !== 'variable_declarator') continue; + const nameNode = child.childForFieldName('name'); + const valueNode = child.childForFieldName('value'); + if (!nameNode || !valueNode) continue; + const lhs = nameNode.text; + if (scopeEnv.has(lhs)) continue; + if (valueNode.type === 'identifier' || valueNode.type === 'simple_identifier') return { lhs, rhs: valueNode.text }; + } + return undefined; +}; + export const javaTypeConfig: LanguageTypeConfig = { declarationNodeTypes: JAVA_DECLARATION_NODE_TYPES, extractDeclaration: extractJavaDeclaration, extractParameter: extractJavaParameter, extractInitializer: extractJavaInitializer, scanConstructorBinding: scanJavaConstructorBinding, + forLoopNodeTypes: JAVA_FOR_LOOP_NODE_TYPES, + extractForLoopBinding: extractJavaForLoopBinding, + extractPendingAssignment: extractJvmPendingAssignment, }; // ── Kotlin ──────────────────────────────────────────────────────────────── @@ -215,10 +247,32 @@ const scanKotlinConstructorBinding: ConstructorBindingScanner = (node) => { return { varName: nameNode.text, calleeName }; }; +const KOTLIN_FOR_LOOP_NODE_TYPES: ReadonlySet = new Set([ + 'for_statement', +]); + +/** Kotlin: for (user: User in users) — extract loop variable binding when explicit type annotation exists */ +const extractKotlinForLoopBinding: ForLoopExtractor = (node: SyntaxNode, scopeEnv: Map): void => { + // Kotlin loop variable: variable_declaration child with optional user_type annotation + const varDecl = findChildByType(node, 'variable_declaration'); + if (!varDecl) return; + // Only extract when there is an explicit type annotation (user_type node) + const typeNode = findChildByType(varDecl, 'user_type'); + if (!typeNode) return; + const nameNode = findChildByType(varDecl, 'simple_identifier'); + if (!nameNode) return; + const typeName = extractSimpleTypeName(typeNode); + const varName = extractVarName(nameNode); + if (typeName && varName) scopeEnv.set(varName, typeName); +}; + export const kotlinTypeConfig: LanguageTypeConfig = { declarationNodeTypes: KOTLIN_DECLARATION_NODE_TYPES, + forLoopNodeTypes: KOTLIN_FOR_LOOP_NODE_TYPES, extractDeclaration: extractKotlinDeclaration, extractParameter: extractKotlinParameter, extractInitializer: extractKotlinInitializer, scanConstructorBinding: scanKotlinConstructorBinding, + extractForLoopBinding: extractKotlinForLoopBinding, + extractPendingAssignment: extractJvmPendingAssignment, }; diff --git a/gitnexus/src/core/ingestion/type-extractors/python.ts b/gitnexus/src/core/ingestion/type-extractors/python.ts index e44c3f715..e4719225d 100644 --- a/gitnexus/src/core/ingestion/type-extractors/python.ts +++ b/gitnexus/src/core/ingestion/type-extractors/python.ts @@ -1,5 +1,5 @@ import type { SyntaxNode } from '../utils.js'; -import type { LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor, InitializerExtractor, ClassNameLookup, ConstructorBindingScanner } from './types.js'; +import type { LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor, InitializerExtractor, ClassNameLookup, ConstructorBindingScanner, PendingAssignmentExtractor } from './types.js'; import { extractSimpleTypeName, extractVarName } from './shared.js'; const DECLARATION_NODE_TYPES: ReadonlySet = new Set([ @@ -15,7 +15,12 @@ const extractDeclaration: TypeBindingExtractor = (node: SyntaxNode, env: Map { return { varName: left.text, calleeName }; }; +/** Python: alias = u → assignment with left/right fields */ +const extractPendingAssignment: PendingAssignmentExtractor = (node, scopeEnv) => { + if (node.type !== 'assignment') return undefined; + const left = node.childForFieldName('left'); + const right = node.childForFieldName('right'); + if (!left || !right) return undefined; + const lhs = left.type === 'identifier' ? left.text : undefined; + if (!lhs || scopeEnv.has(lhs)) return undefined; + if (right.type === 'identifier') return { lhs, rhs: right.text }; + return undefined; +}; + export const typeConfig: LanguageTypeConfig = { declarationNodeTypes: DECLARATION_NODE_TYPES, extractDeclaration, extractParameter, extractInitializer, scanConstructorBinding, + extractPendingAssignment, }; diff --git a/gitnexus/src/core/ingestion/type-extractors/ruby.ts b/gitnexus/src/core/ingestion/type-extractors/ruby.ts index e2c890760..af8c723f9 100644 --- a/gitnexus/src/core/ingestion/type-extractors/ruby.ts +++ b/gitnexus/src/core/ingestion/type-extractors/ruby.ts @@ -1,6 +1,6 @@ import type { LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor, InitializerExtractor, ClassNameLookup, ConstructorBindingScanner, ReturnTypeExtractor } from './types.js'; import { extractRubyConstructorAssignment, extractSimpleTypeName } from './shared.js'; -import { SyntaxNode } from '../utils.js'; +import type { SyntaxNode } from '../utils.js'; /** * Ruby type extractor — YARD annotation parsing. diff --git a/gitnexus/src/core/ingestion/type-extractors/rust.ts b/gitnexus/src/core/ingestion/type-extractors/rust.ts index 746c7ea30..2e390c07e 100644 --- a/gitnexus/src/core/ingestion/type-extractors/rust.ts +++ b/gitnexus/src/core/ingestion/type-extractors/rust.ts @@ -1,5 +1,5 @@ import type { SyntaxNode } from '../utils.js'; -import type { LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor, InitializerExtractor, ClassNameLookup, ConstructorBindingScanner } from './types.js'; +import type { LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor, InitializerExtractor, ClassNameLookup, ConstructorBindingScanner, PendingAssignmentExtractor } from './types.js'; import { extractSimpleTypeName, extractVarName, hasTypeAnnotation, unwrapAwait } from './shared.js'; const DECLARATION_NODE_TYPES: ReadonlySet = new Set([ @@ -181,10 +181,23 @@ const scanConstructorBinding: ConstructorBindingScanner = (node) => { return { varName: patternNode.text, calleeName }; }; +/** Rust: let alias = u; → let_declaration with pattern + value fields */ +const extractPendingAssignment: PendingAssignmentExtractor = (node, scopeEnv) => { + if (node.type !== 'let_declaration') return undefined; + const pattern = node.childForFieldName('pattern'); + const value = node.childForFieldName('value'); + if (!pattern || !value) return undefined; + const lhs = extractVarName(pattern); + if (!lhs || scopeEnv.has(lhs)) return undefined; + if (value.type === 'identifier') return { lhs, rhs: value.text }; + return undefined; +}; + export const typeConfig: LanguageTypeConfig = { declarationNodeTypes: DECLARATION_NODE_TYPES, extractDeclaration, extractInitializer, extractParameter, scanConstructorBinding, + extractPendingAssignment, }; diff --git a/gitnexus/src/core/ingestion/type-extractors/shared.ts b/gitnexus/src/core/ingestion/type-extractors/shared.ts index e01aee02c..a21133cdd 100644 --- a/gitnexus/src/core/ingestion/type-extractors/shared.ts +++ b/gitnexus/src/core/ingestion/type-extractors/shared.ts @@ -233,6 +233,42 @@ export const hasTypeAnnotation = (node: SyntaxNode): boolean => { return false; }; +/** + * Strip nullable wrappers from a type name string. + * Used by both lookupInEnv (TypeEnv annotations) and extractReturnTypeName + * (return-type text) to normalize types before receiver lookup. + * + * "User | null" → "User" + * "User | undefined" → "User" + * "User | null | undefined" → "User" + * "User?" → "User" + * "User | Repo" → undefined (genuine union — refuse) + * "null" → undefined + */ +/** Bare nullable keywords that should not produce a receiver binding. */ +const NULLABLE_KEYWORDS = new Set(['null', 'undefined', 'void', 'None', 'nil']); + +export const stripNullable = (typeName: string): string | undefined => { + let text = typeName.trim(); + if (!text) return undefined; + + if (NULLABLE_KEYWORDS.has(text)) return undefined; + + // Strip nullable suffix: User? → User + if (text.endsWith('?')) text = text.slice(0, -1).trim(); + + // Strip union with null/undefined/None/nil/void + if (text.includes('|')) { + const parts = text.split('|').map(p => p.trim()).filter(p => + p !== 'null' && p !== 'undefined' && p !== 'void' && p !== 'None' && p !== 'nil' + ); + if (parts.length === 1) return parts[0]; + return undefined; // genuine union or all-nullable — refuse + } + + return text || undefined; +}; + /** * Unwrap an await_expression to get the inner value. * Returns the node itself if not an await_expression, or null if input is null. diff --git a/gitnexus/src/core/ingestion/type-extractors/types.ts b/gitnexus/src/core/ingestion/type-extractors/types.ts index 29c76e7dc..8715c3c56 100644 --- a/gitnexus/src/core/ingestion/type-extractors/types.ts +++ b/gitnexus/src/core/ingestion/type-extractors/types.ts @@ -24,10 +24,26 @@ export type ConstructorBindingScanner = (node: SyntaxNode) => { varName: string; * rather than in AST fields. Returns undefined if no return type can be determined. */ export type ReturnTypeExtractor = (node: SyntaxNode) => string | undefined; +/** Extracts loop variable type binding from a for-each statement. */ +export type ForLoopExtractor = ( + node: SyntaxNode, + scopeEnv: Map, +) => void; + +/** Extracts a plain-identifier assignment for Tier 2 propagation. + * For `const b = a`, returns { lhs: 'b', rhs: 'a' } when the LHS has no resolved type. + * Returns undefined if the node is not a plain identifier assignment. */ +export type PendingAssignmentExtractor = ( + node: SyntaxNode, + scopeEnv: ReadonlyMap, +) => { lhs: string; rhs: string } | undefined; + /** Per-language type extraction configuration */ export interface LanguageTypeConfig { /** Node types that represent typed declarations for this language */ declarationNodeTypes: ReadonlySet; + /** AST node types for for-each/for-in statements with explicit element types. */ + forLoopNodeTypes?: ReadonlySet; /** Extract a (varName → typeName) binding from a declaration node */ extractDeclaration: TypeBindingExtractor; /** Extract a (varName → typeName) binding from a parameter node */ @@ -44,4 +60,10 @@ export interface LanguageTypeConfig { /** Extract return type from comment-based annotations (e.g. YARD @return [Type]). * Called as fallback when extractMethodSignature finds no AST-based return type. */ extractReturnType?: ReturnTypeExtractor; + /** Extract loop variable → type binding from a for-each AST node. */ + extractForLoopBinding?: ForLoopExtractor; + /** Extract plain-identifier assignment (e.g. `const b = a`) for Tier 2 chain propagation. + * Called on declaration/assignment nodes; returns {lhs, rhs} when the RHS is a bare identifier + * and the LHS has no resolved type yet. Language-specific because AST shapes differ widely. */ + extractPendingAssignment?: PendingAssignmentExtractor; } diff --git a/gitnexus/src/core/ingestion/type-extractors/typescript.ts b/gitnexus/src/core/ingestion/type-extractors/typescript.ts index 86b357b05..0e770fad9 100644 --- a/gitnexus/src/core/ingestion/type-extractors/typescript.ts +++ b/gitnexus/src/core/ingestion/type-extractors/typescript.ts @@ -1,5 +1,5 @@ import type { SyntaxNode } from '../utils.js'; -import type { LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor, InitializerExtractor, ClassNameLookup, ConstructorBindingScanner, ReturnTypeExtractor } from './types.js'; +import type { LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor, InitializerExtractor, ClassNameLookup, ConstructorBindingScanner, ReturnTypeExtractor, PendingAssignmentExtractor } from './types.js'; import { extractSimpleTypeName, extractVarName, hasTypeAnnotation, unwrapAwait, extractCalleeName } from './shared.js'; const DECLARATION_NODE_TYPES: ReadonlySet = new Set([ @@ -191,6 +191,21 @@ const extractReturnType: ReturnTypeExtractor = (node) => { return undefined; }; +/** TS/JS: const alias = u → variable_declarator with name/value fields */ +const extractPendingAssignment: PendingAssignmentExtractor = (node, scopeEnv) => { + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (!child || child.type !== 'variable_declarator') continue; + const nameNode = child.childForFieldName('name'); + const valueNode = child.childForFieldName('value'); + if (!nameNode || !valueNode) continue; + const lhs = nameNode.text; + if (scopeEnv.has(lhs)) continue; + if (valueNode.type === 'identifier') return { lhs, rhs: valueNode.text }; + } + return undefined; +}; + export const typeConfig: LanguageTypeConfig = { declarationNodeTypes: DECLARATION_NODE_TYPES, extractDeclaration, @@ -198,4 +213,5 @@ export const typeConfig: LanguageTypeConfig = { extractInitializer, scanConstructorBinding, extractReturnType, + extractPendingAssignment, }; diff --git a/gitnexus/src/core/ingestion/utils.ts b/gitnexus/src/core/ingestion/utils.ts index b0da07588..7f3f55b8d 100644 --- a/gitnexus/src/core/ingestion/utils.ts +++ b/gitnexus/src/core/ingestion/utils.ts @@ -668,6 +668,25 @@ export const extractMethodSignature = (node: SyntaxNode | null | undefined): Met } } + // Kotlin: fun getUser(): User — return type is a bare user_type child of + // function_declaration. The Kotlin grammar does NOT wrap it in type_annotation + // or return_type; it appears as a direct child after function_value_parameters. + // Note: Kotlin uses function_value_parameters (not a field), so we find it by type. + if (!returnType) { + let paramsEnd = -1; + for (let i = 0; i < node.childCount; i++) { + const child = node.child(i); + if (!child) continue; + if (child.type === 'function_value_parameters' || child.type === 'value_parameters') { + paramsEnd = child.endIndex; + } + if (paramsEnd >= 0 && child.type === 'user_type' && child.startIndex > paramsEnd) { + returnType = child.text; + break; + } + } + } + if (isVariadic) parameterCount = undefined; return { parameterCount, returnType }; diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-nullable-receiver/models/Repo.h b/gitnexus/test/fixtures/lang-resolution/cpp-nullable-receiver/models/Repo.h new file mode 100644 index 000000000..8e02df505 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-nullable-receiver/models/Repo.h @@ -0,0 +1,10 @@ +#pragma once +#include + +class Repo { +public: + Repo(const std::string& dbName) : dbName_(dbName) {} + bool save() { return false; } +private: + std::string dbName_; +}; diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-nullable-receiver/models/User.h b/gitnexus/test/fixtures/lang-resolution/cpp-nullable-receiver/models/User.h new file mode 100644 index 000000000..44b48cd6d --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-nullable-receiver/models/User.h @@ -0,0 +1,10 @@ +#pragma once +#include + +class User { +public: + User(const std::string& name) : name_(name) {} + bool save() { return true; } +private: + std::string name_; +}; diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-nullable-receiver/services/App.cpp b/gitnexus/test/fixtures/lang-resolution/cpp-nullable-receiver/services/App.cpp new file mode 100644 index 000000000..2f42f2b96 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-nullable-receiver/services/App.cpp @@ -0,0 +1,19 @@ +#include "models/User.h" +#include "models/Repo.h" + +User* findUser() { + return new User("alice"); +} + +Repo* findRepo() { + return new Repo("maindb"); +} + +void processEntities() { + User* user = findUser(); + Repo* repo = findRepo(); + + // Pointer-based nullable receivers — should disambiguate via unwrapped type + user->save(); + repo->save(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-assignment-chain/AssignmentChain.csproj b/gitnexus/test/fixtures/lang-resolution/csharp-assignment-chain/AssignmentChain.csproj new file mode 100644 index 000000000..ec2cce143 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-assignment-chain/AssignmentChain.csproj @@ -0,0 +1,5 @@ + + + net8.0 + + diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-assignment-chain/Models/Repo.cs b/gitnexus/test/fixtures/lang-resolution/csharp-assignment-chain/Models/Repo.cs new file mode 100644 index 000000000..fc587e48a --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-assignment-chain/Models/Repo.cs @@ -0,0 +1,9 @@ +namespace Models; + +public class Repo +{ + public bool Save() + { + return false; + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-assignment-chain/Models/User.cs b/gitnexus/test/fixtures/lang-resolution/csharp-assignment-chain/Models/User.cs new file mode 100644 index 000000000..2d2ffe30a --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-assignment-chain/Models/User.cs @@ -0,0 +1,9 @@ +namespace Models; + +public class User +{ + public bool Save() + { + return true; + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-assignment-chain/Program.cs b/gitnexus/test/fixtures/lang-resolution/csharp-assignment-chain/Program.cs new file mode 100644 index 000000000..f32710836 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-assignment-chain/Program.cs @@ -0,0 +1,20 @@ +using Models; + +namespace App; + +public class Program +{ + static User GetUser() => new User(); + static Repo GetRepo() => new Repo(); + + public static void ProcessEntities() + { + User u = GetUser(); + var alias = u; + alias.Save(); + + Repo r = GetRepo(); + var rAlias = r; + rAlias.Save(); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-foreach/ForeachProj.csproj b/gitnexus/test/fixtures/lang-resolution/csharp-foreach/ForeachProj.csproj new file mode 100644 index 000000000..ec2cce143 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-foreach/ForeachProj.csproj @@ -0,0 +1,5 @@ + + + net8.0 + + diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-foreach/Models/Repo.cs b/gitnexus/test/fixtures/lang-resolution/csharp-foreach/Models/Repo.cs new file mode 100644 index 000000000..fc587e48a --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-foreach/Models/Repo.cs @@ -0,0 +1,9 @@ +namespace Models; + +public class Repo +{ + public bool Save() + { + return false; + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-foreach/Models/User.cs b/gitnexus/test/fixtures/lang-resolution/csharp-foreach/Models/User.cs new file mode 100644 index 000000000..2d2ffe30a --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-foreach/Models/User.cs @@ -0,0 +1,9 @@ +namespace Models; + +public class User +{ + public bool Save() + { + return true; + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-foreach/Program.cs b/gitnexus/test/fixtures/lang-resolution/csharp-foreach/Program.cs new file mode 100644 index 000000000..75c0da7c7 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-foreach/Program.cs @@ -0,0 +1,19 @@ +using Models; +using System.Collections.Generic; + +namespace App; + +public class AppService +{ + public void ProcessEntities(List users, List repos) + { + foreach (User user in users) + { + user.Save(); + } + foreach (Repo repo in repos) + { + repo.Save(); + } + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-null-conditional/App.cs b/gitnexus/test/fixtures/lang-resolution/csharp-null-conditional/App.cs index 8869ba372..be7f9770b 100644 --- a/gitnexus/test/fixtures/lang-resolution/csharp-null-conditional/App.cs +++ b/gitnexus/test/fixtures/lang-resolution/csharp-null-conditional/App.cs @@ -6,10 +6,10 @@ public class AppService { public void Process() { - User user = new User(); - Repo repo = new Repo(); + User? user = new User(); + Repo? repo = new Repo(); - // Null-conditional calls — should disambiguate via receiver type + // Null-conditional calls — nullable receiver should be unwrapped user?.Save(); repo?.Save(); } diff --git a/gitnexus/test/fixtures/lang-resolution/go-assignment-chain/cmd/main.go b/gitnexus/test/fixtures/lang-resolution/go-assignment-chain/cmd/main.go new file mode 100644 index 000000000..5dfeb7a1a --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/go-assignment-chain/cmd/main.go @@ -0,0 +1,21 @@ +package main + +import "example.com/go-assignment-chain/models" + +func getUser() models.User { + return models.User{} +} + +func getRepo() models.Repo { + return models.Repo{} +} + +func processEntities() { + var u models.User = getUser() + alias := u + alias.Save() + + var r models.Repo = getRepo() + rAlias := r + rAlias.Save() +} diff --git a/gitnexus/test/fixtures/lang-resolution/go-assignment-chain/go.mod b/gitnexus/test/fixtures/lang-resolution/go-assignment-chain/go.mod new file mode 100644 index 000000000..ae305b7f7 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/go-assignment-chain/go.mod @@ -0,0 +1,3 @@ +module example.com/go-assignment-chain + +go 1.21 diff --git a/gitnexus/test/fixtures/lang-resolution/go-assignment-chain/models/repo.go b/gitnexus/test/fixtures/lang-resolution/go-assignment-chain/models/repo.go new file mode 100644 index 000000000..25e42eaf5 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/go-assignment-chain/models/repo.go @@ -0,0 +1,7 @@ +package models + +type Repo struct{} + +func (r *Repo) Save() bool { + return false +} diff --git a/gitnexus/test/fixtures/lang-resolution/go-assignment-chain/models/user.go b/gitnexus/test/fixtures/lang-resolution/go-assignment-chain/models/user.go new file mode 100644 index 000000000..7307a10d6 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/go-assignment-chain/models/user.go @@ -0,0 +1,7 @@ +package models + +type User struct{} + +func (u *User) Save() bool { + return true +} diff --git a/gitnexus/test/fixtures/lang-resolution/go-nullable-receiver/cmd/main.go b/gitnexus/test/fixtures/lang-resolution/go-nullable-receiver/cmd/main.go new file mode 100644 index 000000000..b38f62555 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/go-nullable-receiver/cmd/main.go @@ -0,0 +1,18 @@ +package main + +import "example.com/go-nullable-receiver/models" + +func findUser() *models.User { + return &models.User{} +} + +func findRepo() *models.Repo { + return &models.Repo{} +} + +func processEntities() { + var user *models.User = findUser() + var repo *models.Repo = findRepo() + user.Save() + repo.Save() +} diff --git a/gitnexus/test/fixtures/lang-resolution/go-nullable-receiver/go.mod b/gitnexus/test/fixtures/lang-resolution/go-nullable-receiver/go.mod new file mode 100644 index 000000000..5a9859a88 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/go-nullable-receiver/go.mod @@ -0,0 +1,3 @@ +module example.com/go-nullable-receiver + +go 1.21 diff --git a/gitnexus/test/fixtures/lang-resolution/go-nullable-receiver/models/repo.go b/gitnexus/test/fixtures/lang-resolution/go-nullable-receiver/models/repo.go new file mode 100644 index 000000000..25e42eaf5 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/go-nullable-receiver/models/repo.go @@ -0,0 +1,7 @@ +package models + +type Repo struct{} + +func (r *Repo) Save() bool { + return false +} diff --git a/gitnexus/test/fixtures/lang-resolution/go-nullable-receiver/models/user.go b/gitnexus/test/fixtures/lang-resolution/go-nullable-receiver/models/user.go new file mode 100644 index 000000000..7307a10d6 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/go-nullable-receiver/models/user.go @@ -0,0 +1,7 @@ +package models + +type User struct{} + +func (u *User) Save() bool { + return true +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-assignment-chain/App.java b/gitnexus/test/fixtures/lang-resolution/java-assignment-chain/App.java new file mode 100644 index 000000000..a41cdbcb3 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-assignment-chain/App.java @@ -0,0 +1,17 @@ +import models.User; +import models.Repo; + +public class App { + static User getUser() { return new User(); } + static Repo getRepo() { return new Repo(); } + + public static void processEntities() { + User u = getUser(); + var alias = u; + alias.save(); + + Repo r = getRepo(); + var rAlias = r; + rAlias.save(); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-assignment-chain/models/Repo.java b/gitnexus/test/fixtures/lang-resolution/java-assignment-chain/models/Repo.java new file mode 100644 index 000000000..cf3712bbc --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-assignment-chain/models/Repo.java @@ -0,0 +1,7 @@ +package models; + +public class Repo { + public boolean save() { + return false; + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-assignment-chain/models/User.java b/gitnexus/test/fixtures/lang-resolution/java-assignment-chain/models/User.java new file mode 100644 index 000000000..e8dacc136 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-assignment-chain/models/User.java @@ -0,0 +1,7 @@ +package models; + +public class User { + public boolean save() { + return true; + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-foreach/App.java b/gitnexus/test/fixtures/lang-resolution/java-foreach/App.java new file mode 100644 index 000000000..a3675e102 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-foreach/App.java @@ -0,0 +1,13 @@ +import models.User; +import models.Repo; + +public class App { + public static void processEntities(User[] users, Repo[] repos) { + for (User user : users) { + user.save(); + } + for (Repo repo : repos) { + repo.save(); + } + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-foreach/models/Repo.java b/gitnexus/test/fixtures/lang-resolution/java-foreach/models/Repo.java new file mode 100644 index 000000000..cf3712bbc --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-foreach/models/Repo.java @@ -0,0 +1,7 @@ +package models; + +public class Repo { + public boolean save() { + return false; + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-foreach/models/User.java b/gitnexus/test/fixtures/lang-resolution/java-foreach/models/User.java new file mode 100644 index 000000000..e8dacc136 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-foreach/models/User.java @@ -0,0 +1,7 @@ +package models; + +public class User { + public boolean save() { + return true; + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-nullable-receiver/App.java b/gitnexus/test/fixtures/lang-resolution/java-nullable-receiver/App.java new file mode 100644 index 000000000..0ea99662a --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-nullable-receiver/App.java @@ -0,0 +1,19 @@ +import models.User; +import models.Repo; + +public class App { + public static void processEntities() { + User user = findUser(); + Repo repo = findRepo(); + user.save(); + repo.save(); + } + + private static User findUser() { + return new User(); + } + + private static Repo findRepo() { + return new Repo(); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-nullable-receiver/models/Repo.java b/gitnexus/test/fixtures/lang-resolution/java-nullable-receiver/models/Repo.java new file mode 100644 index 000000000..cf3712bbc --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-nullable-receiver/models/Repo.java @@ -0,0 +1,7 @@ +package models; + +public class Repo { + public boolean save() { + return false; + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-nullable-receiver/models/User.java b/gitnexus/test/fixtures/lang-resolution/java-nullable-receiver/models/User.java new file mode 100644 index 000000000..e8dacc136 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-nullable-receiver/models/User.java @@ -0,0 +1,7 @@ +package models; + +public class User { + public boolean save() { + return true; + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/js-nullable-receiver/src/app.js b/gitnexus/test/fixtures/lang-resolution/js-nullable-receiver/src/app.js new file mode 100644 index 000000000..f9e08c969 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/js-nullable-receiver/src/app.js @@ -0,0 +1,11 @@ +const { User } = require('./user'); +const { Repo } = require('./repo'); + +/** + * @param {User | null} user + * @param {Repo | null} repo + */ +function processEntities(user, repo) { + if (user) user.save(); + if (repo) repo.save(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/js-nullable-receiver/src/repo.js b/gitnexus/test/fixtures/lang-resolution/js-nullable-receiver/src/repo.js new file mode 100644 index 000000000..41ab63ddd --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/js-nullable-receiver/src/repo.js @@ -0,0 +1,4 @@ +class Repo { + save() { return true; } +} +module.exports = { Repo }; diff --git a/gitnexus/test/fixtures/lang-resolution/js-nullable-receiver/src/user.js b/gitnexus/test/fixtures/lang-resolution/js-nullable-receiver/src/user.js new file mode 100644 index 000000000..e11c8bc1a --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/js-nullable-receiver/src/user.js @@ -0,0 +1,4 @@ +class User { + save() { return true; } +} +module.exports = { User }; diff --git a/gitnexus/test/fixtures/lang-resolution/kotlin-foreach/App.kt b/gitnexus/test/fixtures/lang-resolution/kotlin-foreach/App.kt new file mode 100644 index 000000000..02896161f --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/kotlin-foreach/App.kt @@ -0,0 +1,16 @@ +package app + +import models.User +import models.Repo + +fun processUsers(users: List) { + for (user: User in users) { + user.save() + } +} + +fun processRepos(repos: List) { + for (repo: Repo in repos) { + repo.save() + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/kotlin-foreach/models/Repo.kt b/gitnexus/test/fixtures/lang-resolution/kotlin-foreach/models/Repo.kt new file mode 100644 index 000000000..8e31ab2a0 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/kotlin-foreach/models/Repo.kt @@ -0,0 +1,5 @@ +package models + +class Repo { + fun save() {} +} diff --git a/gitnexus/test/fixtures/lang-resolution/kotlin-foreach/models/User.kt b/gitnexus/test/fixtures/lang-resolution/kotlin-foreach/models/User.kt new file mode 100644 index 000000000..494376e42 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/kotlin-foreach/models/User.kt @@ -0,0 +1,5 @@ +package models + +class User { + fun save() {} +} diff --git a/gitnexus/test/fixtures/lang-resolution/kotlin-nullable-receiver/models/Repo.kt b/gitnexus/test/fixtures/lang-resolution/kotlin-nullable-receiver/models/Repo.kt new file mode 100644 index 000000000..c337e368c --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/kotlin-nullable-receiver/models/Repo.kt @@ -0,0 +1,5 @@ +package models + +class Repo(val dbName: String) { + fun save(): Boolean = false +} diff --git a/gitnexus/test/fixtures/lang-resolution/kotlin-nullable-receiver/models/User.kt b/gitnexus/test/fixtures/lang-resolution/kotlin-nullable-receiver/models/User.kt new file mode 100644 index 000000000..7b3c39610 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/kotlin-nullable-receiver/models/User.kt @@ -0,0 +1,5 @@ +package models + +class User(val name: String) { + fun save(): Boolean = true +} diff --git a/gitnexus/test/fixtures/lang-resolution/kotlin-nullable-receiver/services/App.kt b/gitnexus/test/fixtures/lang-resolution/kotlin-nullable-receiver/services/App.kt new file mode 100644 index 000000000..63a2e97d1 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/kotlin-nullable-receiver/services/App.kt @@ -0,0 +1,13 @@ +package services + +import models.User +import models.Repo + +fun processEntities() { + val user: User? = User("alice") + val repo: Repo? = Repo("maindb") + + // Safe calls on nullable receivers — should disambiguate via unwrapped type + user?.save() + repo?.save() +} diff --git a/gitnexus/test/fixtures/lang-resolution/php-nullable-receiver/app/Models/Repo.php b/gitnexus/test/fixtures/lang-resolution/php-nullable-receiver/app/Models/Repo.php new file mode 100644 index 000000000..d3670e2f5 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/php-nullable-receiver/app/Models/Repo.php @@ -0,0 +1,11 @@ +save(); + $repo->save(); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/php-nullable-receiver/composer.json b/gitnexus/test/fixtures/lang-resolution/php-nullable-receiver/composer.json new file mode 100644 index 000000000..386b0bd2d --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/php-nullable-receiver/composer.json @@ -0,0 +1,7 @@ +{ + "autoload": { + "psr-4": { + "App\\": "app/" + } + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/python-assignment-chain/app.py b/gitnexus/test/fixtures/lang-resolution/python-assignment-chain/app.py new file mode 100644 index 000000000..7277e3904 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/python-assignment-chain/app.py @@ -0,0 +1,17 @@ +from user import User +from repo import Repo + +def get_user() -> User: + return User() + +def get_repo() -> Repo: + return Repo() + +def process(): + u: User = get_user() + alias = u + alias.save() + + r: Repo = get_repo() + r_alias = r + r_alias.save() diff --git a/gitnexus/test/fixtures/lang-resolution/python-assignment-chain/repo.py b/gitnexus/test/fixtures/lang-resolution/python-assignment-chain/repo.py new file mode 100644 index 000000000..18ce75c49 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/python-assignment-chain/repo.py @@ -0,0 +1,3 @@ +class Repo: + def save(self): + return False diff --git a/gitnexus/test/fixtures/lang-resolution/python-assignment-chain/user.py b/gitnexus/test/fixtures/lang-resolution/python-assignment-chain/user.py new file mode 100644 index 000000000..a9220e744 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/python-assignment-chain/user.py @@ -0,0 +1,3 @@ +class User: + def save(self): + return True diff --git a/gitnexus/test/fixtures/lang-resolution/python-nullable-receiver/app.py b/gitnexus/test/fixtures/lang-resolution/python-nullable-receiver/app.py new file mode 100644 index 000000000..4521d5913 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/python-nullable-receiver/app.py @@ -0,0 +1,14 @@ +from user import User +from repo import Repo + +def find_user() -> User | None: + return User() + +def find_repo() -> Repo | None: + return Repo() + +def process_entities(): + user: User | None = find_user() + user.save() + repo: Repo | None = find_repo() + repo.save() diff --git a/gitnexus/test/fixtures/lang-resolution/python-nullable-receiver/repo.py b/gitnexus/test/fixtures/lang-resolution/python-nullable-receiver/repo.py new file mode 100644 index 000000000..18ce75c49 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/python-nullable-receiver/repo.py @@ -0,0 +1,3 @@ +class Repo: + def save(self): + return False diff --git a/gitnexus/test/fixtures/lang-resolution/python-nullable-receiver/user.py b/gitnexus/test/fixtures/lang-resolution/python-nullable-receiver/user.py new file mode 100644 index 000000000..a9220e744 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/python-nullable-receiver/user.py @@ -0,0 +1,3 @@ +class User: + def save(self): + return True diff --git a/gitnexus/test/fixtures/lang-resolution/rust-assignment-chain/src/main.rs b/gitnexus/test/fixtures/lang-resolution/rust-assignment-chain/src/main.rs new file mode 100644 index 000000000..dd98690ed --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-assignment-chain/src/main.rs @@ -0,0 +1,19 @@ +mod user; +mod repo; +use crate::user::User; +use crate::repo::Repo; + +fn get_user() -> User { User } +fn get_repo() -> Repo { Repo } + +fn process_entities() { + let u: User = get_user(); + let alias = u; + alias.save(); + + let r: Repo = get_repo(); + let r_alias = r; + r_alias.save(); +} + +fn main() {} diff --git a/gitnexus/test/fixtures/lang-resolution/rust-assignment-chain/src/repo.rs b/gitnexus/test/fixtures/lang-resolution/rust-assignment-chain/src/repo.rs new file mode 100644 index 000000000..18a786288 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-assignment-chain/src/repo.rs @@ -0,0 +1,7 @@ +pub struct Repo; + +impl Repo { + pub fn save(&self) -> bool { + false + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/rust-assignment-chain/src/user.rs b/gitnexus/test/fixtures/lang-resolution/rust-assignment-chain/src/user.rs new file mode 100644 index 000000000..59bcfd2b2 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-assignment-chain/src/user.rs @@ -0,0 +1,7 @@ +pub struct User; + +impl User { + pub fn save(&self) -> bool { + true + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/rust-nullable-receiver/src/main.rs b/gitnexus/test/fixtures/lang-resolution/rust-nullable-receiver/src/main.rs new file mode 100644 index 000000000..c94c16a09 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-nullable-receiver/src/main.rs @@ -0,0 +1,21 @@ +mod user; +mod repo; +use crate::user::User; +use crate::repo::Repo; + +fn find_user() -> Option { + Some(User) +} + +fn find_repo() -> Option { + Some(Repo) +} + +fn process_entities() { + let user: Option = find_user(); + user.unwrap().save(); + let repo: Option = find_repo(); + repo.unwrap().save(); +} + +fn main() {} diff --git a/gitnexus/test/fixtures/lang-resolution/rust-nullable-receiver/src/repo.rs b/gitnexus/test/fixtures/lang-resolution/rust-nullable-receiver/src/repo.rs new file mode 100644 index 000000000..18a786288 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-nullable-receiver/src/repo.rs @@ -0,0 +1,7 @@ +pub struct Repo; + +impl Repo { + pub fn save(&self) -> bool { + false + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/rust-nullable-receiver/src/user.rs b/gitnexus/test/fixtures/lang-resolution/rust-nullable-receiver/src/user.rs new file mode 100644 index 000000000..59bcfd2b2 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-nullable-receiver/src/user.rs @@ -0,0 +1,7 @@ +pub struct User; + +impl User { + pub fn save(&self) -> bool { + true + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/ts-assignment-chain/src/app.ts b/gitnexus/test/fixtures/lang-resolution/ts-assignment-chain/src/app.ts new file mode 100644 index 000000000..d8a84d558 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/ts-assignment-chain/src/app.ts @@ -0,0 +1,15 @@ +import { User } from './user'; +import { Repo } from './repo'; + +function getUser(): User { return new User(); } +function getRepo(): Repo { return new Repo(); } + +export function processEntities(): void { + const u: User = getUser(); + const alias = u; + alias.save(); + + const r: Repo = getRepo(); + const rAlias = r; + rAlias.save(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/ts-assignment-chain/src/repo.ts b/gitnexus/test/fixtures/lang-resolution/ts-assignment-chain/src/repo.ts new file mode 100644 index 000000000..19631246b --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/ts-assignment-chain/src/repo.ts @@ -0,0 +1,5 @@ +export class Repo { + save(): boolean { + return false; + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/ts-assignment-chain/src/user.ts b/gitnexus/test/fixtures/lang-resolution/ts-assignment-chain/src/user.ts new file mode 100644 index 000000000..e2af97ca7 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/ts-assignment-chain/src/user.ts @@ -0,0 +1,5 @@ +export class User { + save(): boolean { + return true; + } +} diff --git a/gitnexus/test/integration/resolvers/cpp.test.ts b/gitnexus/test/integration/resolvers/cpp.test.ts index 09e5ed50b..ff48d3e0c 100644 --- a/gitnexus/test/integration/resolvers/cpp.test.ts +++ b/gitnexus/test/integration/resolvers/cpp.test.ts @@ -555,3 +555,50 @@ describe('C++ return-type inference via function return type', () => { expect(saveCall).toBeDefined(); }); }); + +// --------------------------------------------------------------------------- +// Nullable receiver unwrapping: User* pointer type stripped for resolution +// --------------------------------------------------------------------------- + +describe('C++ nullable receiver resolution (pointer types)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'cpp-nullable-receiver'), + () => {}, + ); + }, 60000); + + it('detects User and Repo classes with competing save methods', () => { + expect(getNodesByLabel(result, 'Class')).toContain('User'); + expect(getNodesByLabel(result, 'Class')).toContain('Repo'); + const saveMethods = getNodesByLabel(result, 'Method').filter((m: string) => m === 'save'); + expect(saveMethods.length).toBe(2); + }); + + it('resolves user->save() to User#save via pointer receiver typing', () => { + const calls = getRelationships(result, 'CALLS'); + const userSave = calls.find(c => + c.target === 'save' && c.source === 'processEntities' && c.targetFilePath.includes('User.h'), + ); + expect(userSave).toBeDefined(); + }); + + it('resolves repo->save() to Repo#save via pointer receiver typing', () => { + const calls = getRelationships(result, 'CALLS'); + const repoSave = calls.find(c => + c.target === 'save' && c.source === 'processEntities' && c.targetFilePath.includes('Repo.h'), + ); + expect(repoSave).toBeDefined(); + }); + + it('does NOT cross-contaminate (exactly 1 save per receiver file)', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCalls = calls.filter(c => c.target === 'save' && c.source === 'processEntities'); + const userTargeted = saveCalls.filter(c => c.targetFilePath.includes('User.h')); + const repoTargeted = saveCalls.filter(c => c.targetFilePath.includes('Repo.h')); + expect(userTargeted.length).toBe(1); + expect(repoTargeted.length).toBe(1); + }); +}); diff --git a/gitnexus/test/integration/resolvers/csharp.test.ts b/gitnexus/test/integration/resolvers/csharp.test.ts index 5c7abdc1f..ed14e3170 100644 --- a/gitnexus/test/integration/resolvers/csharp.test.ts +++ b/gitnexus/test/integration/resolvers/csharp.test.ts @@ -387,6 +387,49 @@ describe('C# local definition shadows import', () => { }); }); +// --------------------------------------------------------------------------- +// For-each loop element typing: foreach (User user in users) user.Save() +// C#: explicit type in foreach_statement binds loop variable +// --------------------------------------------------------------------------- + +describe('C# foreach loop element type resolution', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'csharp-foreach'), + () => {}, + ); + }, 60000); + + it('detects User and Repo classes, both with Save methods', () => { + expect(getNodesByLabel(result, 'Class')).toContain('User'); + expect(getNodesByLabel(result, 'Class')).toContain('Repo'); + const saveMethods = getNodesByLabel(result, 'Method').filter(m => m === 'Save'); + expect(saveMethods.length).toBe(2); + }); + + it('resolves user.Save() in foreach to User#Save (not Repo#Save)', () => { + const calls = getRelationships(result, 'CALLS'); + const userSave = calls.find(c => c.target === 'Save' && c.targetFilePath === 'Models/User.cs'); + expect(userSave).toBeDefined(); + expect(userSave!.source).toBe('ProcessEntities'); + }); + + it('resolves repo.Save() in foreach to Repo#Save (not User#Save)', () => { + const calls = getRelationships(result, 'CALLS'); + const repoSave = calls.find(c => c.target === 'Save' && c.targetFilePath === 'Models/Repo.cs'); + expect(repoSave).toBeDefined(); + expect(repoSave!.source).toBe('ProcessEntities'); + }); + + it('emits exactly 2 Save() CALLS edges (one per receiver type)', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCalls = calls.filter(c => c.target === 'Save'); + expect(saveCalls.length).toBe(2); + }); +}); + // --------------------------------------------------------------------------- // this.Save() resolves to enclosing class's own Save method // --------------------------------------------------------------------------- @@ -719,3 +762,65 @@ describe('C# async await constructor binding resolution', () => { expect(wrongSave).toBeUndefined(); }); }); + +// --------------------------------------------------------------------------- +// Assignment chain propagation (Phase 4.3) +// --------------------------------------------------------------------------- + +describe('C# assignment chain propagation', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'csharp-assignment-chain'), + () => {}, + ); + }, 60000); + + it('detects User and Repo classes each with a Save method', () => { + expect(getNodesByLabel(result, 'Class')).toContain('User'); + expect(getNodesByLabel(result, 'Class')).toContain('Repo'); + const saveMethods = getNodesByLabel(result, 'Method').filter(m => m === 'Save'); + expect(saveMethods.length).toBe(2); + }); + + it('resolves alias.Save() to User#Save via assignment chain', () => { + const calls = getRelationships(result, 'CALLS'); + // Positive: alias.Save() must resolve to User#Save + const userSave = calls.find(c => + c.target === 'Save' && c.source === 'ProcessEntities' && c.targetFilePath.includes('User.cs'), + ); + expect(userSave).toBeDefined(); + }); + + it('alias.Save() does NOT resolve to Repo#Save', () => { + const calls = getRelationships(result, 'CALLS'); + // Negative: alias comes from User, so only one edge to User.cs + const wrongCall = calls.filter(c => + c.target === 'Save' && c.source === 'ProcessEntities' && c.targetFilePath.includes('User.cs'), + ); + expect(wrongCall.length).toBe(1); + }); + + it('resolves rAlias.Save() to Repo#Save via assignment chain', () => { + const calls = getRelationships(result, 'CALLS'); + // Positive: rAlias.Save() must resolve to Repo#Save + const repoSave = calls.find(c => + c.target === 'Save' && c.source === 'ProcessEntities' && c.targetFilePath.includes('Repo.cs'), + ); + expect(repoSave).toBeDefined(); + }); + + it('each alias resolves to its own class, not the other', () => { + const calls = getRelationships(result, 'CALLS'); + const userSave = calls.find(c => + c.target === 'Save' && c.source === 'ProcessEntities' && c.targetFilePath.includes('User.cs'), + ); + const repoSave = calls.find(c => + c.target === 'Save' && c.source === 'ProcessEntities' && c.targetFilePath.includes('Repo.cs'), + ); + expect(userSave).toBeDefined(); + expect(repoSave).toBeDefined(); + expect(userSave!.targetFilePath).not.toBe(repoSave!.targetFilePath); + }); +}); diff --git a/gitnexus/test/integration/resolvers/go.test.ts b/gitnexus/test/integration/resolvers/go.test.ts index f2be3460c..3712d16c9 100644 --- a/gitnexus/test/integration/resolvers/go.test.ts +++ b/gitnexus/test/integration/resolvers/go.test.ts @@ -658,3 +658,115 @@ describe('Go multi-return factory type inference', () => { expect(wrongSave).toBeUndefined(); }); }); + +// --------------------------------------------------------------------------- +// Nullable receiver: var user *models.User = findUser(); user.Save() +// Go pointer types (*User) — extractSimpleTypeName strips pointer prefix. +// --------------------------------------------------------------------------- + +describe('Go nullable receiver resolution (pointer types)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'go-nullable-receiver'), + () => {}, + ); + }, 60000); + + it('detects User and Repo structs, both with Save methods', () => { + expect(getNodesByLabel(result, 'Struct')).toContain('User'); + expect(getNodesByLabel(result, 'Struct')).toContain('Repo'); + const saveMethods = getNodesByLabel(result, 'Method').filter(m => m === 'Save'); + expect(saveMethods.length).toBe(2); + }); + + it('resolves user.Save() to User.Save via pointer receiver typing', () => { + const calls = getRelationships(result, 'CALLS'); + const userSave = calls.find(c => c.target === 'Save' && c.targetFilePath === 'models/user.go'); + expect(userSave).toBeDefined(); + expect(userSave!.source).toBe('processEntities'); + }); + + it('resolves repo.Save() to Repo.Save via pointer receiver typing', () => { + const calls = getRelationships(result, 'CALLS'); + const repoSave = calls.find(c => c.target === 'Save' && c.targetFilePath === 'models/repo.go'); + expect(repoSave).toBeDefined(); + expect(repoSave!.source).toBe('processEntities'); + }); + + it('user.Save() does NOT resolve to Repo.Save (negative disambiguation)', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCalls = calls.filter(c => c.target === 'Save' && c.source === 'processEntities'); + expect(saveCalls.filter(c => c.targetFilePath === 'models/user.go').length).toBe(1); + expect(saveCalls.filter(c => c.targetFilePath === 'models/repo.go').length).toBe(1); + }); + + it('emits exactly 2 Save() CALLS edges (one per receiver type)', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCalls = calls.filter(c => c.target === 'Save'); + expect(saveCalls.length).toBe(2); + }); +}); + +// --------------------------------------------------------------------------- +// Assignment chain propagation (Phase 4.3) +// --------------------------------------------------------------------------- + +describe('Go assignment chain propagation', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'go-assignment-chain'), + () => {}, + ); + }, 60000); + + it('detects User and Repo structs each with a Save method', () => { + expect(getNodesByLabel(result, 'Struct')).toContain('User'); + expect(getNodesByLabel(result, 'Struct')).toContain('Repo'); + const saveMethods = getNodesByLabel(result, 'Method').filter(m => m === 'Save'); + expect(saveMethods.length).toBe(2); + }); + + it('resolves alias.Save() to User#Save via assignment chain', () => { + const calls = getRelationships(result, 'CALLS'); + // Positive: alias.Save() must resolve to User#Save + const userSave = calls.find(c => + c.target === 'Save' && c.source === 'processEntities' && c.targetFilePath.includes('user.go'), + ); + expect(userSave).toBeDefined(); + }); + + it('alias.Save() does NOT resolve to Repo#Save', () => { + const calls = getRelationships(result, 'CALLS'); + // Negative: alias comes from User, so only one edge to user.go + const wrongCall = calls.filter(c => + c.target === 'Save' && c.source === 'processEntities' && c.targetFilePath.includes('user.go'), + ); + expect(wrongCall.length).toBe(1); + }); + + it('resolves rAlias.Save() to Repo#Save via assignment chain', () => { + const calls = getRelationships(result, 'CALLS'); + // Positive: rAlias.Save() must resolve to Repo#Save + const repoSave = calls.find(c => + c.target === 'Save' && c.source === 'processEntities' && c.targetFilePath.includes('repo.go'), + ); + expect(repoSave).toBeDefined(); + }); + + it('each alias resolves to its own struct, not the other', () => { + const calls = getRelationships(result, 'CALLS'); + const userSave = calls.find(c => + c.target === 'Save' && c.source === 'processEntities' && c.targetFilePath.includes('user.go'), + ); + const repoSave = calls.find(c => + c.target === 'Save' && c.source === 'processEntities' && c.targetFilePath.includes('repo.go'), + ); + expect(userSave).toBeDefined(); + expect(repoSave).toBeDefined(); + expect(userSave!.targetFilePath).not.toBe(repoSave!.targetFilePath); + }); +}); diff --git a/gitnexus/test/integration/resolvers/java.test.ts b/gitnexus/test/integration/resolvers/java.test.ts index 2b0fd622f..fff9cb865 100644 --- a/gitnexus/test/integration/resolvers/java.test.ts +++ b/gitnexus/test/integration/resolvers/java.test.ts @@ -401,6 +401,49 @@ describe('Java constructor-inferred type resolution', () => { }); }); +// --------------------------------------------------------------------------- +// For-each loop element typing: for (User user : users) user.save() +// Java: explicit type in enhanced_for_statement binds loop variable +// --------------------------------------------------------------------------- + +describe('Java for-each loop element type resolution', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'java-foreach'), + () => {}, + ); + }, 60000); + + it('detects User and Repo classes, both with save methods', () => { + expect(getNodesByLabel(result, 'Class')).toContain('User'); + expect(getNodesByLabel(result, 'Class')).toContain('Repo'); + const saveMethods = getNodesByLabel(result, 'Method').filter(m => m === 'save'); + expect(saveMethods.length).toBe(2); + }); + + it('resolves user.save() in for-each to User#save (not Repo#save)', () => { + const calls = getRelationships(result, 'CALLS'); + const userSave = calls.find(c => c.target === 'save' && c.targetFilePath === 'models/User.java'); + expect(userSave).toBeDefined(); + expect(userSave!.source).toBe('processEntities'); + }); + + it('resolves repo.save() in for-each to Repo#save (not User#save)', () => { + const calls = getRelationships(result, 'CALLS'); + const repoSave = calls.find(c => c.target === 'save' && c.targetFilePath === 'models/Repo.java'); + expect(repoSave).toBeDefined(); + expect(repoSave!.source).toBe('processEntities'); + }); + + it('emits exactly 2 save() CALLS edges (one per receiver type)', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCalls = calls.filter(c => c.target === 'save'); + expect(saveCalls.length).toBe(2); + }); +}); + // --------------------------------------------------------------------------- // this.save() resolves to enclosing class's own save method // --------------------------------------------------------------------------- @@ -565,3 +608,119 @@ describe('Java return type inference via explicit method return type', () => { expect(saveCall).toBeDefined(); }); }); + +// --------------------------------------------------------------------------- +// Nullable receiver: Java uses explicit type annotations (User user = findUser()) +// Tests that regular typed receiver resolution works with competing save() methods +// when the variable is assigned from a factory method returning the same type. +// Note: Java Optional stores just "Optional" in TypeEnv (generics stripped), +// so this test uses plain typed variables to validate receiver disambiguation. +// --------------------------------------------------------------------------- + +describe('Java nullable receiver resolution (typed factory return)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'java-nullable-receiver'), + () => {}, + ); + }, 60000); + + it('detects User and Repo classes, both with save methods', () => { + expect(getNodesByLabel(result, 'Class')).toContain('User'); + expect(getNodesByLabel(result, 'Class')).toContain('Repo'); + const saveMethods = getNodesByLabel(result, 'Method').filter(m => m === 'save'); + expect(saveMethods.length).toBe(2); + }); + + it('resolves user.save() to User.save via receiver typing', () => { + const calls = getRelationships(result, 'CALLS'); + const userSave = calls.find(c => c.target === 'save' && c.targetFilePath === 'models/User.java'); + expect(userSave).toBeDefined(); + expect(userSave!.source).toBe('processEntities'); + }); + + it('resolves repo.save() to Repo.save via receiver typing', () => { + const calls = getRelationships(result, 'CALLS'); + const repoSave = calls.find(c => c.target === 'save' && c.targetFilePath === 'models/Repo.java'); + expect(repoSave).toBeDefined(); + expect(repoSave!.source).toBe('processEntities'); + }); + + it('user.save() does NOT resolve to Repo.save (negative disambiguation)', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCalls = calls.filter(c => c.target === 'save' && c.source === 'processEntities'); + // Each save() call should resolve to exactly one target file + expect(saveCalls.filter(c => c.targetFilePath === 'models/User.java').length).toBe(1); + expect(saveCalls.filter(c => c.targetFilePath === 'models/Repo.java').length).toBe(1); + }); + + it('emits exactly 2 save() CALLS edges (one per receiver type)', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCalls = calls.filter(c => c.target === 'save'); + expect(saveCalls.length).toBe(2); + }); +}); + +// --------------------------------------------------------------------------- +// Assignment chain propagation (Phase 4.3) +// --------------------------------------------------------------------------- + +describe('Java assignment chain propagation', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'java-assignment-chain'), + () => {}, + ); + }, 60000); + + it('detects User and Repo classes each with a save method', () => { + expect(getNodesByLabel(result, 'Class')).toContain('User'); + expect(getNodesByLabel(result, 'Class')).toContain('Repo'); + const saveMethods = getNodesByLabel(result, 'Method').filter(m => m === 'save'); + expect(saveMethods.length).toBe(2); + }); + + it('resolves alias.save() to User#save via assignment chain', () => { + const calls = getRelationships(result, 'CALLS'); + // Positive: alias.save() must resolve to User#save + const userSave = calls.find(c => + c.target === 'save' && c.source === 'processEntities' && c.targetFilePath.includes('User.java'), + ); + expect(userSave).toBeDefined(); + }); + + it('alias.save() does NOT resolve to Repo#save', () => { + const calls = getRelationships(result, 'CALLS'); + // Negative: alias comes from User, so only one edge to User.java + const wrongCall = calls.filter(c => + c.target === 'save' && c.source === 'processEntities' && c.targetFilePath.includes('User.java'), + ); + expect(wrongCall.length).toBe(1); + }); + + it('resolves rAlias.save() to Repo#save via assignment chain', () => { + const calls = getRelationships(result, 'CALLS'); + // Positive: rAlias.save() must resolve to Repo#save + const repoSave = calls.find(c => + c.target === 'save' && c.source === 'processEntities' && c.targetFilePath.includes('Repo.java'), + ); + expect(repoSave).toBeDefined(); + }); + + it('each alias resolves to its own class, not the other', () => { + const calls = getRelationships(result, 'CALLS'); + const userSave = calls.find(c => + c.target === 'save' && c.source === 'processEntities' && c.targetFilePath.includes('User.java'), + ); + const repoSave = calls.find(c => + c.target === 'save' && c.source === 'processEntities' && c.targetFilePath.includes('Repo.java'), + ); + expect(userSave).toBeDefined(); + expect(repoSave).toBeDefined(); + expect(userSave!.targetFilePath).not.toBe(repoSave!.targetFilePath); + }); +}); diff --git a/gitnexus/test/integration/resolvers/javascript.test.ts b/gitnexus/test/integration/resolvers/javascript.test.ts index 41be8a841..b184eab42 100644 --- a/gitnexus/test/integration/resolvers/javascript.test.ts +++ b/gitnexus/test/integration/resolvers/javascript.test.ts @@ -69,6 +69,52 @@ describe('JavaScript parent resolution', () => { }); }); +// --------------------------------------------------------------------------- +// Nullable receiver: JSDoc @param {User | null} strips nullable via TypeEnv +// --------------------------------------------------------------------------- + +describe('JavaScript nullable receiver resolution', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'js-nullable-receiver'), + () => {}, + ); + }, 60000); + + it('detects User and Repo classes, both with save methods', () => { + expect(getNodesByLabel(result, 'Class')).toEqual(['Repo', 'User']); + const saveMethods = getNodesByLabel(result, 'Method').filter(m => m === 'save'); + expect(saveMethods.length).toBe(2); + }); + + it('resolves user.save() to src/user.js via nullable-stripped JSDoc type', () => { + const calls = getRelationships(result, 'CALLS'); + const userSave = calls.find(c => c.target === 'save' && c.source === 'processEntities' && c.targetFilePath === 'src/user.js'); + expect(userSave).toBeDefined(); + }); + + it('resolves repo.save() to src/repo.js via nullable-stripped JSDoc type', () => { + const calls = getRelationships(result, 'CALLS'); + const repoSave = calls.find(c => c.target === 'save' && c.source === 'processEntities' && c.targetFilePath === 'src/repo.js'); + expect(repoSave).toBeDefined(); + }); + + it('emits exactly 2 save() CALLS edges (one per receiver type)', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCalls = calls.filter(c => c.target === 'save'); + expect(saveCalls.length).toBe(2); + }); + + it('each save() call resolves to a distinct file (no duplicates)', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCalls = calls.filter(c => c.target === 'save' && c.source === 'processEntities'); + const files = saveCalls.map(c => c.targetFilePath).sort(); + expect(files).toEqual(['src/repo.js', 'src/user.js']); + }); +}); + // --------------------------------------------------------------------------- // super.save() resolves to parent class's save method // --------------------------------------------------------------------------- diff --git a/gitnexus/test/integration/resolvers/kotlin.test.ts b/gitnexus/test/integration/resolvers/kotlin.test.ts index fe61412da..671fbb128 100644 --- a/gitnexus/test/integration/resolvers/kotlin.test.ts +++ b/gitnexus/test/integration/resolvers/kotlin.test.ts @@ -481,35 +481,28 @@ describe('Kotlin return type inference', () => { expect(saveFns.length).toBe(2); }); - // Known gap: Kotlin return-type disambiguation does not yet resolve competing - // same-named methods. With two save() functions (User#save, Repo#save), the - // resolver correctly refuses to emit an ambiguous edge — but it also cannot - // narrow to the correct target via return type inference. This gap needs - // investigation into whether Kotlin import resolution + scanConstructorBinding - // produces verified receiver bindings end-to-end. - it('does not emit spurious save() edges when disambiguation fails', () => { + it('resolves user.save() to User#save via return type inference', () => { const calls = getRelationships(result, 'CALLS'); const userSave = calls.find(c => - c.target === 'save' && c.source === 'processUser', + c.target === 'save' && c.source === 'processUser' && c.targetFilePath?.includes('User.kt'), ); + expect(userSave).toBeDefined(); + }); + + it('resolves repo.save() to Repo#save via return type inference', () => { + const calls = getRelationships(result, 'CALLS'); const repoSave = calls.find(c => - c.target === 'save' && c.source === 'processRepo', + c.target === 'save' && c.source === 'processRepo' && c.targetFilePath?.includes('Repo.kt'), ); - // With two competing save() methods and no working disambiguation, - // the resolver should refuse to emit edges (no false positives). - // When Kotlin return-type inference is fixed, update these to expect - // the edges to be defined and point to the correct files. - if (!userSave) { - expect(userSave).toBeUndefined(); - } else { - // If disambiguation starts working, verify it points to the right file - expect(userSave.targetFilePath).toContain('User.kt'); - } - if (!repoSave) { - expect(repoSave).toBeUndefined(); - } else { - expect(repoSave.targetFilePath).toContain('Repo.kt'); - } + expect(repoSave).toBeDefined(); + }); + + it('user.save() does NOT resolve to Repo#save', () => { + const calls = getRelationships(result, 'CALLS'); + const wrongSave = calls.find(c => + c.target === 'save' && c.source === 'processUser' && c.targetFilePath?.includes('Repo.kt'), + ); + expect(wrongSave).toBeUndefined(); }); }); @@ -583,6 +576,58 @@ describe('Kotlin super resolution', () => { }); }); +// --------------------------------------------------------------------------- +// For-each loop variable type resolution: for (user: User in users) { user.save() } +// --------------------------------------------------------------------------- + +describe('Kotlin for-each loop type resolution', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'kotlin-foreach'), + () => {}, + ); + }, 60000); + + it('detects User and Repo classes, both with save functions', () => { + expect(getNodesByLabel(result, 'Class')).toContain('User'); + expect(getNodesByLabel(result, 'Class')).toContain('Repo'); + const saveFns = getNodesByLabel(result, 'Function').filter(f => f === 'save'); + expect(saveFns.length).toBe(2); + }); + + it('resolves user.save() inside for-each to models/User.kt', () => { + const calls = getRelationships(result, 'CALLS'); + const userSave = calls.find(c => c.target === 'save' && c.source === 'processUsers' && c.targetFilePath === 'models/User.kt'); + expect(userSave).toBeDefined(); + }); + + it('resolves repo.save() inside for-each to models/Repo.kt', () => { + const calls = getRelationships(result, 'CALLS'); + const repoSave = calls.find(c => c.target === 'save' && c.source === 'processRepos' && c.targetFilePath === 'models/Repo.kt'); + expect(repoSave).toBeDefined(); + }); + + it('emits exactly 2 save() CALLS edges (one per receiver type)', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCalls = calls.filter(c => c.target === 'save'); + expect(saveCalls.length).toBe(2); + }); + + it('user.save() does NOT resolve to Repo.save', () => { + const calls = getRelationships(result, 'CALLS'); + const wrongSave = calls.find(c => c.target === 'save' && c.source === 'processUsers' && c.targetFilePath === 'models/Repo.kt'); + expect(wrongSave).toBeUndefined(); + }); + + it('repo.save() does NOT resolve to User.save', () => { + const calls = getRelationships(result, 'CALLS'); + const wrongSave = calls.find(c => c.target === 'save' && c.source === 'processRepos' && c.targetFilePath === 'models/User.kt'); + expect(wrongSave).toBeUndefined(); + }); +}); + // --------------------------------------------------------------------------- // super.save() resolves to generic parent class's save method // --------------------------------------------------------------------------- @@ -610,3 +655,50 @@ describe('Kotlin generic parent super resolution', () => { expect(repoSave).toBeUndefined(); }); }); + +// --------------------------------------------------------------------------- +// Nullable receiver unwrapping: user?.save() with User? type resolves through ?. +// --------------------------------------------------------------------------- + +describe('Kotlin nullable receiver resolution (safe calls)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'kotlin-nullable-receiver'), + () => {}, + ); + }, 60000); + + it('detects User and Repo classes with competing save methods', () => { + expect(getNodesByLabel(result, 'Class')).toContain('User'); + expect(getNodesByLabel(result, 'Class')).toContain('Repo'); + const saveMethods = getNodesByLabel(result, 'Function').filter((m: string) => m === 'save'); + expect(saveMethods.length).toBe(2); + }); + + it('resolves user?.save() to User#save via receiver typing', () => { + const calls = getRelationships(result, 'CALLS'); + const userSave = calls.find(c => + c.target === 'save' && c.source === 'processEntities' && c.targetFilePath.includes('User.kt'), + ); + expect(userSave).toBeDefined(); + }); + + it('resolves repo?.save() to Repo#save via receiver typing', () => { + const calls = getRelationships(result, 'CALLS'); + const repoSave = calls.find(c => + c.target === 'save' && c.source === 'processEntities' && c.targetFilePath.includes('Repo.kt'), + ); + expect(repoSave).toBeDefined(); + }); + + it('does NOT cross-contaminate (exactly 1 save per receiver file)', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCalls = calls.filter(c => c.target === 'save' && c.source === 'processEntities'); + const userTargeted = saveCalls.filter(c => c.targetFilePath.includes('User.kt')); + const repoTargeted = saveCalls.filter(c => c.targetFilePath.includes('Repo.kt')); + expect(userTargeted.length).toBe(1); + expect(repoTargeted.length).toBe(1); + }); +}); diff --git a/gitnexus/test/integration/resolvers/php.test.ts b/gitnexus/test/integration/resolvers/php.test.ts index 28b91f3d4..709b2bf27 100644 --- a/gitnexus/test/integration/resolvers/php.test.ts +++ b/gitnexus/test/integration/resolvers/php.test.ts @@ -882,3 +882,50 @@ describe('PHP $this->method() receiver disambiguation', () => { expect(saveCall).toBeDefined(); }); }); + +// --------------------------------------------------------------------------- +// Nullable receiver unwrapping: ?User type hint stripped to User for resolution +// --------------------------------------------------------------------------- + +describe('PHP nullable receiver resolution (?Type hint)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'php-nullable-receiver'), + () => {}, + ); + }, 60000); + + it('detects User and Repo classes with competing save methods', () => { + expect(getNodesByLabel(result, 'Class')).toContain('User'); + expect(getNodesByLabel(result, 'Class')).toContain('Repo'); + const saveMethods = getNodesByLabel(result, 'Method').filter((m: string) => m === 'save'); + expect(saveMethods.length).toBe(2); + }); + + it('resolves $user->save() to User#save via nullable param type', () => { + const calls = getRelationships(result, 'CALLS'); + const userSave = calls.find(c => + c.target === 'save' && c.source === 'process' && c.targetFilePath.includes('User.php'), + ); + expect(userSave).toBeDefined(); + }); + + it('resolves $repo->save() to Repo#save via nullable param type', () => { + const calls = getRelationships(result, 'CALLS'); + const repoSave = calls.find(c => + c.target === 'save' && c.source === 'process' && c.targetFilePath.includes('Repo.php'), + ); + expect(repoSave).toBeDefined(); + }); + + it('does NOT cross-contaminate (exactly 1 save per receiver file)', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCalls = calls.filter(c => c.target === 'save' && c.source === 'process'); + const userTargeted = saveCalls.filter(c => c.targetFilePath.includes('User.php')); + const repoTargeted = saveCalls.filter(c => c.targetFilePath.includes('Repo.php')); + expect(userTargeted.length).toBe(1); + expect(repoTargeted.length).toBe(1); + }); +}); diff --git a/gitnexus/test/integration/resolvers/python.test.ts b/gitnexus/test/integration/resolvers/python.test.ts index c2ce2dd46..fd4dfbc7b 100644 --- a/gitnexus/test/integration/resolvers/python.test.ts +++ b/gitnexus/test/integration/resolvers/python.test.ts @@ -720,3 +720,119 @@ describe('Python static/classmethod class resolution (issue #289)', () => { expect(findCalls.length === 0 || findCalls.length === 2).toBe(true); }); }); + +// --------------------------------------------------------------------------- +// Nullable receiver: user: User | None = find_user(); user.save() +// Python 3.10+ union syntax — stripNullable unwraps `User | None` → `User` +// --------------------------------------------------------------------------- + +describe('Python nullable receiver resolution', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'python-nullable-receiver'), + () => {}, + ); + }, 60000); + + it('detects User and Repo classes, both with save functions', () => { + expect(getNodesByLabel(result, 'Class')).toContain('User'); + expect(getNodesByLabel(result, 'Class')).toContain('Repo'); + const saveFns = getNodesByLabel(result, 'Function').filter(m => m === 'save'); + expect(saveFns.length).toBe(2); + }); + + it('resolves user.save() to User.save via nullable receiver typing', () => { + const calls = getRelationships(result, 'CALLS'); + const userSave = calls.find(c => c.target === 'save' && c.targetFilePath === 'user.py'); + expect(userSave).toBeDefined(); + expect(userSave!.source).toBe('process_entities'); + }); + + it('resolves repo.save() to Repo.save via nullable receiver typing', () => { + const calls = getRelationships(result, 'CALLS'); + const repoSave = calls.find(c => c.target === 'save' && c.targetFilePath === 'repo.py'); + expect(repoSave).toBeDefined(); + expect(repoSave!.source).toBe('process_entities'); + }); + + it('user.save() does NOT resolve to Repo.save (negative disambiguation)', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCalls = calls.filter(c => c.target === 'save' && c.source === 'process_entities'); + // Each save() call should resolve to exactly one target file + const userSaveToRepo = saveCalls.filter(c => c.targetFilePath === 'repo.py'); + const repoSaveToUser = saveCalls.filter(c => c.targetFilePath === 'user.py'); + // Exactly 1 edge to each file (not 2 to either) + expect(userSaveToRepo.length).toBe(1); + expect(repoSaveToUser.length).toBe(1); + }); + + it('emits exactly 2 save() CALLS edges (one per receiver type)', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCalls = calls.filter(c => c.target === 'save'); + expect(saveCalls.length).toBe(2); + }); +}); + +// --------------------------------------------------------------------------- +// Assignment chain propagation (Phase 4.3) +// --------------------------------------------------------------------------- + +describe('Python assignment chain propagation', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'python-assignment-chain'), + () => {}, + ); + }, 60000); + + it('detects User and Repo classes each with a save method', () => { + expect(getNodesByLabel(result, 'Class')).toContain('User'); + expect(getNodesByLabel(result, 'Class')).toContain('Repo'); + const saveFns = getNodesByLabel(result, 'Function').filter(m => m === 'save'); + expect(saveFns.length).toBe(2); + }); + + it('resolves alias.save() to User#save via assignment chain', () => { + const calls = getRelationships(result, 'CALLS'); + // Positive: alias.save() must resolve to User#save + const userSave = calls.find(c => + c.target === 'save' && c.source === 'process' && c.targetFilePath.includes('user.py'), + ); + expect(userSave).toBeDefined(); + }); + + it('alias.save() does NOT resolve to Repo#save', () => { + const calls = getRelationships(result, 'CALLS'); + // Negative: only one save call from process to User#save + const wrongCall = calls.filter(c => + c.target === 'save' && c.source === 'process' && c.targetFilePath.includes('user.py'), + ); + expect(wrongCall.length).toBe(1); + }); + + it('resolves r_alias.save() to Repo#save via assignment chain', () => { + const calls = getRelationships(result, 'CALLS'); + // Positive: r_alias.save() must resolve to Repo#save + const repoSave = calls.find(c => + c.target === 'save' && c.source === 'process' && c.targetFilePath.includes('repo.py'), + ); + expect(repoSave).toBeDefined(); + }); + + it('each alias resolves to its own class, not the other', () => { + const calls = getRelationships(result, 'CALLS'); + const userSave = calls.find(c => + c.target === 'save' && c.source === 'process' && c.targetFilePath.includes('user.py'), + ); + const repoSave = calls.find(c => + c.target === 'save' && c.source === 'process' && c.targetFilePath.includes('repo.py'), + ); + expect(userSave).toBeDefined(); + expect(repoSave).toBeDefined(); + expect(userSave!.targetFilePath).not.toBe(repoSave!.targetFilePath); + }); +}); diff --git a/gitnexus/test/integration/resolvers/rust.test.ts b/gitnexus/test/integration/resolvers/rust.test.ts index 6af948514..13471b8ae 100644 --- a/gitnexus/test/integration/resolvers/rust.test.ts +++ b/gitnexus/test/integration/resolvers/rust.test.ts @@ -809,3 +809,76 @@ describe('Rust async .await constructor binding resolution', () => { expect(wrongSave).toBeUndefined(); }); }); + +// --------------------------------------------------------------------------- +// Nullable receiver: let user: Option = find_user(); user.unwrap().save() +// Rust Option — stripNullable unwraps Option wrapper to inner type. +// --------------------------------------------------------------------------- + +describe('Rust nullable receiver resolution (Option)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'rust-nullable-receiver'), + () => {}, + ); + }, 60000); + + it('detects User and Repo structs, both with save functions', () => { + expect(getNodesByLabel(result, 'Struct')).toContain('User'); + expect(getNodesByLabel(result, 'Struct')).toContain('Repo'); + const saveFns = getNodesByLabel(result, 'Function').filter(m => m === 'save'); + expect(saveFns.length).toBe(2); + }); + + // Known limitation: user.unwrap().save() chains two method calls. unwrap() + // returns User but TypeEnv doesn't track intermediate return values in chains. + // Disambiguating through .unwrap() requires chained return type inference (Phase 5). + it.todo('resolves user.unwrap().save() to User.save (requires chained call inference)'); +}); + +// --------------------------------------------------------------------------- +// Assignment chain propagation (Phase 4.3) +// --------------------------------------------------------------------------- + +describe('Rust assignment chain propagation', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'rust-assignment-chain'), + () => {}, + ); + }, 60000); + + it('detects User and Repo structs each with a save function', () => { + expect(getNodesByLabel(result, 'Struct')).toContain('User'); + expect(getNodesByLabel(result, 'Struct')).toContain('Repo'); + const saveFns = getNodesByLabel(result, 'Function').filter(m => m === 'save'); + expect(saveFns.length).toBe(2); + }); + + it('resolves alias.save() to User#save via assignment chain', () => { + const calls = getRelationships(result, 'CALLS'); + const userSave = calls.find(c => + c.target === 'save' && c.source === 'process_entities' && c.targetFilePath?.includes('user.rs'), + ); + expect(userSave).toBeDefined(); + }); + + it('resolves r_alias.save() to Repo#save via assignment chain', () => { + const calls = getRelationships(result, 'CALLS'); + const repoSave = calls.find(c => + c.target === 'save' && c.source === 'process_entities' && c.targetFilePath?.includes('repo.rs'), + ); + expect(repoSave).toBeDefined(); + }); + + it('alias.save() does NOT resolve to Repo#save', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCalls = calls.filter(c => c.target === 'save' && c.source === 'process_entities'); + expect(saveCalls.filter(c => c.targetFilePath?.includes('user.rs')).length).toBe(1); + expect(saveCalls.filter(c => c.targetFilePath?.includes('repo.rs')).length).toBe(1); + }); +}); diff --git a/gitnexus/test/integration/resolvers/typescript.test.ts b/gitnexus/test/integration/resolvers/typescript.test.ts index 8c355f8b5..1b40ea33c 100644 --- a/gitnexus/test/integration/resolvers/typescript.test.ts +++ b/gitnexus/test/integration/resolvers/typescript.test.ts @@ -850,6 +850,14 @@ describe('TypeScript nullable receiver resolution (optional chaining)', () => { expect(userCtor).toBeDefined(); expect(repoCtor).toBeDefined(); }); + + it('emits exactly 2 save() CALLS edges (one per receiver type)', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCalls = calls.filter(c => c.target === 'save'); + // user?.save() → User.save + repo?.save() → Repo.save = 2 edges + // If nullable unwrapping fails, the resolver refuses ambiguous matches and emits 0 + expect(saveCalls.length).toBe(2); + }); }); // --------------------------------------------------------------------------- @@ -1036,3 +1044,59 @@ describe('JavaScript qualified return type via JSDoc @returns {Promise { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'ts-assignment-chain'), + () => {}, + ); + }, 60000); + + it('detects User and Repo classes each with a save method', () => { + expect(getNodesByLabel(result, 'Class')).toContain('User'); + expect(getNodesByLabel(result, 'Class')).toContain('Repo'); + const saveMethods = getNodesByLabel(result, 'Method').filter(m => m === 'save'); + expect(saveMethods.length).toBe(2); + }); + + it('resolves alias.save() to User#save via assignment chain', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find(c => + c.target === 'save' && c.targetFilePath.includes('user.ts'), + ); + // Positive: alias.save() must resolve to User#save + expect(saveCall).toBeDefined(); + expect(saveCall!.source).toBe('processEntities'); + // Negative: alias.save() must NOT resolve to Repo#save + const wrongCall = calls.find(c => + c.target === 'save' && c.source === 'processEntities' && c.targetFilePath.includes('repo.ts'), + ); + // rAlias.save() correctly goes to Repo — but we verify there is exactly one + // per-receiver resolution (user alias → User, repo alias → Repo) + expect(wrongCall).toBeDefined(); // rAlias.save() resolves to Repo + }); + + it('resolves rAlias.save() to Repo#save via assignment chain', () => { + const calls = getRelationships(result, 'CALLS'); + const repoSave = calls.find(c => + c.target === 'save' && c.targetFilePath.includes('repo.ts'), + ); + expect(repoSave).toBeDefined(); + expect(repoSave!.source).toBe('processEntities'); + // Negative: rAlias.save() must NOT resolve to User#save (only) + const userSave = calls.find(c => + c.target === 'save' && c.targetFilePath.includes('user.ts'), + ); + expect(userSave).toBeDefined(); + // Both resolve separately — alias → User, rAlias → Repo + expect(userSave!.targetFilePath).not.toBe(repoSave!.targetFilePath); + }); +}); + diff --git a/gitnexus/test/unit/type-env.test.ts b/gitnexus/test/unit/type-env.test.ts index ff0c8cf1d..37db1764a 100644 --- a/gitnexus/test/unit/type-env.test.ts +++ b/gitnexus/test/unit/type-env.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from 'vitest'; import { buildTypeEnv, type TypeEnv, type TypeEnvironment } from '../../src/core/ingestion/type-env.js'; +import { stripNullable } from '../../src/core/ingestion/type-extractors/shared.js'; import Parser from 'tree-sitter'; import TypeScript from 'tree-sitter-typescript'; import Java from 'tree-sitter-java'; @@ -1905,4 +1906,125 @@ svc = App::Models::Service.new expect(binding!.calleeName).toBe('GetUser'); }); }); + + describe('assignment chain propagation (Tier 2, depth-1)', () => { + it('propagates explicit annotation: const a: User = ...; const b = a → b is User', () => { + const tree = parse(` + const a: User = getUser(); + const b = a; + `, TypeScript.typescript); + const { env } = buildTypeEnv(tree, 'typescript'); + expect(flatGet(env, 'a')).toBe('User'); + expect(flatGet(env, 'b')).toBe('User'); + }); + + it('propagates constructor inference: const a = new User(); const b = a → b is User', () => { + const tree = parse(` + const a = new User(); + const b = a; + `, TypeScript.typescript); + const { env } = buildTypeEnv(tree, 'typescript'); + expect(flatGet(env, 'a')).toBe('User'); + expect(flatGet(env, 'b')).toBe('User'); + }); + + it('depth-2 in declaration order resolves because single pass iterates sequentially', () => { + // b = a → resolved (a has User), c = b → also resolved because the same + // pass sets b before processing c (declarations are always in order). + // The "depth-1" limit applies to out-of-order or cyclic references. + const tree = parse(` + const a: User = getUser(); + const b = a; + const c = b; + `, TypeScript.typescript); + const { env } = buildTypeEnv(tree, 'typescript'); + expect(flatGet(env, 'a')).toBe('User'); + expect(flatGet(env, 'b')).toBe('User'); + expect(flatGet(env, 'c')).toBe('User'); + }); + + it('propagates typed function parameter to local alias', () => { + const tree = parse(` + function process(user: User) { + const alias = user; + alias.save(); + } + `, TypeScript.typescript); + const { env } = buildTypeEnv(tree, 'typescript'); + // 'alias' should get User from the parameter 'user' + const scopeKey = [...env.keys()].find(k => k.startsWith('process@')); + expect(scopeKey).toBeDefined(); + expect(env.get(scopeKey!)?.get('user')).toBe('User'); + expect(env.get(scopeKey!)?.get('alias')).toBe('User'); + }); + + it('propagates file-level typed variable to local alias inside function', () => { + const tree = parse(` + const config: Config = getConfig(); + function process() { + const cfg = config; + } + `, TypeScript.typescript); + const { env } = buildTypeEnv(tree, 'typescript'); + // cfg in process scope picks up Config from the file-level config binding + const scopeKey = [...env.keys()].find(k => k.startsWith('process@')); + expect(scopeKey).toBeDefined(); + expect(env.get(scopeKey!)?.get('cfg')).toBe('Config'); + }); + + it('does not propagate when RHS is a call expression (not a plain identifier)', () => { + const tree = parse(` + const x = getUser(); + `, TypeScript.typescript); + const { env } = buildTypeEnv(tree, 'typescript'); + // getUser() is a call_expression — should not create a binding + expect(flatGet(env, 'x')).toBeUndefined(); + }); + }); + + describe('stripNullable', () => { + it('strips User | null → User', () => { + expect(stripNullable('User | null')).toBe('User'); + }); + + it('strips User | undefined → User', () => { + expect(stripNullable('User | undefined')).toBe('User'); + }); + + it('strips User | null | undefined → User', () => { + expect(stripNullable('User | null | undefined')).toBe('User'); + }); + + it('strips User? → User', () => { + expect(stripNullable('User?')).toBe('User'); + }); + + it('passes through User unchanged', () => { + expect(stripNullable('User')).toBe('User'); + }); + + it('refuses genuine union User | Repo → undefined', () => { + expect(stripNullable('User | Repo')).toBeUndefined(); + }); + + it('returns undefined for null alone', () => { + expect(stripNullable('null')).toBeUndefined(); + }); + + it('returns undefined for empty string', () => { + expect(stripNullable('')).toBeUndefined(); + }); + + it('strips User | void → User', () => { + expect(stripNullable('User | void')).toBe('User'); + }); + + it('strips User | None → User (Python)', () => { + expect(stripNullable('User | None')).toBe('User'); + }); + + it('strips User | nil → User (Ruby)', () => { + expect(stripNullable('User | nil')).toBe('User'); + }); + }); });