mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-01 02:01:24 +00:00
* docs(plans): add ruby gem require boundary plan * fix(ruby): guard gem requires with dependency metadata * fix(ruby): scope gem sources by manifest * test(ruby): model resolved lockfile specs * fix(ruby): stop local gem suffix fallthrough * docs: remove Ruby resolution plan * test(ruby): gate gem resolution correctness and scaling * test(ruby): align gem benchmark baseline with ratio gates * Address PR review feedback (#3096) - Strip a trailing .rb so local and external gem prefix matching follows Ruby's optional-suffix require. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Gergő Magyar <gergomagyar@icloud.com> Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com> Co-authored-by: Cursor <cursoragent@cursor.com>
311 lines
11 KiB
TypeScript
311 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, manifest-scoped external require resolution, 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
|
|
* a single-worker pool (the sequential parser was removed).
|
|
*/
|
|
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}-`));
|
|
fs.writeFileSync(
|
|
path.join(dir, 'Gemfile'),
|
|
["source 'https://rubygems.org'", "gem 'rails'"].join('\n'),
|
|
);
|
|
|
|
// 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 'rails/generators'`,
|
|
`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);
|
|
});
|