From 9fbbe2da8dccf5d8f0ef729662971326e03dbf71 Mon Sep 17 00:00:00 2001 From: Gergo Magyar Date: Sun, 15 Mar 2026 13:19:56 +0000 Subject: [PATCH] fix: sanitizeReturnType dot corruption, JS test assertions, Ruby constant receiver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove redundant dot-path stripping from sanitizeReturnType that corrupted qualified names inside generics (e.g. Promise → User>) - Split JS async fixture into separate files and add negative assertions to properly verify disambiguation (mirroring PHP test pattern) - Accept 'constant' node type in Ruby scanConstructorBinding for factory call assignments (SERVICE = build_service()) - Add 'constant' to SIMPLE_RECEIVER_TYPES so extractReceiverName handles Ruby constant receivers (SERVICE.process) --- .../core/ingestion/type-extractors/ruby.ts | 2 +- .../ingestion/type-extractors/typescript.ts | 3 -- gitnexus/src/core/ingestion/utils.ts | 1 + .../js-jsdoc-async-return-type/app.js | 3 +- .../js-jsdoc-async-return-type/models.js | 21 ---------- .../js-jsdoc-async-return-type/repo.js | 11 +++++ .../js-jsdoc-async-return-type/user.js | 11 +++++ .../js-jsdoc-qualified-return-type/app.js | 13 ++++++ .../js-jsdoc-qualified-return-type/user.js | 11 +++++ .../ruby-constant-factory-call/app.rb | 10 +++++ .../user_service.rb | 19 +++++++++ .../test/integration/resolvers/ruby.test.ts | 40 +++++++++++++++++++ .../integration/resolvers/typescript.test.ts | 38 +++++++++++++++++- 13 files changed, 155 insertions(+), 28 deletions(-) delete mode 100644 gitnexus/test/fixtures/lang-resolution/js-jsdoc-async-return-type/models.js create mode 100644 gitnexus/test/fixtures/lang-resolution/js-jsdoc-async-return-type/repo.js create mode 100644 gitnexus/test/fixtures/lang-resolution/js-jsdoc-async-return-type/user.js create mode 100644 gitnexus/test/fixtures/lang-resolution/js-jsdoc-qualified-return-type/app.js create mode 100644 gitnexus/test/fixtures/lang-resolution/js-jsdoc-qualified-return-type/user.js create mode 100644 gitnexus/test/fixtures/lang-resolution/ruby-constant-factory-call/app.rb create mode 100644 gitnexus/test/fixtures/lang-resolution/ruby-constant-factory-call/user_service.rb diff --git a/gitnexus/src/core/ingestion/type-extractors/ruby.ts b/gitnexus/src/core/ingestion/type-extractors/ruby.ts index 47ac776f9..370d884c0 100644 --- a/gitnexus/src/core/ingestion/type-extractors/ruby.ts +++ b/gitnexus/src/core/ingestion/type-extractors/ruby.ts @@ -226,7 +226,7 @@ const scanConstructorBinding: ConstructorBindingScanner = (node) => { const left = node.childForFieldName('left'); const right = node.childForFieldName('right'); if (!left || !right) return undefined; - if (left.type !== 'identifier') return undefined; + if (left.type !== 'identifier' && left.type !== 'constant') return undefined; if (right.type !== 'call') return undefined; const method = right.childForFieldName('method'); if (!method) return undefined; diff --git a/gitnexus/src/core/ingestion/type-extractors/typescript.ts b/gitnexus/src/core/ingestion/type-extractors/typescript.ts index e610d0124..7039097ba 100644 --- a/gitnexus/src/core/ingestion/type-extractors/typescript.ts +++ b/gitnexus/src/core/ingestion/type-extractors/typescript.ts @@ -168,9 +168,6 @@ const sanitizeReturnType = (raw: string): string | undefined => { if (type.startsWith('?') || type.startsWith('!')) type = type.slice(1); // 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 dotIdx = type.lastIndexOf('.'); - if (dotIdx >= 0) type = type.slice(dotIdx + 1); // Reject unions (ambiguous) if (type.includes('|')) return undefined; if (!type) return undefined; diff --git a/gitnexus/src/core/ingestion/utils.ts b/gitnexus/src/core/ingestion/utils.ts index 649254be1..cd6d89f03 100644 --- a/gitnexus/src/core/ingestion/utils.ts +++ b/gitnexus/src/core/ingestion/utils.ts @@ -804,6 +804,7 @@ const SIMPLE_RECEIVER_TYPES = new Set([ 'super_expression', // Kotlin wraps super in super_expression 'base', // C# base.Method() 'parent', // PHP parent::method() + 'constant', // Ruby CONSTANT.method() (uppercase identifiers) ]); export const extractReceiverName = ( diff --git a/gitnexus/test/fixtures/lang-resolution/js-jsdoc-async-return-type/app.js b/gitnexus/test/fixtures/lang-resolution/js-jsdoc-async-return-type/app.js index 372f17022..01b8de143 100644 --- a/gitnexus/test/fixtures/lang-resolution/js-jsdoc-async-return-type/app.js +++ b/gitnexus/test/fixtures/lang-resolution/js-jsdoc-async-return-type/app.js @@ -1,4 +1,5 @@ -const { User, Repo } = require('./models'); +const { User } = require('./user'); +const { Repo } = require('./repo'); /** * @returns {Promise} diff --git a/gitnexus/test/fixtures/lang-resolution/js-jsdoc-async-return-type/models.js b/gitnexus/test/fixtures/lang-resolution/js-jsdoc-async-return-type/models.js deleted file mode 100644 index f590df38b..000000000 --- a/gitnexus/test/fixtures/lang-resolution/js-jsdoc-async-return-type/models.js +++ /dev/null @@ -1,21 +0,0 @@ -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/js-jsdoc-async-return-type/repo.js b/gitnexus/test/fixtures/lang-resolution/js-jsdoc-async-return-type/repo.js new file mode 100644 index 000000000..a85e9c39a --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/js-jsdoc-async-return-type/repo.js @@ -0,0 +1,11 @@ +class Repo { + constructor(path) { + this.path = path; + } + + save() { + return true; + } +} + +module.exports = { Repo }; diff --git a/gitnexus/test/fixtures/lang-resolution/js-jsdoc-async-return-type/user.js b/gitnexus/test/fixtures/lang-resolution/js-jsdoc-async-return-type/user.js new file mode 100644 index 000000000..7f19a622d --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/js-jsdoc-async-return-type/user.js @@ -0,0 +1,11 @@ +class User { + constructor(name) { + this.name = name; + } + + save() { + return true; + } +} + +module.exports = { User }; diff --git a/gitnexus/test/fixtures/lang-resolution/js-jsdoc-qualified-return-type/app.js b/gitnexus/test/fixtures/lang-resolution/js-jsdoc-qualified-return-type/app.js new file mode 100644 index 000000000..8b1473dab --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/js-jsdoc-qualified-return-type/app.js @@ -0,0 +1,13 @@ +const { User } = require('./user'); + +/** + * @returns {Promise} + */ +async function fetchUser(name) { + return new User(name); +} + +async function processUser() { + const user = await fetchUser('alice'); + user.save(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/js-jsdoc-qualified-return-type/user.js b/gitnexus/test/fixtures/lang-resolution/js-jsdoc-qualified-return-type/user.js new file mode 100644 index 000000000..7f19a622d --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/js-jsdoc-qualified-return-type/user.js @@ -0,0 +1,11 @@ +class User { + constructor(name) { + this.name = name; + } + + save() { + return true; + } +} + +module.exports = { User }; diff --git a/gitnexus/test/fixtures/lang-resolution/ruby-constant-factory-call/app.rb b/gitnexus/test/fixtures/lang-resolution/ruby-constant-factory-call/app.rb new file mode 100644 index 000000000..955fc08aa --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/ruby-constant-factory-call/app.rb @@ -0,0 +1,10 @@ +require_relative 'user_service' + +# @return [UserService] +def build_service + UserService.new +end + +SERVICE = build_service() +SERVICE.process +SERVICE.validate diff --git a/gitnexus/test/fixtures/lang-resolution/ruby-constant-factory-call/user_service.rb b/gitnexus/test/fixtures/lang-resolution/ruby-constant-factory-call/user_service.rb new file mode 100644 index 000000000..f053bf949 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/ruby-constant-factory-call/user_service.rb @@ -0,0 +1,19 @@ +class UserService + def process + true + end + + def validate + true + end +end + +class AdminService + def process + true + end + + def validate + true + end +end diff --git a/gitnexus/test/integration/resolvers/ruby.test.ts b/gitnexus/test/integration/resolvers/ruby.test.ts index 594013dee..669de7961 100644 --- a/gitnexus/test/integration/resolvers/ruby.test.ts +++ b/gitnexus/test/integration/resolvers/ruby.test.ts @@ -641,3 +641,43 @@ describe('Ruby return type inference via function call', () => { expect(saveCall).toBeDefined(); }); }); + +// --------------------------------------------------------------------------- +// Ruby constant LHS factory call: SERVICE = build_service() with YARD @return +// Verifies that constant assignments (uppercase LHS) from plain function calls +// are captured by scanConstructorBinding, not just identifier assignments. +// --------------------------------------------------------------------------- + +describe('Ruby constant factory call resolution (SERVICE = build_service())', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'ruby-constant-factory-call'), + () => {}, + ); + }, 60000); + + it('detects UserService and AdminService classes with process and validate methods', () => { + expect(getNodesByLabel(result, 'Class')).toContain('UserService'); + expect(getNodesByLabel(result, 'Class')).toContain('AdminService'); + expect(getNodesByLabel(result, 'Method')).toContain('process'); + expect(getNodesByLabel(result, 'Method')).toContain('validate'); + }); + + it('resolves SERVICE.process() to UserService#process via constant factory call', () => { + const calls = getRelationships(result, 'CALLS'); + const processCall = calls.find(c => + c.target === 'process' && c.targetFilePath.includes('user_service.rb'), + ); + expect(processCall).toBeDefined(); + }); + + it('resolves SERVICE.validate() to UserService#validate via constant factory call', () => { + const calls = getRelationships(result, 'CALLS'); + const validateCall = calls.find(c => + c.target === 'validate' && c.targetFilePath.includes('user_service.rb'), + ); + expect(validateCall).toBeDefined(); + }); +}); diff --git a/gitnexus/test/integration/resolvers/typescript.test.ts b/gitnexus/test/integration/resolvers/typescript.test.ts index 2b2de20f4..1529ce05e 100644 --- a/gitnexus/test/integration/resolvers/typescript.test.ts +++ b/gitnexus/test/integration/resolvers/typescript.test.ts @@ -968,15 +968,49 @@ describe('JavaScript async return type inference via JSDoc @returns {Promise} unwrapping', () => { const calls = getRelationships(result, 'CALLS'); const saveCall = calls.find(c => - c.target === 'save' && c.source === 'processUser' && c.targetFilePath.includes('models.js'), + c.target === 'save' && c.source === 'processUser' && c.targetFilePath.includes('user.js'), ); expect(saveCall).toBeDefined(); + // Negative: must NOT resolve to Repo#save + const wrongCall = calls.find(c => + c.target === 'save' && c.source === 'processUser' && c.targetFilePath.includes('repo.js'), + ); + expect(wrongCall).toBeUndefined(); }); it('resolves repo.save() to Repo#save via @returns {Promise} unwrapping', () => { const calls = getRelationships(result, 'CALLS'); const saveCall = calls.find(c => - c.target === 'save' && c.source === 'processRepo' && c.targetFilePath.includes('models.js'), + c.target === 'save' && c.source === 'processRepo' && c.targetFilePath.includes('repo.js'), + ); + expect(saveCall).toBeDefined(); + // Negative: must NOT resolve to User#save + const wrongCall = calls.find(c => + c.target === 'save' && c.source === 'processRepo' && c.targetFilePath.includes('user.js'), + ); + expect(wrongCall).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// JavaScript qualified return type: @returns {Promise} +// Verifies that dot-qualified names inside generics are not corrupted. +// --------------------------------------------------------------------------- + +describe('JavaScript qualified return type via JSDoc @returns {Promise}', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'js-jsdoc-qualified-return-type'), + () => {}, + ); + }, 60000); + + it('resolves user.save() to User#save despite qualified return type', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find(c => + c.target === 'save' && c.source === 'processUser' && c.targetFilePath.includes('user.js'), ); expect(saveCall).toBeDefined(); });