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>
184 lines
6.8 KiB
JavaScript
184 lines
6.8 KiB
JavaScript
/**
|
|
* Ruby gem-boundary correctness and cost gate (#3096).
|
|
*
|
|
* Calls the production ScopeResolver hooks, including the filesystem-backed
|
|
* configuration loader. Fixture creation and correctness hashing are untimed.
|
|
* Each timed pass reloads config and owns a fresh file Set, so neither the
|
|
* manifest load nor the per-pass fallback index is hidden by a warm memo.
|
|
*
|
|
* Small/large grow sibling projects, files, declarations and imports together.
|
|
* Dense keeps projects/imports fixed and grows extra declarations from 8 to 128:
|
|
* lookup must depend on require/path depth, not the number of declared gems.
|
|
*
|
|
* node --import tsx bench/ruby-gem-resolution/measure.mjs [--check]
|
|
*/
|
|
import assert from 'node:assert/strict';
|
|
import crypto from 'node:crypto';
|
|
import fs from 'node:fs';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { rubyScopeResolver } from '../../src/core/ingestion/languages/ruby/scope-resolver.ts';
|
|
|
|
const baselinePath = fileURLToPath(new URL('./baseline.json', import.meta.url));
|
|
const CHECK = process.argv.includes('--check');
|
|
const WARMUP = 2;
|
|
const REPS = 15;
|
|
const IMPORTS_PER_PROJECT = 256;
|
|
const roots = [];
|
|
|
|
function corpus(projects, gemsPerProject = 8) {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-ruby-gems-bench-'));
|
|
roots.push(root);
|
|
const files = ['decoy/lib/generators.rb', 'decoy/lib/types.rb', 'decoy/lib/missing.rb'];
|
|
const queries = [];
|
|
for (let i = 0; i < projects; i++) {
|
|
const directory = `packages/pkg${i}`;
|
|
const onDisk = path.join(root, directory);
|
|
fs.mkdirSync(path.join(onDisk, 'engine'), { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(onDisk, 'Gemfile'),
|
|
[
|
|
"gem 'rails'",
|
|
"gem 'dry-types'",
|
|
"gem 'aliased', require: 'custom/entry'",
|
|
"gem 'my_engine', path: 'engine'",
|
|
...Array.from({ length: gemsPerProject }, (_, gem) => `gem 'dependency_${i}_${gem}'`),
|
|
].join('\n'),
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(onDisk, 'engine', 'my_engine.gemspec'),
|
|
"Gem::Specification.new do |s|\n s.name = 'my_engine'\n s.require_paths = ['lib']\nend\n",
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(onDisk, 'Gemfile.lock'),
|
|
'GEM\n remote: https://rubygems.org/\n specs:\n locked_gem (1.0.0)\n',
|
|
);
|
|
const from = `${directory}/app/main.rb`;
|
|
files.push(
|
|
from,
|
|
`${directory}/app/helper.rb`,
|
|
`${directory}/lib/local_${i}.rb`,
|
|
`${directory}/engine/lib/my_engine.rb`,
|
|
);
|
|
const cases = [
|
|
['rails/generators', null],
|
|
['dry/types', null],
|
|
['custom/entry', null],
|
|
['my_engine', `${directory}/engine/lib/my_engine.rb`],
|
|
['my_engine/missing', null],
|
|
['./helper', `${directory}/app/helper.rb`],
|
|
[`local_${i}`, `${directory}/lib/local_${i}.rb`],
|
|
['locked_gem/missing', null],
|
|
[`dependency_${i}_0/missing`, null],
|
|
[`dependency_${(i + 1) % projects}_0/missing`, 'decoy/lib/missing.rb'],
|
|
];
|
|
for (let j = 0; j < IMPORTS_PER_PROJECT; j++) {
|
|
const [target, expected] = cases[j % cases.length];
|
|
queries.push({ from, target, expected });
|
|
}
|
|
}
|
|
return { root, projects, gemsPerProject, files, queries };
|
|
}
|
|
|
|
function resolve(query, pass) {
|
|
return rubyScopeResolver.resolveImportTarget(query.target, query.from, pass.files, pass.config);
|
|
}
|
|
|
|
function prepare(input) {
|
|
return {
|
|
files: new Set(input.files),
|
|
config: rubyScopeResolver.loadResolutionConfig(input.root),
|
|
};
|
|
}
|
|
|
|
function correctness(input) {
|
|
const pass = prepare(input);
|
|
assert.ok(pass.config, 'the real manifest loader must supply configuration');
|
|
const records = [];
|
|
let resolved = 0;
|
|
for (const query of input.queries) {
|
|
const answer = resolve(query, pass);
|
|
assert.equal(answer, query.expected, `${query.from}: ${query.target}`);
|
|
if (answer !== null) resolved++;
|
|
records.push(`${query.from}|${query.target}->${answer}`);
|
|
}
|
|
// The external decoy must actually be reachable without dependency evidence;
|
|
// otherwise an always-null resolver could make a negative-only gate pass.
|
|
assert.equal(
|
|
resolve(
|
|
{ from: input.queries[0].from, target: 'rails/generators' },
|
|
{ files: pass.files, config: undefined },
|
|
),
|
|
'decoy/lib/generators.rb',
|
|
);
|
|
return {
|
|
projects: input.projects,
|
|
declarations_per_project: input.gemsPerProject + 4,
|
|
scopes: pass.config.scopesByDirectory.size,
|
|
files: input.files.length,
|
|
imports: records.length,
|
|
resolved,
|
|
fingerprint: crypto.createHash('sha256').update(records.join('\n')).digest('hex'),
|
|
};
|
|
}
|
|
|
|
function measure(input, expectedResolved) {
|
|
const loading = [];
|
|
const resolution = [];
|
|
for (let run = 0; run < WARMUP + REPS; run++) {
|
|
const files = new Set(input.files);
|
|
const loadStart = performance.now();
|
|
const config = rubyScopeResolver.loadResolutionConfig(input.root);
|
|
const loadMs = performance.now() - loadStart;
|
|
const pass = { files, config };
|
|
const start = performance.now();
|
|
let resolved = 0;
|
|
for (const query of input.queries) if (resolve(query, pass) !== null) resolved++;
|
|
const resolveMs = performance.now() - start;
|
|
assert.equal(resolved, expectedResolved, 'timed pass must do the validated work');
|
|
if (run >= WARMUP) {
|
|
loading.push(loadMs);
|
|
resolution.push(resolveMs);
|
|
}
|
|
}
|
|
// Like the neighboring resolver gates: min-of-N limits scheduler/GC noise.
|
|
return { load_ms: Math.min(...loading), resolve_ms: Math.min(...resolution) };
|
|
}
|
|
|
|
try {
|
|
const inputs = { small: corpus(32), large: corpus(128), dense: corpus(32, 128) };
|
|
const shapes = {};
|
|
const timings = {};
|
|
for (const [name, input] of Object.entries(inputs)) {
|
|
shapes[name] = correctness(input);
|
|
timings[name] = measure(input, shapes[name].resolved);
|
|
}
|
|
const scale = inputs.large.projects / inputs.small.projects;
|
|
const metrics = {
|
|
load_scaling_ratio: timings.large.load_ms / timings.small.load_ms / scale,
|
|
resolve_scaling_ratio: timings.large.resolve_ms / timings.small.resolve_ms / scale,
|
|
dependency_count_ratio: timings.dense.resolve_ms / timings.small.resolve_ms,
|
|
};
|
|
const report = {
|
|
shapes,
|
|
timings,
|
|
metrics,
|
|
estimator: { warmup: WARMUP, samples: REPS, statistic: 'minimum' },
|
|
};
|
|
console.log(JSON.stringify(report, null, 2));
|
|
if (CHECK) {
|
|
const baseline = JSON.parse(fs.readFileSync(baselinePath, 'utf8'));
|
|
assert.deepEqual(shapes, baseline.shapes, 'Ruby workload/fingerprint drift');
|
|
const failures = [];
|
|
for (const [name, budget] of Object.entries(baseline.budgets)) {
|
|
if (!Number.isFinite(metrics[name]) || metrics[name] > budget) {
|
|
failures.push(`${name}: ${metrics[name]} > ${budget}`);
|
|
}
|
|
}
|
|
assert.equal(failures.length, 0, failures.join('\n'));
|
|
console.log('[ruby-gem-resolution --check] PASS');
|
|
}
|
|
} finally {
|
|
for (const root of roots) fs.rmSync(root, { recursive: true, force: true });
|
|
}
|