fix(ruby): guard gem requires with dependency metadata

This commit is contained in:
y4ho0 2026-08-30 04:59:48 +08:00
parent b4d5aa4d7c
commit 8140430bdf
5 changed files with 207 additions and 3 deletions

View file

@ -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<string>,
_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);
}

View file

@ -0,0 +1,120 @@
import { readdirSync, readFileSync, type Dirent } from 'node:fs';
import { dirname, join } from 'node:path';
export interface RubyResolutionConfig {
readonly gemNames: ReadonlySet<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 LOCKFILE_SPECS_HEADER = /^ {2}specs:\s*$/;
const LOCKFILE_SPEC = /^ {4}([A-Za-z0-9_.-]+) \(/;
function addLiteralDependencies(
contents: string,
dependencyPattern: RegExp,
gemNames: Set<string>,
): 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<string>): 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<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'))) {
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<string>();
const manifestDirectories = new Set<string>();
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 };
}

View file

@ -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),

View file

@ -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<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',
@ -373,7 +375,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`'],

View file

@ -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',
);
});
});