diff --git a/gitnexus/src/core/ingestion/languages/ruby/import-target.ts b/gitnexus/src/core/ingestion/languages/ruby/import-target.ts index 62d6fca3d..e8d2c6b40 100644 --- a/gitnexus/src/core/ingestion/languages/ruby/import-target.ts +++ b/gitnexus/src/core/ingestion/languages/ruby/import-target.ts @@ -10,6 +10,7 @@ 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'; export interface RubyResolveContext { readonly fromFile: string; @@ -35,7 +36,7 @@ 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; @@ -52,6 +53,10 @@ export function resolveRubyImportTarget( } // ── 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; + return resolveBare(targetRaw, allFilePaths); } 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..4f8f66b25 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/ruby/resolution-config.ts @@ -0,0 +1,120 @@ +import { readdirSync, readFileSync, type Dirent } from 'node:fs'; +import { dirname, join } from 'node:path'; + +export interface RubyResolutionConfig { + readonly gemNames: ReadonlySet; +} + +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 LOCKFILE_SPECS_HEADER = /^ {2}specs:\s*$/; +const LOCKFILE_SPEC = /^ {4}([A-Za-z0-9_.-]+) \(/; + +function addLiteralDependencies( + contents: string, + dependencyPattern: RegExp, + gemNames: Set, +): void { + for (const line of contents.split(/\r?\n/)) { + const match = dependencyPattern.exec(line); + if (match !== null) gemNames.add(match[2]); + } +} + +function addLockedDependencies(contents: string, gemNames: Set): void { + let inSpecs = false; + for (const line of contents.split(/\r?\n/)) { + if (LOCKFILE_SPECS_HEADER.test(line)) { + inSpecs = true; + continue; + } + if (inSpecs && /^\S/.test(line)) { + inSpecs = false; + continue; + } + if (!inSpecs) continue; + const match = LOCKFILE_SPEC.exec(line); + if (match !== null) gemNames.add(match[1]); + } +} + +/** + * 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. + * + * 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 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'))) { + manifests.push(join(directory, entry.name)); + } else if (entry.isFile() && entry.name === 'Gemfile.lock') { + lockfilesByDirectory.set(directory, join(directory, entry.name)); + } + } + } + + if (manifests.length === 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. + } + } + + for (const directory of [...manifestDirectories].sort()) { + const lockfile = lockfilesByDirectory.get(directory); + if (lockfile === undefined) continue; + try { + addLockedDependencies(readFileSync(lockfile, 'utf8'), gemNames); + } catch { + // Direct declarations still provide safe evidence when the lock is unreadable. + } + } + + return { gemNames }; +} diff --git a/gitnexus/src/core/ingestion/languages/ruby/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/ruby/scope-resolver.ts index 160bec94e..02035bc4f 100644 --- a/gitnexus/src/core/ingestion/languages/ruby/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/ruby/scope-resolver.ts @@ -10,6 +10,7 @@ import type { GraphNodeLookup } from '../../scope-resolution/graph-bridge/node-l import type { KnowledgeGraph } from '../../../graph/types.js'; import { generateId } from '../../../../lib/utils.js'; import { decodeMarker } from '../../utils/heritage-marker.js'; +import { loadRubyResolutionConfig } from './resolution-config.js'; /** * #1991: resolve a BARE mixin reference (`include Loggable`) to a nested module by @@ -268,6 +269,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/unit/scope-resolution/external-import-conformance.test.ts b/gitnexus/test/unit/scope-resolution/external-import-conformance.test.ts index 2d27a2268..3d20c5218 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,8 @@ 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']) }; /** 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 +274,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', @@ -373,7 +375,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..a9155423c --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/ruby/ruby-resolution-config.test.ts @@ -0,0 +1,75 @@ +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('loads literal Gemfile/gemspec dependencies and adjacent locked transitive gems', () => { + 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)', 'DEPENDENCIES'].join( + '\n', + ), + ); + mkdirSync(join(repo, 'packages')); + writeFileSync( + join(repo, 'packages', 'widget.gemspec'), + ["spec.add_dependency 'dry-types'", 'spec.add_development_dependency("rspec")'].join('\n'), + ); + + const config = loadRubyResolutionConfig(repo); + + expect(config?.gemNames).toEqual(new Set(['rails', 'pg', 'dry-types', 'rspec', 'actionpack'])); + }); + + 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('threads the config through the resolver without changing local bare or relative 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', + ]); + const config = await rubyScopeResolver.loadResolutionConfig?.(repo); + + expect( + rubyScopeResolver.resolveImportTarget('rails/generators', '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)).toBe('lib/generators.rb'); + expect(resolveRubyImportTarget('./generators', 'lib/main.rb', files, config)).toBe( + 'lib/generators.rb', + ); + }); +});