From d8fa5e5d52a2c3f80d14c0c907d471050b849011 Mon Sep 17 00:00:00 2001 From: y4ho0 <119646314+y4ho0@users.noreply.github.com> Date: Mon, 31 Aug 2026 03:38:00 +0800 Subject: [PATCH] fix(ruby): scope gem sources by manifest --- .../ingestion/languages/ruby/import-target.ts | 60 +++- .../languages/ruby/resolution-config.ts | 338 ++++++++++++++++-- .../ruby-pipeline-benchmark.test.ts | 10 +- .../external-import-conformance.test.ts | 14 +- .../ruby/ruby-resolution-config.test.ts | 141 +++++++- 5 files changed, 501 insertions(+), 62 deletions(-) diff --git a/gitnexus/src/core/ingestion/languages/ruby/import-target.ts b/gitnexus/src/core/ingestion/languages/ruby/import-target.ts index e8d2c6b40..462a47e08 100644 --- a/gitnexus/src/core/ingestion/languages/ruby/import-target.ts +++ b/gitnexus/src/core/ingestion/languages/ruby/import-target.ts @@ -10,7 +10,11 @@ import { resolveRubyImportInternal } from '../../import-resolvers/ruby.js'; import { getWorkspaceFileIndex } from '../../import-resolvers/workspace-file-index.js'; import { isHeritageMarker } from '../../utils/heritage-marker.js'; -import type { RubyResolutionConfig } from './resolution-config.js'; +import { + findRubyResolutionScope, + type RubyResolutionConfig, + type RubyResolutionScope, +} from './resolution-config.js'; export interface RubyResolveContext { readonly fromFile: string; @@ -52,10 +56,15 @@ export function resolveRubyImportTarget( return resolved; } - // ── require: bare/gem-style suffix matching ───────────────────────── - const gemNames = (resolutionConfig as RubyResolutionConfig | undefined)?.gemNames; - const firstSegment = targetRaw.split('/', 1)[0]; - if (gemNames?.has(firstSegment)) return null; + // ── 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 !== null) return localGemTarget; + if (matchesRequirePrefix(targetRaw, scope.externalRequirePrefixes)) return null; + } return resolveBare(targetRaw, allFilePaths); } @@ -100,6 +109,47 @@ function resolveRelative( return null; } +/** + * Yield the target itself, then each slash-delimited ancestor. This makes both + * local-root and external-gem lookup O(require path depth), not O(gem count). + */ +function requirePrefixCandidates(targetRaw: string): readonly string[] { + const candidates = [targetRaw]; + let slash = targetRaw.lastIndexOf('/'); + while (slash !== -1) { + candidates.push(targetRaw.slice(0, slash)); + slash = targetRaw.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. */ +function resolveLocalGemTarget( + targetRaw: string, + scope: RubyResolutionScope, + allFilePaths: ReadonlySet, +): string | null { + 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; + } + } + return null; +} + /** * 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 index 4f8f66b25..52c213af3 100644 --- a/gitnexus/src/core/ingestion/languages/ruby/resolution-config.ts +++ b/gitnexus/src/core/ingestion/languages/ruby/resolution-config.ts @@ -1,8 +1,26 @@ import { readdirSync, readFileSync, type Dirent } from 'node:fs'; -import { dirname, join } from 'node:path'; +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 { - readonly gemNames: ReadonlySet; + /** 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; @@ -16,51 +34,281 @@ const SKIP_DIRECTORIES = new Set([ 'coverage', ]); -const GEMFILE_DEPENDENCY = /^\s*gem\s*(?:\(\s*)?(['"])([A-Za-z0-9_.-]+)\1/; +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 addLiteralDependencies( - contents: string, - dependencyPattern: RegExp, - gemNames: Set, +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 line of contents.split(/\r?\n/)) { - const match = dependencyPattern.exec(line); - if (match !== null) gemNames.add(match[2]); + for (const prefix of requirePrefixes(gemName, requireAs)) { + if (!scope.localLoadRootsByPrefix.has(prefix)) scope.externalRequirePrefixes.add(prefix); } } -function addLockedDependencies(contents: string, gemNames: Set): void { - let inSpecs = false; +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 && /^\S/.test(line)) { - inSpecs = false; + 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 (!inSpecs) continue; - const match = LOCKFILE_SPEC.exec(line); - if (match !== null) gemNames.add(match[1]); + + 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 names without evaluating Gemfile or - * gemspec code. A lockfile contributes transitive dependencies only when an - * adjacent Gemfile or gemspec establishes a Bundler/RubyGems project. + * 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 manifests: string[] = []; + const manifestsByDirectory = new Map(); const lockfilesByDirectory = new Map(); let visitedDirectories = 0; @@ -82,39 +330,51 @@ export function loadRubyResolutionConfig(repoPath: string): RubyResolutionConfig 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 (manifests.length === 0) return null; + if (manifestsByDirectory.size === 0) return null; - const gemNames = new Set(); - const manifestDirectories = new Set(); - for (const manifest of manifests.sort()) { - manifestDirectories.add(dirname(manifest)); - try { - addLiteralDependencies( - readFileSync(manifest, 'utf8'), - manifest.endsWith('.gemspec') ? GEMSPEC_DEPENDENCY : GEMFILE_DEPENDENCY, - gemNames, - ); - } catch { - // A partially readable workspace remains fail-open for unknown gems. + 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. + } } - } - for (const directory of [...manifestDirectories].sort()) { const lockfile = lockfilesByDirectory.get(directory); if (lockfile === undefined) continue; try { - addLockedDependencies(readFileSync(lockfile, 'utf8'), gemNames); + addLockedDependencies(readFileSync(lockfile, 'utf8'), directory, repoPath, scope); } catch { - // Direct declarations still provide safe evidence when the lock is unreadable. + // Direct declarations still provide bounded evidence when the lock is unreadable. } } - return { gemNames }; + const scopesByDirectory = new Map(); + for (const [directory, scope] of mutableScopes) { + scopesByDirectory.set(directory, freezeScope(scope)); + } + return { scopesByDirectory }; } 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 3d20c5218..d25d02559 100644 --- a/gitnexus/test/unit/scope-resolution/external-import-conformance.test.ts +++ b/gitnexus/test/unit/scope-resolution/external-import-conformance.test.ts @@ -53,8 +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 dependency set `loadRubyResolutionConfig` would have produced. */ -const RUBY_GEMS = { gemNames: new Set(['rails']) }; +/** 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 = { 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 index a9155423c..fbb3c6665 100644 --- a/gitnexus/test/unit/scope-resolution/ruby/ruby-resolution-config.test.ts +++ b/gitnexus/test/unit/scope-resolution/ruby/ruby-resolution-config.test.ts @@ -20,7 +20,7 @@ function makeRepo(): string { } describe('Ruby dependency resolution config (#2966)', () => { - it('loads literal Gemfile/gemspec dependencies and adjacent locked transitive gems', () => { + it('keeps manifest directories separate and records conventional require prefixes', () => { const repo = makeRepo(); writeFileSync( join(repo, 'Gemfile'), @@ -32,15 +32,24 @@ describe('Ruby dependency resolution config (#2966)', () => { '\n', ), ); - mkdirSync(join(repo, 'packages')); + mkdirSync(join(repo, 'packages', 'widget'), { recursive: true }); writeFileSync( - join(repo, 'packages', 'widget.gemspec'), - ["spec.add_dependency 'dry-types'", 'spec.add_development_dependency("rspec")'].join('\n'), + 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(config?.gemNames).toEqual(new Set(['rails', 'pg', 'dry-types', 'rspec', 'actionpack'])); + expect(root?.externalRequirePrefixes).toEqual(new Set(['rails', 'pg', 'actionpack'])); + 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', () => { @@ -50,15 +59,10 @@ describe('Ruby dependency resolution config (#2966)', () => { expect(loadRubyResolutionConfig(repo)).toBeNull(); }); - it('threads the config through the resolver without changing local bare or relative resolution', async () => { + it('gates external gems without changing local bare, relative, or no-config resolution', async () => { const repo = makeRepo(); - writeFileSync(join(repo, 'Gemfile'), ["gem 'rails'", "gem 'generators'"].join('\n')); - const files = new Set([ - 'lib/app/models/user.rb', - 'lib/generators.rb', - 'lib/rails/generators.rb', - 'lib/main.rb', - ]); + 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( @@ -67,9 +71,118 @@ describe('Ruby dependency resolution config (#2966)', () => { expect( rubyScopeResolver.resolveImportTarget('app/models/user', 'lib/main.rb', files, config), ).toBe('lib/app/models/user.rb'); - expect(resolveRubyImportTarget('generators', 'lib/main.rb', files)).toBe('lib/generators.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', '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', + '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('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'); }); });