mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-08 22:22:52 +00:00
* 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>
305 lines
11 KiB
TypeScript
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);
|
|
});
|