fix: sanitizeReturnType dot corruption, JS test assertions, Ruby constant receiver

- Remove redundant dot-path stripping from sanitizeReturnType that corrupted
  qualified names inside generics (e.g. Promise<models.User> → 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)
This commit is contained in:
Gergo Magyar 2026-03-15 13:19:56 +00:00
parent fda19a915c
commit 9fbbe2da8d
13 changed files with 155 additions and 28 deletions

View file

@ -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;

View file

@ -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;

View file

@ -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 = (

View file

@ -1,4 +1,5 @@
const { User, Repo } = require('./models');
const { User } = require('./user');
const { Repo } = require('./repo');
/**
* @returns {Promise<User>}

View file

@ -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 };

View file

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

View file

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

View file

@ -0,0 +1,13 @@
const { User } = require('./user');
/**
* @returns {Promise<models.User>}
*/
async function fetchUser(name) {
return new User(name);
}
async function processUser() {
const user = await fetchUser('alice');
user.save();
}

View file

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

View file

@ -0,0 +1,10 @@
require_relative 'user_service'
# @return [UserService]
def build_service
UserService.new
end
SERVICE = build_service()
SERVICE.process
SERVICE.validate

View file

@ -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

View file

@ -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();
});
});

View file

@ -968,15 +968,49 @@ describe('JavaScript async return type inference via JSDoc @returns {Promise<Use
it('resolves user.save() to User#save via @returns {Promise<User>} 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<Repo>} 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<models.User>}
// Verifies that dot-qualified names inside generics are not corrupted.
// ---------------------------------------------------------------------------
describe('JavaScript qualified return type via JSDoc @returns {Promise<models.User>}', () => {
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();
});