mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-10 03:27:59 +00:00
fix(ruby): guard gem requires with dependency metadata (#3096)
* 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>
This commit is contained in:
parent
e7141ab0c1
commit
0edf9ce0ff
9 changed files with 954 additions and 12 deletions
7
.github/workflows/ci-tests.yml
vendored
7
.github/workflows/ci-tests.yml
vendored
|
|
@ -718,6 +718,13 @@ jobs:
|
|||
run: node --import tsx bench/kotlin-import-target/measure.mjs --check
|
||||
working-directory: gitnexus
|
||||
|
||||
- name: Ruby gem-boundary correctness + scaling guards (#3096)
|
||||
if: ${{ !cancelled() }}
|
||||
# Includes real manifest loading; checks scoped resolution and scaling
|
||||
# as sibling projects or declared gem counts grow independently.
|
||||
run: node --import tsx bench/ruby-gem-resolution/measure.mjs --check
|
||||
working-directory: gitnexus
|
||||
|
||||
- name: Receiver-resolution drop guards
|
||||
if: ${{ !cancelled() }}
|
||||
# NOT build-free: this one runs the real pipeline, so it needs dist/
|
||||
|
|
|
|||
61
gitnexus/bench/ruby-gem-resolution/baseline.json
Normal file
61
gitnexus/bench/ruby-gem-resolution/baseline.json
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
{
|
||||
"_what": "Synthetic Ruby manifest + ScopeResolver gate for #3096, following bench/parse-dispatch-rounds/baselines.json: exact correctness floors and fingerprints, with ratios as the only timing gates. Calls the real config loader and resolver; parser, DB, installed gems and full-pipeline memory are outside this microbenchmark.",
|
||||
"_triage": "Shape and fingerprint failures are deterministic: inspect the fixture and resolved targets, never rebaseline just to make CI green. For a timing failure, rerun on an idle machine and verify the report uses 15 samples after 2 warmups before investigating a regression. Millisecond observations are context, not gates.",
|
||||
"shapes": {
|
||||
"small": {
|
||||
"projects": 32,
|
||||
"declarations_per_project": 12,
|
||||
"scopes": 64,
|
||||
"files": 131,
|
||||
"imports": 8192,
|
||||
"resolved": 3264,
|
||||
"fingerprint": "e37f4c81bc7f4bc8416732d0e8ebb00e16743b08a44646e1a6fa45c04283cfb3"
|
||||
},
|
||||
"large": {
|
||||
"projects": 128,
|
||||
"declarations_per_project": 12,
|
||||
"scopes": 256,
|
||||
"files": 515,
|
||||
"imports": 32768,
|
||||
"resolved": 13056,
|
||||
"fingerprint": "2524217404b1d4cf118e7d13895d757c821029bf59230b6c3cc7ce2fc2cf5dd2"
|
||||
},
|
||||
"dense": {
|
||||
"projects": 32,
|
||||
"declarations_per_project": 132,
|
||||
"scopes": 64,
|
||||
"files": 131,
|
||||
"imports": 8192,
|
||||
"resolved": 3264,
|
||||
"fingerprint": "e37f4c81bc7f4bc8416732d0e8ebb00e16743b08a44646e1a6fa45c04283cfb3"
|
||||
}
|
||||
},
|
||||
"_shape_note": "Exact projects, declarations, scopes, files, imports and resolved counts are the workload FLOOR, not ceilings. They prevent a shrunken corpus or disconnected manifest loader from passing by doing less work. Small/large grow projects 4x; dense keeps files/imports fixed while growing extra Gemfile declarations from 8 to 128 per project.",
|
||||
"_fingerprint_note": "Hashes pin every importer/require/target tuple, including external decoys, aliases, local path gem hits and misses, relative/local imports and sibling isolation. The runner also checks each expected target before hashing and proves the external decoy is reachable without config. An always-null resolver or missing config must fail, not get a new fingerprint.",
|
||||
"budgets": {
|
||||
"load_scaling_ratio": 2.2,
|
||||
"resolve_scaling_ratio": 2.2,
|
||||
"dependency_count_ratio": 2
|
||||
},
|
||||
"_scaling_note": "load_scaling_ratio and resolve_scaling_ratio are (t_4n/t_n)/4: about 1 is linear, about 4 is quadratic. Keep the existing 2.2 regression budgets; do not tighten them to local milliseconds. Filesystem loading and lookup are measured separately with fresh config/file Sets per pass.",
|
||||
"_dependency_count_note": "dense.resolve_ms/small.resolve_ms should stay near 1: require lookup must scale with prefix/path depth, not all declared gems. The budget remains 2. Extra declaration parsing belongs to config loading, not this fixed-query lookup arm.",
|
||||
"_negative_controls_note": "Disconnecting loadResolutionConfig fails the real-loader assertion. A wrapper scanning all scopes' external prefixes on every import leaves targets/fingerprints unchanged but fails resolve_scaling_ratio (3.427 > 2.2) and dependency_count_ratio (7.912 > 2) with 15 samples. These are deliberate regressions, not baseline observations.",
|
||||
"_measured": {
|
||||
"environment": "macOS arm64 / Node 25.8.0, 2026-09-10",
|
||||
"load_scaling_ratio": 1.094,
|
||||
"load_scaling_ratio_samples": [0.993, 1.06, 1.05, 1.019, 1.094],
|
||||
"resolve_scaling_ratio": 1.066,
|
||||
"resolve_scaling_ratio_samples": [1.058, 1.059, 1.056, 1.066, 1.061],
|
||||
"dependency_count_ratio": 1.009,
|
||||
"dependency_count_ratio_samples": [1.009, 1.003, 0.994, 0.999, 1.0],
|
||||
"small_load_ms": 3.569,
|
||||
"small_resolve_ms": 11.782,
|
||||
"large_load_ms": 14.812,
|
||||
"large_resolve_ms": 50.222,
|
||||
"dense_load_ms": 7.27,
|
||||
"dense_resolve_ms": 11.852,
|
||||
"reps": 15,
|
||||
"warmup": 2
|
||||
},
|
||||
"_measured_note": "Five consecutive local runs using the min-of-15 estimator from parse-dispatch-rounds, with 2 warmups here. Scalar ratios and millisecond values are per-metric maxima across those runs (rounded), not one combined run. Budgets retain about 2x headroom above observed ratios. Milliseconds are diagnostic context only, not gated or real-repository speed claims. Exact shapes and fingerprints are unchanged."
|
||||
}
|
||||
184
gitnexus/bench/ruby-gem-resolution/measure.mjs
Normal file
184
gitnexus/bench/ruby-gem-resolution/measure.mjs
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
/**
|
||||
* 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 });
|
||||
}
|
||||
|
|
@ -3,13 +3,19 @@
|
|||
*
|
||||
* Ruby import resolution rules:
|
||||
* - `require_relative './foo'` → resolve relative to the importing file's dir
|
||||
* - `require 'foo'` → suffix-match via the existing Ruby import resolver
|
||||
* - External gems → null (unresolvable within the repo)
|
||||
* - `require 'foo'` → use scoped gem metadata before legacy suffix matching
|
||||
* - Known local gems → resolve only within their declared load roots
|
||||
* - Known external gems → null, even if an unrelated repo file suffix matches
|
||||
*/
|
||||
|
||||
import { resolveRubyImportInternal } from '../../import-resolvers/ruby.js';
|
||||
import { getWorkspaceFileIndex } from '../../import-resolvers/workspace-file-index.js';
|
||||
import { isHeritageMarker } from '../../utils/heritage-marker.js';
|
||||
import {
|
||||
findRubyResolutionScope,
|
||||
type RubyResolutionConfig,
|
||||
type RubyResolutionScope,
|
||||
} from './resolution-config.js';
|
||||
|
||||
export interface RubyResolveContext {
|
||||
readonly fromFile: string;
|
||||
|
|
@ -26,16 +32,17 @@ export interface RubyResolveContext {
|
|||
* against the importing file's directory, trying `.rb` and `/index.rb`
|
||||
* suffixes.
|
||||
*
|
||||
* For bare requires (gem-style like `'json'`, `'serializable'`), delegates
|
||||
* to the existing `resolveRubyImportInternal` which uses suffix matching.
|
||||
*
|
||||
* Returns `null` for external gems that have no matching file in the repo.
|
||||
* For bare requires, scoped manifest metadata takes precedence: known local
|
||||
* gems resolve only within their declared load roots (a miss returns `null`),
|
||||
* and known external gem prefixes return `null` even if a repo suffix matches.
|
||||
* Only requires without matching gem evidence delegate to the existing
|
||||
* `resolveRubyImportInternal` suffix matcher.
|
||||
*/
|
||||
export function resolveRubyImportTarget(
|
||||
targetRaw: string,
|
||||
fromFile: string,
|
||||
allFilePaths: ReadonlySet<string>,
|
||||
_resolutionConfig?: unknown,
|
||||
resolutionConfig?: unknown,
|
||||
): string | readonly string[] | null {
|
||||
if (!targetRaw) return null;
|
||||
if (isHeritageMarker(targetRaw)) return null;
|
||||
|
|
@ -51,7 +58,16 @@ export function resolveRubyImportTarget(
|
|||
return resolved;
|
||||
}
|
||||
|
||||
// ── require: bare/gem-style suffix matching ─────────────────────────
|
||||
// ── require: scoped gem evidence before repository-wide fallback ────
|
||||
const config = resolutionConfig as RubyResolutionConfig | null | undefined;
|
||||
const scope =
|
||||
config === null || config === undefined ? undefined : findRubyResolutionScope(config, fromFile);
|
||||
if (scope !== undefined) {
|
||||
const localGemTarget = resolveLocalGemTarget(targetRaw, scope, allFilePaths);
|
||||
if (localGemTarget !== undefined) return localGemTarget;
|
||||
if (matchesRequirePrefix(targetRaw, scope.externalRequirePrefixes)) return null;
|
||||
}
|
||||
|
||||
return resolveBare(targetRaw, allFilePaths);
|
||||
}
|
||||
|
||||
|
|
@ -95,6 +111,60 @@ function resolveRelative(
|
|||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Yield the require stem, then each slash-delimited ancestor. This makes both
|
||||
* local-root and external-gem lookup O(require path depth), not O(gem count).
|
||||
* A trailing `.rb` is stripped first so `require 'my_engine.rb'` matches the
|
||||
* configured prefix `my_engine`, matching Ruby's optional-suffix require.
|
||||
*/
|
||||
function requirePrefixCandidates(targetRaw: string): readonly string[] {
|
||||
const stem = targetRaw.endsWith('.rb') ? targetRaw.slice(0, -3) : targetRaw;
|
||||
if (stem.length === 0) return [];
|
||||
const candidates = [stem];
|
||||
let slash = stem.lastIndexOf('/');
|
||||
while (slash !== -1) {
|
||||
candidates.push(stem.slice(0, slash));
|
||||
slash = stem.lastIndexOf('/', slash - 1);
|
||||
}
|
||||
return candidates;
|
||||
}
|
||||
|
||||
function matchesRequirePrefix(targetRaw: string, prefixes: ReadonlySet<string>): boolean {
|
||||
return requirePrefixCandidates(targetRaw).some((candidate) => prefixes.has(candidate));
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a path/gemspec-backed gem against its declared Ruby load roots.
|
||||
* `undefined` means no local-gem prefix matched; `null` means one matched but
|
||||
* none of its declared load roots contained the target.
|
||||
*/
|
||||
function resolveLocalGemTarget(
|
||||
targetRaw: string,
|
||||
scope: RubyResolutionScope,
|
||||
allFilePaths: ReadonlySet<string>,
|
||||
): string | null | undefined {
|
||||
for (const prefix of requirePrefixCandidates(targetRaw)) {
|
||||
const loadRoots = scope.localLoadRootsByPrefix.get(prefix);
|
||||
if (loadRoots === undefined) continue;
|
||||
|
||||
for (const loadRoot of loadRoots) {
|
||||
const base = loadRoot ? `${loadRoot}/${targetRaw}` : targetRaw;
|
||||
if (base.endsWith('.rb')) {
|
||||
if (allFilePaths.has(base)) return base;
|
||||
continue;
|
||||
}
|
||||
const rbFile = `${base}.rb`;
|
||||
if (allFilePaths.has(rbFile)) return rbFile;
|
||||
}
|
||||
|
||||
// The prefix is owned by a known local gem. If its declared roots do not
|
||||
// contain the target, repository-wide suffix matching would fabricate an
|
||||
// edge to an unrelated file.
|
||||
return null;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a bare require path (`'serializable'`, `'json'`, `'net/http'`)
|
||||
* via suffix matching using the existing Ruby import resolver.
|
||||
|
|
|
|||
380
gitnexus/src/core/ingestion/languages/ruby/resolution-config.ts
Normal file
380
gitnexus/src/core/ingestion/languages/ruby/resolution-config.ts
Normal file
|
|
@ -0,0 +1,380 @@
|
|||
import { readdirSync, readFileSync, type Dirent } from 'node:fs';
|
||||
import { isAbsolute, join, relative, resolve } from 'node:path';
|
||||
|
||||
export interface RubyResolutionScope {
|
||||
/** Require prefixes provided by gems whose source is outside this repository. */
|
||||
readonly externalRequirePrefixes: ReadonlySet<string>;
|
||||
/** Require prefix -> repository-relative Ruby load roots for local gems. */
|
||||
readonly localLoadRootsByPrefix: ReadonlyMap<string, readonly string[]>;
|
||||
}
|
||||
|
||||
export interface RubyResolutionConfig {
|
||||
/** Manifest directory (repository-relative POSIX path) -> dependency scope. */
|
||||
readonly scopesByDirectory: ReadonlyMap<string, RubyResolutionScope>;
|
||||
}
|
||||
|
||||
interface MutableRubyResolutionScope {
|
||||
readonly externalRequirePrefixes: Set<string>;
|
||||
readonly localLoadRootsByPrefix: Map<string, Set<string>>;
|
||||
}
|
||||
|
||||
interface GemspecMetadata {
|
||||
readonly name: string | null;
|
||||
readonly requirePaths: readonly string[];
|
||||
}
|
||||
|
||||
const MAX_VISITED_DIRECTORIES = 20_000;
|
||||
const SKIP_DIRECTORIES = new Set([
|
||||
'.git',
|
||||
'.gitnexus',
|
||||
'node_modules',
|
||||
'vendor',
|
||||
'dist',
|
||||
'build',
|
||||
'coverage',
|
||||
]);
|
||||
|
||||
const GEMFILE_DEPENDENCY = /^\s*gem\s*(?:\(\s*)?(['"])([A-Za-z0-9_.-]+)\1(?<options>.*)$/;
|
||||
const GEMSPEC_DEPENDENCY =
|
||||
/^\s*(?:[A-Za-z_]\w*\.)?(?:add_dependency|add_runtime_dependency|add_development_dependency)\s*(?:\(\s*)?(['"])([A-Za-z0-9_.-]+)\1/;
|
||||
const GEMSPEC_NAME = /^\s*(?:[A-Za-z_]\w*\.)?name\s*=\s*(['"])([A-Za-z0-9_.-]+)\1/;
|
||||
const GEMSPEC_REQUIRE_PATHS = /^\s*(?:[A-Za-z_]\w*\.)?require_paths\s*=\s*\[([^\]]*)\]/;
|
||||
const QUOTED_LITERAL = /(['"])([^'"]+)\1/g;
|
||||
const LOCKFILE_SECTION = /^([A-Z][A-Z ]*)\s*$/;
|
||||
const LOCKFILE_REMOTE = /^ {2}remote:\s*(.+?)\s*$/;
|
||||
const LOCKFILE_SPECS_HEADER = /^ {2}specs:\s*$/;
|
||||
const LOCKFILE_SPEC = /^ {4}([A-Za-z0-9_.-]+) \(/;
|
||||
|
||||
function createMutableScope(): MutableRubyResolutionScope {
|
||||
return {
|
||||
externalRequirePrefixes: new Set<string>(),
|
||||
localLoadRootsByPrefix: new Map<string, Set<string>>(),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeRepoPath(filePath: string): string {
|
||||
return filePath.replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/$/, '');
|
||||
}
|
||||
|
||||
function repoRelativePath(repoPath: string, candidate: string): string | null {
|
||||
const rel = relative(resolve(repoPath), resolve(candidate));
|
||||
if (rel === '') return '';
|
||||
if (rel === '..' || rel.startsWith(`..\\`) || rel.startsWith('../') || isAbsolute(rel)) {
|
||||
return null;
|
||||
}
|
||||
return normalizeRepoPath(rel);
|
||||
}
|
||||
|
||||
function requirePrefixes(gemName: string, requireAs?: string): readonly string[] {
|
||||
const prefixes = new Set<string>([gemName]);
|
||||
if (gemName.includes('-')) prefixes.add(gemName.replaceAll('-', '/'));
|
||||
if (requireAs !== undefined && requireAs.length > 0) prefixes.add(requireAs);
|
||||
return [...prefixes];
|
||||
}
|
||||
|
||||
function literalOption(options: string, name: string): string | undefined {
|
||||
const modern = new RegExp(`(?:^|[,\\s])${name}\\s*:\\s*(['"])([^'"]+)\\1`).exec(options);
|
||||
if (modern !== null) return modern[2];
|
||||
const hashRocket = new RegExp(`(?:^|[,\\s]):${name}\\s*=>\\s*(['"])([^'"]+)\\1`).exec(options);
|
||||
return hashRocket?.[2];
|
||||
}
|
||||
|
||||
function hasOption(options: string, name: string): boolean {
|
||||
return (
|
||||
new RegExp(`(?:^|[,\\s])${name}\\s*:`).test(options) ||
|
||||
new RegExp(`(?:^|[,\\s]):${name}\\s*=>`).test(options)
|
||||
);
|
||||
}
|
||||
|
||||
function addExternalGem(
|
||||
scope: MutableRubyResolutionScope,
|
||||
gemName: string,
|
||||
requireAs?: string,
|
||||
): void {
|
||||
for (const prefix of requirePrefixes(gemName, requireAs)) {
|
||||
if (!scope.localLoadRootsByPrefix.has(prefix)) scope.externalRequirePrefixes.add(prefix);
|
||||
}
|
||||
}
|
||||
|
||||
function addLocalGem(
|
||||
scope: MutableRubyResolutionScope,
|
||||
gemName: string,
|
||||
loadRoots: readonly string[],
|
||||
requireAs?: string,
|
||||
): void {
|
||||
for (const prefix of requirePrefixes(gemName, requireAs)) {
|
||||
scope.externalRequirePrefixes.delete(prefix);
|
||||
let roots = scope.localLoadRootsByPrefix.get(prefix);
|
||||
if (roots === undefined) {
|
||||
roots = new Set<string>();
|
||||
scope.localLoadRootsByPrefix.set(prefix, roots);
|
||||
}
|
||||
for (const loadRoot of loadRoots) roots.add(loadRoot);
|
||||
}
|
||||
}
|
||||
|
||||
function parseGemspecMetadata(contents: string): GemspecMetadata {
|
||||
let name: string | null = null;
|
||||
let requirePaths: string[] | null = null;
|
||||
|
||||
for (const line of contents.split(/\r?\n/)) {
|
||||
const nameMatch = GEMSPEC_NAME.exec(line);
|
||||
if (nameMatch !== null) name = nameMatch[2];
|
||||
|
||||
const requirePathsMatch = GEMSPEC_REQUIRE_PATHS.exec(line);
|
||||
if (requirePathsMatch === null) continue;
|
||||
const paths: string[] = [];
|
||||
for (const match of requirePathsMatch[1].matchAll(QUOTED_LITERAL)) paths.push(match[2]);
|
||||
if (paths.length > 0) requirePaths = paths;
|
||||
}
|
||||
|
||||
return { name, requirePaths: requirePaths ?? ['lib'] };
|
||||
}
|
||||
|
||||
function loadLocalGemRoots(
|
||||
repoPath: string,
|
||||
gemRoot: string,
|
||||
expectedGemName: string,
|
||||
): readonly string[] | null {
|
||||
const gemRootRelative = repoRelativePath(repoPath, gemRoot);
|
||||
if (gemRootRelative === null) return null;
|
||||
|
||||
let requirePaths: readonly string[] = ['lib'];
|
||||
try {
|
||||
const gemspecs = readdirSync(gemRoot, { withFileTypes: true })
|
||||
.filter((entry) => entry.isFile() && entry.name.endsWith('.gemspec'))
|
||||
.map((entry) => join(gemRoot, entry.name))
|
||||
.sort();
|
||||
for (const gemspec of gemspecs) {
|
||||
const metadata = parseGemspecMetadata(readFileSync(gemspec, 'utf8'));
|
||||
if (metadata.name === expectedGemName) {
|
||||
requirePaths = metadata.requirePaths;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// An unreadable local gem stays local; the conventional lib root is a
|
||||
// bounded best effort, and resolution falls through if no file matches.
|
||||
}
|
||||
|
||||
return requirePaths
|
||||
.map((requirePath) => repoRelativePath(repoPath, resolve(gemRoot, requirePath)))
|
||||
.filter((loadRoot): loadRoot is string => loadRoot !== null);
|
||||
}
|
||||
|
||||
function addGemfileDependencies(
|
||||
contents: string,
|
||||
manifestDirectory: string,
|
||||
repoPath: string,
|
||||
scope: MutableRubyResolutionScope,
|
||||
): void {
|
||||
for (const line of contents.split(/\r?\n/)) {
|
||||
const match = GEMFILE_DEPENDENCY.exec(line);
|
||||
if (match === null) continue;
|
||||
|
||||
const gemName = match[2];
|
||||
const options = match.groups?.options ?? '';
|
||||
const requireAs = literalOption(options, 'require');
|
||||
const localPath = literalOption(options, 'path');
|
||||
|
||||
if (localPath !== undefined) {
|
||||
const loadRoots = loadLocalGemRoots(repoPath, resolve(manifestDirectory, localPath), gemName);
|
||||
if (loadRoots === null) addExternalGem(scope, gemName, requireAs);
|
||||
else addLocalGem(scope, gemName, loadRoots, requireAs);
|
||||
continue;
|
||||
}
|
||||
|
||||
// A dynamic path expression cannot be classified without evaluating Ruby.
|
||||
// Fail open instead of deleting a potentially real in-repo import edge.
|
||||
if (hasOption(options, 'path')) continue;
|
||||
|
||||
addExternalGem(scope, gemName, requireAs);
|
||||
}
|
||||
}
|
||||
|
||||
function addGemspecDependencies(
|
||||
contents: string,
|
||||
gemspecDirectory: string,
|
||||
repoPath: string,
|
||||
scope: MutableRubyResolutionScope,
|
||||
): void {
|
||||
const metadata = parseGemspecMetadata(contents);
|
||||
if (metadata.name !== null) {
|
||||
if (repoRelativePath(repoPath, gemspecDirectory) !== null) {
|
||||
const loadRoots = metadata.requirePaths
|
||||
.map((requirePath) => repoRelativePath(repoPath, resolve(gemspecDirectory, requirePath)))
|
||||
.filter((loadRoot): loadRoot is string => loadRoot !== null);
|
||||
addLocalGem(scope, metadata.name, loadRoots);
|
||||
}
|
||||
}
|
||||
|
||||
for (const line of contents.split(/\r?\n/)) {
|
||||
const match = GEMSPEC_DEPENDENCY.exec(line);
|
||||
if (match !== null) addExternalGem(scope, match[2]);
|
||||
}
|
||||
}
|
||||
|
||||
function addLockedDependencies(
|
||||
contents: string,
|
||||
lockfileDirectory: string,
|
||||
repoPath: string,
|
||||
scope: MutableRubyResolutionScope,
|
||||
): void {
|
||||
let section = '';
|
||||
let remote: string | null = null;
|
||||
let inSpecs = false;
|
||||
|
||||
for (const line of contents.split(/\r?\n/)) {
|
||||
const sectionMatch = LOCKFILE_SECTION.exec(line);
|
||||
if (sectionMatch !== null) {
|
||||
section = sectionMatch[1];
|
||||
remote = null;
|
||||
inSpecs = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
const remoteMatch = LOCKFILE_REMOTE.exec(line);
|
||||
if (remoteMatch !== null) {
|
||||
remote = remoteMatch[1];
|
||||
continue;
|
||||
}
|
||||
|
||||
if (LOCKFILE_SPECS_HEADER.test(line)) {
|
||||
inSpecs = true;
|
||||
continue;
|
||||
}
|
||||
if (!inSpecs) continue;
|
||||
|
||||
const specMatch = LOCKFILE_SPEC.exec(line);
|
||||
if (specMatch === null) continue;
|
||||
const gemName = specMatch[1];
|
||||
|
||||
if (section === 'GEM' || section === 'GIT') {
|
||||
addExternalGem(scope, gemName);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (section !== 'PATH' && section !== 'GEMSPEC') continue;
|
||||
if (remote === null) continue;
|
||||
|
||||
const loadRoots = loadLocalGemRoots(repoPath, resolve(lockfileDirectory, remote), gemName);
|
||||
if (loadRoots === null) addExternalGem(scope, gemName);
|
||||
else addLocalGem(scope, gemName, loadRoots);
|
||||
}
|
||||
}
|
||||
|
||||
function freezeScope(scope: MutableRubyResolutionScope): RubyResolutionScope {
|
||||
const localLoadRootsByPrefix = new Map<string, readonly string[]>();
|
||||
for (const [prefix, roots] of scope.localLoadRootsByPrefix) {
|
||||
localLoadRootsByPrefix.set(prefix, [...roots].sort());
|
||||
}
|
||||
return {
|
||||
externalRequirePrefixes: new Set(scope.externalRequirePrefixes),
|
||||
localLoadRootsByPrefix,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Select the nearest manifest directory that owns `fromFile`.
|
||||
*
|
||||
* Walking ancestors makes lookup O(path depth), independent of how many
|
||||
* sibling Gemfiles a monorepo contains.
|
||||
*/
|
||||
export function findRubyResolutionScope(
|
||||
config: RubyResolutionConfig,
|
||||
fromFile: string,
|
||||
): RubyResolutionScope | undefined {
|
||||
const normalized = normalizeRepoPath(fromFile);
|
||||
let directory = normalized.includes('/') ? normalized.slice(0, normalized.lastIndexOf('/')) : '';
|
||||
|
||||
for (;;) {
|
||||
const scope = config.scopesByDirectory.get(directory);
|
||||
if (scope !== undefined) return scope;
|
||||
if (directory === '') return undefined;
|
||||
const slash = directory.lastIndexOf('/');
|
||||
directory = slash === -1 ? '' : directory.slice(0, slash);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Statically collect Ruby dependency sources without evaluating Gemfile or
|
||||
* gemspec code. Scopes stay separate by manifest directory so sibling projects
|
||||
* in a monorepo cannot suppress one another's local imports. Lockfiles
|
||||
* contribute remote GEM/GIT specs and local PATH/GEMSPEC load roots only when
|
||||
* an adjacent Gemfile or gemspec establishes a Bundler/RubyGems project.
|
||||
*
|
||||
* No declarative manifest means no safe gate, so return null and preserve the
|
||||
* resolver's existing fail-open behavior for loose script directories.
|
||||
*/
|
||||
export function loadRubyResolutionConfig(repoPath: string): RubyResolutionConfig | null {
|
||||
const pending = [repoPath];
|
||||
const manifestsByDirectory = new Map<string, string[]>();
|
||||
const lockfilesByDirectory = new Map<string, string>();
|
||||
let visitedDirectories = 0;
|
||||
|
||||
while (pending.length > 0 && visitedDirectories < MAX_VISITED_DIRECTORIES) {
|
||||
const directory = pending.pop();
|
||||
if (directory === undefined) break;
|
||||
visitedDirectories++;
|
||||
|
||||
let entries: Dirent[];
|
||||
try {
|
||||
entries = readdirSync(directory, { withFileTypes: true }).sort((left, right) =>
|
||||
left.name.localeCompare(right.name),
|
||||
);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory() && !SKIP_DIRECTORIES.has(entry.name)) {
|
||||
pending.push(join(directory, entry.name));
|
||||
} else if (entry.isFile() && (entry.name === 'Gemfile' || entry.name.endsWith('.gemspec'))) {
|
||||
const manifests = manifestsByDirectory.get(directory) ?? [];
|
||||
manifests.push(join(directory, entry.name));
|
||||
manifestsByDirectory.set(directory, manifests);
|
||||
} else if (entry.isFile() && entry.name === 'Gemfile.lock') {
|
||||
lockfilesByDirectory.set(directory, join(directory, entry.name));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (manifestsByDirectory.size === 0) return null;
|
||||
|
||||
const mutableScopes = new Map<string, MutableRubyResolutionScope>();
|
||||
for (const [directory, manifests] of [...manifestsByDirectory].sort(([left], [right]) =>
|
||||
left.localeCompare(right),
|
||||
)) {
|
||||
const directoryRelative = repoRelativePath(repoPath, directory);
|
||||
if (directoryRelative === null) continue;
|
||||
const scope = createMutableScope();
|
||||
mutableScopes.set(directoryRelative, scope);
|
||||
|
||||
for (const manifest of manifests.sort()) {
|
||||
try {
|
||||
const contents = readFileSync(manifest, 'utf8');
|
||||
if (manifest.endsWith('.gemspec')) {
|
||||
addGemspecDependencies(contents, directory, repoPath, scope);
|
||||
} else {
|
||||
addGemfileDependencies(contents, directory, repoPath, scope);
|
||||
}
|
||||
} catch {
|
||||
// A partially readable scope remains fail-open for unknown gems.
|
||||
}
|
||||
}
|
||||
|
||||
const lockfile = lockfilesByDirectory.get(directory);
|
||||
if (lockfile === undefined) continue;
|
||||
try {
|
||||
addLockedDependencies(readFileSync(lockfile, 'utf8'), directory, repoPath, scope);
|
||||
} catch {
|
||||
// Direct declarations still provide bounded evidence when the lock is unreadable.
|
||||
}
|
||||
}
|
||||
|
||||
const scopesByDirectory = new Map<string, RubyResolutionScope>();
|
||||
for (const [directory, scope] of mutableScopes) {
|
||||
scopesByDirectory.set(directory, freezeScope(scope));
|
||||
}
|
||||
return { scopesByDirectory };
|
||||
}
|
||||
|
|
@ -11,6 +11,7 @@ import type { KnowledgeGraph } from '../../../graph/types.js';
|
|||
import { generateId } from '../../../../lib/utils.js';
|
||||
import { decodeMarker } from '../../utils/heritage-marker.js';
|
||||
import { rubyIsGlobalNameFallbackPlausible } from './name-fallback-visibility.js';
|
||||
import { loadRubyResolutionConfig } from './resolution-config.js';
|
||||
|
||||
/**
|
||||
* #1991: resolve a BARE mixin reference (`include Loggable`) to a nested module by
|
||||
|
|
@ -269,6 +270,8 @@ export const rubyScopeResolver: ScopeResolver = {
|
|||
resolveImportTarget: (targetRaw, fromFile, allFilePaths, resolutionConfig) =>
|
||||
resolveRubyImportTarget(targetRaw, fromFile, allFilePaths, resolutionConfig),
|
||||
|
||||
loadResolutionConfig: (repoPath) => loadRubyResolutionConfig(repoPath),
|
||||
|
||||
expandsWildcardTo: (targetModuleScope, parsedFiles) =>
|
||||
expandRubyWildcardNames(targetModuleScope, parsedFiles),
|
||||
|
||||
|
|
|
|||
|
|
@ -3,8 +3,9 @@
|
|||
*
|
||||
* 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.
|
||||
* 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
|
||||
*
|
||||
|
|
@ -38,6 +39,10 @@ function generateRubyFixture(
|
|||
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
|
||||
|
|
@ -120,6 +125,7 @@ function generateRubyFixture(
|
|||
const crossClass = `Model${crossIdx}`;
|
||||
|
||||
const requireLines = [
|
||||
`require 'rails/generators'`,
|
||||
`require_relative '../concerns/${incMixin.toLowerCase()}'`,
|
||||
`require_relative '../concerns/${incMixin2.toLowerCase()}'`,
|
||||
`require_relative '../concerns/${extMixin.toLowerCase()}'`,
|
||||
|
|
|
|||
|
|
@ -53,6 +53,18 @@ import {
|
|||
const PHP_COMPOSER: ComposerConfig = { psr4: new Map([['App', 'app']]) };
|
||||
/** The value `loadGoModulePath` produces for a repo with a `go.mod`. */
|
||||
const GO_MODULE = { modulePath: 'example.com/mod' };
|
||||
/** The root dependency scope `loadRubyResolutionConfig` would have produced. */
|
||||
const RUBY_GEMS = {
|
||||
scopesByDirectory: new Map([
|
||||
[
|
||||
'',
|
||||
{
|
||||
externalRequirePrefixes: new Set(['rails']),
|
||||
localLoadRootsByPrefix: new Map(),
|
||||
},
|
||||
],
|
||||
]),
|
||||
};
|
||||
/** What `scanCSharpProject` would report for the C# workspace below — the
|
||||
* in-repo namespace evidence the #1881 suffix-fallback gate reads. */
|
||||
const CSHARP_NAMESPACES = {
|
||||
|
|
@ -272,7 +284,7 @@ const CASES: ReadonlyMap<SupportedLanguages, ConformanceCase> = new Map([
|
|||
{
|
||||
files: ['lib/app/models/user.rb', 'lib/generators.rb', 'lib/main.rb'],
|
||||
fromFile: 'lib/main.rb',
|
||||
resolutionConfig: undefined,
|
||||
resolutionConfig: RUBY_GEMS,
|
||||
external: 'rails/generators',
|
||||
decoy: 'lib/generators.rb',
|
||||
reachesDecoy: 'generators',
|
||||
|
|
@ -404,7 +416,6 @@ const CASES: ReadonlyMap<SupportedLanguages, ConformanceCase> = new Map([
|
|||
* TypeScript one, then deleting its line here.
|
||||
*/
|
||||
const KNOWN_GAPS: ReadonlyMap<SupportedLanguages, string> = new Map<SupportedLanguages, string>([
|
||||
[SupportedLanguages.Ruby, '`rails/generators` -> `lib/generators.rb`'],
|
||||
[SupportedLanguages.Dart, '`package:http/http.dart` -> `lib/http.dart`'],
|
||||
[SupportedLanguages.Swift, '`Foundation` -> `Sources/Foundation/Thing.swift`'],
|
||||
[SupportedLanguages.C, '`stdio.h` -> `src/stdio.h`'],
|
||||
|
|
|
|||
|
|
@ -0,0 +1,220 @@
|
|||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
|
||||
import { resolveRubyImportTarget } from '../../../../src/core/ingestion/languages/ruby/import-target.js';
|
||||
import { loadRubyResolutionConfig } from '../../../../src/core/ingestion/languages/ruby/resolution-config.js';
|
||||
import { rubyScopeResolver } from '../../../../src/core/ingestion/languages/ruby/scope-resolver.js';
|
||||
|
||||
const temporaryRepos: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const repo of temporaryRepos.splice(0)) rmSync(repo, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function makeRepo(): string {
|
||||
const repo = mkdtempSync(join(tmpdir(), 'gitnexus-ruby-dependencies-'));
|
||||
temporaryRepos.push(repo);
|
||||
return repo;
|
||||
}
|
||||
|
||||
describe('Ruby dependency resolution config (#2966)', () => {
|
||||
it('keeps manifest directories separate and records conventional require prefixes', () => {
|
||||
const repo = makeRepo();
|
||||
writeFileSync(
|
||||
join(repo, 'Gemfile'),
|
||||
["source 'https://rubygems.org'", "gem 'rails'", 'gem("pg")', "# gem 'commented'"].join('\n'),
|
||||
);
|
||||
writeFileSync(
|
||||
join(repo, 'Gemfile.lock'),
|
||||
[
|
||||
'GEM',
|
||||
' specs:',
|
||||
' actionpack (8.0.0)',
|
||||
' rack (~> 3.0)',
|
||||
' rack (3.0.0)',
|
||||
'DEPENDENCIES',
|
||||
].join('\n'),
|
||||
);
|
||||
mkdirSync(join(repo, 'packages', 'widget'), { recursive: true });
|
||||
writeFileSync(
|
||||
join(repo, 'packages', 'widget', 'widget.gemspec'),
|
||||
[
|
||||
"spec.name = 'widget'",
|
||||
"spec.require_paths = ['src']",
|
||||
"spec.add_dependency 'dry-types'",
|
||||
'spec.add_development_dependency("rspec")',
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
const config = loadRubyResolutionConfig(repo);
|
||||
const root = config?.scopesByDirectory.get('');
|
||||
const widget = config?.scopesByDirectory.get('packages/widget');
|
||||
|
||||
expect(root?.externalRequirePrefixes).toEqual(new Set(['rails', 'pg', 'actionpack', 'rack']));
|
||||
expect(widget?.externalRequirePrefixes).toEqual(new Set(['dry-types', 'dry/types', 'rspec']));
|
||||
expect(widget?.localLoadRootsByPrefix.get('widget')).toEqual(['packages/widget/src']);
|
||||
});
|
||||
|
||||
it('fails open when a lockfile exists without a Gemfile or gemspec', () => {
|
||||
const repo = makeRepo();
|
||||
writeFileSync(join(repo, 'Gemfile.lock'), ['GEM', ' specs:', ' rails (8.0.0)'].join('\n'));
|
||||
|
||||
expect(loadRubyResolutionConfig(repo)).toBeNull();
|
||||
});
|
||||
|
||||
it('gates external gems without changing local bare, relative, or no-config resolution', async () => {
|
||||
const repo = makeRepo();
|
||||
writeFileSync(join(repo, 'Gemfile'), "gem 'rails'\n");
|
||||
const files = new Set(['lib/app/models/user.rb', 'lib/generators.rb', 'lib/main.rb']);
|
||||
const config = await rubyScopeResolver.loadResolutionConfig?.(repo);
|
||||
|
||||
expect(
|
||||
rubyScopeResolver.resolveImportTarget('rails/generators', 'lib/main.rb', files, config),
|
||||
).toBeNull();
|
||||
expect(resolveRubyImportTarget('rails.rb', 'lib/main.rb', files, config)).toBeNull();
|
||||
expect(resolveRubyImportTarget('rails/generators.rb', 'lib/main.rb', files, config)).toBeNull();
|
||||
expect(
|
||||
rubyScopeResolver.resolveImportTarget('app/models/user', 'lib/main.rb', files, config),
|
||||
).toBe('lib/app/models/user.rb');
|
||||
expect(resolveRubyImportTarget('generators', 'lib/main.rb', files, config)).toBe(
|
||||
'lib/generators.rb',
|
||||
);
|
||||
expect(resolveRubyImportTarget('./generators', 'lib/main.rb', files, config)).toBe(
|
||||
'lib/generators.rb',
|
||||
);
|
||||
expect(resolveRubyImportTarget('rails/generators', 'lib/main.rb', files)).toBe(
|
||||
'lib/generators.rb',
|
||||
);
|
||||
});
|
||||
|
||||
it('gates the conventional slash prefix of a hyphenated gem', () => {
|
||||
const repo = makeRepo();
|
||||
writeFileSync(join(repo, 'Gemfile'), "gem 'dry-types'\n");
|
||||
const files = new Set(['lib/types.rb', 'lib/main.rb']);
|
||||
const config = loadRubyResolutionConfig(repo);
|
||||
|
||||
expect(resolveRubyImportTarget('dry/types', 'lib/main.rb', files, config)).toBeNull();
|
||||
expect(resolveRubyImportTarget('dry/types.rb', 'lib/main.rb', files, config)).toBeNull();
|
||||
expect(resolveRubyImportTarget('dry-types', 'lib/main.rb', files, config)).toBeNull();
|
||||
expect(resolveRubyImportTarget('types', 'lib/main.rb', files, config)).toBe('lib/types.rb');
|
||||
});
|
||||
|
||||
it('resolves a Gemfile path gem through its local load root', () => {
|
||||
const repo = makeRepo();
|
||||
mkdirSync(join(repo, 'engines', 'my_engine'), { recursive: true });
|
||||
writeFileSync(join(repo, 'Gemfile'), "gem 'my_engine', path: 'engines/my_engine'\n");
|
||||
writeFileSync(
|
||||
join(repo, 'engines', 'my_engine', 'my_engine.gemspec'),
|
||||
["spec.name = 'my_engine'", "spec.require_paths = ['lib']"].join('\n'),
|
||||
);
|
||||
const files = new Set([
|
||||
'engines/my_engine/lib/my_engine.rb',
|
||||
'engines/my_engine/lib/my_engine/feature.rb',
|
||||
'lib/my_engine.rb',
|
||||
'lib/main.rb',
|
||||
]);
|
||||
const config = loadRubyResolutionConfig(repo);
|
||||
|
||||
expect(resolveRubyImportTarget('my_engine', 'lib/main.rb', files, config)).toBe(
|
||||
'engines/my_engine/lib/my_engine.rb',
|
||||
);
|
||||
expect(resolveRubyImportTarget('my_engine.rb', 'lib/main.rb', files, config)).toBe(
|
||||
'engines/my_engine/lib/my_engine.rb',
|
||||
);
|
||||
expect(resolveRubyImportTarget('my_engine/feature.rb', 'lib/main.rb', files, config)).toBe(
|
||||
'engines/my_engine/lib/my_engine/feature.rb',
|
||||
);
|
||||
expect(config?.scopesByDirectory.get('')?.externalRequirePrefixes).not.toContain('my_engine');
|
||||
});
|
||||
|
||||
it('does not suffix-fallback when a local gem has no in-repository load root', () => {
|
||||
const repo = makeRepo();
|
||||
writeFileSync(
|
||||
join(repo, 'local_widget.gemspec'),
|
||||
["spec.name = 'local_widget'", "spec.require_paths = ['../outside']"].join('\n'),
|
||||
);
|
||||
const files = new Set(['lib/feature.rb', 'lib/main.rb']);
|
||||
const config = loadRubyResolutionConfig(repo);
|
||||
|
||||
expect(config?.scopesByDirectory.get('')?.localLoadRootsByPrefix.get('local_widget')).toEqual(
|
||||
[],
|
||||
);
|
||||
expect(
|
||||
resolveRubyImportTarget('local_widget/feature', 'lib/main.rb', files, config),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('recognizes the Gemfile hashrocket path syntax as local', () => {
|
||||
const repo = makeRepo();
|
||||
mkdirSync(join(repo, 'engines', 'my_engine'), { recursive: true });
|
||||
writeFileSync(join(repo, 'Gemfile'), "gem 'my_engine', :path => 'engines/my_engine'\n");
|
||||
writeFileSync(
|
||||
join(repo, 'engines', 'my_engine', 'my_engine.gemspec'),
|
||||
"spec.name = 'my_engine'\n",
|
||||
);
|
||||
const files = new Set(['engines/my_engine/lib/my_engine.rb', 'lib/main.rb']);
|
||||
const config = loadRubyResolutionConfig(repo);
|
||||
|
||||
expect(resolveRubyImportTarget('my_engine', 'lib/main.rb', files, config)).toBe(
|
||||
'engines/my_engine/lib/my_engine.rb',
|
||||
);
|
||||
expect(config?.scopesByDirectory.get('')?.externalRequirePrefixes).not.toContain('my_engine');
|
||||
});
|
||||
|
||||
it('keeps lockfile PATH specs local while gating GEM specs', () => {
|
||||
const repo = makeRepo();
|
||||
writeFileSync(
|
||||
join(repo, 'mygem.gemspec'),
|
||||
["spec.name = 'mygem'", "spec.require_paths = ['lib']"].join('\n'),
|
||||
);
|
||||
writeFileSync(
|
||||
join(repo, 'Gemfile.lock'),
|
||||
[
|
||||
'PATH',
|
||||
' remote: .',
|
||||
' specs:',
|
||||
' mygem (1.0.0)',
|
||||
'',
|
||||
'GEM',
|
||||
' remote: https://rubygems.org/',
|
||||
' specs:',
|
||||
' rails (8.0.0)',
|
||||
'',
|
||||
'DEPENDENCIES',
|
||||
' mygem!',
|
||||
' rails',
|
||||
].join('\n'),
|
||||
);
|
||||
const files = new Set(['lib/mygem.rb', 'lib/generators.rb', 'lib/main.rb']);
|
||||
const config = loadRubyResolutionConfig(repo);
|
||||
const root = config?.scopesByDirectory.get('');
|
||||
|
||||
expect(resolveRubyImportTarget('mygem', 'lib/main.rb', files, config)).toBe('lib/mygem.rb');
|
||||
expect(resolveRubyImportTarget('rails/generators', 'lib/main.rb', files, config)).toBeNull();
|
||||
expect(root?.externalRequirePrefixes).toContain('rails');
|
||||
expect(root?.externalRequirePrefixes).not.toContain('mygem');
|
||||
});
|
||||
|
||||
it('uses the nearest manifest instead of a repository-wide monorepo union', () => {
|
||||
const repo = makeRepo();
|
||||
mkdirSync(join(repo, 'packages', 'a'), { recursive: true });
|
||||
mkdirSync(join(repo, 'packages', 'b'), { recursive: true });
|
||||
writeFileSync(join(repo, 'packages', 'a', 'Gemfile'), "gem 'rails'\n");
|
||||
writeFileSync(join(repo, 'packages', 'b', 'Gemfile'), "source 'https://rubygems.org'\n");
|
||||
const files = new Set([
|
||||
'packages/a/lib/main.rb',
|
||||
'packages/b/lib/main.rb',
|
||||
'packages/b/lib/rails/generators.rb',
|
||||
]);
|
||||
const config = loadRubyResolutionConfig(repo);
|
||||
|
||||
expect(
|
||||
resolveRubyImportTarget('rails/generators', 'packages/a/lib/main.rb', files, config),
|
||||
).toBeNull();
|
||||
expect(
|
||||
resolveRubyImportTarget('rails/generators', 'packages/b/lib/main.rb', files, config),
|
||||
).toBe('packages/b/lib/rails/generators.rb');
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue