GitNexus/gitnexus/test/integration/ruby-pipeline-benchmark.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

305 lines
11 KiB
TypeScript

/**
* Ruby ingestion pipeline benchmark.
*
* Generates synthetic Ruby codebases at increasing scales and measures
* wall-clock time and peak heap through the full pipeline — parsing,
* scope extraction, heritage (include/extend/prepend), MRO construction,
* and call resolution via the registry-primary scope-resolution path.
*
* Run: GITNEXUS_BENCH=1 npx vitest run test/integration/ruby-pipeline-benchmark.test.ts
*
* The benchmark uses workers (production path) by default. Set
* skipWorkers to test the sequential fallback path.
*/
import { describe, it, expect } from 'vitest';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js';
const BENCH_ENABLED = process.env.GITNEXUS_BENCH === '1';
interface BenchResult {
fileCount: number;
classCount: number;
moduleCount: number;
mixinModuleCount: number;
elapsedMs: number;
peakHeapMB: number;
nodeCount: number;
edgeCount: number;
implementsCount: number;
hasPropertyCount: number;
extendsCount: number;
}
function generateRubyFixture(
fileCount: number,
modulesPerLevel: number,
): { dir: string; classCount: number; moduleCount: number; mixinModuleCount: number } {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), `ruby-bench-${fileCount}-`));
// Three families of mixins: one for include, one for extend, one for prepend.
// Each family has modulesPerLevel² modules so the MRO partitioning logic is
// exercised with all three heritage kinds and varied orderings.
const includeMixins: string[] = [];
const extendMixins: string[] = [];
const prependMixins: string[] = [];
for (let i = 0; i < modulesPerLevel; i++) {
for (let j = 0; j < modulesPerLevel; j++) {
includeMixins.push(`Includable${i}x${j}`);
extendMixins.push(`Extendable${i}x${j}`);
prependMixins.push(`Prependable${i}x${j}`);
}
}
const allMixins = [...includeMixins, ...extendMixins, ...prependMixins];
const moduleCount = allMixins.length;
const classCount = fileCount;
// Generate mixin module files — each module includes a shared base module
// to create diamond mixin patterns (class includes A and B, both include Base).
const concernsDir = path.join(dir, 'lib', 'concerns');
fs.mkdirSync(concernsDir, { recursive: true });
// Shared base modules that other mixins include (diamond pattern)
const baseModuleCount = Math.max(2, Math.floor(modulesPerLevel / 2));
for (let b = 0; b < baseModuleCount; b++) {
const baseName = `BaseMixin${b}`;
const content = [
`module ${baseName}`,
` def base${b}_check`,
' true',
' end',
'end',
'',
].join('\n');
fs.writeFileSync(path.join(concernsDir, `${baseName.toLowerCase()}.rb`), content);
}
for (let m = 0; m < allMixins.length; m++) {
const moduleName = allMixins[m];
const baseIdx = m % baseModuleCount;
const baseName = `BaseMixin${baseIdx}`;
const content = [
`require_relative '${baseName.toLowerCase()}'`,
'',
`module ${moduleName}`,
` include ${baseName}`,
'',
` def ${moduleName.toLowerCase()}_action`,
` base${baseIdx}_check`,
' end',
'end',
'',
].join('\n');
fs.writeFileSync(path.join(concernsDir, `${moduleName.toLowerCase()}.rb`), content);
}
// Generate class files — each class uses include + extend + prepend with
// different modules, creating a rich MRO that exercises all three
// heritage-kind partitions in buildRubyMro.
const modelsDir = path.join(dir, 'lib', 'models');
fs.mkdirSync(modelsDir, { recursive: true });
for (let f = 0; f < fileCount; f++) {
const className = `Model${f}`;
// Pick one mixin of each kind (rotating through the pools)
const incMixin = includeMixins[f % includeMixins.length];
const extMixin = extendMixins[f % extendMixins.length];
const preMixin = prependMixins[f % prependMixins.length];
// Second include mixin for diamond-overlap testing
const incMixin2 = includeMixins[(f + 1) % includeMixins.length];
const siblingIdx = (f + 1) % fileCount;
const siblingClass = `Model${siblingIdx}`;
const crossIdx = (f + Math.floor(fileCount / 3)) % fileCount;
const crossClass = `Model${crossIdx}`;
const requireLines = [
`require_relative '../concerns/${incMixin.toLowerCase()}'`,
`require_relative '../concerns/${incMixin2.toLowerCase()}'`,
`require_relative '../concerns/${extMixin.toLowerCase()}'`,
`require_relative '../concerns/${preMixin.toLowerCase()}'`,
f !== siblingIdx ? `require_relative '${siblingClass.toLowerCase()}'` : '',
f !== crossIdx ? `require_relative '${crossClass.toLowerCase()}'` : '',
].filter(Boolean);
const content = [
...requireLines,
'',
`class ${className}`,
` include ${incMixin}`,
` include ${incMixin2}`,
` extend ${extMixin}`,
` prepend ${preMixin}`,
'',
` attr_accessor :id, :name, :status`,
'',
` # @param other [${siblingClass}]`,
` # @return [${siblingClass}]`,
` def process(other)`,
` other.save`,
` ${incMixin.toLowerCase()}_action`,
` other`,
' end',
'',
' def save',
' true',
' end',
'',
` # @return [${crossClass}]`,
` def build_cross`,
` ${crossClass}.new`,
' end',
'',
` def self.class_action`,
` ${extMixin.toLowerCase()}_action`,
' end',
'end',
'',
].join('\n');
fs.writeFileSync(path.join(modelsDir, `${className.toLowerCase()}.rb`), content);
}
return {
dir,
classCount,
moduleCount: moduleCount + baseModuleCount,
mixinModuleCount: moduleCount,
};
}
async function runBenchmark(
fileCount: number,
moduleLevels: number,
budgetMs: number,
): Promise<BenchResult> {
const { dir, classCount, moduleCount, mixinModuleCount } = generateRubyFixture(
fileCount,
moduleLevels,
);
let peakHeapMB = 0;
const heapSampler = setInterval(() => {
const heap = process.memoryUsage().heapUsed / 1024 / 1024;
if (heap > peakHeapMB) peakHeapMB = heap;
}, 50);
try {
const start = Date.now();
const result = await Promise.race([
runPipelineFromRepo(dir, () => {}, { skipGraphPhases: true }),
new Promise<never>((_, reject) =>
setTimeout(
() => reject(new Error(`Pipeline exceeded ${budgetMs}ms at ${fileCount} files`)),
budgetMs,
),
),
]);
const elapsedMs = Date.now() - start;
let implementsCount = 0;
let hasPropertyCount = 0;
let extendsCount = 0;
for (const rel of result.graph.iterRelationshipsByType('IMPLEMENTS')) {
implementsCount++;
void rel;
}
for (const rel of result.graph.iterRelationshipsByType('HAS_PROPERTY')) {
hasPropertyCount++;
void rel;
}
for (const rel of result.graph.iterRelationshipsByType('EXTENDS')) {
extendsCount++;
void rel;
}
return {
fileCount,
classCount,
moduleCount,
mixinModuleCount,
elapsedMs,
peakHeapMB: Math.round(peakHeapMB),
nodeCount: result.graph.nodeCount,
edgeCount: result.graph.relationshipCount,
implementsCount,
hasPropertyCount,
extendsCount,
};
} finally {
clearInterval(heapSampler);
fs.rmSync(dir, { recursive: true, force: true });
}
}
function printResults(label: string, results: BenchResult[]) {
console.log(`\n${label}`);
console.log(
'┌──────────┬─────────┬──────────┬───────────┬──────────┬───────┬───────┬──────┬───────┬─────┐',
);
console.log(
'│ Files │ Classes │ Modules │ Time (ms) │ Heap MB │ Nodes │ Edges │ IMPL │ PROPS │ EXT │',
);
console.log(
'├──────────┼─────────┼──────────┼───────────┼──────────┼───────┼───────┼──────┼───────┼─────┤',
);
for (const r of results) {
console.log(
`${String(r.fileCount).padStart(8)}${String(r.classCount).padStart(7)}${String(r.moduleCount).padStart(8)}${String(r.elapsedMs).padStart(9)}${String(r.peakHeapMB).padStart(8)}${String(r.nodeCount).padStart(5)}${String(r.edgeCount).padStart(5)}${String(r.implementsCount).padStart(4)}${String(r.hasPropertyCount).padStart(5)}${String(r.extendsCount).padStart(3)}`,
);
}
console.log(
'└──────────┴─────────┴──────────┴───────────┴──────────┴───────┴───────┴──────┴───────┴─────┘',
);
if (results.length >= 2) {
console.log('\nScaling ratios (time_ratio / file_ratio):');
for (let i = 1; i < results.length; i++) {
const fileRatio = results[i].fileCount / results[i - 1].fileCount;
const timeRatio = results[i].elapsedMs / results[i - 1].elapsedMs;
const scaling = timeRatio / fileRatio;
console.log(
` ${results[i - 1].fileCount}${results[i].fileCount}: ${scaling.toFixed(2)}x (${scaling < 1.5 ? 'linear' : scaling < 3 ? 'superlinear' : 'WARNING: quadratic'})`,
);
}
}
}
describe.skipIf(!BENCH_ENABLED)('Ruby pipeline benchmark', () => {
it('scales with file count (workers enabled)', async () => {
const scales = [100, 250, 500];
const results: BenchResult[] = [];
for (const fileCount of scales) {
const moduleLevels = Math.max(2, Math.ceil(Math.sqrt(fileCount / 4)));
const result = await runBenchmark(fileCount, moduleLevels, 180_000);
results.push(result);
console.log(
` ${fileCount} files: ${result.elapsedMs}ms, ${result.peakHeapMB}MB heap, ${result.nodeCount} nodes, ${result.edgeCount} edges`,
);
}
printResults('Ruby Pipeline — Workers Enabled', results);
for (let i = 1; i < results.length; i++) {
const fileRatio = results[i].fileCount / results[i - 1].fileCount;
const timeRatio = results[i].elapsedMs / results[i - 1].elapsedMs;
expect(timeRatio / fileRatio).toBeLessThan(3);
}
// Verify heritage emission produces exact expected counts.
// Each class: 2x include + 1x extend + 1x prepend = 4 IMPLEMENTS.
// Each mixin module (non-base) includes one BaseMixin = 1 IMPLEMENTS.
// Each class: attr_accessor :id, :name, :status = 3 HAS_PROPERTY.
for (const r of results) {
expect(r.implementsCount).toBe(r.classCount * 4 + r.mixinModuleCount);
expect(r.hasPropertyCount).toBe(r.classCount * 3);
}
}, 300_000);
});