From dbf37cd572f8363342a2d3580f5a742aba47cc2c Mon Sep 17 00:00:00 2001 From: Gergo Magyar Date: Sun, 15 Mar 2026 09:21:03 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20Phase=204=20=E2=80=94=20CONSTRUCTOR=5FB?= =?UTF-8?q?INDING=5FSCANNERS=20for=20all=20languages=20+=20return=20type?= =?UTF-8?q?=20inference=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add CONSTRUCTOR_BINDING_SCANNERS for 6 missing languages, completing return type inference coverage across all 11 supported languages: - TypeScript/JS: variable_declarator with call_expression, unwraps await - Go: short_var_declaration single-assignment (skips multi-return, new/make) - Java: local_variable_declaration with `var` type + method_invocation - C#: variable_declaration with implicit_type (var) + invocation_expression - Rust: let_declaration without type annotation, handles mut_pattern - PHP: assignment_expression with function_call_expression Also adds property_identifier to extractSimpleTypeName for qualified member calls (repo.getUser → getUser), fixing namespaced constructor inference that was previously a known limitation. Integration tests added for all 11 languages with correct label assertions (Function vs Method per language's tree-sitter queries). --- .gitignore | 7 +- gitnexus/src/core/ingestion/type-env.ts | 170 ++++++++++++++++++ .../core/ingestion/type-extractors/shared.ts | 2 +- .../lang-resolution/cpp-return-type/app.cpp | 10 ++ .../lang-resolution/cpp-return-type/user.h | 11 ++ .../csharp-return-type/Models/User.cs | 24 +++ .../csharp-return-type/Services/App.cs | 13 ++ .../kotlin-return-type/models/User.kt | 9 + .../kotlin-return-type/services/App.kt | 8 + .../php-return-type/app/Models/User.php | 15 ++ .../app/Services/UserService.php | 16 ++ .../lang-resolution/ruby-return-type/app.rb | 6 + .../ruby-return-type/models.rb | 13 ++ .../rust-return-type/src/main.rs | 7 + .../rust-return-type/src/models.rs | 11 ++ .../swift-return-type/App.swift | 4 + .../swift-return-type/Models.swift | 13 ++ .../test/integration/resolvers/cpp.test.ts | 35 ++++ .../test/integration/resolvers/csharp.test.ts | 39 ++++ .../test/integration/resolvers/go.test.ts | 17 +- .../test/integration/resolvers/java.test.ts | 10 +- .../test/integration/resolvers/kotlin.test.ts | 34 ++++ .../test/integration/resolvers/php.test.ts | 36 ++++ .../test/integration/resolvers/ruby.test.ts | 36 ++++ .../test/integration/resolvers/rust.test.ts | 36 ++++ .../test/integration/resolvers/swift.test.ts | 34 ++++ .../integration/resolvers/typescript.test.ts | 5 +- gitnexus/test/unit/type-env.test.ts | 58 +++++- 28 files changed, 661 insertions(+), 18 deletions(-) create mode 100644 gitnexus/test/fixtures/lang-resolution/cpp-return-type/app.cpp create mode 100644 gitnexus/test/fixtures/lang-resolution/cpp-return-type/user.h create mode 100644 gitnexus/test/fixtures/lang-resolution/csharp-return-type/Models/User.cs create mode 100644 gitnexus/test/fixtures/lang-resolution/csharp-return-type/Services/App.cs create mode 100644 gitnexus/test/fixtures/lang-resolution/kotlin-return-type/models/User.kt create mode 100644 gitnexus/test/fixtures/lang-resolution/kotlin-return-type/services/App.kt create mode 100644 gitnexus/test/fixtures/lang-resolution/php-return-type/app/Models/User.php create mode 100644 gitnexus/test/fixtures/lang-resolution/php-return-type/app/Services/UserService.php create mode 100644 gitnexus/test/fixtures/lang-resolution/ruby-return-type/app.rb create mode 100644 gitnexus/test/fixtures/lang-resolution/ruby-return-type/models.rb create mode 100644 gitnexus/test/fixtures/lang-resolution/rust-return-type/src/main.rs create mode 100644 gitnexus/test/fixtures/lang-resolution/rust-return-type/src/models.rs create mode 100644 gitnexus/test/fixtures/lang-resolution/swift-return-type/App.swift create mode 100644 gitnexus/test/fixtures/lang-resolution/swift-return-type/Models.swift diff --git a/.gitignore b/.gitignore index 692aa1e76..9853c332e 100644 --- a/.gitignore +++ b/.gitignore @@ -62,4 +62,9 @@ docs/plans/ gitnexus/test/fixtures/mini-repo/*.md gitnexus/test/fixtures/mini-repo/.claude -gitnexus/test/fixtures/mini-repo/.gitignore \ No newline at end of file +gitnexus/test/fixtures/mini-repo/.gitignore + +# Ignore csharp generated obj and bin folders +gitnexus/test/fixtures/lang-resolution/**/obj +gitnexus/test/fixtures/lang-resolution/**/bin +GitNexus.sln \ No newline at end of file diff --git a/gitnexus/src/core/ingestion/type-env.ts b/gitnexus/src/core/ingestion/type-env.ts index 2849f6e09..6016bfd55 100644 --- a/gitnexus/src/core/ingestion/type-env.ts +++ b/gitnexus/src/core/ingestion/type-env.ts @@ -420,8 +420,42 @@ const extractCppConstructorBinding = (node: SyntaxNode): { varName: string; call return { varName, calleeName: func.text }; }; +/** + * TypeScript/JavaScript: const user = getUser() — variable_declarator with call_expression value. + * Only matches unannotated declarators; annotated ones are handled by extractDeclaration. + * await is unwrapped: const user = await fetchUser() → callee = 'fetchUser'. + */ +const extractTsJsConstructorBinding = (node: SyntaxNode): { varName: string; calleeName: string } | undefined => { + if (node.type !== 'variable_declarator') return undefined; + // Skip if has an explicit type annotation — extractDeclaration handles those + if (node.childForFieldName('type')) return undefined; + for (const child of node.children) { + if (child.type === 'type_annotation') return undefined; + } + const nameNode = node.childForFieldName('name'); + if (!nameNode || nameNode.type !== 'identifier') return undefined; + let value = node.childForFieldName('value'); + if (!value) return undefined; + // Unwrap await expressions: const user = await fetchUser() + if (value.type === 'await_expression') { + value = value.firstNamedChild; + if (!value) return undefined; + } + if (value.type !== 'call_expression') return undefined; + // Skip new_expression — extractInitializer handles constructor calls + const func = value.childForFieldName('function'); + if (!func) return undefined; + const calleeName = extractSimpleTypeName(func); + if (!calleeName) return undefined; + return { varName: nameNode.text, calleeName }; +}; + /** Language-specific constructor-binding scanners. */ const CONSTRUCTOR_BINDING_SCANNERS: Partial { varName: string; calleeName: string } | undefined>> = { + // TypeScript/JavaScript share the same variable_declarator scanner + [SupportedLanguages.TypeScript]: extractTsJsConstructorBinding, + [SupportedLanguages.JavaScript]: extractTsJsConstructorBinding, + // Kotlin: val x = User(...) — property_declaration with call_expression [SupportedLanguages.Kotlin]: (node) => { if (node.type !== 'property_declaration') return undefined; @@ -509,5 +543,141 @@ const CONSTRUCTOR_BINDING_SCANNERS: Partial { + if (node.type !== 'let_declaration') return undefined; + // Skip if has explicit type annotation — extractDeclaration handles those + if (node.childForFieldName('type')) return undefined; + for (const child of node.children) { + if (child.type === 'type_annotation') return undefined; + } + let patternNode = node.childForFieldName('pattern'); + if (!patternNode) return undefined; + // Unwrap mut: `let mut user` → mut_pattern > identifier + if (patternNode.type === 'mut_pattern') { + patternNode = patternNode.firstNamedChild; + if (!patternNode) return undefined; + } + if (patternNode.type !== 'identifier') return undefined; + const value = node.childForFieldName('value'); + if (!value || value.type !== 'call_expression') return undefined; + const func = value.childForFieldName('function'); + if (!func) return undefined; + // Skip Struct::new() patterns — handled by extractInitializer in rust.ts + if (func.type === 'scoped_identifier') { + const methodName = func.lastNamedChild; + if (methodName?.text === 'new') return undefined; + } + const calleeName = extractSimpleTypeName(func); + if (!calleeName) return undefined; + return { varName: patternNode.text, calleeName }; + }, + + // PHP: $user = getUser() — assignment_expression with variable_name left and function_call_expression right + // object_creation_expression ($user = new User()) is handled by extractInitializer. + // Explicit typed properties (private UserRepo $repo) are handled by extractDeclaration. + // PHP variable names include the $ sigil — kept as-is to match what extractVarName stores in the env. + [SupportedLanguages.PHP]: (node) => { + if (node.type !== 'assignment_expression') return undefined; + const left = node.childForFieldName('left'); + const right = node.childForFieldName('right'); + if (!left || !right) return undefined; + if (left.type !== 'variable_name') return undefined; + // Skip object_creation_expression (new User()) — handled by extractInitializer + if (right.type === 'object_creation_expression') return undefined; + if (right.type !== 'function_call_expression') return undefined; + const func = right.childForFieldName('function') ?? right.firstNamedChild; + if (!func) return undefined; + const calleeName = extractSimpleTypeName(func); + if (!calleeName) return undefined; + // Keep the $ sigil — PHP env keys are stored with $ (e.g. "$user") by extractVarName + const varName = left.text; + if (!varName) return undefined; + return { varName, calleeName }; + }, + + // Java: var user = getUser() — local_variable_declaration with `var` type and method_invocation value + // Explicit types (User user = getUser()) are handled by extractDeclaration. + // object_creation_expression (new User()) is handled by extractJavaInitializer. + [SupportedLanguages.Java]: (node) => { + if (node.type !== 'local_variable_declaration') return undefined; + const typeNode = node.childForFieldName('type'); + if (!typeNode) return undefined; + // Only handle `var` — explicit types are handled by extractDeclaration + if (typeNode.text !== 'var') return undefined; + const declarator = node.namedChildren.find((c: any) => c.type === 'variable_declarator'); + if (!declarator) return undefined; + const nameNode = declarator.childForFieldName('name'); + const value = declarator.childForFieldName('value'); + if (!nameNode || !value) return undefined; + // Skip object_creation_expression (new User()) — handled by extractInitializer + if (value.type === 'object_creation_expression') return undefined; + if (value.type !== 'method_invocation') return undefined; + const methodName = value.childForFieldName('name'); + if (!methodName) return undefined; + return { varName: nameNode.text, calleeName: methodName.text }; + }, + + // C#: var user = GetUser() — variable_declaration with implicit_type and invocation_expression value. + // Explicit types (User user = GetUser()) are handled by extractDeclaration. + // object_creation_expression (new User()) is handled by extractInitializer. + [SupportedLanguages.CSharp]: (node) => { + if (node.type !== 'variable_declaration') return undefined; + const typeNode = node.childForFieldName('type'); + // Only handle implicit_type (var) — explicit types handled by extractDeclaration + if (!typeNode || typeNode.type !== 'implicit_type') return undefined; + // Find first variable_declarator child + let declarator: any = null; + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child?.type === 'variable_declarator') { declarator = child; break; } + } + if (!declarator) return undefined; + const nameNode = declarator.childForFieldName('name') ?? declarator.firstNamedChild; + if (!nameNode || nameNode.type !== 'identifier') return undefined; + // Find equals_value_clause + let eqClause: any = null; + for (let i = 0; i < declarator.namedChildCount; i++) { + const child = declarator.namedChild(i); + if (child?.type === 'equals_value_clause') { eqClause = child; break; } + } + if (!eqClause) return undefined; + const value = eqClause.firstNamedChild; + if (!value) return undefined; + // Skip object_creation_expression (new User()) — handled by extractInitializer + if (value.type === 'object_creation_expression') return undefined; + if (value.type !== 'invocation_expression') return undefined; + const func = value.firstNamedChild; + if (!func) return undefined; + const calleeName = extractSimpleTypeName(func); + if (!calleeName) return undefined; + return { varName: nameNode.text, calleeName }; + }, + + // Go: user := GetUser("alice") — short_var_declaration with single call_expression on the right. + // Multi-return (`user, err := GetUser()`) is intentionally skipped. + // new() and make() are already handled by extractDeclaration in go.ts. + [SupportedLanguages.Go]: (node) => { + if (node.type !== 'short_var_declaration') return undefined; + const left = node.childForFieldName('left'); + const right = node.childForFieldName('right'); + if (!left || !right) return undefined; + // Single assignment only — skip multi-return like `user, err := GetUser()` + const leftIds = left.type === 'expression_list' ? left.namedChildren : [left]; + if (leftIds.length !== 1 || leftIds[0].type !== 'identifier') return undefined; + const rightExprs = right.type === 'expression_list' ? right.namedChildren : [right]; + if (rightExprs.length !== 1 || rightExprs[0].type !== 'call_expression') return undefined; + const func = rightExprs[0].childForFieldName('function'); + if (!func) return undefined; + // Skip new() and make() — already handled by extractDeclaration + if (func.text === 'new' || func.text === 'make') return undefined; + const calleeName = extractSimpleTypeName(func); + if (!calleeName) return undefined; + return { varName: leftIds[0].text, calleeName }; + }, }; diff --git a/gitnexus/src/core/ingestion/type-extractors/shared.ts b/gitnexus/src/core/ingestion/type-extractors/shared.ts index 11d6fd5ba..398234fb7 100644 --- a/gitnexus/src/core/ingestion/type-extractors/shared.ts +++ b/gitnexus/src/core/ingestion/type-extractors/shared.ts @@ -22,7 +22,7 @@ export const extractSimpleTypeName = (typeNode: SyntaxNode): string | undefined const last = typeNode.lastNamedChild; if (last && (last.type === 'type_identifier' || last.type === 'identifier' || last.type === 'simple_identifier' || last.type === 'name' - || last.type === 'constant')) { + || last.type === 'constant' || last.type === 'property_identifier')) { return last.text; } } diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-return-type/app.cpp b/gitnexus/test/fixtures/lang-resolution/cpp-return-type/app.cpp new file mode 100644 index 000000000..6925e024e --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-return-type/app.cpp @@ -0,0 +1,10 @@ +#include "user.h" + +User getUser(const char* name) { + return User(name); +} + +void processUser() { + auto user = getUser("alice"); + user.save(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-return-type/user.h b/gitnexus/test/fixtures/lang-resolution/cpp-return-type/user.h new file mode 100644 index 000000000..df2ca1918 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-return-type/user.h @@ -0,0 +1,11 @@ +#pragma once + +class User { +public: + User(const char* name) : name_(name) {} + void save() {} +private: + const char* name_; +}; + +User getUser(const char* name); diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-return-type/Models/User.cs b/gitnexus/test/fixtures/lang-resolution/csharp-return-type/Models/User.cs new file mode 100644 index 000000000..c4909d0c9 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-return-type/Models/User.cs @@ -0,0 +1,24 @@ +namespace Models; + +public class User +{ + private string _name; + + public User(string name) + { + _name = name; + } + + public bool Save() + { + return true; + } +} + +public class UserService +{ + public User GetUser(string name) + { + return new User(name); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-return-type/Services/App.cs b/gitnexus/test/fixtures/lang-resolution/csharp-return-type/Services/App.cs new file mode 100644 index 000000000..5b4aabe80 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-return-type/Services/App.cs @@ -0,0 +1,13 @@ +using Models; + +namespace Services; + +public class App +{ + public void Run() + { + var svc = new UserService(); + var user = svc.GetUser("alice"); + user.Save(); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/kotlin-return-type/models/User.kt b/gitnexus/test/fixtures/lang-resolution/kotlin-return-type/models/User.kt new file mode 100644 index 000000000..e3a3e71ec --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/kotlin-return-type/models/User.kt @@ -0,0 +1,9 @@ +package models + +class User(val name: String) { + fun save() {} +} + +fun getUser(name: String): User { + return User(name) +} diff --git a/gitnexus/test/fixtures/lang-resolution/kotlin-return-type/services/App.kt b/gitnexus/test/fixtures/lang-resolution/kotlin-return-type/services/App.kt new file mode 100644 index 000000000..b4d6baf31 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/kotlin-return-type/services/App.kt @@ -0,0 +1,8 @@ +package services + +import models.getUser + +fun processUser() { + val user = getUser("alice") + user.save() +} diff --git a/gitnexus/test/fixtures/lang-resolution/php-return-type/app/Models/User.php b/gitnexus/test/fixtures/lang-resolution/php-return-type/app/Models/User.php new file mode 100644 index 000000000..481a1b39a --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/php-return-type/app/Models/User.php @@ -0,0 +1,15 @@ +name = $name; + } + + public function save(): bool { + return true; + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/php-return-type/app/Services/UserService.php b/gitnexus/test/fixtures/lang-resolution/php-return-type/app/Services/UserService.php new file mode 100644 index 000000000..6f366dbce --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/php-return-type/app/Services/UserService.php @@ -0,0 +1,16 @@ +getUser("alice"); + $user->save(); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/ruby-return-type/app.rb b/gitnexus/test/fixtures/lang-resolution/ruby-return-type/app.rb new file mode 100644 index 000000000..39e84e919 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/ruby-return-type/app.rb @@ -0,0 +1,6 @@ +require_relative 'models' + +def process_user + user = get_user('alice') + user.save +end diff --git a/gitnexus/test/fixtures/lang-resolution/ruby-return-type/models.rb b/gitnexus/test/fixtures/lang-resolution/ruby-return-type/models.rb new file mode 100644 index 000000000..153126126 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/ruby-return-type/models.rb @@ -0,0 +1,13 @@ +class User + def initialize(name) + @name = name + end + + def save + true + end +end + +def get_user(name) + User.new(name) +end diff --git a/gitnexus/test/fixtures/lang-resolution/rust-return-type/src/main.rs b/gitnexus/test/fixtures/lang-resolution/rust-return-type/src/main.rs new file mode 100644 index 000000000..f4f40ec5a --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-return-type/src/main.rs @@ -0,0 +1,7 @@ +mod models; +use crate::models::get_user; + +fn main() { + let user = get_user("alice"); + user.save(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/rust-return-type/src/models.rs b/gitnexus/test/fixtures/lang-resolution/rust-return-type/src/models.rs new file mode 100644 index 000000000..85ce57013 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-return-type/src/models.rs @@ -0,0 +1,11 @@ +pub struct User { + pub name: String, +} + +impl User { + pub fn save(&self) {} +} + +pub fn get_user(name: &str) -> User { + User { name: name.to_string() } +} diff --git a/gitnexus/test/fixtures/lang-resolution/swift-return-type/App.swift b/gitnexus/test/fixtures/lang-resolution/swift-return-type/App.swift new file mode 100644 index 000000000..edf1600d9 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/swift-return-type/App.swift @@ -0,0 +1,4 @@ +func processUser() { + let user = getUser(name: "alice") + user.save() +} diff --git a/gitnexus/test/fixtures/lang-resolution/swift-return-type/Models.swift b/gitnexus/test/fixtures/lang-resolution/swift-return-type/Models.swift new file mode 100644 index 000000000..69a9139c4 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/swift-return-type/Models.swift @@ -0,0 +1,13 @@ +class User { + let name: String + + init(name: String) { + self.name = name + } + + func save() {} +} + +func getUser(name: String) -> User { + return User(name: name) +} diff --git a/gitnexus/test/integration/resolvers/cpp.test.ts b/gitnexus/test/integration/resolvers/cpp.test.ts index 41eaa5c5e..7d3c6a914 100644 --- a/gitnexus/test/integration/resolvers/cpp.test.ts +++ b/gitnexus/test/integration/resolvers/cpp.test.ts @@ -477,3 +477,38 @@ describe('C++ range-for explicit type resolution', () => { expect(edge).toBeDefined(); }); }); + +// --------------------------------------------------------------------------- +// Return type inference: auto user = getUser("alice"); user.save() +// C++'s CONSTRUCTOR_BINDING_SCANNER captures auto declarations with +// call_expression values, enabling return type inference from function results. +// --------------------------------------------------------------------------- + +describe('C++ return type inference via auto + function call', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'cpp-return-type'), + () => {}, + ); + }, 60000); + + it('detects User class and getUser function', () => { + expect(getNodesByLabel(result, 'Class')).toContain('User'); + expect(getNodesByLabel(result, 'Function')).toContain('getUser'); + }); + + it('detects save method on User', () => { + const methods = getNodesByLabel(result, 'Method'); + expect(methods).toContain('save'); + }); + + it('resolves user.save() to User#save via return type of getUser(): User', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find(c => + c.target === 'save' && c.source === 'processUser' && c.targetFilePath.includes('user.h'), + ); + expect(saveCall).toBeDefined(); + }); +}); diff --git a/gitnexus/test/integration/resolvers/csharp.test.ts b/gitnexus/test/integration/resolvers/csharp.test.ts index db6345cc6..016166a03 100644 --- a/gitnexus/test/integration/resolvers/csharp.test.ts +++ b/gitnexus/test/integration/resolvers/csharp.test.ts @@ -558,3 +558,42 @@ describe('C# is pattern matching resolution', () => { expect(catExtends!.target).toBe('Animal'); }); }); + +// --------------------------------------------------------------------------- +// Return type inference: var user = svc.GetUser("alice"); user.Save() +// C#'s CONSTRUCTOR_BINDING_SCANNER handles `var` declarations with +// invocation_expression values, enabling end-to-end return type inference. +// --------------------------------------------------------------------------- + +describe('C# return type inference via var + invocation', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'csharp-return-type'), + () => {}, + ); + }, 60000); + + it('detects User and UserService classes', () => { + expect(getNodesByLabel(result, 'Class')).toContain('User'); + expect(getNodesByLabel(result, 'Class')).toContain('UserService'); + }); + + it('detects Save and GetUser methods', () => { + const methods = getNodesByLabel(result, 'Method'); + expect(methods).toContain('Save'); + expect(methods).toContain('GetUser'); + }); + + it('resolves user.Save() to User#Save via return type of GetUser(): User', () => { + // C#'s CONSTRUCTOR_BINDING_SCANNER binds `var user = svc.GetUser()` to the + // return type of GetUser (User), so the subsequent user.Save() call resolves + // to User#Save rather than an unresolved target. + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find(c => + c.target === 'Save' && c.source === 'Run' && c.targetFilePath.includes('Models'), + ); + expect(saveCall).toBeDefined(); + }); +}); diff --git a/gitnexus/test/integration/resolvers/go.test.ts b/gitnexus/test/integration/resolvers/go.test.ts index 80e4e360b..68f771664 100644 --- a/gitnexus/test/integration/resolvers/go.test.ts +++ b/gitnexus/test/integration/resolvers/go.test.ts @@ -544,8 +544,8 @@ describe('Go type assertion type inference', () => { // --------------------------------------------------------------------------- // Return type inference: user := GetUser("alice"); user.Save() -// Go has no CONSTRUCTOR_BINDING_SCANNER for short variable declarations yet, -// so return type inference does NOT work end-to-end for `user := GetUser()`. +// Go now has a CONSTRUCTOR_BINDING_SCANNER for short_var_declaration, so +// return type inference works end-to-end for `user := GetUser()`. // --------------------------------------------------------------------------- describe('Go return type inference via explicit function return type', () => { @@ -565,12 +565,21 @@ describe('Go return type inference via explicit function return type', () => { }); it('resolves user.Save() to User#Save via return type of GetUser() *models.User', () => { - // Go's extractMethodSignature captures *models.User as the return type. - // extractReturnTypeName strips the pointer prefix and qualified name → User. + // The Go CONSTRUCTOR_BINDING_SCANNER registers `user` → GetUser in the type + // environment. Combined with GetUser's declared return type (*models.User), + // the pipeline resolves user.Save() to the correct User#Save method. const calls = getRelationships(result, 'CALLS'); const saveCall = calls.find(c => c.target === 'Save' && c.source === 'processUser' && c.targetFilePath.includes('models') ); expect(saveCall).toBeDefined(); }); + + it('resolves user.Save() via short_var_declaration binding (CONSTRUCTOR_BINDING_SCANNER)', () => { + // Verifies that the scanner path — not just explicit return-type annotation — + // is responsible for establishing the user → User binding. + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find(c => c.target === 'Save' && c.targetFilePath.includes('models')); + expect(saveCall).toBeDefined(); + }); }); diff --git a/gitnexus/test/integration/resolvers/java.test.ts b/gitnexus/test/integration/resolvers/java.test.ts index 5bb07a572..2b0fd622f 100644 --- a/gitnexus/test/integration/resolvers/java.test.ts +++ b/gitnexus/test/integration/resolvers/java.test.ts @@ -529,8 +529,8 @@ describe('Java generic parent super resolution', () => { // --------------------------------------------------------------------------- // Return type inference: var user = svc.getUser("alice"); user.save() -// Java has no CONSTRUCTOR_BINDING_SCANNER for `var` declarations yet, -// so return type inference does NOT work end-to-end. +// Java's CONSTRUCTOR_BINDING_SCANNER handles `var` declarations with +// method_invocation values, enabling end-to-end return type inference. // --------------------------------------------------------------------------- describe('Java return type inference via explicit method return type', () => { @@ -555,9 +555,9 @@ describe('Java return type inference via explicit method return type', () => { }); it('resolves user.save() to User#save via return type of getUser(): User', () => { - // Java's type extractor handles `var user = svc.getUser()` through - // the local_variable_declaration path. The return type of getUser is User, - // enabling save() to resolve to User#save. + // Java's CONSTRUCTOR_BINDING_SCANNER binds `var user = svc.getUser()` to the + // return type of getUser (User), so the subsequent user.save() call resolves + // to User#save rather than an unresolved target. const calls = getRelationships(result, 'CALLS'); const saveCall = calls.find(c => c.target === 'save' && c.source === 'processUser' && c.targetFilePath.includes('models') diff --git a/gitnexus/test/integration/resolvers/kotlin.test.ts b/gitnexus/test/integration/resolvers/kotlin.test.ts index 4ee89971b..0be7b4143 100644 --- a/gitnexus/test/integration/resolvers/kotlin.test.ts +++ b/gitnexus/test/integration/resolvers/kotlin.test.ts @@ -458,6 +458,40 @@ describe('Kotlin this resolution', () => { }); }); +// --------------------------------------------------------------------------- +// Return type inference: val user = getUser("alice"); user.save() +// Kotlin's CONSTRUCTOR_BINDING_SCANNER captures property_declaration with +// call_expression values, enabling return type inference from function results. +// --------------------------------------------------------------------------- + +describe('Kotlin return type inference', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'kotlin-return-type'), + () => {}, + ); + }, 60000); + + it('detects User class and getUser function', () => { + expect(getNodesByLabel(result, 'Class')).toContain('User'); + expect(getNodesByLabel(result, 'Function')).toContain('getUser'); + }); + + it('detects save function on User (Kotlin class methods are Function nodes)', () => { + expect(getNodesByLabel(result, 'Function')).toContain('save'); + }); + + it('resolves user.save() to User#save via return type of getUser(): User', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find(c => + c.target === 'save' && c.source === 'processUser' && c.targetFilePath.includes('User.kt'), + ); + expect(saveCall).toBeDefined(); + }); +}); + // --------------------------------------------------------------------------- // Parent class resolution: EXTENDS + IMPLEMENTS edges // --------------------------------------------------------------------------- diff --git a/gitnexus/test/integration/resolvers/php.test.ts b/gitnexus/test/integration/resolvers/php.test.ts index 2803f8d14..5f9b64048 100644 --- a/gitnexus/test/integration/resolvers/php.test.ts +++ b/gitnexus/test/integration/resolvers/php.test.ts @@ -695,3 +695,39 @@ describe('PHP typed class property resolution', () => { expect(saveCall!.targetFilePath).toBe('app/Models/UserRepo.php'); }); }); + +// --------------------------------------------------------------------------- +// Return type inference: $user = $this->getUser("alice"); $user->save() +// PHP's CONSTRUCTOR_BINDING_SCANNER captures assignment_expression with +// function_call_expression values, enabling return type inference. +// --------------------------------------------------------------------------- + +describe('PHP return type inference via function call', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'php-return-type'), + () => {}, + ); + }, 60000); + + it('detects User and UserService classes', () => { + expect(getNodesByLabel(result, 'Class')).toContain('User'); + expect(getNodesByLabel(result, 'Class')).toContain('UserService'); + }); + + it('detects save and getUser methods', () => { + const methods = getNodesByLabel(result, 'Method'); + expect(methods).toContain('save'); + expect(methods).toContain('getUser'); + }); + + it('resolves $user->save() to User#save via return type of getUser(): User', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find(c => + c.target === 'save' && c.source === 'processUser' && c.targetFilePath.includes('User.php'), + ); + expect(saveCall).toBeDefined(); + }); +}); diff --git a/gitnexus/test/integration/resolvers/ruby.test.ts b/gitnexus/test/integration/resolvers/ruby.test.ts index cb40b6118..0d19c27d6 100644 --- a/gitnexus/test/integration/resolvers/ruby.test.ts +++ b/gitnexus/test/integration/resolvers/ruby.test.ts @@ -587,3 +587,39 @@ describe('Ruby namespaced constructor resolution (Models::UserService.new)', () expect(validateCall).toBeDefined(); }); }); + +// --------------------------------------------------------------------------- +// Return type inference: user = get_user('alice'); user.save +// Ruby's CONSTRUCTOR_BINDING_SCANNER captures assignment nodes with +// User.new calls. For plain function calls like get_user(), inference +// works via the scanner + SymbolTable return type lookup. +// --------------------------------------------------------------------------- + +describe('Ruby return type inference via function call', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'ruby-return-type'), + () => {}, + ); + }, 60000); + + it('detects User class and get_user method', () => { + expect(getNodesByLabel(result, 'Class')).toContain('User'); + // Ruby `def` is always captured as definition.method (Method label) + expect(getNodesByLabel(result, 'Method')).toContain('get_user'); + }); + + it('detects save method on User', () => { + expect(getNodesByLabel(result, 'Method')).toContain('save'); + }); + + it('resolves user.save to User#save via return type of get_user()', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find(c => + c.target === 'save' && c.source === 'process_user' && c.targetFilePath.includes('models.rb'), + ); + expect(saveCall).toBeDefined(); + }); +}); diff --git a/gitnexus/test/integration/resolvers/rust.test.ts b/gitnexus/test/integration/resolvers/rust.test.ts index c14adcc1a..930df4885 100644 --- a/gitnexus/test/integration/resolvers/rust.test.ts +++ b/gitnexus/test/integration/resolvers/rust.test.ts @@ -600,3 +600,39 @@ describe('Rust if-let captured_pattern type resolution', () => { expect(validateCall!.targetFilePath).toBe('models.rs'); }); }); + +// --------------------------------------------------------------------------- +// Return type inference: let user = get_user("alice"); user.save() +// Plain function call (no ::new) with no type annotation +// --------------------------------------------------------------------------- + +describe('Rust return type inference', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'rust-return-type'), + () => {}, + ); + }, 60000); + + it('detects User struct and get_user + save functions', () => { + expect(getNodesByLabel(result, 'Struct')).toContain('User'); + expect(getNodesByLabel(result, 'Function')).toContain('get_user'); + expect(getNodesByLabel(result, 'Function')).toContain('save'); + }); + + it('resolves main → get_user as a CALLS edge to src/models.rs', () => { + const calls = getRelationships(result, 'CALLS'); + const getUserCall = calls.find(c => c.target === 'get_user' && c.source === 'main'); + expect(getUserCall).toBeDefined(); + expect(getUserCall!.targetFilePath).toBe('src/models.rs'); + }); + + it('resolves user.save() to src/models.rs via return-type-inferred binding', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find(c => c.target === 'save' && c.source === 'main'); + expect(saveCall).toBeDefined(); + expect(saveCall!.targetFilePath).toBe('src/models.rs'); + }); +}); diff --git a/gitnexus/test/integration/resolvers/swift.test.ts b/gitnexus/test/integration/resolvers/swift.test.ts index cca1a3459..d20e548af 100644 --- a/gitnexus/test/integration/resolvers/swift.test.ts +++ b/gitnexus/test/integration/resolvers/swift.test.ts @@ -145,3 +145,37 @@ describe.skipIf(!swiftAvailable)('Swift cross-file User.init() inference', () => expect(greetCall!.source).toBe('main'); }); }); + +// --------------------------------------------------------------------------- +// Return type inference: let user = getUser(name: "alice"); user.save() +// Swift's CONSTRUCTOR_BINDING_SCANNER captures property_declaration with +// call_expression values, enabling return type inference from function results. +// --------------------------------------------------------------------------- + +describe.skipIf(!swiftAvailable)('Swift return type inference', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'swift-return-type'), + () => {}, + ); + }, 60000); + + it('detects User class and getUser function', () => { + expect(getNodesByLabel(result, 'Class')).toContain('User'); + expect(getNodesByLabel(result, 'Function')).toContain('getUser'); + }); + + it('detects save function on User (Swift class methods are Function nodes)', () => { + expect(getNodesByLabel(result, 'Function')).toContain('save'); + }); + + it('resolves user.save() to User#save via return type of getUser() -> User', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find(c => + c.target === 'save' && c.source === 'processUser' && c.targetFilePath.includes('Models.swift'), + ); + expect(saveCall).toBeDefined(); + }); +}); diff --git a/gitnexus/test/integration/resolvers/typescript.test.ts b/gitnexus/test/integration/resolvers/typescript.test.ts index 4cb7470f0..b1652e6d2 100644 --- a/gitnexus/test/integration/resolvers/typescript.test.ts +++ b/gitnexus/test/integration/resolvers/typescript.test.ts @@ -854,9 +854,8 @@ describe('TypeScript nullable receiver resolution (optional chaining)', () => { // --------------------------------------------------------------------------- // Return type inference: const user = getUser('alice'); user.save() -// TS/JS has no CONSTRUCTOR_BINDING_SCANNER for plain function calls yet, -// so return type inference does NOT work end-to-end. These tests document -// the current state and will activate once a TS scanner is added. +// The TS/JS CONSTRUCTOR_BINDING_SCANNER captures variable_declarator nodes +// with plain call_expression values, enabling end-to-end return type inference. // --------------------------------------------------------------------------- describe('TypeScript return type inference via explicit function return type', () => { diff --git a/gitnexus/test/unit/type-env.test.ts b/gitnexus/test/unit/type-env.test.ts index 215e0df91..af75d616e 100644 --- a/gitnexus/test/unit/type-env.test.ts +++ b/gitnexus/test/unit/type-env.test.ts @@ -877,12 +877,11 @@ class RepoService { expect(flatGet(env, 'user')).toBe('BaseUser'); }); - it('does not infer from namespaced constructor (known limitation)', () => { - // extractSimpleTypeName only handles simple identifiers, not member expressions + it('infers from namespaced constructor: new ns.Service()', () => { + // extractSimpleTypeName handles member_expression via property_identifier const tree = parse('const svc = new ns.Service();', TypeScript.typescript); const { env } = buildTypeEnv(tree, 'typescript'); - // member_expression as constructor → extractSimpleTypeName returns undefined - expect(flatGet(env, 'svc')).toBeUndefined(); + expect(flatGet(env, 'svc')).toBe('Service'); }); it('infers type from new expression with as cast', () => { @@ -1744,5 +1743,56 @@ svc = App::Models::Service.new expect(constructorBindings.length).toBe(1); expect(constructorBindings[0].scope).toMatch(/^process@\d+$/); }); + + it('returns constructor bindings for TypeScript const user = getUser()', () => { + const tree = parse('const user = getUser();', TypeScript.typescript); + const { env, constructorBindings } = buildTypeEnv(tree, 'typescript'); + expect(flatGet(env, 'user')).toBeUndefined(); + expect(constructorBindings.length).toBe(1); + expect(constructorBindings[0].varName).toBe('user'); + expect(constructorBindings[0].calleeName).toBe('getUser'); + }); + + it('does NOT emit constructor binding when TypeScript var has explicit type annotation', () => { + const tree = parse('const user: User = getUser();', TypeScript.typescript); + const { env, constructorBindings } = buildTypeEnv(tree, 'typescript'); + expect(flatGet(env, 'user')).toBe('User'); + expect(constructorBindings.find(b => b.varName === 'user')).toBeUndefined(); + }); + + it('skips destructuring patterns (array_pattern) for TypeScript', () => { + const tree = parse('const [a, b] = getPair();', TypeScript.typescript); + const { constructorBindings } = buildTypeEnv(tree, 'typescript'); + expect(constructorBindings).toEqual([]); + }); + + it('skips destructuring patterns (object_pattern) for TypeScript', () => { + const tree = parse('const { name, age } = getUser();', TypeScript.typescript); + const { constructorBindings } = buildTypeEnv(tree, 'typescript'); + expect(constructorBindings).toEqual([]); + }); + + it('unwraps await in TypeScript: const user = await fetchUser()', () => { + const tree = parse('async function f() { const user = await fetchUser(); }', TypeScript.typescript); + const { constructorBindings } = buildTypeEnv(tree, 'typescript'); + expect(constructorBindings.length).toBe(1); + expect(constructorBindings[0].varName).toBe('user'); + expect(constructorBindings[0].calleeName).toBe('fetchUser'); + }); + + it('handles qualified callee in TypeScript: const user = repo.getUser()', () => { + const tree = parse('const user = repo.getUser();', TypeScript.typescript); + const { constructorBindings } = buildTypeEnv(tree, 'typescript'); + expect(constructorBindings.length).toBe(1); + expect(constructorBindings[0].varName).toBe('user'); + expect(constructorBindings[0].calleeName).toBe('getUser'); + }); + + it('does not emit binding for TypeScript new expression (handled by extractInitializer)', () => { + const tree = parse('const user = new User();', TypeScript.typescript); + const { env, constructorBindings } = buildTypeEnv(tree, 'typescript'); + expect(flatGet(env, 'user')).toBe('User'); + expect(constructorBindings.find(b => b.varName === 'user')).toBeUndefined(); + }); }); });