fix: Phase 3 gaps — WRAPPER_GENERICS correctness, Ruby :: qualifier, namespaced constructors

- Remove collection types (List, Array, Vec, Set) from WRAPPER_GENERICS to prevent
  false CALLS edges (e.g. List<User> no longer unwraps to User)
- Add :: qualifier handling in extractReturnTypeName for Ruby/C++/Rust namespaced types
- Add Ruby `constant` and `scope_resolution` node types to shared extractors
- Extract shared extractRubyConstructorAssignment helper (dedup type-env.ts + ruby.ts)
- Add integration tests for return type inference: Python, TypeScript, Go, Java, Ruby
- Add Ruby namespaced constructor fixture (Models::UserService.new)
- Add unit tests for collection reclassification and :: qualifiers
This commit is contained in:
Gergo Magyar 2026-03-15 08:56:38 +00:00
parent f8685781d0
commit 55c13fc0e8
15 changed files with 665 additions and 70 deletions

View file

@ -0,0 +1,328 @@
---
title: "feat: Phase 4 — Complete Return Type Inference for All Languages"
type: feat
status: active
date: 2026-03-15
---
# Phase 4: Complete Return Type Inference for All Languages
## Overview
Phase 3 established the return type inference pipeline: when a function call result is assigned to a variable (`val user = getUser()`), and the called function has a known return type in the SymbolTable, subsequent member calls on that variable (`user.save()`) resolve to the return type's methods. This works via `CONSTRUCTOR_BINDING_SCANNERS` in `type-env.ts` which capture `var = callee()` assignments, and `processCallsFromExtracted` in `call-processor.ts` which looks up the callee's return type.
**Problem:** Only 5 of 12 languages have scanners (Kotlin, Python, Swift, C++, Ruby). The remaining 7 languages (TypeScript/JS, Go, Java, C#, Rust, PHP) can only resolve member calls when variables have explicit type annotations — they miss the common `var x = func()` pattern.
## Problem Statement
Without scanners, this code produces no CALLS edge for `user.save()`:
```typescript
// TypeScript — no scanner, so `user` type is unknown
const user = getUser("alice"); // ← not captured
user.save(); // ← unresolved
```
But this works because extractDeclaration handles explicit types:
```typescript
const user: User = getUser("alice"); // ← extractDeclaration captures User
user.save(); // ← resolves to User#save
```
## Proposed Solution
Add `CONSTRUCTOR_BINDING_SCANNERS` for all missing languages. Each scanner captures `var = callee()` patterns where the variable has no explicit type annotation, emitting a `{ varName, calleeName }` binding that `processCallsFromExtracted` resolves via the SymbolTable.
## Technical Approach
### Architecture
The pipeline is already built — we just need per-language AST pattern matchers:
```
AST walk (type-env.ts buildTypeEnv)
↓ scanner(node) → { varName, calleeName }
↓ collected in constructorBindings[]
↓ passed to processCallsFromExtracted
↓ ctx.resolve(calleeName) → SymbolDefinition
↓ if Class → bind varName to calleeName
↓ if Function/Method with returnType → extractReturnTypeName → bind varName to return type
↓ receiverMap[varName] = typeName
↓ resolveCallTarget uses receiverTypeName for member calls
```
### Phase 4.1: TypeScript/JavaScript Scanner
**AST pattern:** `lexical_declaration` → `variable_declarator` with no type_annotation and call_expression value
```typescript
// const user = getUser("alice")
// AST: lexical_declaration > variable_declarator[name=identifier, value=call_expression]
[SupportedLanguages.TypeScript]: (node) => {
if (node.type !== 'variable_declarator') return undefined;
// Skip if parent is not a lexical/variable declaration
const parent = node.parent;
if (!parent || (parent.type !== 'lexical_declaration' && parent.type !== 'variable_declaration')) return undefined;
// Skip if has type annotation
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;
const value = node.childForFieldName('value');
if (!value || value.type !== 'call_expression') return undefined;
const func = value.childForFieldName('function');
if (!func) return undefined;
const calleeName = extractSimpleTypeName(func);
if (!calleeName) return undefined;
return { varName: nameNode.text, calleeName };
},
// JavaScript shares the same config
[SupportedLanguages.JavaScript]: /* same as TypeScript */
```
**Key files:**
- `src/core/ingestion/type-env.ts:424` — add to CONSTRUCTOR_BINDING_SCANNERS
- `test/unit/type-env.test.ts` — unit tests for the scanner
- `test/integration/resolvers/typescript.test.ts` — update existing return type tests
**Node types to handle:**
- `variable_declarator` inside `lexical_declaration` (const/let)
- `variable_declarator` inside `variable_declaration` (var)
- Must skip destructuring patterns (`array_pattern`, `object_pattern`)
### Phase 4.2: Go Scanner
**AST pattern:** `short_var_declaration` → left identifier, right call_expression
```go
// user := GetUser("alice")
// AST: short_var_declaration[left=expression_list>identifier, right=expression_list>call_expression]
[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-assign like `a, b := ...`)
const leftIds = left.namedChildren.filter(c => c.type === 'identifier');
if (leftIds.length !== 1) return undefined;
// Right must be a single call_expression
const rightExprs = right.namedChildren;
if (rightExprs.length !== 1 || rightExprs[0].type !== 'call_expression') return undefined;
const func = rightExprs[0].childForFieldName('function');
if (!func) return undefined;
const calleeName = extractSimpleTypeName(func);
if (!calleeName) return undefined;
return { varName: leftIds[0].text, calleeName };
},
```
**Key files:**
- `src/core/ingestion/type-env.ts:424` — add Go scanner
- `test/integration/resolvers/go.test.ts` — update return type tests
**Edge cases:**
- Multi-return: `user, err := GetUser()` — skip (multiple left identifiers)
- Composite literal: `user := User{Name: "alice"}` — already handled by extractInitializer
- Selector call: `user := models.GetUser()` — extractSimpleTypeName handles `selector_expression`
### Phase 4.3: Java Scanner
**AST pattern:** `local_variable_declaration` with `var` type → `variable_declarator` with call value
```java
// var user = getUser("alice");
// AST: local_variable_declaration[type=void_type("var"), declarator=variable_declarator[value=method_invocation]]
[SupportedLanguages.Java]: (node) => {
if (node.type !== 'local_variable_declaration') return undefined;
const typeNode = node.childForFieldName('type');
if (!typeNode) return undefined;
// Only handle `var` — explicitly typed declarations are handled by extractDeclaration
if (typeNode.text !== 'var') return undefined;
const declarator = node.namedChildren.find(c => c.type === 'variable_declarator');
if (!declarator) return undefined;
const nameNode = declarator.childForFieldName('name');
const value = declarator.childForFieldName('value');
if (!nameNode || !value) return undefined;
if (value.type !== 'method_invocation') return undefined;
const methodName = value.childForFieldName('name');
if (!methodName) return undefined;
// For qualified calls (obj.getUser()), take the method name
// For unqualified calls (getUser()), take the function name
const calleeName = methodName.text;
if (!calleeName) return undefined;
return { varName: nameNode.text, calleeName };
},
```
**Key files:**
- `src/core/ingestion/type-env.ts:424` — add Java scanner
- `test/integration/resolvers/java.test.ts` — update return type tests
**Edge cases:**
- Java 10+ `var` only — pre-10 declarations always have explicit types
- `var list = List.of(...)` — method_invocation with object reference
- `var user = new User()` — object_creation_expression (already handled by extractInitializer)
### Phase 4.4: C# Scanner
**AST pattern:** `local_declaration_statement` → `variable_declaration` with `implicit_type` (var)
```csharp
// var user = GetUser("alice");
// AST: local_declaration_statement > variable_declaration[type=implicit_type, declarator=variable_declarator[value=invocation_expression]]
[SupportedLanguages.CSharp]: (node) => {
if (node.type !== 'variable_declaration') return undefined;
const typeNode = node.childForFieldName('type');
if (!typeNode || typeNode.type !== 'implicit_type') return undefined;
const declarator = node.namedChildren.find(c => c.type === 'variable_declarator');
if (!declarator) return undefined;
const nameNode = declarator.childForFieldName('name') ?? declarator.firstNamedChild;
if (!nameNode) return undefined;
const eqClause = declarator.namedChildren.find(c => c.type === 'equals_value_clause');
if (!eqClause) return undefined;
const value = eqClause.firstNamedChild;
if (!value || 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 };
},
```
**Key files:**
- `src/core/ingestion/type-env.ts:424` — add C# scanner
- `test/integration/resolvers/csharp.test.ts` — add return type tests
### Phase 4.5: Rust Scanner
**AST pattern:** `let_declaration` with no type annotation and call_expression value
```rust
// let user = get_user("alice");
// AST: let_declaration[pattern=identifier, value=call_expression]
[SupportedLanguages.Rust]: (node) => {
if (node.type !== 'let_declaration') return undefined;
// Skip if has type annotation
if (node.childForFieldName('type')) return undefined;
for (const child of node.children) {
if (child.type === 'type_annotation') return undefined;
}
const pattern = node.childForFieldName('pattern');
if (!pattern || pattern.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;
const calleeName = extractSimpleTypeName(func);
if (!calleeName) return undefined;
return { varName: pattern.text, calleeName };
},
```
**Key files:**
- `src/core/ingestion/type-env.ts:424` — add Rust scanner
- `test/integration/resolvers/rust.test.ts` — add return type tests
### Phase 4.6: PHP Scanner
**AST pattern:** `expression_statement` → `assignment_expression` with function call
```php
// $user = getUser("alice");
// AST: expression_statement > assignment_expression[left=variable_name, right=function_call_expression]
[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;
if (right.type !== 'function_call_expression' && right.type !== 'member_call_expression') return undefined;
const func = right.childForFieldName('function') ?? right.childForFieldName('name');
if (!func) return undefined;
const calleeName = extractSimpleTypeName(func);
if (!calleeName) return undefined;
// Strip leading $ from PHP variable names
const varName = left.text.startsWith('$') ? left.text.slice(1) : left.text;
return { varName, calleeName };
},
```
**Key files:**
- `src/core/ingestion/type-env.ts:424` — add PHP scanner
- `test/integration/resolvers/php.test.ts` — add return type tests (if exists)
### Phase 4.7: Ruby YARD @return Annotation
**Current gap:** Ruby's `extractMethodSignature` in `utils.ts:531-650` doesn't check YARD doc comments for `@return [Type]`. Python has a similar gap with `:rtype:` but Python type hints are more common.
**Approach:** Add a per-language `extractReturnTypeFromComment` hook called from `extractMethodSignature` when no AST return type is found.
```ruby
# @return [User] the found user
def get_user(name)
User.find_by(name: name)
end
```
**Implementation:**
1. After the existing return type extraction in `extractMethodSignature` (line ~640), if `returnType` is still undefined, check preceding sibling comment nodes
2. Parse `@return [Type]` (Ruby YARD) and `:rtype: Type` (Python docstring in comment form)
3. This is a cross-cutting concern — add a `RETURN_TYPE_COMMENT_PATTERNS` map in `type-env.ts` keyed by language
**Key files:**
- `src/core/ingestion/utils.ts:531-650` — add comment-based return type extraction
- `src/core/ingestion/type-env.ts` — add RETURN_TYPE_COMMENT_PATTERNS
**Pattern per language:**
- Ruby YARD: `# @return [User]` or `# @return [Array<User>]`
- Python: `""":rtype: User"""` (in docstring) — may need special AST handling
- JSDoc: `/** @returns {User} */` — already works via TS type annotations mostly
## Acceptance Criteria
- [ ] TypeScript/JavaScript: `const user = getUser()` → `user.save()` resolves via return type
- [ ] Go: `user := GetUser()` → `user.Save()` resolves via return type
- [ ] Java: `var user = getUser()` → `user.save()` resolves via return type
- [ ] C#: `var user = GetUser()` → `user.Save()` resolves via return type
- [ ] Rust: `let user = get_user()` → `user.save()` resolves via return type
- [ ] PHP: `$user = getUser()` → `$user->save()` resolves via return type
- [ ] Ruby YARD: `@return [User]` populates returnType in SymbolDefinition
- [ ] All existing tests continue to pass (no regressions)
- [ ] Integration tests for each new scanner
- [ ] Unit tests for each scanner's AST pattern matching
## Dependencies & Risks
**Dependencies:**
- Phase 3 changes must be committed first (WRAPPER_GENERICS fix, Ruby `::` handling)
- `extractSimpleTypeName` in `shared.ts` must handle each language's AST node types
**Risks:**
- **Low:** Scanner false positives — a function call assigned to a variable where the function has no return type is harmless (no binding created)
- **Medium:** Go multi-return — `user, err := GetUser()` must be skipped (handled by leftIds.length check)
- **Medium:** PHP variable name collision — `$user` in different scopes could collide (scope-aware keying already handles this)
- **Low:** YARD parsing — regex on comment text is fragile but good enough for the common `@return [Type]` pattern
## Implementation Order
Recommended order (easiest → hardest, most impactful first):
1. **TypeScript/JavaScript** — largest user base, simplest AST pattern
2. **Go** — common `:=` pattern, straightforward
3. **Java** — `var` keyword, well-defined AST
4. **C#** — `var` keyword, similar to Java
5. **Rust** — `let` without annotation, clean AST
6. **PHP** — `$var = func()`, needs `$` stripping
7. **Ruby YARD** — different mechanism (comment parsing, not scanner)
## Sources
- `src/core/ingestion/type-env.ts:424-512` — existing CONSTRUCTOR_BINDING_SCANNERS
- `src/core/ingestion/call-processor.ts:462-485` — return type inference in processCallsFromExtracted
- `src/core/ingestion/call-processor.ts:381-425` — extractReturnTypeName
- `src/core/ingestion/utils.ts:531-650` — extractMethodSignature
- `src/core/ingestion/type-extractors/types.ts` — LanguageTypeConfig interface
- Phase 3 plan: `docs/plans/2026-03-14-feat-type-resolution-gap-closure-plan.md`

View file

@ -370,10 +370,12 @@ const PRIMITIVE_TYPES = new Set([
* Returns undefined for complex types or primitives.
*/
const WRAPPER_GENERICS = new Set([
'Promise', 'Observable', 'Option', 'Some', 'Result',
'Optional', 'Future', 'Task', 'ValueTask',
'List', 'Array', 'Vec', 'Set', 'Iterable',
'Sequence', 'MutableList', 'ArrayList',
'Promise', 'Observable', 'Future', 'Task', 'ValueTask', // async wrappers
'Option', 'Some', 'Optional', 'Maybe', // nullable wrappers
'Result', 'Either', // result wrappers
// Containers (List, Array, Vec, Set, etc.) are intentionally excluded —
// methods are called on the container, not the element type.
// Non-wrapper generics return the base type (e.g., List) via the else branch.
]);
export const extractReturnTypeName = (raw: string): string | undefined => {
@ -408,9 +410,9 @@ export const extractReturnTypeName = (raw: string): string | undefined => {
return PRIMITIVE_TYPES.has(base.toLowerCase()) ? undefined : base;
}
// Handle qualified names: models.User → User, com.example.User → User
if (text.includes('.')) {
text = text.split('.').pop()!;
// Handle qualified names: models.User → User, Models::User → User
if (text.includes('::') || text.includes('.')) {
text = text.split(/::|\./).pop()!;
}
// Final check: skip primitives

View file

@ -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, extractRubyConstructorAssignment } from './type-extractors/shared.js';
import type { SymbolTable } from './symbol-table.js';
/**
@ -420,22 +420,6 @@ const extractCppConstructorBinding = (node: SyntaxNode): { varName: string; call
return { varName, calleeName: func.text };
};
/** Ruby: user = User.new — assignment with call where method is 'new' and receiver is a constant */
const extractRubyConstructorBinding = (node: SyntaxNode): { varName: string; calleeName: string } | undefined => {
if (node.type !== 'assignment') return undefined;
const left = node.childForFieldName('left');
const right = node.childForFieldName('right');
if (!left || !right) return undefined;
// Support both local variables (identifier) and constants (USER = User.new)
if (left.type !== 'identifier' && left.type !== 'constant') return undefined;
if (right.type !== 'call') return undefined;
const method = right.childForFieldName('method');
if (!method || method.text !== 'new') return undefined;
const receiver = right.childForFieldName('receiver');
if (!receiver || receiver.type !== 'constant') return undefined;
return { varName: left.text, calleeName: receiver.text };
};
/** Language-specific constructor-binding scanners. */
const CONSTRUCTOR_BINDING_SCANNERS: Partial<Record<SupportedLanguages, (node: SyntaxNode) => { varName: string; calleeName: string } | undefined>> = {
// Kotlin: val x = User(...) — property_declaration with call_expression
@ -523,7 +507,7 @@ const CONSTRUCTOR_BINDING_SCANNERS: Partial<Record<SupportedLanguages, (node: Sy
// Note: C is excluded — C has no constructors and `auto` is a storage-class specifier, not type inference.
[SupportedLanguages.CPlusPlus]: extractCppConstructorBinding,
// Ruby: user = User.new — assignment with call where method is 'new' and receiver is a constant
[SupportedLanguages.Ruby]: extractRubyConstructorBinding,
// Ruby: user = User.new — uses shared helper that also handles Models::User.new
[SupportedLanguages.Ruby]: extractRubyConstructorAssignment,
};

View file

@ -34,4 +34,11 @@ export const typeConfigs = {
} satisfies Record<SupportedLanguages, LanguageTypeConfig>;
export type { LanguageTypeConfig, TypeBindingExtractor, ParameterExtractor } from './types.js';
export { TYPED_PARAMETER_TYPES, extractSimpleTypeName, extractGenericTypeArgs, extractVarName, findChildByType } from './shared.js';
export {
TYPED_PARAMETER_TYPES,
extractSimpleTypeName,
extractGenericTypeArgs,
extractVarName,
findChildByType,
extractRubyConstructorAssignment
} from './shared.js';

View file

@ -1,6 +1,5 @@
import type { SyntaxNode } from '../utils.js';
import type { LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor, InitializerExtractor, ClassNameLookup } from './types.js';
import { extractVarName } from './shared.js';
import { extractRubyConstructorAssignment } from './shared.js';
/**
* Ruby type extractor — YARD annotation parsing.
@ -123,17 +122,15 @@ const collectYardParams = (methodNode: SyntaxNode): Map<string, string> => {
};
/**
* Ruby declaration node types that may carry YARD annotations.
* `method` is the tree-sitter-ruby node for `def name ... end`.
* `singleton_method` is `def self.name ... end`.
*
* We intercept these in extractDeclaration to pre-populate the env
* with YARD parameter types before the standard parameter walk.
* Ruby node types that may carry type bindings.
* - `method`/`singleton_method`: YARD @param annotations (via extractDeclaration)
* - `assignment`: Constructor inference like `user = User.new` (via extractInitializer;
* extractDeclaration returns early for these nodes)
*/
const DECLARATION_NODE_TYPES: ReadonlySet<string> = new Set([
'method',
'singleton_method',
'assignment', // For constructor inference: user = User.new
'assignment',
]);
/**
@ -167,33 +164,16 @@ const extractParameter: ParameterExtractor = (_node: SyntaxNode, _env: Map<strin
};
/**
* Ruby constructor inference: user = User.new
* Extracts type from assignments where the RHS is a `.new` call on a constant.
* This complements the CONSTRUCTOR_BINDING_SCANNERS in type-env.ts by also
* handling local class names (already defined in the same file).
* Ruby constructor inference: user = User.new or service = Models::User.new
* Uses the shared extractRubyConstructorAssignment helper for AST matching,
* then resolves against locally-known class names.
*/
const extractInitializer: InitializerExtractor = (node: SyntaxNode, env: Map<string, string>, classNames: ClassNameLookup): void => {
if (node.type !== 'assignment') return;
const left = node.childForFieldName('left');
const right = node.childForFieldName('right');
if (!left || !right) return;
// Support both local variables (identifier) and constants (SERVICE = UserService.new)
if (left.type !== 'identifier' && left.type !== 'constant') return;
const varName = extractVarName(left);
if (!varName || env.has(varName)) return;
// Ruby constructor pattern: ClassName.new(args)
if (right.type !== 'call') return;
const method = right.childForFieldName('method');
if (!method || method.text !== 'new') return;
const receiver = right.childForFieldName('receiver');
if (!receiver || receiver.type !== 'constant') return;
const calleeName = receiver.text;
if (classNames.has(calleeName)) {
env.set(varName, calleeName);
const extractInitializer: InitializerExtractor = (node, env, classNames): void => {
const result = extractRubyConstructorAssignment(node);
if (!result) return;
if (env.has(result.varName)) return;
if (classNames.has(result.calleeName)) {
env.set(result.varName, result.calleeName);
}
};

View file

@ -7,20 +7,22 @@ import type { SyntaxNode } from '../utils.js';
* Returns undefined for complex types (unions, intersections, function types).
*/
export const extractSimpleTypeName = (typeNode: SyntaxNode): string | undefined => {
// Direct type identifier
// Direct type identifier (includes Ruby 'constant' for class names)
if (typeNode.type === 'type_identifier' || typeNode.type === 'identifier'
|| typeNode.type === 'simple_identifier') {
|| typeNode.type === 'simple_identifier' || typeNode.type === 'constant') {
return typeNode.text;
}
// Qualified/scoped names: take the last segment (e.g., models.User → User)
// Qualified/scoped names: take the last segment (e.g., models.User → User, Models::User → User)
if (typeNode.type === 'scoped_identifier' || typeNode.type === 'qualified_identifier'
|| typeNode.type === 'scoped_type_identifier' || typeNode.type === 'qualified_name'
|| typeNode.type === 'qualified_type'
|| typeNode.type === 'member_expression' || typeNode.type === 'attribute') {
|| typeNode.type === 'member_expression' || typeNode.type === 'attribute'
|| typeNode.type === 'scope_resolution') {
const last = typeNode.lastNamedChild;
if (last && (last.type === 'type_identifier' || last.type === 'identifier'
|| last.type === 'simple_identifier' || last.type === 'name')) {
|| last.type === 'simple_identifier' || last.type === 'name'
|| last.type === 'constant')) {
return last.text;
}
}
@ -95,7 +97,8 @@ export const extractSimpleTypeName = (typeNode: SyntaxNode): string | undefined
*/
export const extractVarName = (node: SyntaxNode): string | undefined => {
if (node.type === 'identifier' || node.type === 'simple_identifier'
|| node.type === 'variable_name' || node.type === 'name') {
|| node.type === 'variable_name' || node.type === 'name'
|| node.type === 'constant') {
return node.text;
}
// variable_declarator (Java/C#): has a 'name' field
@ -182,6 +185,38 @@ export const extractGenericTypeArgs = (typeNode: SyntaxNode): string[] => {
return result;
};
/**
* Match Ruby constructor assignment: `user = User.new` or `service = Models::User.new`.
* Returns { varName, calleeName } or undefined if the node is not a Ruby constructor assignment.
* Handles both simple constants and scope_resolution (namespaced) receivers.
*/
export const extractRubyConstructorAssignment = (
node: SyntaxNode,
): { varName: string; calleeName: string } | undefined => {
if (node.type !== 'assignment') return undefined;
const left = node.childForFieldName('left');
const right = node.childForFieldName('right');
if (!left || !right) return undefined;
if (left.type !== 'identifier' && left.type !== 'constant') return undefined;
if (right.type !== 'call') return undefined;
const method = right.childForFieldName('method');
if (!method || method.text !== 'new') return undefined;
const receiver = right.childForFieldName('receiver');
if (!receiver) return undefined;
let calleeName: string;
if (receiver.type === 'constant') {
calleeName = receiver.text;
} else if (receiver.type === 'scope_resolution') {
// Models::User → extract last segment "User"
const last = receiver.lastNamedChild;
if (!last || last.type !== 'constant') return undefined;
calleeName = last.text;
} else {
return undefined;
}
return { varName: left.text, calleeName };
};
/** Find the first named child with the given node type */
export const findChildByType = (node: SyntaxNode, type: string): SyntaxNode | null => {
for (let i = 0; i < node.namedChildCount; i++) {

View file

@ -0,0 +1,5 @@
require_relative 'models/user_service'
svc = Models::UserService.new
svc.process('alice')
svc.validate

View file

@ -0,0 +1,11 @@
module Models
class UserService
def process(name)
name.upcase
end
def validate
true
end
end
end

View file

@ -541,3 +541,36 @@ describe('Go type assertion type inference', () => {
expect(greetCall!.source).toBe('process');
});
});
// ---------------------------------------------------------------------------
// 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()`.
// ---------------------------------------------------------------------------
describe('Go return type inference via explicit function return type', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'go-return-type-inference'),
() => {},
);
}, 60000);
it('detects GetUser and Save symbols', () => {
const allSymbols = [...getNodesByLabel(result, 'Function'), ...getNodesByLabel(result, 'Method')];
expect(allSymbols).toContain('GetUser');
expect(allSymbols).toContain('Save');
});
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.
const calls = getRelationships(result, 'CALLS');
const saveCall = calls.find(c =>
c.target === 'Save' && c.source === 'processUser' && c.targetFilePath.includes('models')
);
expect(saveCall).toBeDefined();
});
});

View file

@ -526,3 +526,42 @@ describe('Java generic parent super resolution', () => {
expect(repoSave).toBeUndefined();
});
});
// ---------------------------------------------------------------------------
// 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.
// ---------------------------------------------------------------------------
describe('Java return type inference via explicit method return type', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'java-return-type-inference'),
() => {},
);
}, 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', () => {
// 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.
const calls = getRelationships(result, 'CALLS');
const saveCall = calls.find(c =>
c.target === 'save' && c.source === 'processUser' && c.targetFilePath.includes('models')
);
expect(saveCall).toBeDefined();
});
});

View file

@ -616,3 +616,39 @@ describe('Python class-level annotation resolution', () => {
expect(saveCalls.length).toBe(2);
});
});
// ---------------------------------------------------------------------------
// Return type inference: user = get_user('alice'); user.save()
// Python's scanner captures ALL call assignments, enabling return type inference.
// ---------------------------------------------------------------------------
describe('Python return type inference', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'python-return-type-inference'),
() => {},
);
}, 60000);
it('detects User class', () => {
expect(getNodesByLabel(result, 'Class')).toContain('User');
});
it('detects get_user and save symbols', () => {
// Python methods inside classes may be labeled Method or Function depending on nesting
const allSymbols = [...getNodesByLabel(result, 'Function'), ...getNodesByLabel(result, 'Method')];
expect(allSymbols).toContain('get_user');
expect(allSymbols).toContain('save');
});
it('resolves user.save() to User#save via return type inference from get_user() -> User', () => {
const calls = getRelationships(result, 'CALLS');
const saveCall = calls.find(c =>
c.target === 'save' && c.source === 'process_user'
);
expect(saveCall).toBeDefined();
expect(saveCall!.targetFilePath).toContain('models.py');
});
});

View file

@ -548,3 +548,42 @@ describe('Ruby YARD annotation type resolution', () => {
expect(greetCall!.targetFilePath).toContain('models.rb');
});
});
// ---------------------------------------------------------------------------
// Namespaced constructor: svc = Models::UserService.new; svc.process()
// Tests scope_resolution receiver handling for Ruby namespaced classes.
// ---------------------------------------------------------------------------
describe('Ruby namespaced constructor resolution (Models::UserService.new)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'ruby-namespaced-constructor'),
() => {},
);
}, 60000);
it('detects UserService class with process and validate methods', () => {
expect(getNodesByLabel(result, 'Class')).toContain('UserService');
const methods = getNodesByLabel(result, 'Method');
expect(methods).toContain('process');
expect(methods).toContain('validate');
});
it('resolves svc.process() via namespaced constructor Models::UserService.new', () => {
const calls = getRelationships(result, 'CALLS');
const processCall = calls.find(c =>
c.target === 'process' && c.targetFilePath.includes('user_service.rb')
);
expect(processCall).toBeDefined();
});
it('resolves svc.validate() via namespaced constructor Models::UserService.new', () => {
const calls = getRelationships(result, 'CALLS');
const validateCall = calls.find(c =>
c.target === 'validate' && c.targetFilePath.includes('user_service.rb')
);
expect(validateCall).toBeDefined();
});
});

View file

@ -852,3 +852,45 @@ 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.
// ---------------------------------------------------------------------------
describe('TypeScript return type inference via explicit function return type', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'ts-return-type-inference'),
() => {},
);
}, 60000);
it('detects User class with save and getName methods', () => {
expect(getNodesByLabel(result, 'Class')).toContain('User');
const methods = getNodesByLabel(result, 'Method');
expect(methods).toContain('save');
expect(methods).toContain('getName');
});
it('detects getUser and fetchUserAsync functions', () => {
const functions = getNodesByLabel(result, 'Function');
expect(functions).toContain('getUser');
expect(functions).toContain('fetchUserAsync');
});
it('resolves user.save() to User#save via return type of getUser(): User', () => {
// TS has explicit return types in the source, so extractMethodSignature captures
// the return type. The TS extractInitializer handles `const user = getUser()`
// via the variable_declarator path, enabling save() to resolve to User#save.
const calls = getRelationships(result, 'CALLS');
const saveCall = calls.find(c =>
c.target === 'save' && c.source === 'processUser' && c.targetFilePath.includes('models')
);
expect(saveCall).toBeDefined();
});
});

View file

@ -589,14 +589,48 @@ describe('extractReturnTypeName', () => {
expect(extractReturnTypeName('Promise<Option<User>>')).toBe('User');
});
it('unwraps Vec<User>', () => {
expect(extractReturnTypeName('Vec<User>')).toBe('User');
it('returns base type for collection generics (not unwrapped)', () => {
expect(extractReturnTypeName('Vec<User>')).toBe('Vec');
expect(extractReturnTypeName('List<User>')).toBe('List');
expect(extractReturnTypeName('Array<User>')).toBe('Array');
expect(extractReturnTypeName('Set<User>')).toBe('Set');
expect(extractReturnTypeName('ArrayList<User>')).toBe('ArrayList');
});
it('unwraps Optional<User>', () => {
expect(extractReturnTypeName('Optional<User>')).toBe('User');
});
it('extracts Ruby :: qualified type: Models::User → User', () => {
expect(extractReturnTypeName('Models::User')).toBe('User');
});
it('extracts C++ :: qualified type: ns::HttpClient → HttpClient', () => {
expect(extractReturnTypeName('ns::HttpClient')).toBe('HttpClient');
});
it('extracts deep :: qualified type: crate::models::User → User', () => {
expect(extractReturnTypeName('crate::models::User')).toBe('User');
});
it('extracts mixed qualifier: ns.module::User → User', () => {
expect(extractReturnTypeName('ns.module::User')).toBe('User');
});
it('returns undefined for lowercase :: qualified: std::vector', () => {
expect(extractReturnTypeName('std::vector')).toBeUndefined();
});
it('extracts deep dot-qualified: com.example.models.User → User', () => {
expect(extractReturnTypeName('com.example.models.User')).toBe('User');
});
it('returns undefined for nested generic with comma (known limitation)', () => {
// Promise<Map<string, User>> — comma splits inside nested generics produce incorrect results.
// Fixing requires balanced-bracket-aware splitting, which is out of scope.
expect(extractReturnTypeName('Promise<Map<string, User>>')).toBeUndefined();
});
it('returns undefined for lowercase non-class types', () => {
expect(extractReturnTypeName('error')).toBeUndefined();
});

View file

@ -1714,6 +1714,26 @@ REPO = Repo.new
expect(constructorBindings[0].calleeName).toBe('Repo');
});
it('returns constructor bindings for Ruby namespaced constructor (service = Models::UserService.new)', () => {
const tree = parse(`
service = Models::UserService.new
`, Ruby);
const { constructorBindings } = buildTypeEnv(tree, 'ruby');
expect(constructorBindings.length).toBe(1);
expect(constructorBindings[0].varName).toBe('service');
expect(constructorBindings[0].calleeName).toBe('UserService');
});
it('returns constructor bindings for deeply namespaced Ruby constructor (svc = App::Models::Service.new)', () => {
const tree = parse(`
svc = App::Models::Service.new
`, Ruby);
const { constructorBindings } = buildTypeEnv(tree, 'ruby');
expect(constructorBindings.length).toBe(1);
expect(constructorBindings[0].varName).toBe('svc');
expect(constructorBindings[0].calleeName).toBe('Service');
});
it('includes scope key in constructor bindings', () => {
const tree = parse(`
fun process() {