mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-09 03:17:54 +00:00
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:
parent
292cb8e642
commit
36ca3a3d54
20 changed files with 558 additions and 19 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,3 +7,7 @@ type User struct {
|
|||
func (u *User) Save() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func NewUser(name string) *User {
|
||||
return &User{Name: name}
|
||||
}
|
||||
|
|
|
|||
39
gitnexus/test/fixtures/lang-resolution/js-jsdoc-return-type/app.js
vendored
Normal file
39
gitnexus/test/fixtures/lang-resolution/js-jsdoc-return-type/app.js
vendored
Normal 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();
|
||||
}
|
||||
21
gitnexus/test/fixtures/lang-resolution/js-jsdoc-return-type/models.js
vendored
Normal file
21
gitnexus/test/fixtures/lang-resolution/js-jsdoc-return-type/models.js
vendored
Normal 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 };
|
||||
8
gitnexus/test/fixtures/lang-resolution/php-phpdoc-return-type/Models.php
vendored
Normal file
8
gitnexus/test/fixtures/lang-resolution/php-phpdoc-return-type/Models.php
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
<?php
|
||||
class User {
|
||||
public function save() { return true; }
|
||||
}
|
||||
|
||||
class Repo {
|
||||
public function save() { return true; }
|
||||
}
|
||||
42
gitnexus/test/fixtures/lang-resolution/php-phpdoc-return-type/Services.php
vendored
Normal file
42
gitnexus/test/fixtures/lang-resolution/php-phpdoc-return-type/Services.php
vendored
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ class User
|
|||
end
|
||||
end
|
||||
|
||||
# @return [User]
|
||||
def get_user(name)
|
||||
User.new(name)
|
||||
end
|
||||
|
|
|
|||
14
gitnexus/test/fixtures/lang-resolution/ruby-return-type/repo.rb
vendored
Normal file
14
gitnexus/test/fixtures/lang-resolution/ruby-return-type/repo.rb
vendored
Normal 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
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue