GitNexus/gitnexus/test/integration/resolvers/ruby-scope.test.ts
Gergő Magyar 05d269ec28
feat(ruby): migrate Ruby to scope-based resolution (RFC #909 Ring 3) (#1831)
* feat(ruby): migrate Ruby to scope-based resolution (RFC #909 Ring 3)

Implement the full scope-resolution pipeline for Ruby following the
PR #1639 (Rust migration) standard, targeting registration in
MIGRATED_LANGUAGES with 100% scope parity.

Scope resolver hooks (languages/ruby/):
- query.ts: RUBY_SCOPE_QUERY covering scopes, declarations, imports,
  type-bindings (constructor inference via .new), and references
- captures.ts: emitRubyScopeCaptures orchestrator with import
  decomposition, receiver-binding synthesis, method reclassification,
  and arity metadata for both declarations and calls
- receiver-binding.ts: self type-binding synthesis for instance methods,
  singleton methods, and class << self blocks
- interpret.ts: interpretRubyImport (wildcard semantics) and
  interpretRubyTypeBinding (YARD, constructor, alias sources)
- import-target.ts: resolveRubyImportTarget adapting the existing
  suffix resolver for require/require_relative/load
- merge-bindings.ts: tier-based shadowing (local > namespace > import)
- arity.ts: Ruby arity check with *args/**kwargs/&block support
- scope-resolver.ts: rubyScopeResolver with custom buildRubyMro
  (kind-aware IMPLEMENTS partitioning: prepend > direct > include;
  extend excluded from instance MRO per legacy semantics)
- simple-hooks.ts: bindingScopeFor, importOwningScope, receiverBinding

Wiring:
- ruby.ts provider gains 7 scope-resolution hooks
- Registered in SCOPE_RESOLVERS map and MIGRATED_LANGUAGES
- 127 legacy tests wired with createResolverParityIt('ruby')
- 27 new scope-specific tests in ruby-scope.test.ts

Parity: 89/127 legacy tests pass under registry-primary; 38 are
heritage/property/YARD gaps expected in V1. All 127 pass under legacy.

Closes #931

* feat(ruby): add emitHeritageEdges hook, YARD parsing, bare calls, property emission

Extend the scope-resolution pipeline with a new optional `emitHeritageEdges`
hook (ScopeResolver contract + run.ts wiring) that runs between
`preEmitInheritanceEdges` and `buildMro`. This lets languages whose heritage
declarations are syntactic method calls (Ruby include/extend/prepend) emit
IMPLEMENTS edges from the scope-resolver without touching the legacy pipeline.

Ruby scope-resolution improvements:
- Heritage: intercept include/extend/prepend in captures.ts, encode as
  special imports, emit IMPLEMENTS edges via emitHeritageEdges hook
- Properties: intercept attr_accessor/attr_reader/attr_writer, emit
  Property nodes + HAS_PROPERTY edges via the same hook
- Bare calls: add (body_statement (identifier)) capture to scope query,
  matching the legacy query pattern for zero-arity method calls
- YARD parsing: second-pass comment scanner for @param/@return/@type
  annotations with findFollowingMethod that handles body_statement nesting
- Query fixes: @declaration.trait for modules (was @declaration.module
  which normalizeNodeLabel didn't recognize), constant constructor
  bindings (SERVICE = UserService.new), call-return inference

Parity: 114/127 legacy tests pass under registry-primary (up from 89).
Remaining 13 are advanced type-inference chain resolution (compound
receiver, cross-file return-type propagation, for-in element types).

* feat(ruby): achieve 100% scope-resolution parity (127/127)

Fix all 13 remaining type-inference failures:

- Add expandsWildcardTo hook (expandRubyWildcardNames) so finalize can
  materialize individual bindings from require/require_relative wildcard
  imports, unblocking cross-file return-type propagation
- Add member-call-return type binding synthesis in captures.ts for
  assignments like `x = obj.method()` — enables compound receiver
  chaining through member call return types
- Add YARD @return support for attr_accessor/attr_reader/attr_writer
  calls, creating field-type bindings for chain resolution
- Add @declaration.property captures alongside __property__ imports so
  properties register in localDefs → model.fields → write-access
- Add constructor-return inference for methods ending with Foo.new()
- Add for-loop variable type aliasing in scope query
- Rebuild nodeLookup after emitHeritageEdges in run.ts so Property
  nodes created by the heritage hook are visible to downstream passes
- Extend compound-receiver resolver to handle compound member-call
  rawNames with () and increase max depth from 4 to 8
- Extend receiver-bound-calls Case 3b for compound rawNames

All 127 legacy Ruby tests pass under both REGISTRY_PRIMARY_RUBY=0
(legacy) and =1 (registry-primary). Ruby is now fully registered
in MIGRATED_LANGUAGES with 100% scope parity.

* test(ruby): add pipeline benchmark exercising heritage emission

Synthetic Ruby codebases at 100/250/500 files with include + extend +
prepend mixins, diamond mixin patterns (shared BaseMixin modules),
attr_accessor properties, YARD annotations, and cross-file imports.

Strict equality assertions verify exact IMPLEMENTS and HAS_PROPERTY
edge counts: 4 IMPLEMENTS per class (include x2, extend, prepend)
plus 1 per non-base mixin module, 3 HAS_PROPERTY per class.

Dedup in emitRubyMixinEdges prevents double-counting when the worker
path (repos >= 15 files) already created Property/IMPLEMENTS edges
before scope-resolution runs.

Scaling: 0.76x and 1.40x (both linear, well under 3x threshold).

* ci: retrigger build

* fix(ci): resolve format, registry-primary-flag, and sequential-mixin test failures

- Run prettier on all changed files (captures.ts, run.ts, ruby-scope.test.ts,
  ruby.test.ts, ruby-pipeline-benchmark.test.ts)
- Update registry-primary-flag.test.ts: use Swift (not in MIGRATED_LANGUAGES)
  instead of Ruby for the isolation and env-var mutation tests
- Pin ruby-sequential-mixin.test.ts to REGISTRY_PRIMARY_RUBY=0 (legacy mode)
  since it tests inferImplicitReceiver + selectDispatch hooks that live in the
  legacy call-processor (gated off under registry-primary)

---------

Co-authored-by: Test <test@example.com>
2026-05-26 16:16:49 +01:00

481 lines
13 KiB
TypeScript

/**
* Ruby scope-resolution integration tests (U8).
*
* These tests run with REGISTRY_PRIMARY_RUBY=true to exercise the
* scope-based resolution path. They validate class methods, module mixins,
* singleton methods, require_relative imports, constructor inference,
* block scope, class inheritance, and super resolution.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import path from 'path';
import fs from 'node:fs';
import os from 'node:os';
import {
getRelationships,
getNodesByLabel,
runPipelineFromRepo,
type PipelineResult,
} from './helpers.js';
function writeFixtureRepo(root: string, files: Record<string, string>): void {
for (const [rel, content] of Object.entries(files)) {
const abs = path.join(root, rel);
fs.mkdirSync(path.dirname(abs), { recursive: true });
fs.writeFileSync(abs, content, 'utf8');
}
}
let savedEnv: string | undefined;
beforeAll(() => {
savedEnv = process.env['REGISTRY_PRIMARY_RUBY'];
process.env['REGISTRY_PRIMARY_RUBY'] = 'true';
});
afterAll(() => {
if (savedEnv === undefined) delete process.env['REGISTRY_PRIMARY_RUBY'];
else process.env['REGISTRY_PRIMARY_RUBY'] = savedEnv;
});
// ---------------------------------------------------------------------------
// 1. Basic class method resolution
// ---------------------------------------------------------------------------
describe('Ruby scope: basic class method resolution', () => {
let result: PipelineResult;
let tmpDir: string;
beforeAll(async () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ruby-scope-basic-'));
writeFixtureRepo(tmpDir, {
'models/user.rb': `
class User
def save
true
end
def greet
"hello"
end
end
`,
'app.rb': `
require_relative 'models/user'
def main
u = User.new
u.save
u.greet
end
`,
});
result = await runPipelineFromRepo(tmpDir, () => {});
}, 60000);
afterAll(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it('detects User class', () => {
expect(getNodesByLabel(result, 'Class')).toContain('User');
});
it('detects save and greet as Method nodes', () => {
const methods = getNodesByLabel(result, 'Method');
expect(methods).toContain('save');
expect(methods).toContain('greet');
});
it('emits HAS_METHOD edges from User to save and greet', () => {
const edges = getRelationships(result, 'HAS_METHOD');
const userSave = edges.find((e) => e.source === 'User' && e.target === 'save');
const userGreet = edges.find((e) => e.source === 'User' && e.target === 'greet');
expect(userSave).toBeDefined();
expect(userGreet).toBeDefined();
});
it('resolves main → u.save() as CALLS edge', () => {
const calls = getRelationships(result, 'CALLS');
const saveCall = calls.find(
(c) => c.target === 'save' && c.source === 'main' && c.targetFilePath?.includes('user.rb'),
);
expect(saveCall).toBeDefined();
});
});
// ---------------------------------------------------------------------------
// 2. Module mixin with include
// ---------------------------------------------------------------------------
describe('Ruby scope: module mixin with include', () => {
let result: PipelineResult;
let tmpDir: string;
beforeAll(async () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ruby-scope-mixin-'));
writeFixtureRepo(tmpDir, {
'serializable.rb': `
module Serializable
def serialize
to_json
end
end
`,
'user.rb': `
require_relative 'serializable'
class User
include Serializable
def save
serialize
end
end
`,
});
result = await runPipelineFromRepo(tmpDir, () => {});
}, 60000);
afterAll(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it('detects User as Class and Serializable as Trait', () => {
expect(getNodesByLabel(result, 'Class')).toContain('User');
expect(getNodesByLabel(result, 'Trait')).toContain('Serializable');
});
it('emits IMPLEMENTS edge from User to Serializable', () => {
const impls = getRelationships(result, 'IMPLEMENTS');
const edge = impls.find((e) => e.source === 'User' && e.target === 'Serializable');
expect(edge).toBeDefined();
});
it('resolves save → serialize as CALLS edge', () => {
const calls = getRelationships(result, 'CALLS');
const serializeCall = calls.find((c) => c.target === 'serialize' && c.source === 'save');
expect(serializeCall).toBeDefined();
});
});
// ---------------------------------------------------------------------------
// 3. Singleton method (def self.foo)
// ---------------------------------------------------------------------------
describe('Ruby scope: singleton method (def self.foo)', () => {
let result: PipelineResult;
let tmpDir: string;
beforeAll(async () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ruby-scope-singleton-'));
writeFixtureRepo(tmpDir, {
'config.rb': `
class Config
def self.load
new
end
def validate
true
end
end
`,
});
result = await runPipelineFromRepo(tmpDir, () => {});
}, 60000);
afterAll(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it('detects Config class', () => {
expect(getNodesByLabel(result, 'Class')).toContain('Config');
});
it('detects load (singleton) and validate (instance) as Method nodes', () => {
const methods = getNodesByLabel(result, 'Method');
expect(methods).toContain('load');
expect(methods).toContain('validate');
});
it('emits HAS_METHOD edges from Config to both methods', () => {
const edges = getRelationships(result, 'HAS_METHOD');
const configLoad = edges.find((e) => e.source === 'Config' && e.target === 'load');
const configValidate = edges.find((e) => e.source === 'Config' && e.target === 'validate');
expect(configLoad).toBeDefined();
expect(configValidate).toBeDefined();
});
});
// ---------------------------------------------------------------------------
// 4. Require/require_relative import resolution
// ---------------------------------------------------------------------------
describe('Ruby scope: require_relative import resolution', () => {
let result: PipelineResult;
let tmpDir: string;
beforeAll(async () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ruby-scope-imports-'));
writeFixtureRepo(tmpDir, {
'lib/utils.rb': `
class Utils
def format(text)
text.strip
end
end
`,
'app.rb': `
require_relative 'lib/utils'
def run
u = Utils.new
u.format("hello")
end
`,
});
result = await runPipelineFromRepo(tmpDir, () => {});
}, 60000);
afterAll(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it('emits IMPORTS edge from app.rb to lib/utils.rb', () => {
const imports = getRelationships(result, 'IMPORTS');
const imp = imports.find(
(e) => e.sourceFilePath?.includes('app.rb') && e.targetFilePath?.includes('utils.rb'),
);
expect(imp).toBeDefined();
});
it('resolves run → u.format() as CALLS edge to utils.rb', () => {
const calls = getRelationships(result, 'CALLS');
const formatCall = calls.find(
(c) => c.target === 'format' && c.source === 'run' && c.targetFilePath?.includes('utils.rb'),
);
expect(formatCall).toBeDefined();
});
it('detects Utils class and format method', () => {
expect(getNodesByLabel(result, 'Class')).toContain('Utils');
expect(getNodesByLabel(result, 'Method')).toContain('format');
});
});
// ---------------------------------------------------------------------------
// 5. Constructor inference (User.new)
// ---------------------------------------------------------------------------
describe('Ruby scope: constructor inference via .new', () => {
let result: PipelineResult;
let tmpDir: string;
beforeAll(async () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ruby-scope-ctor-'));
writeFixtureRepo(tmpDir, {
'formatter.rb': `
class Formatter
def format(text)
text.upcase
end
end
def main
f = Formatter.new
f.format("hello")
end
`,
});
result = await runPipelineFromRepo(tmpDir, () => {});
}, 60000);
afterAll(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it('detects Formatter class and format method', () => {
expect(getNodesByLabel(result, 'Class')).toContain('Formatter');
expect(getNodesByLabel(result, 'Method')).toContain('format');
});
it('emits HAS_METHOD edge from Formatter to format', () => {
const edges = getRelationships(result, 'HAS_METHOD');
const fmtEdge = edges.find((e) => e.source === 'Formatter' && e.target === 'format');
expect(fmtEdge).toBeDefined();
});
it('resolves main → f.format() to Formatter#format via constructor inference', () => {
const calls = getRelationships(result, 'CALLS');
const formatCall = calls.find(
(c) =>
c.target === 'format' && c.source === 'main' && c.targetFilePath?.includes('formatter.rb'),
);
expect(formatCall).toBeDefined();
});
});
// ---------------------------------------------------------------------------
// 6. Block scope (do...end with params)
// ---------------------------------------------------------------------------
describe('Ruby scope: block scope with do...end', () => {
let result: PipelineResult;
let tmpDir: string;
beforeAll(async () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ruby-scope-block-'));
writeFixtureRepo(tmpDir, {
'processor.rb': `
class Processor
def run
items = [1, 2, 3]
items.each do |item|
process(item)
end
end
def process(x)
x * 2
end
end
`,
});
result = await runPipelineFromRepo(tmpDir, () => {});
}, 60000);
afterAll(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it('detects Processor class', () => {
expect(getNodesByLabel(result, 'Class')).toContain('Processor');
});
it('detects run and process as Method nodes on Processor', () => {
const methods = getNodesByLabel(result, 'Method');
expect(methods).toContain('run');
expect(methods).toContain('process');
});
it('emits HAS_METHOD edges from Processor to run and process', () => {
const edges = getRelationships(result, 'HAS_METHOD');
const procRun = edges.find((e) => e.source === 'Processor' && e.target === 'run');
const procProcess = edges.find((e) => e.source === 'Processor' && e.target === 'process');
expect(procRun).toBeDefined();
expect(procProcess).toBeDefined();
});
it('resolves run → process() as CALLS edge inside block scope', () => {
const calls = getRelationships(result, 'CALLS');
const processCall = calls.find((c) => c.target === 'process' && c.source === 'run');
expect(processCall).toBeDefined();
});
});
// ---------------------------------------------------------------------------
// 7. Class inheritance (EXTENDS)
// ---------------------------------------------------------------------------
describe('Ruby scope: class inheritance via <', () => {
let result: PipelineResult;
let tmpDir: string;
beforeAll(async () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ruby-scope-inherit-'));
writeFixtureRepo(tmpDir, {
'animals.rb': `
class Animal
def speak
"..."
end
end
class Dog < Animal
def bark
speak
end
end
`,
});
result = await runPipelineFromRepo(tmpDir, () => {});
}, 60000);
afterAll(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it('detects Animal and Dog as Class nodes', () => {
const classes = getNodesByLabel(result, 'Class');
expect(classes).toContain('Animal');
expect(classes).toContain('Dog');
});
it('emits EXTENDS edge from Dog to Animal', () => {
const extends_ = getRelationships(result, 'EXTENDS');
const edge = extends_.find((e) => e.source === 'Dog' && e.target === 'Animal');
expect(edge).toBeDefined();
});
it('resolves bark → speak as CALLS edge', () => {
const calls = getRelationships(result, 'CALLS');
const speakCall = calls.find((c) => c.target === 'speak' && c.source === 'bark');
expect(speakCall).toBeDefined();
});
});
// ---------------------------------------------------------------------------
// 8. Super resolution
// ---------------------------------------------------------------------------
describe('Ruby scope: super resolution in subclass', () => {
let result: PipelineResult;
let tmpDir: string;
beforeAll(async () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ruby-scope-super-'));
writeFixtureRepo(tmpDir, {
'hierarchy.rb': `
class Base
def greet
"hello"
end
end
class Child < Base
def greet
super
end
end
def main
c = Child.new
c.greet
end
`,
});
result = await runPipelineFromRepo(tmpDir, () => {});
}, 60000);
afterAll(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it('detects Base and Child as Class nodes', () => {
const classes = getNodesByLabel(result, 'Class');
expect(classes).toContain('Base');
expect(classes).toContain('Child');
});
it('emits EXTENDS edge from Child to Base', () => {
const extends_ = getRelationships(result, 'EXTENDS');
const edge = extends_.find((e) => e.source === 'Child' && e.target === 'Base');
expect(edge).toBeDefined();
});
it('resolves main → c.greet() as CALLS edge', () => {
const calls = getRelationships(result, 'CALLS');
const greetCall = calls.find((c) => c.target === 'greet' && c.source === 'main');
expect(greetCall).toBeDefined();
});
});