diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml index b19785cfa..20657d9b2 100644 --- a/.github/workflows/ci-tests.yml +++ b/.github/workflows/ci-tests.yml @@ -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/ diff --git a/gitnexus/bench/ruby-gem-resolution/baseline.json b/gitnexus/bench/ruby-gem-resolution/baseline.json new file mode 100644 index 000000000..8c618ad00 --- /dev/null +++ b/gitnexus/bench/ruby-gem-resolution/baseline.json @@ -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." +} diff --git a/gitnexus/bench/ruby-gem-resolution/measure.mjs b/gitnexus/bench/ruby-gem-resolution/measure.mjs new file mode 100644 index 000000000..8f7ef7875 --- /dev/null +++ b/gitnexus/bench/ruby-gem-resolution/measure.mjs @@ -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 }); +} diff --git a/gitnexus/src/core/ingestion/languages/ruby/import-target.ts b/gitnexus/src/core/ingestion/languages/ruby/import-target.ts index 62d6fca3d..eae1f56e3 100644 --- a/gitnexus/src/core/ingestion/languages/ruby/import-target.ts +++ b/gitnexus/src/core/ingestion/languages/ruby/import-target.ts @@ -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, - _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): 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 | 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. diff --git a/gitnexus/src/core/ingestion/languages/ruby/resolution-config.ts b/gitnexus/src/core/ingestion/languages/ruby/resolution-config.ts new file mode 100644 index 000000000..52c213af3 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/ruby/resolution-config.ts @@ -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; + /** Require prefix -> repository-relative Ruby load roots for local gems. */ + readonly localLoadRootsByPrefix: ReadonlyMap; +} + +export interface RubyResolutionConfig { + /** Manifest directory (repository-relative POSIX path) -> dependency scope. */ + readonly scopesByDirectory: ReadonlyMap; +} + +interface MutableRubyResolutionScope { + readonly externalRequirePrefixes: Set; + readonly localLoadRootsByPrefix: Map>; +} + +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(?.*)$/; +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(), + localLoadRootsByPrefix: new Map>(), + }; +} + +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([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(); + 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(); + 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(); + const lockfilesByDirectory = new Map(); + 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(); + 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(); + for (const [directory, scope] of mutableScopes) { + scopesByDirectory.set(directory, freezeScope(scope)); + } + return { scopesByDirectory }; +} diff --git a/gitnexus/src/core/ingestion/languages/ruby/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/ruby/scope-resolver.ts index 088651a81..2ce047dbd 100644 --- a/gitnexus/src/core/ingestion/languages/ruby/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/ruby/scope-resolver.ts @@ -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), diff --git a/gitnexus/test/integration/ruby-pipeline-benchmark.test.ts b/gitnexus/test/integration/ruby-pipeline-benchmark.test.ts index e43dc37d7..350e34f9f 100644 --- a/gitnexus/test/integration/ruby-pipeline-benchmark.test.ts +++ b/gitnexus/test/integration/ruby-pipeline-benchmark.test.ts @@ -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()}'`, diff --git a/gitnexus/test/unit/scope-resolution/external-import-conformance.test.ts b/gitnexus/test/unit/scope-resolution/external-import-conformance.test.ts index 957bda48a..c1932e02c 100644 --- a/gitnexus/test/unit/scope-resolution/external-import-conformance.test.ts +++ b/gitnexus/test/unit/scope-resolution/external-import-conformance.test.ts @@ -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 = 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 = new Map([ * TypeScript one, then deleting its line here. */ const KNOWN_GAPS: ReadonlyMap = new Map([ - [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`'], diff --git a/gitnexus/test/unit/scope-resolution/ruby/ruby-resolution-config.test.ts b/gitnexus/test/unit/scope-resolution/ruby/ruby-resolution-config.test.ts new file mode 100644 index 000000000..b175bb847 --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/ruby/ruby-resolution-config.test.ts @@ -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'); + }); +});