GitNexus/gitnexus/test/integration/resolvers/ruby.test.ts
claude[bot] 8d39532185 test(ruby): add ingestion integration tests for Ruby language support
Adds 8 fixture-driven integration test scenarios modelled after the
existing C/C++ and Python resolver test suites:

- ruby-pkg: require_relative imports, class inheritance (EXTENDS), module
  include (IMPLEMENTS), method detection across 5 files
- ruby-ambiguous: two Handler classes in a/ and b/, require_relative to a/
  disambiguates the EXTENDS target
- ruby-calls: arity-filtered call resolution — write_audit("hello") resolves
  to one.rb (1-param) not zero.rb (0-params) via import-resolved
- ruby-member-calls: obj.method() member call resolves User.save via the
  import map; verifies HAS_METHOD edge
- ruby-mixin-heritage: include / extend / prepend all produce IMPLEMENTS
  edges; no EXTENDS emitted for any mixin form
- ruby-attr-properties: attr_accessor / attr_reader / attr_writer create
  Property nodes with correct accessorType in description
- ruby-receiver-resolution: both User and Repo classes exist with save
  methods; HAS_METHOD verified for each; at least one save call resolved
- ruby-local-shadow: local def save in main.rb shadows the require'd
  utils.rb#save; run → save resolves to same-file definition

Co-authored-by: Gergő Magyar <magyargergo@users.noreply.github.com>
2026-03-13 19:47:23 +00:00

354 lines
13 KiB
TypeScript

/**
* Ruby: require/require_relative imports + class inheritance + mixin heritage +
* attr_* properties + arity-filtered call resolution + member-call resolution
*/
import { describe, it, expect, beforeAll } from 'vitest';
import path from 'path';
import {
FIXTURES, getRelationships, getNodesByLabel, edgeSet,
runPipelineFromRepo, type PipelineResult,
} from './helpers.js';
// ---------------------------------------------------------------------------
// Package: require_relative imports + class inheritance + module include
// ---------------------------------------------------------------------------
describe('Ruby require_relative imports and class hierarchy', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'ruby-pkg'),
() => {},
);
}, 60000);
it('detects exactly 3 classes and 1 module', () => {
expect(getNodesByLabel(result, 'Class')).toEqual(['AuthService', 'BaseModel', 'User']);
expect(getNodesByLabel(result, 'Module')).toEqual(['Serializable']);
});
it('detects all 6 methods', () => {
const methods = getNodesByLabel(result, 'Method');
expect(methods).toContain('save');
expect(methods).toContain('validate');
expect(methods).toContain('serialize');
expect(methods).toContain('get_name');
expect(methods).toContain('authenticate');
expect(methods).toContain('process_model');
});
it('emits exactly 1 EXTENDS edge: User → BaseModel', () => {
const extends_ = getRelationships(result, 'EXTENDS');
expect(extends_.length).toBe(1);
expect(extends_[0].source).toBe('User');
expect(extends_[0].target).toBe('BaseModel');
expect(extends_[0].targetFilePath).toBe('lib/base_model.rb');
});
it('emits exactly 1 IMPLEMENTS edge: User → Serializable (include)', () => {
const implements_ = getRelationships(result, 'IMPLEMENTS');
expect(implements_.length).toBe(1);
expect(implements_[0].source).toBe('User');
expect(implements_[0].target).toBe('Serializable');
});
it('resolves all 4 require_relative imports', () => {
const imports = getRelationships(result, 'IMPORTS');
expect(imports.length).toBe(4);
expect(edgeSet(imports)).toEqual([
'auth_service.rb → user.rb',
'helpers.rb → base_model.rb',
'user.rb → base_model.rb',
'user.rb → serializable.rb',
]);
});
it('no OVERRIDES edges target Property nodes', () => {
const overrides = getRelationships(result, 'OVERRIDES');
for (const edge of overrides) {
const target = result.graph.getNode(edge.rel.targetId);
expect(target).toBeDefined();
expect(target!.label).not.toBe('Property');
}
});
});
// ---------------------------------------------------------------------------
// Ambiguous: two files with same class name, require_relative disambiguates
// ---------------------------------------------------------------------------
describe('Ruby ambiguous symbol resolution', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'ruby-ambiguous'),
() => {},
);
}, 60000);
it('detects 2 Handler classes', () => {
const classes = getNodesByLabel(result, 'Class');
expect(classes.filter(n => n === 'Handler').length).toBe(2);
expect(classes).toContain('Processor');
});
it('resolves EXTENDS to handlers/a/handler.rb (not handlers/b/handler.rb)', () => {
const extends_ = getRelationships(result, 'EXTENDS');
expect(extends_.length).toBe(1);
expect(extends_[0].source).toBe('Processor');
expect(extends_[0].target).toBe('Handler');
expect(extends_[0].targetFilePath).toBe('handlers/a/handler.rb');
});
it('require_relative resolves to handlers/a/handler.rb', () => {
const imports = getRelationships(result, 'IMPORTS');
expect(imports.length).toBe(1);
expect(imports[0].targetFilePath).toBe('handlers/a/handler.rb');
});
it('all heritage edges point to real graph nodes', () => {
for (const edge of getRelationships(result, 'EXTENDS')) {
const target = result.graph.getNode(edge.rel.targetId);
expect(target).toBeDefined();
expect(target!.properties.name).toBe(edge.target);
}
});
});
// ---------------------------------------------------------------------------
// Call resolution with arity filtering
// ---------------------------------------------------------------------------
describe('Ruby call resolution with arity filtering', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'ruby-calls'),
() => {},
);
}, 60000);
it('resolves run → write_audit to one.rb via arity narrowing', () => {
const calls = getRelationships(result, 'CALLS');
expect(calls.length).toBe(1);
expect(calls[0].source).toBe('run');
expect(calls[0].target).toBe('write_audit');
expect(calls[0].targetFilePath).toBe('one.rb');
expect(calls[0].rel.reason).toBe('import-resolved');
});
});
// ---------------------------------------------------------------------------
// Member-call resolution: obj.method() resolves through pipeline
// ---------------------------------------------------------------------------
describe('Ruby member-call resolution', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'ruby-member-calls'),
() => {},
);
}, 60000);
it('detects User class (Class node) and save method (Method node)', () => {
expect(getNodesByLabel(result, 'Class')).toContain('User');
// Ruby instance methods are Method nodes
expect(getNodesByLabel(result, 'Method')).toContain('save');
});
it('emits HAS_METHOD edge from User to save', () => {
const hasMethod = getRelationships(result, 'HAS_METHOD');
const edge = hasMethod.find(e => e.source === 'User' && e.target === 'save');
expect(edge).toBeDefined();
});
it('resolves process_user → save as a member call on User', () => {
const calls = getRelationships(result, 'CALLS');
const saveCall = calls.find(c => c.target === 'save');
expect(saveCall).toBeDefined();
expect(saveCall!.source).toBe('process_user');
expect(saveCall!.targetFilePath).toBe('user.rb');
});
it('emits 1 require_relative IMPORTS edge', () => {
const imports = getRelationships(result, 'IMPORTS');
expect(imports.length).toBe(1);
expect(imports[0].source).toBe('app.rb');
expect(imports[0].targetFilePath).toBe('user.rb');
});
});
// ---------------------------------------------------------------------------
// Mixin heritage: include / extend / prepend all produce IMPLEMENTS edges
// ---------------------------------------------------------------------------
describe('Ruby mixin heritage (include / extend / prepend)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'ruby-mixin-heritage'),
() => {},
);
}, 60000);
it('detects 1 class and 3 modules', () => {
expect(getNodesByLabel(result, 'Class')).toEqual(['Record']);
expect(getNodesByLabel(result, 'Module')).toEqual(['Auditable', 'Cacheable', 'Hookable']);
});
it('emits exactly 3 IMPLEMENTS edges for all three mixin types', () => {
const implements_ = getRelationships(result, 'IMPLEMENTS');
expect(implements_.length).toBe(3);
expect(edgeSet(implements_)).toEqual([
'Record → Auditable',
'Record → Cacheable',
'Record → Hookable',
]);
});
it('emits no EXTENDS edges (mixins are not inheritance)', () => {
const extends_ = getRelationships(result, 'EXTENDS');
expect(extends_.length).toBe(0);
});
it('resolves 3 require_relative IMPORTS edges', () => {
const imports = getRelationships(result, 'IMPORTS');
expect(imports.length).toBe(3);
expect(edgeSet(imports)).toEqual([
'record.rb → auditable.rb',
'record.rb → cacheable.rb',
'record.rb → hookable.rb',
]);
});
it('all IMPLEMENTS targets are real graph nodes (Module label)', () => {
for (const edge of getRelationships(result, 'IMPLEMENTS')) {
const target = result.graph.getNode(edge.rel.targetId);
expect(target).toBeDefined();
expect(target!.label).toBe('Module');
}
});
});
// ---------------------------------------------------------------------------
// Property extraction: attr_accessor / attr_reader / attr_writer
// ---------------------------------------------------------------------------
describe('Ruby attr_* property extraction', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'ruby-attr-properties'),
() => {},
);
}, 60000);
it('detects Person class and greet method', () => {
expect(getNodesByLabel(result, 'Class')).toContain('Person');
expect(getNodesByLabel(result, 'Method')).toContain('greet');
});
it('creates 4 Property nodes for attr_accessor, attr_reader, attr_writer', () => {
const props = getNodesByLabel(result, 'Property');
expect(props).toEqual(['age', 'email', 'id', 'name']);
});
it('all Property nodes have filePath pointing to person.rb', () => {
result.graph.forEachNode(n => {
if (n.label === 'Property') {
expect(n.properties.filePath).toBe('models/person.rb');
}
});
});
it('Property nodes carry accessorType in description field', () => {
const accessorTypes = new Map<string, string>();
result.graph.forEachNode(n => {
if (n.label === 'Property') {
accessorTypes.set(n.properties.name as string, n.properties.description as string);
}
});
expect(accessorTypes.get('name')).toBe('attr_accessor');
expect(accessorTypes.get('age')).toBe('attr_accessor');
expect(accessorTypes.get('id')).toBe('attr_reader');
expect(accessorTypes.get('email')).toBe('attr_writer');
});
});
// ---------------------------------------------------------------------------
// Receiver-constrained resolution: two classes with same method name
// ---------------------------------------------------------------------------
describe('Ruby receiver-constrained resolution', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'ruby-receiver-resolution'),
() => {},
);
}, 60000);
it('detects User and Repo classes, both with save methods', () => {
expect(getNodesByLabel(result, 'Class')).toContain('User');
expect(getNodesByLabel(result, 'Class')).toContain('Repo');
const saveMethods = getNodesByLabel(result, 'Method').filter(m => m === 'save');
expect(saveMethods.length).toBe(2);
});
it('emits HAS_METHOD edges for both User.save and Repo.save', () => {
const hasMethod = getRelationships(result, 'HAS_METHOD');
const userSave = hasMethod.find(e => e.source === 'User' && e.target === 'save');
const repoSave = hasMethod.find(e => e.source === 'Repo' && e.target === 'save');
expect(userSave).toBeDefined();
expect(repoSave).toBeDefined();
});
it('resolves at least one save call from process_entities', () => {
const calls = getRelationships(result, 'CALLS');
const saveCalls = calls.filter(c => c.target === 'save' && c.source === 'process_entities');
expect(saveCalls.length).toBeGreaterThanOrEqual(1);
});
it('all heritage edges point to real graph nodes', () => {
for (const edge of getRelationships(result, 'EXTENDS')) {
const target = result.graph.getNode(edge.rel.targetId);
expect(target).toBeDefined();
}
});
});
// ---------------------------------------------------------------------------
// Local shadow: same-file definition takes priority over require'd name
// ---------------------------------------------------------------------------
describe('Ruby local definition shadows require', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'ruby-local-shadow'),
() => {},
);
}, 60000);
it('resolves run → save to same-file definition in main.rb, not utils.rb', () => {
const calls = getRelationships(result, 'CALLS');
const saveCall = calls.find(c => c.target === 'save' && c.source === 'run');
expect(saveCall).toBeDefined();
expect(saveCall!.targetFilePath).toBe('src/main.rb');
});
it('does NOT resolve save to utils.rb', () => {
const calls = getRelationships(result, 'CALLS');
const saveToUtils = calls.find(c => c.target === 'save' && c.targetFilePath === 'src/utils.rb');
expect(saveToUtils).toBeUndefined();
});
});