feat: per-language ReturnTypeExtractor + doc-comment @param parsing for PHP, JS, Ruby

Add ReturnTypeExtractor to LanguageTypeConfig interface with implementations
for Ruby (YARD @return), PHP (PHPDoc @return), and JS/TS (JSDoc @returns).
The fallback is wired in both parsing-processor and parse-worker paths,
activating only when extractMethodSignature finds no AST-based return type.

Also add doc-comment @param type extraction for PHP and JS/TS, following
Ruby's existing collectYardParams pattern. This enables parameter.method()
resolution in loosely-typed codebases using PHPDoc @param or JSDoc @param.

Additional fixes from PR #284 code review:
- Go: add selector_expression + field_identifier to extractSimpleTypeName
  (enables package-qualified factory calls like models.NewUser())
- Ruby: broaden scanConstructorBinding to capture plain call assignments
  (user = get_user()) in addition to Class.new patterns
- Ruby: harden return-type fixture with disambiguation (two save methods)

Test coverage: +14 new integration tests across Go, Ruby, PHP, JS/TS
This commit is contained in:
Gergo Magyar 2026-03-15 11:50:56 +00:00
parent 292cb8e642
commit 36ca3a3d54
20 changed files with 558 additions and 19 deletions

View file

@ -8,6 +8,7 @@ import { ASTCache } from './ast-cache.js';
import { getLanguageFromFilename, yieldToEventLoop, DEFINITION_CAPTURE_KEYS, getDefinitionNodeFromCaptures, findEnclosingClassId, extractMethodSignature } from './utils.js';
import { isNodeExported } from './export-detection.js';
import { detectFrameworkFromAST } from './framework-detection.js';
import { typeConfigs } from './type-extractors/index.js';
import { WorkerPool } from './workers/worker-pool.js';
import type { ParseWorkerResult, ParseWorkerInput, ExtractedImport, ExtractedCall, ExtractedHeritage, ExtractedRoute, FileConstructorBindings } from './workers/parse-worker.js';
import { getTreeSitterBufferSize, TREE_SITTER_MAX_BUFFER } from './constants.js';
@ -215,6 +216,14 @@ const processParsingSequential = async (
? extractMethodSignature(definitionNode)
: undefined;
// Language-specific return type fallback (e.g. Ruby YARD @return [Type])
if (methodSig && !methodSig.returnType && definitionNode) {
const tc = typeConfigs[language as keyof typeof typeConfigs];
if (tc?.extractReturnType) {
methodSig.returnType = tc.extractReturnType(definitionNode);
}
}
const node: GraphNode = {
id: nodeId,
label: nodeLabel as any,

View file

@ -1,10 +1,12 @@
import type { SyntaxNode } from '../utils.js';
import type { LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor, InitializerExtractor, ClassNameLookup, ConstructorBindingScanner } from './types.js';
import type { LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor, InitializerExtractor, ClassNameLookup, ConstructorBindingScanner, ReturnTypeExtractor } from './types.js';
import { extractSimpleTypeName, extractVarName, extractCalleeName } from './shared.js';
const DECLARATION_NODE_TYPES: ReadonlySet<string> = new Set([
'assignment_expression', // For constructor inference: $x = new User()
'property_declaration', // PHP 7.4+ typed properties: private UserRepo $repo;
'assignment_expression', // For constructor inference: $x = new User()
'property_declaration', // PHP 7.4+ typed properties: private UserRepo $repo;
'method_declaration', // PHPDoc @param on class methods
'function_definition', // PHPDoc @param on top-level functions
]);
/** Walk up the AST to find the enclosing class declaration. */
@ -45,8 +47,73 @@ const resolvePhpKeyword = (keyword: string, node: SyntaxNode): string | undefine
return undefined;
};
/** PHP: typed class properties (PHP 7.4+): private UserRepo $repo; */
const normalizePhpType = (raw: string): string | undefined => {
// Strip nullable prefix: ?User → User
let type = raw.startsWith('?') ? raw.slice(1) : raw;
// Strip array suffix: User[] → User
type = type.replace(/\[\]$/, '');
// Strip union with null/false/void: User|null → User
const parts = type.split('|').filter(p => p !== 'null' && p !== 'false' && p !== 'void' && p !== 'mixed');
if (parts.length !== 1) return undefined;
type = parts[0];
// Strip namespace: \App\Models\User → User
const segments = type.split('\\');
type = segments[segments.length - 1];
// Skip uninformative types
if (type === 'mixed' || type === 'void' || type === 'self' || type === 'static' || type === 'object') return undefined;
if (/^\w+$/.test(type)) return type;
return undefined;
};
/** Regex to extract PHPDoc @param annotations: `@param Type $name` */
const PHPDOC_PARAM_RE = /@param\s+(\S+)\s+\$(\w+)/g;
/**
* Collect PHPDoc @param type bindings from comment nodes preceding a method/function.
* Returns a map of paramName → typeName (without $ prefix).
*/
const collectPhpDocParams = (methodNode: SyntaxNode): Map<string, string> => {
const commentTexts: string[] = [];
let sibling = methodNode.previousSibling;
while (sibling) {
if (sibling.type === 'comment') {
commentTexts.unshift(sibling.text);
} else if (sibling.isNamed) {
break;
}
sibling = sibling.previousSibling;
}
if (commentTexts.length === 0) return new Map();
const params = new Map<string, string>();
const commentBlock = commentTexts.join('\n');
PHPDOC_PARAM_RE.lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = PHPDOC_PARAM_RE.exec(commentBlock)) !== null) {
const typeName = normalizePhpType(match[1]);
const paramName = match[2]; // without $ prefix
if (typeName) {
// Store with $ prefix to match how PHP variables appear in the env
params.set('$' + paramName, typeName);
}
}
return params;
};
/**
* PHP: typed class properties (PHP 7.4+): private UserRepo $repo;
* Also: PHPDoc @param annotations on method/function definitions.
*/
const extractDeclaration: TypeBindingExtractor = (node: SyntaxNode, env: Map<string, string>): void => {
// PHPDoc @param on methods/functions — pre-populate env with param types
if (node.type === 'method_declaration' || node.type === 'function_definition') {
const phpDocParams = collectPhpDocParams(node);
for (const [paramName, typeName] of phpDocParams) {
if (!env.has(paramName)) env.set(paramName, typeName);
}
return;
}
if (node.type !== 'property_declaration') return;
const typeNode = node.childForFieldName('type');
@ -133,10 +200,30 @@ const scanConstructorBinding: ConstructorBindingScanner = (node) => {
return undefined;
};
/** Regex to extract PHPDoc @return annotations: `@return User` */
const PHPDOC_RETURN_RE = /@return\s+(\S+)/;
/**
* Extract return type from PHPDoc `@return Type` annotation preceding a method.
* Walks backwards through preceding siblings looking for comment nodes.
*/
const extractReturnType: ReturnTypeExtractor = (node) => {
let sibling = node.previousSibling;
while (sibling) {
if (sibling.type === 'comment') {
const match = PHPDOC_RETURN_RE.exec(sibling.text);
if (match) return normalizePhpType(match[1]);
} else if (sibling.isNamed) break;
sibling = sibling.previousSibling;
}
return undefined;
};
export const typeConfig: LanguageTypeConfig = {
declarationNodeTypes: DECLARATION_NODE_TYPES,
extractDeclaration,
extractParameter,
extractInitializer,
scanConstructorBinding,
extractReturnType,
};

View file

@ -1,5 +1,5 @@
import type { LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor, InitializerExtractor, ClassNameLookup } from './types.js';
import { extractRubyConstructorAssignment } from './shared.js';
import type { LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor, InitializerExtractor, ClassNameLookup, ConstructorBindingScanner, ReturnTypeExtractor } from './types.js';
import { extractRubyConstructorAssignment, extractSimpleTypeName } from './shared.js';
import { SyntaxNode } from '../utils.js';
/**
@ -26,6 +26,9 @@ import { SyntaxNode } from '../utils.js';
/** Regex to extract @param annotations: `@param name [Type]` */
const YARD_PARAM_RE = /@param\s+(\w+)\s+\[([^\]]+)\]/g;
/** Regex to extract @return annotations: `@return [Type]` */
const YARD_RETURN_RE = /@return\s+\[([^\]]+)\]/;
/**
* Extract the simple type name from a YARD type string.
* Handles:
@ -178,10 +181,65 @@ const extractInitializer: InitializerExtractor = (node, env, classNames): void =
}
};
/**
* Extract return type from YARD `@return [Type]` annotation preceding a method.
* Reuses the same comment-walking strategy as collectYardParams: try direct
* siblings first, fall back to parent (body_statement) siblings for class methods.
*/
const extractReturnType: ReturnTypeExtractor = (node) => {
const search = (startNode: SyntaxNode): string | undefined => {
let sibling = startNode.previousSibling;
while (sibling) {
if (sibling.type === 'comment') {
const match = YARD_RETURN_RE.exec(sibling.text);
if (match) return extractYardTypeName(match[1]);
} else if (sibling.isNamed) {
break;
}
sibling = sibling.previousSibling;
}
return undefined;
};
const result = search(node);
if (result) return result;
if (node.parent?.type === 'body_statement') {
return search(node.parent);
}
return undefined;
};
/**
* Ruby constructor binding scanner: captures both `user = User.new` and
* plain call assignments like `user = get_user()`.
* The `.new` pattern returns the class name directly; plain calls return the
* callee name for return-type inference via SymbolTable lookup.
*/
const scanConstructorBinding: ConstructorBindingScanner = (node) => {
// Try the .new pattern first (returns class name directly)
const newResult = extractRubyConstructorAssignment(node);
if (newResult) return newResult;
// Plain call assignment: user = get_user() / user = Models.create()
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') return undefined;
if (right.type !== 'call') return undefined;
const method = right.childForFieldName('method');
if (!method) return undefined;
const calleeName = extractSimpleTypeName(method);
if (!calleeName) return undefined;
return { varName: left.text, calleeName };
};
export const typeConfig: LanguageTypeConfig = {
declarationNodeTypes: DECLARATION_NODE_TYPES,
extractDeclaration,
extractParameter,
extractInitializer,
scanConstructorBinding: extractRubyConstructorAssignment,
scanConstructorBinding,
extractReturnType,
};

View file

@ -19,11 +19,13 @@ export const extractSimpleTypeName = (typeNode: SyntaxNode): string | undefined
|| typeNode.type === 'qualified_type'
|| typeNode.type === 'member_expression' || typeNode.type === 'member_access_expression'
|| typeNode.type === 'attribute'
|| typeNode.type === 'scope_resolution') {
|| typeNode.type === 'scope_resolution'
|| typeNode.type === 'selector_expression') {
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 === 'property_identifier')) {
|| last.type === 'constant' || last.type === 'property_identifier'
|| last.type === 'field_identifier')) {
return last.text;
}
}

View file

@ -17,6 +17,11 @@ export type InitializerExtractor = (node: SyntaxNode, env: Map<string, string>,
* Returns { varName, calleeName } if the node matches, undefined otherwise. */
export type ConstructorBindingScanner = (node: SyntaxNode) => { varName: string; calleeName: string } | undefined;
/** Extracts a return type string from a method/function definition node.
* Used for languages where return types are expressed in comments (e.g. YARD @return [Type])
* rather than in AST fields. Returns undefined if no return type can be determined. */
export type ReturnTypeExtractor = (node: SyntaxNode) => string | undefined;
/** Per-language type extraction configuration */
export interface LanguageTypeConfig {
/** Node types that represent typed declarations for this language */
@ -34,4 +39,7 @@ export interface LanguageTypeConfig {
* Called on every AST node during buildTypeEnv walk; returns undefined for non-matches.
* The callee binding is unverified — the caller must confirm against the SymbolTable. */
scanConstructorBinding?: ConstructorBindingScanner;
/** Extract return type from comment-based annotations (e.g. YARD @return [Type]).
* Called as fallback when extractMethodSignature finds no AST-based return type. */
extractReturnType?: ReturnTypeExtractor;
}

View file

@ -1,14 +1,85 @@
import type { SyntaxNode } from '../utils.js';
import type { LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor, InitializerExtractor, ClassNameLookup, ConstructorBindingScanner } from './types.js';
import type { LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor, InitializerExtractor, ClassNameLookup, ConstructorBindingScanner, ReturnTypeExtractor } from './types.js';
import { extractSimpleTypeName, extractVarName, hasTypeAnnotation, unwrapAwait, extractCalleeName } from './shared.js';
const DECLARATION_NODE_TYPES: ReadonlySet<string> = new Set([
'lexical_declaration',
'variable_declaration',
'function_declaration', // JSDoc @param on function declarations
'method_definition', // JSDoc @param on class methods
]);
/** TypeScript: const x: Foo = ..., let x: Foo */
const normalizeJsDocType = (raw: string): string | undefined => {
let type = raw.trim();
// Strip JSDoc nullable/non-nullable prefixes: ?User → User, !User → User
if (type.startsWith('?') || type.startsWith('!')) type = type.slice(1);
// Strip union with null/undefined/void: User|null → User
const parts = type.split('|').map(p => p.trim()).filter(p =>
p !== 'null' && p !== 'undefined' && p !== 'void'
);
if (parts.length !== 1) return undefined; // ambiguous union
type = parts[0];
// Strip module: prefix — module:models.User → models.User
if (type.startsWith('module:')) type = type.slice(7);
// Take last segment of dotted path: models.User → User
const segments = type.split('.');
type = segments[segments.length - 1];
// Strip generic wrapper: Promise<User> → Promise (base type, not inner)
const genericMatch = type.match(/^(\w+)\s*</);
if (genericMatch) type = genericMatch[1];
// Simple identifier check
if (/^\w+$/.test(type)) return type;
return undefined;
};
/** Regex to extract JSDoc @param annotations: `@param {Type} name` */
const JSDOC_PARAM_RE = /@param\s*\{([^}]+)\}\s+(\w+)/g;
/**
* Collect JSDoc @param type bindings from comment nodes preceding a function/method.
* Returns a map of paramName → typeName.
*/
const collectJsDocParams = (funcNode: SyntaxNode): Map<string, string> => {
const commentTexts: string[] = [];
let sibling = funcNode.previousSibling;
while (sibling) {
if (sibling.type === 'comment') {
commentTexts.unshift(sibling.text);
} else if (sibling.isNamed) {
break;
}
sibling = sibling.previousSibling;
}
if (commentTexts.length === 0) return new Map();
const params = new Map<string, string>();
const commentBlock = commentTexts.join('\n');
JSDOC_PARAM_RE.lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = JSDOC_PARAM_RE.exec(commentBlock)) !== null) {
const typeName = normalizeJsDocType(match[1]);
const paramName = match[2];
if (typeName) {
params.set(paramName, typeName);
}
}
return params;
};
/**
* TypeScript: const x: Foo = ..., let x: Foo
* Also: JSDoc @param annotations on function/method definitions (for .js files).
*/
const extractDeclaration: TypeBindingExtractor = (node: SyntaxNode, env: Map<string, string>): void => {
// JSDoc @param on functions/methods — pre-populate env with param types
if (node.type === 'function_declaration' || node.type === 'method_definition') {
const jsDocParams = collectJsDocParams(node);
for (const [paramName, typeName] of jsDocParams) {
if (!env.has(paramName)) env.set(paramName, typeName);
}
return;
}
for (let i = 0; i < node.namedChildCount; i++) {
const declarator = node.namedChild(i);
if (declarator?.type !== 'variable_declarator') continue;
@ -82,10 +153,31 @@ const scanConstructorBinding: ConstructorBindingScanner = (node) => {
return { varName: nameNode.text, calleeName };
};
/** Regex to extract @returns or @return from JSDoc comments: `@returns {Type}` */
const JSDOC_RETURN_RE = /@returns?\s*\{([^}]+)\}/;
/**
* Extract return type from JSDoc `@returns {Type}` or `@return {Type}` annotation
* preceding a function/method definition. Walks backwards through preceding siblings
* looking for comment nodes containing the annotation.
*/
const extractReturnType: ReturnTypeExtractor = (node) => {
let sibling = node.previousSibling;
while (sibling) {
if (sibling.type === 'comment') {
const match = JSDOC_RETURN_RE.exec(sibling.text);
if (match) return normalizeJsDocType(match[1]);
} else if (sibling.isNamed) break;
sibling = sibling.previousSibling;
}
return undefined;
};
export const typeConfig: LanguageTypeConfig = {
declarationNodeTypes: DECLARATION_NODE_TYPES,
extractDeclaration,
extractParameter,
extractInitializer,
scanConstructorBinding,
extractReturnType,
};

View file

@ -37,6 +37,7 @@ import { buildTypeEnv } from '../type-env.js';
import type { ConstructorBinding } from '../type-env.js';
import { isNodeExported } from '../export-detection.js';
import { detectFrameworkFromAST } from '../framework-detection.js';
import { typeConfigs } from '../type-extractors/index.js';
import { generateId } from '../../../lib/utils.js';
import { extractNamedBindings } from '../named-binding-extraction.js';
import { appendKotlinWildcard } from '../resolvers/index.js';
@ -1046,6 +1047,14 @@ const processFileGroup = (
const sig = extractMethodSignature(definitionNode);
parameterCount = sig.parameterCount;
returnType = sig.returnType;
// Language-specific return type fallback (e.g. Ruby YARD @return [Type])
if (!returnType && definitionNode) {
const tc = typeConfigs[language as keyof typeof typeConfigs];
if (tc?.extractReturnType) {
returnType = tc.extractReturnType(definitionNode);
}
}
}
result.nodes.push({

View file

@ -10,3 +10,9 @@ func processUser() {
user := GetUser("alice")
user.Save()
}
// Cross-package factory call: models.NewUser() uses selector_expression in the AST
func processUserCrossPackage() {
user := models.NewUser("bob")
user.Save()
}

View file

@ -7,3 +7,7 @@ type User struct {
func (u *User) Save() bool {
return true
}
func NewUser(name string) *User {
return &User{Name: name}
}

View file

@ -0,0 +1,39 @@
const { User, Repo } = require('./models');
/**
* @returns {User}
*/
function getUser(name) {
return new User(name);
}
/**
* @returns {Repo}
*/
function getRepo(path) {
return new Repo(path);
}
function processUser() {
const user = getUser('alice');
user.save();
}
function processRepo() {
const repo = getRepo('/data');
repo.save();
}
/**
* @param {User} user the user to handle
*/
function handleUser(user) {
user.save();
}
/**
* @param {Repo} repo the repo to handle
*/
function handleRepo(repo) {
repo.save();
}

View file

@ -0,0 +1,21 @@
class User {
constructor(name) {
this.name = name;
}
save() {
return true;
}
}
class Repo {
constructor(path) {
this.path = path;
}
save() {
return true;
}
}
module.exports = { User, Repo };

View file

@ -0,0 +1,8 @@
<?php
class User {
public function save() { return true; }
}
class Repo {
public function save() { return true; }
}

View file

@ -0,0 +1,42 @@
<?php
require_once 'Models.php';
class UserService {
/**
* @return User
*/
public function getUser(string $name) {
return new User();
}
/**
* @return Repo
*/
public function getRepo(string $path) {
return new Repo();
}
public function processUser() {
$user = $this->getUser("alice");
$user->save();
}
public function processRepo() {
$repo = $this->getRepo("/data");
$repo->save();
}
/**
* @param User $user the user to handle
*/
public function handleUser($user) {
$user->save();
}
/**
* @param Repo $repo the repo to handle
*/
public function handleRepo($repo) {
$repo->save();
}
}

View file

@ -1,6 +1,12 @@
require_relative 'models'
require_relative 'repo'
def process_user
user = get_user('alice')
user.save
end
def process_repo
repo = get_repo('/data')
repo.save
end

View file

@ -8,6 +8,7 @@ class User
end
end
# @return [User]
def get_user(name)
User.new(name)
end

View file

@ -0,0 +1,14 @@
class Repo
def initialize(path)
@path = path
end
def save
true
end
end
# @return [Repo]
def get_repo(path)
Repo.new(path)
end

View file

@ -582,4 +582,15 @@ describe('Go return type inference via explicit function return type', () => {
const saveCall = calls.find(c => c.target === 'Save' && c.targetFilePath.includes('models'));
expect(saveCall).toBeDefined();
});
it('resolves user.Save() via cross-package factory call models.NewUser()', () => {
// Go package-qualified calls use `selector_expression` in the AST.
// extractSimpleTypeName must handle selector_expression + field_identifier
// to extract "NewUser" from `models.NewUser()`.
const calls = getRelationships(result, 'CALLS');
const saveCall = calls.find(c =>
c.target === 'Save' && c.source === 'processUserCrossPackage' && c.targetFilePath.includes('models')
);
expect(saveCall).toBeDefined();
});
});

View file

@ -740,3 +740,55 @@ describe('PHP return type inference via member call', () => {
expect(repoSave).toBeUndefined();
});
});
// ---------------------------------------------------------------------------
// PHPDoc @return annotation: return type inference without native type hints
// ---------------------------------------------------------------------------
describe('PHP return type inference via PHPDoc @return annotation', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'php-phpdoc-return-type'),
() => {},
);
}, 60000);
it('detects User and Repo classes with save methods', () => {
expect(getNodesByLabel(result, 'Class')).toContain('User');
expect(getNodesByLabel(result, 'Class')).toContain('Repo');
});
it('resolves $user->save() to User#save via PHPDoc @return User', () => {
const calls = getRelationships(result, 'CALLS');
const saveCall = calls.find(c =>
c.target === 'save' && c.source === 'processUser' && c.targetFilePath.includes('Models.php'),
);
expect(saveCall).toBeDefined();
});
it('resolves $repo->save() to Repo#save via PHPDoc @return Repo', () => {
const calls = getRelationships(result, 'CALLS');
const saveCall = calls.find(c =>
c.target === 'save' && c.source === 'processRepo' && c.targetFilePath.includes('Models.php'),
);
expect(saveCall).toBeDefined();
});
it('resolves $user->save() via PHPDoc @param User $user in handleUser()', () => {
const calls = getRelationships(result, 'CALLS');
const saveCall = calls.find(c =>
c.target === 'save' && c.source === 'handleUser' && c.targetFilePath.includes('Models.php'),
);
expect(saveCall).toBeDefined();
});
it('resolves $repo->save() via PHPDoc @param Repo $repo in handleRepo()', () => {
const calls = getRelationships(result, 'CALLS');
const saveCall = calls.find(c =>
c.target === 'save' && c.source === 'handleRepo' && c.targetFilePath.includes('Models.php'),
);
expect(saveCall).toBeDefined();
});
});

View file

@ -590,9 +590,11 @@ describe('Ruby namespaced constructor resolution (Models::UserService.new)', ()
// ---------------------------------------------------------------------------
// 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.
// Ruby's scanConstructorBinding captures assignment nodes with call RHS.
// Combined with YARD @return annotation parsing, the pipeline resolves
// `user.save` to User#save (not Repo#save) via return type disambiguation.
// The fixture has BOTH User#save and Repo#save — fuzzy matching alone
// cannot disambiguate, so return type inference must be working.
// ---------------------------------------------------------------------------
describe('Ruby return type inference via function call', () => {
@ -605,21 +607,37 @@ describe('Ruby return type inference via function call', () => {
);
}, 60000);
it('detects User class and get_user method', () => {
it('detects User and Repo classes', () => {
expect(getNodesByLabel(result, 'Class')).toContain('User');
// Ruby `def` is always captured as definition.method (Method label)
expect(getNodesByLabel(result, 'Class')).toContain('Repo');
});
it('detects get_user and get_repo methods', () => {
expect(getNodesByLabel(result, 'Method')).toContain('get_user');
expect(getNodesByLabel(result, 'Method')).toContain('get_repo');
});
it('detects save method on User', () => {
expect(getNodesByLabel(result, 'Method')).toContain('save');
it('detects save method on both User and Repo (disambiguation required)', () => {
const methods = getNodesByLabel(result, 'Method');
// Both classes have save — fuzzy match alone cannot resolve this
expect(methods.filter(m => m === 'save').length).toBeGreaterThanOrEqual(2);
});
it('resolves user.save to User#save via return type of get_user()', () => {
it('resolves user.save to User#save via YARD @return [User] on get_user()', () => {
// With both User#save and Repo#save in scope, resolving user.save
// requires return type inference: get_user() → @return [User] → user is 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();
});
it('resolves repo.save to Repo#save via YARD @return [Repo] on get_repo()', () => {
const calls = getRelationships(result, 'CALLS');
const saveCall = calls.find(c =>
c.target === 'save' && c.source === 'process_repo' && c.targetFilePath.includes('repo.rb'),
);
expect(saveCall).toBeDefined();
});
});

View file

@ -893,3 +893,55 @@ describe('TypeScript return type inference via explicit function return type', (
});
});
// ---------------------------------------------------------------------------
// JavaScript return type inference via JSDoc @returns annotation
// ---------------------------------------------------------------------------
describe('JavaScript return type inference via JSDoc @returns annotation', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'js-jsdoc-return-type'),
() => {},
);
}, 60000);
it('detects User and Repo classes with save methods', () => {
expect(getNodesByLabel(result, 'Class')).toContain('User');
expect(getNodesByLabel(result, 'Class')).toContain('Repo');
});
it('resolves user.save() to User#save via JSDoc @returns {User}', () => {
const calls = getRelationships(result, 'CALLS');
const saveCall = calls.find(c =>
c.target === 'save' && c.source === 'processUser' && c.targetFilePath.includes('models.js'),
);
expect(saveCall).toBeDefined();
});
it('resolves repo.save() to Repo#save via JSDoc @returns {Repo}', () => {
const calls = getRelationships(result, 'CALLS');
const saveCall = calls.find(c =>
c.target === 'save' && c.source === 'processRepo' && c.targetFilePath.includes('models.js'),
);
expect(saveCall).toBeDefined();
});
it('resolves user.save() via JSDoc @param {User} in handleUser()', () => {
const calls = getRelationships(result, 'CALLS');
const saveCall = calls.find(c =>
c.target === 'save' && c.source === 'handleUser' && c.targetFilePath.includes('models.js'),
);
expect(saveCall).toBeDefined();
});
it('resolves repo.save() via JSDoc @param {Repo} in handleRepo()', () => {
const calls = getRelationships(result, 'CALLS');
const saveCall = calls.find(c =>
c.target === 'save' && c.source === 'handleRepo' && c.targetFilePath.includes('models.js'),
);
expect(saveCall).toBeDefined();
});
});