mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-09 03:17:54 +00:00
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:
parent
f8685781d0
commit
55c13fc0e8
15 changed files with 665 additions and 70 deletions
|
|
@ -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`
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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++) {
|
||||
|
|
|
|||
5
gitnexus/test/fixtures/lang-resolution/ruby-namespaced-constructor/app.rb
vendored
Normal file
5
gitnexus/test/fixtures/lang-resolution/ruby-namespaced-constructor/app.rb
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
require_relative 'models/user_service'
|
||||
|
||||
svc = Models::UserService.new
|
||||
svc.process('alice')
|
||||
svc.validate
|
||||
11
gitnexus/test/fixtures/lang-resolution/ruby-namespaced-constructor/models/user_service.rb
vendored
Normal file
11
gitnexus/test/fixtures/lang-resolution/ruby-namespaced-constructor/models/user_service.rb
vendored
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
module Models
|
||||
class UserService
|
||||
def process(name)
|
||||
name.upcase
|
||||
end
|
||||
|
||||
def validate
|
||||
true
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue