From 36ca3a3d54f6d73f5e3c6bcbe4dde6990b94c4a0 Mon Sep 17 00:00:00 2001 From: Gergo Magyar Date: Sun, 15 Mar 2026 11:50:56 +0000 Subject: [PATCH] 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 --- .../src/core/ingestion/parsing-processor.ts | 9 ++ .../src/core/ingestion/type-extractors/php.ts | 95 +++++++++++++++++- .../core/ingestion/type-extractors/ruby.ts | 64 ++++++++++++- .../core/ingestion/type-extractors/shared.ts | 6 +- .../core/ingestion/type-extractors/types.ts | 8 ++ .../ingestion/type-extractors/typescript.ts | 96 ++++++++++++++++++- .../core/ingestion/workers/parse-worker.ts | 9 ++ .../go-return-type-inference/cmd/main.go | 6 ++ .../go-return-type-inference/models/user.go | 4 + .../js-jsdoc-return-type/app.js | 39 ++++++++ .../js-jsdoc-return-type/models.js | 21 ++++ .../php-phpdoc-return-type/Models.php | 8 ++ .../php-phpdoc-return-type/Services.php | 42 ++++++++ .../lang-resolution/ruby-return-type/app.rb | 6 ++ .../ruby-return-type/models.rb | 1 + .../lang-resolution/ruby-return-type/repo.rb | 14 +++ .../test/integration/resolvers/go.test.ts | 11 +++ .../test/integration/resolvers/php.test.ts | 52 ++++++++++ .../test/integration/resolvers/ruby.test.ts | 34 +++++-- .../integration/resolvers/typescript.test.ts | 52 ++++++++++ 20 files changed, 558 insertions(+), 19 deletions(-) create mode 100644 gitnexus/test/fixtures/lang-resolution/js-jsdoc-return-type/app.js create mode 100644 gitnexus/test/fixtures/lang-resolution/js-jsdoc-return-type/models.js create mode 100644 gitnexus/test/fixtures/lang-resolution/php-phpdoc-return-type/Models.php create mode 100644 gitnexus/test/fixtures/lang-resolution/php-phpdoc-return-type/Services.php create mode 100644 gitnexus/test/fixtures/lang-resolution/ruby-return-type/repo.rb diff --git a/gitnexus/src/core/ingestion/parsing-processor.ts b/gitnexus/src/core/ingestion/parsing-processor.ts index 923ed314b..c3ac9ec55 100644 --- a/gitnexus/src/core/ingestion/parsing-processor.ts +++ b/gitnexus/src/core/ingestion/parsing-processor.ts @@ -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, diff --git a/gitnexus/src/core/ingestion/type-extractors/php.ts b/gitnexus/src/core/ingestion/type-extractors/php.ts index cafdea428..e86fccda6 100644 --- a/gitnexus/src/core/ingestion/type-extractors/php.ts +++ b/gitnexus/src/core/ingestion/type-extractors/php.ts @@ -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 = 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 => { + 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(); + 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): 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, }; diff --git a/gitnexus/src/core/ingestion/type-extractors/ruby.ts b/gitnexus/src/core/ingestion/type-extractors/ruby.ts index 21aecf074..47ac776f9 100644 --- a/gitnexus/src/core/ingestion/type-extractors/ruby.ts +++ b/gitnexus/src/core/ingestion/type-extractors/ruby.ts @@ -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, }; diff --git a/gitnexus/src/core/ingestion/type-extractors/shared.ts b/gitnexus/src/core/ingestion/type-extractors/shared.ts index 45b17e802..e01aee02c 100644 --- a/gitnexus/src/core/ingestion/type-extractors/shared.ts +++ b/gitnexus/src/core/ingestion/type-extractors/shared.ts @@ -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; } } diff --git a/gitnexus/src/core/ingestion/type-extractors/types.ts b/gitnexus/src/core/ingestion/type-extractors/types.ts index f5cd8daf9..943974deb 100644 --- a/gitnexus/src/core/ingestion/type-extractors/types.ts +++ b/gitnexus/src/core/ingestion/type-extractors/types.ts @@ -17,6 +17,11 @@ export type InitializerExtractor = (node: SyntaxNode, env: Map, * 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; } diff --git a/gitnexus/src/core/ingestion/type-extractors/typescript.ts b/gitnexus/src/core/ingestion/type-extractors/typescript.ts index 8089d0965..6248fb4c4 100644 --- a/gitnexus/src/core/ingestion/type-extractors/typescript.ts +++ b/gitnexus/src/core/ingestion/type-extractors/typescript.ts @@ -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 = 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 → Promise (base type, not inner) + const genericMatch = type.match(/^(\w+)\s* => { + 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(); + 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): 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, }; diff --git a/gitnexus/src/core/ingestion/workers/parse-worker.ts b/gitnexus/src/core/ingestion/workers/parse-worker.ts index 54a1c8c19..c74032ef5 100644 --- a/gitnexus/src/core/ingestion/workers/parse-worker.ts +++ b/gitnexus/src/core/ingestion/workers/parse-worker.ts @@ -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({ diff --git a/gitnexus/test/fixtures/lang-resolution/go-return-type-inference/cmd/main.go b/gitnexus/test/fixtures/lang-resolution/go-return-type-inference/cmd/main.go index fc7dea02c..b8c8c3819 100644 --- a/gitnexus/test/fixtures/lang-resolution/go-return-type-inference/cmd/main.go +++ b/gitnexus/test/fixtures/lang-resolution/go-return-type-inference/cmd/main.go @@ -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() +} diff --git a/gitnexus/test/fixtures/lang-resolution/go-return-type-inference/models/user.go b/gitnexus/test/fixtures/lang-resolution/go-return-type-inference/models/user.go index 0e78a30a8..c3714ff74 100644 --- a/gitnexus/test/fixtures/lang-resolution/go-return-type-inference/models/user.go +++ b/gitnexus/test/fixtures/lang-resolution/go-return-type-inference/models/user.go @@ -7,3 +7,7 @@ type User struct { func (u *User) Save() bool { return true } + +func NewUser(name string) *User { + return &User{Name: name} +} diff --git a/gitnexus/test/fixtures/lang-resolution/js-jsdoc-return-type/app.js b/gitnexus/test/fixtures/lang-resolution/js-jsdoc-return-type/app.js new file mode 100644 index 000000000..c89180d61 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/js-jsdoc-return-type/app.js @@ -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(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/js-jsdoc-return-type/models.js b/gitnexus/test/fixtures/lang-resolution/js-jsdoc-return-type/models.js new file mode 100644 index 000000000..f590df38b --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/js-jsdoc-return-type/models.js @@ -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 }; diff --git a/gitnexus/test/fixtures/lang-resolution/php-phpdoc-return-type/Models.php b/gitnexus/test/fixtures/lang-resolution/php-phpdoc-return-type/Models.php new file mode 100644 index 000000000..997516ec8 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/php-phpdoc-return-type/Models.php @@ -0,0 +1,8 @@ +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(); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/ruby-return-type/app.rb b/gitnexus/test/fixtures/lang-resolution/ruby-return-type/app.rb index 39e84e919..a092e4fc2 100644 --- a/gitnexus/test/fixtures/lang-resolution/ruby-return-type/app.rb +++ b/gitnexus/test/fixtures/lang-resolution/ruby-return-type/app.rb @@ -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 diff --git a/gitnexus/test/fixtures/lang-resolution/ruby-return-type/models.rb b/gitnexus/test/fixtures/lang-resolution/ruby-return-type/models.rb index 153126126..e06220f3b 100644 --- a/gitnexus/test/fixtures/lang-resolution/ruby-return-type/models.rb +++ b/gitnexus/test/fixtures/lang-resolution/ruby-return-type/models.rb @@ -8,6 +8,7 @@ class User end end +# @return [User] def get_user(name) User.new(name) end diff --git a/gitnexus/test/fixtures/lang-resolution/ruby-return-type/repo.rb b/gitnexus/test/fixtures/lang-resolution/ruby-return-type/repo.rb new file mode 100644 index 000000000..7268cc96f --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/ruby-return-type/repo.rb @@ -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 diff --git a/gitnexus/test/integration/resolvers/go.test.ts b/gitnexus/test/integration/resolvers/go.test.ts index 68f771664..fa0c71835 100644 --- a/gitnexus/test/integration/resolvers/go.test.ts +++ b/gitnexus/test/integration/resolvers/go.test.ts @@ -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(); + }); }); diff --git a/gitnexus/test/integration/resolvers/php.test.ts b/gitnexus/test/integration/resolvers/php.test.ts index beb983917..7280d0831 100644 --- a/gitnexus/test/integration/resolvers/php.test.ts +++ b/gitnexus/test/integration/resolvers/php.test.ts @@ -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(); + }); +}); diff --git a/gitnexus/test/integration/resolvers/ruby.test.ts b/gitnexus/test/integration/resolvers/ruby.test.ts index 0d19c27d6..594013dee 100644 --- a/gitnexus/test/integration/resolvers/ruby.test.ts +++ b/gitnexus/test/integration/resolvers/ruby.test.ts @@ -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(); + }); }); diff --git a/gitnexus/test/integration/resolvers/typescript.test.ts b/gitnexus/test/integration/resolvers/typescript.test.ts index b1652e6d2..718cb57e7 100644 --- a/gitnexus/test/integration/resolvers/typescript.test.ts +++ b/gitnexus/test/integration/resolvers/typescript.test.ts @@ -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(); + }); +}); +