mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-08 03:08:13 +00:00
fix(ruby): guard gem requires with dependency metadata
This commit is contained in:
parent
b4d5aa4d7c
commit
8140430bdf
5 changed files with 207 additions and 3 deletions
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
120
gitnexus/src/core/ingestion/languages/ruby/resolution-config.ts
Normal file
120
gitnexus/src/core/ingestion/languages/ruby/resolution-config.ts
Normal 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 };
|
||||
}
|
||||
|
|
@ -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),
|
||||
|
||||
|
|
|
|||
|
|
@ -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`'],
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue