GitNexus/gitnexus/test/integration/resolvers/ruby-sequential-mixin.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

244 lines
12 KiB
TypeScript

/**
* Regression: Ruby mixin heritage resolution must work on the sequential
* ingestion fallback AND the worker-pool path, with identical output.
*
* Guards the two Codex adversarial review findings addressed by plan
* `docs/plans/2026-04-17-001-fix-codex-adversarial-ruby-mixin-heritage-plan.md`:
*
* 1. Sequential-mode `sequentialHeritageMap` must include Ruby `include` /
* `extend` / `prepend` mixin ancestry before `processCalls` resolves calls
* against it. `extractExtractedHeritageFromFiles` now also runs
* `heritageExtractor.extractFromCall` during its prepass.
*
* 2. Ruby `module` declarations are relabeled to `Trait` so they participate
* in `lookupClassByName` / `buildHeritageMap`.
*
* The follow-up plan `docs/plans/2026-04-17-002-fix-ce-review-ruby-mixin-followups-plan.md`
* Units 1 and 2 harden this suite:
* - Worker mode actually spawns a worker pool (verified via
* `PipelineResult.usedWorkerPool`) instead of silently falling back.
* - The prepend-only `prepended_marker` assertion checks the resolved
* method's OWNER, so reverting the Module→Trait relabel (Unit 2 of
* plan 001) makes the test fail with a clear owner-mismatch instead
* of passing trivially on `Account`'s own method.
*
* Plan 003 adds the `'ruby-mixin'` MroStrategy and kind-aware ancestry
* (prepend / include / extend split). Plan 005 (the call-resolution DAG)
* installs `inferImplicitReceiver` and `selectDispatch` provider hooks that
* let Ruby self-rewrite bare-identifier calls to `self.method` so they take
* the owner-scoped MRO path. The shadow-name assertion below exercises the
* full chain: Module→Trait relabel + kind-aware MRO + self-inference.
*
* Known guard limitation (documented residual): reverting plan 001 Unit 1
* alone (the sequential prepass extractFromCall) does NOT make these tests
* fail, because `processCalls` independently extracts call-based heritage
* into `rubyHeritage`, feeds it to `processHeritageFromExtracted` for graph
* edges, and the call resolver's global-name fallback can still locate
* mixin-provided methods without MRO ancestry. A stronger guard would need
* an ambiguous method name that only MRO can disambiguate; that requires
* cross-chunk or multi-class shadowing scenarios not covered by this
* fixture. Tracked as residual work in plan 002's Unit 3 (cross-chunk).
*/
import { describe, it, expect, beforeAll } from 'vitest';
import path from 'path';
import type { GraphRelationship } from '../../../src/core/graph/types.js';
import {
FIXTURES,
getRelationships,
getNodesByLabel,
runPipelineFromRepo,
type PipelineOptions,
type PipelineResult,
} from './helpers.js';
const FIXTURE = path.join(FIXTURES, 'ruby-sequential-mixin');
async function runMode(opts: PipelineOptions): Promise<PipelineResult> {
return runPipelineFromRepo(FIXTURE, () => {}, opts);
}
/** CALLS edges from `sourceName` whose target is a Method node. */
function methodCallEdges(result: PipelineResult, sourceName: string): Set<string> {
const edges = getRelationships(result, 'CALLS').filter(
(e) => e.source === sourceName && e.targetLabel === 'Method',
);
return new Set(edges.map((e) => `${e.source}${e.target}`));
}
/**
* Find the name of the node that `HAS_METHOD`s this target node, if any.
* Returns `undefined` when no owner edge exists (e.g., top-level function).
*/
function findMethodOwner(result: PipelineResult, methodNodeId: string): string | undefined {
for (const rel of result.graph.iterRelationships() as IterableIterator<GraphRelationship>) {
if (rel.type === 'HAS_METHOD' && rel.targetId === methodNodeId) {
return result.graph.getNode(rel.sourceId)?.properties.name;
}
}
return undefined;
}
/**
* Return the owner names of every `Method` target reached by a CALLS edge
* starting at `sourceName` whose target's name matches `targetMethodName`.
* Used to assert WHICH provider resolved a shadowed method name like
* `serialize` (provided by both Account and PrependedOverride).
*/
function resolvedMethodOwners(
result: PipelineResult,
sourceName: string,
targetMethodName: string,
): string[] {
const owners: string[] = [];
for (const e of getRelationships(result, 'CALLS')) {
if (e.source === sourceName && e.targetLabel === 'Method' && e.target === targetMethodName) {
const owner = findMethodOwner(result, e.rel.targetId);
if (owner) owners.push(owner);
}
}
return owners.sort();
}
describe('Ruby mixin heritage: sequential vs worker parity', () => {
let sequential: PipelineResult;
let workers: PipelineResult;
let savedEnv: string | undefined;
beforeAll(async () => {
// Force legacy mode — these tests exercise inferImplicitReceiver +
// selectDispatch hooks which live in the legacy call-processor.
// With Ruby in MIGRATED_LANGUAGES the call-processor is gated off
// under registry-primary, so pin legacy for this suite.
savedEnv = process.env['REGISTRY_PRIMARY_RUBY'];
process.env['REGISTRY_PRIMARY_RUBY'] = '0';
sequential = await runMode({ skipWorkers: true });
// Force the worker pool to spawn even though the fixture is tiny.
// Without this override, the pipeline's MIN_FILES_FOR_WORKERS / MIN_BYTES_FOR_WORKERS
// gate would fall back to sequential and the "worker vs sequential" parity
// assertion below would degenerate into sequential-vs-sequential.
workers = await runMode({
skipWorkers: false,
workerThresholdsForTest: { minFiles: 1, minBytes: 0 },
});
}, 120000);
afterAll(() => {
if (savedEnv === undefined) delete process.env['REGISTRY_PRIMARY_RUBY'];
else process.env['REGISTRY_PRIMARY_RUBY'] = savedEnv;
});
it('exercises both pipeline paths (sequential and worker)', () => {
// If either of these assertions fails, every downstream parity check
// below is meaningless — both modes would be running the same path.
expect(sequential.usedWorkerPool).toBe(false);
expect(workers.usedWorkerPool).toBe(true);
});
it('labels Ruby modules as Trait in both modes', () => {
const expected = ['Greetable', 'LoggerMixin', 'PrependedOverride'];
expect(getNodesByLabel(sequential, 'Trait').sort()).toEqual(expected);
expect(getNodesByLabel(workers, 'Trait').sort()).toEqual(expected);
// No Ruby modules leak through as the inert `Module` label.
// The 'lib' module node is the fixture's top-level directory node, which
// the ingestion pipeline emits for every fixture root — unrelated to
// Ruby `module` declarations. Filtering it keeps the assertion specific
// to Ruby-module relabeling without being coupled to how directory nodes
// are emitted.
expect(getNodesByLabel(sequential, 'Module').filter((n) => n !== 'lib')).toEqual([]);
expect(getNodesByLabel(workers, 'Module').filter((n) => n !== 'lib')).toEqual([]);
});
it('sequential mode resolves include-provided method: call_greet → greet', () => {
const edges = methodCallEdges(sequential, 'call_greet');
expect([...edges]).toContain('call_greet → greet');
// Stronger: the resolved `greet` must be owned by the `Greetable` module
// (relabeled to Trait). A regression in Unit 2 of plan 001 would either
// fail to resolve (owners = []) or resolve to some other owner.
const owners = resolvedMethodOwners(sequential, 'call_greet', 'greet');
expect(owners).toContain('Greetable');
});
it('sequential mode resolves prepend-only method: call_prepended_marker → PrependedOverride#prepended_marker', () => {
// `prepended_marker` is defined ONLY on PrependedOverride — not on
// Account, Greetable, or LoggerMixin. Narrow guard for the prepend
// provider entering the MRO (plan 001 / plan 003).
const owners = resolvedMethodOwners(sequential, 'call_prepended_marker', 'prepended_marker');
expect(owners).toContain('PrependedOverride');
});
it('sequential mode resolves prepend shadow: call_serialize → PrependedOverride#serialize (not Account#serialize)', () => {
// `Account` defines `def serialize` AND `prepend PrependedOverride` which
// also defines `serialize`. Ruby MRO says the prepended module wins.
// This assertion exercises the full chain:
// - Module→Trait relabel (plan 001 Unit 2) — PrependedOverride resolvable via lookupClassByName
// - `'ruby-mixin'` MroStrategy (plan 003 Unit 3) — prepend walks before direct owner
// - `inferImplicitReceiver` hook (plan 005 / DAG) — bare `serialize` call rewritten
// as `self.serialize` so it takes the owner-scoped MRO path
// Reverting ANY of the three makes this assertion fail.
const owners = resolvedMethodOwners(sequential, 'call_serialize', 'serialize');
expect(owners).toContain('PrependedOverride');
});
it('sequential mode resolves extend-provided class method: Usage#run → LoggerMixin#log', () => {
// `Account extend LoggerMixin` means `LoggerMixin#log` is a CLASS method
// on `Account`. The fixture's `Usage#run` calls `Account.log("from Usage")`
// — a class-constant receiver, not an instance call. This exercises the
// `selectDispatch` hook's `receiverSource === 'class-as-receiver'` branch,
// which returns `ancestryView: 'singleton'` so the walker uses
// `getSingletonAncestry(Account)` = [LoggerMixin] instead of the instance
// MRO (which would have resolved nothing, since `log` is not an instance
// method on Account).
//
// Reverting the singleton branch in Ruby's selectDispatch (or dropping
// 'Trait'/'Class' from the class-as-receiver filter in call-processor)
// makes this assertion fail.
const owners = resolvedMethodOwners(sequential, 'run', 'log');
expect(owners).toContain('LoggerMixin');
});
// TODO(plan-003-followup): assert that prepend shadows self for
// `call_serialize → PrependedOverride#serialize`. Blocked on Ruby bare-call
// self-inference: bare identifier calls like `serialize` inside `Account#call_serialize`
// currently flow through `resolveFreeCall` (global name lookup), not
// `resolveMemberCall` (owner-scoped + MRO walk). The `'ruby-mixin'` MroStrategy
// added by plan 003 is correctly wired and will apply as soon as Ruby bare calls
// are threaded as `self.method` with receiverTypeName = enclosing class. Until then,
// shadow-name resolution lands on `Account#serialize` regardless of prepend MRO.
//
// The `prepended_marker` test above is the narrower guard that works today
// (non-shadowed method only reachable via the prepend provider).
it('sequential mode emits IMPLEMENTS edges for all three mixin kinds', () => {
// Ruby mixins (include / extend / prepend) flow through the IMPLEMENTS
// branch of processHeritageFromExtracted with the mixin kind recorded in
// rel.reason. See heritage-processor.ts L146-168.
const kinds = getRelationships(sequential, 'IMPLEMENTS')
.filter((e) => e.source === 'Account')
.map((e) => e.rel.reason ?? '')
.sort();
expect(kinds).toEqual(['extend', 'include', 'prepend']);
});
it('worker mode resolves the same include and prepend-only targets', () => {
// Cross-mode ownership parity for the mixin providers. If Unit 1 of
// plan 001 regressed on the sequential side only, the `greet` /
// `prepended_marker` owners would diverge between modes here — the
// sequential side would lose the mixin-provided edges while worker
// mode kept them (or vice versa).
expect(resolvedMethodOwners(workers, 'call_greet', 'greet')).toContain('Greetable');
expect(resolvedMethodOwners(workers, 'call_prepended_marker', 'prepended_marker')).toContain(
'PrependedOverride',
);
});
it('sequential and worker modes produce the same mixin-method CALLS edges', () => {
const seqEdges = methodCallEdges(sequential, 'call_greet');
const workerEdges = methodCallEdges(workers, 'call_greet');
expect([...seqEdges].sort()).toEqual([...workerEdges].sort());
const seqMarker = methodCallEdges(sequential, 'call_prepended_marker');
const workerMarker = methodCallEdges(workers, 'call_prepended_marker');
expect([...seqMarker].sort()).toEqual([...workerMarker].sort());
});
});