This commit is contained in:
Yahoo 2026-09-05 20:45:53 +08:00 committed by GitHub
commit 4fe56e11d0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 858 additions and 6 deletions

View file

@ -0,0 +1,204 @@
# GitNexus Engineering Plan
> Task: Prevent Ruby gem requires from suffix-resolving to unrelated repository files (#2966).
> Evidence verified at commit 170eefd4a0893be9d255dc3581d51fd448ce2e96; GitNexus index fresh at that commit with schema-4 runner identity.
> Evidence provenance schema 2; global dirty digest 0a9c85780067d9afcd0764f307b60891e3cee927ee11eaeb5ec7826d10fd82cd; cited-path manifest 10 sorted entries; exact generated plan path excluded.
## Objective (§1)
Block a bare Ruby `require` before suffix matching when its first path segment is a dependency declared by Bundler/RubyGems metadata, while preserving local bare and `require_relative` resolution and failing open when no Gemfile or gemspec is available.
## Current Behaviour (§23)
- [verified] `resolveRubyImportTarget` sends every non-relative target to `resolveBare`, whose progressive suffix matching can drop `rails/` and select `lib/generators.rb` (`gitnexus/src/core/ingestion/languages/ruby/import-target.ts:34`).
- [verified] The scope-resolution pipeline already loads each language's opaque workspace config once and passes it to every import lookup (`gitnexus/src/core/ingestion/scope-resolution/pipeline/phase.ts:373`).
- [verified] Ruby currently exposes no `loadResolutionConfig`; PHP demonstrates the established bounded manifest-scan and resolver-config seam (`gitnexus/src/core/ingestion/languages/php/import-target.ts:295`).
- [verified] Existing parity coverage calls Ruby with no config and exercises both bare and `./`/`../` targets, pinning the fail-open and relative paths (`gitnexus/test/unit/scope-resolution/import-target-index-parity.test.ts:412`).
## Findings (§45)
- [graph] `impact resolveRubyImportTarget --direction upstream --depth 3 --include-tests` returned LOW risk with exactly two direct dependents: `rubyScopeResolver.resolveImportTarget` and `import-target-index-parity.test.ts`.
- [graph] `context resolveRubyImportTarget --file gitnexus/src/core/ingestion/languages/ruby/import-target.ts` found calls only to `resolveBare`, `resolveRelative`, and `isHeritageMarker`; no process membership was reported.
- [verified] The conformance fixture records `rails/generators -> lib/generators.rb` as Ruby's known external-import gap (`gitnexus/test/unit/scope-resolution/external-import-conformance.test.ts:270`, `:375`).
- No PDG slice is needed: this local fix adds one data gate before an existing pure resolver branch and changes no trust, persistence, concurrency, or state-mutation boundary.
## Proposed Changes (§6)
- Add `ruby/resolution-config.ts` with a bounded, static scanner that collects dependency names from Gemfile and gemspec declarations and transitive gem names from adjacent `Gemfile.lock` files; return `null` when no declarative manifest exists.
- Add `rubyScopeResolver.loadResolutionConfig` so the existing one-load-per-workspace pipeline threads the Ruby dependency set into import resolution.
- Update `resolveRubyImportTarget` to return `null` for bare targets whose first segment is in that set, before suffix matching; leave relative and no-config paths unchanged.
- Remove Ruby from `KNOWN_GAPS`, provide `rails` config to that fixture, and add focused loader/gate tests for Gemfile, gemspec, lockfile, local bare paths, relative paths, and no-manifest fail-open behavior.
## Implementation Sequence (§7)
1. Add the manifest scanner and focused tests; keep parsing static and bounded, with skipped dependency/build directories. Risk: Ruby manifests are executable, so recognize only explicit literal dependency declarations and never evaluate them.
2. Wire the config loader into `rubyScopeResolver`, then gate only non-relative targets in `resolveRubyImportTarget`. Risk: dependency-name/require-path conventions are not universal, so match only the literal first segment proven by metadata.
3. Turn the shared conformance fixture green, run focused/full validation, refresh the local graph, and account for all changed symbols before the implementation commit.
## Test Strategy (§8)
- First make the `rails/generators` regression test fail on the pre-fix resolver, then implement the gate.
- Assert declared direct and locked transitive dependencies return `null` despite decoy suffixes.
- Assert `app/models/user`, `generators`, and `require_relative` continue resolving; assert a workspace without Gemfile/gemspec retains legacy fail-open behavior.
- Run `npm test` and `npx tsc --noEmit` from `gitnexus`, plus targeted Vitest, formatting/lint checks, and `git diff --check`.
## Implementation Context (§11)
```yaml
implementation_context:
task_summary: 'Prevent declared Ruby gem requires from suffix-resolving to unrelated repository files.'
evidence_provenance:
schema_version: 2
head_commit: '170eefd4a0893be9d255dc3581d51fd448ce2e96'
generated_plan_path: 'docs/plans/2026-08-30-gitnexus-plan-ruby-gem-require-boundary.md'
global_dirty_digest:
algorithm: 'sha256'
canonicalization: 'gitnexus-evidence-provenance-v2 NUL-framed UTF-8 records'
value: '0a9c85780067d9afcd0764f307b60891e3cee927ee11eaeb5ec7826d10fd82cd'
cited_path_manifest:
- path: 'gitnexus/package.json'
object_kind: { head: 'regular', index: 'regular', worktree: 'regular', untracked: 'absent' }
state: 'clean'
rename_from: null
rename_to: null
head_digest: 'sha256:157760d8e69666e9e228091077a8c62787d15c1e9e43b059601093d90c081976'
index_digest: 'sha256:157760d8e69666e9e228091077a8c62787d15c1e9e43b059601093d90c081976'
worktree_digest: 'sha256:157760d8e69666e9e228091077a8c62787d15c1e9e43b059601093d90c081976'
untracked_digest: 'absent'
- path: 'gitnexus/src/core/ingestion/languages/php/import-target.ts'
object_kind: { head: 'regular', index: 'regular', worktree: 'regular', untracked: 'absent' }
state: 'clean'
rename_from: null
rename_to: null
head_digest: 'sha256:4e73c2e3f1322defaafcd543d715ab351c2d4f5af0fa481c3c81b3543e7618de'
index_digest: 'sha256:4e73c2e3f1322defaafcd543d715ab351c2d4f5af0fa481c3c81b3543e7618de'
worktree_digest: 'sha256:4e73c2e3f1322defaafcd543d715ab351c2d4f5af0fa481c3c81b3543e7618de'
untracked_digest: 'absent'
- path: 'gitnexus/src/core/ingestion/languages/ruby/import-target.ts'
object_kind: { head: 'regular', index: 'regular', worktree: 'regular', untracked: 'absent' }
state: 'clean'
rename_from: null
rename_to: null
head_digest: 'sha256:326155551dda5d759e6d6e21b09b4e8b5c90b956ed11905568b146e8cc8a3fb8'
index_digest: 'sha256:326155551dda5d759e6d6e21b09b4e8b5c90b956ed11905568b146e8cc8a3fb8'
worktree_digest: 'sha256:326155551dda5d759e6d6e21b09b4e8b5c90b956ed11905568b146e8cc8a3fb8'
untracked_digest: 'absent'
- path: 'gitnexus/src/core/ingestion/languages/ruby/resolution-config.ts'
object_kind: { head: 'absent', index: 'absent', worktree: 'absent', untracked: 'absent' }
state: 'absent'
rename_from: null
rename_to: null
head_digest: 'absent'
index_digest: 'absent'
worktree_digest: 'absent'
untracked_digest: 'absent'
- path: 'gitnexus/src/core/ingestion/languages/ruby/scope-resolver.ts'
object_kind: { head: 'regular', index: 'regular', worktree: 'regular', untracked: 'absent' }
state: 'clean'
rename_from: null
rename_to: null
head_digest: 'sha256:d6b77df3b87b341f3cd7b30576e8fd9b89fbc78dec55a71cf4788c15deebc1bf'
index_digest: 'sha256:d6b77df3b87b341f3cd7b30576e8fd9b89fbc78dec55a71cf4788c15deebc1bf'
worktree_digest: 'sha256:d6b77df3b87b341f3cd7b30576e8fd9b89fbc78dec55a71cf4788c15deebc1bf'
untracked_digest: 'absent'
- path: 'gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts'
object_kind: { head: 'regular', index: 'regular', worktree: 'regular', untracked: 'absent' }
state: 'clean'
rename_from: null
rename_to: null
head_digest: 'sha256:84c8a5edc9776551df216cf3159fdbb0a68ebfb7b2f6d317cc0dd6e8522d93ab'
index_digest: 'sha256:84c8a5edc9776551df216cf3159fdbb0a68ebfb7b2f6d317cc0dd6e8522d93ab'
worktree_digest: 'sha256:84c8a5edc9776551df216cf3159fdbb0a68ebfb7b2f6d317cc0dd6e8522d93ab'
untracked_digest: 'absent'
- path: 'gitnexus/src/core/ingestion/scope-resolution/pipeline/phase.ts'
object_kind: { head: 'regular', index: 'regular', worktree: 'regular', untracked: 'absent' }
state: 'clean'
rename_from: null
rename_to: null
head_digest: 'sha256:911f7cd3d76fc0c47db7498f93c163571440f7dd1d2c6bd9e23f1423465a1398'
index_digest: 'sha256:911f7cd3d76fc0c47db7498f93c163571440f7dd1d2c6bd9e23f1423465a1398'
worktree_digest: 'sha256:911f7cd3d76fc0c47db7498f93c163571440f7dd1d2c6bd9e23f1423465a1398'
untracked_digest: 'absent'
- path: 'gitnexus/test/unit/scope-resolution/external-import-conformance.test.ts'
object_kind: { head: 'regular', index: 'regular', worktree: 'regular', untracked: 'absent' }
state: 'clean'
rename_from: null
rename_to: null
head_digest: 'sha256:faa3f40d302ea7e4b9914aeb91b3ef3a32c511a4c4be16cea966ee0d0e92de55'
index_digest: 'sha256:faa3f40d302ea7e4b9914aeb91b3ef3a32c511a4c4be16cea966ee0d0e92de55'
worktree_digest: 'sha256:faa3f40d302ea7e4b9914aeb91b3ef3a32c511a4c4be16cea966ee0d0e92de55'
untracked_digest: 'absent'
- path: 'gitnexus/test/unit/scope-resolution/import-target-index-parity.test.ts'
object_kind: { head: 'regular', index: 'regular', worktree: 'regular', untracked: 'absent' }
state: 'clean'
rename_from: null
rename_to: null
head_digest: 'sha256:0154615b77157bbfd413867b6c83a837d7fa5d2bf03ecbc969cca873021cf51c'
index_digest: 'sha256:0154615b77157bbfd413867b6c83a837d7fa5d2bf03ecbc969cca873021cf51c'
worktree_digest: 'sha256:0154615b77157bbfd413867b6c83a837d7fa5d2bf03ecbc969cca873021cf51c'
untracked_digest: 'absent'
- path: 'gitnexus/test/unit/scope-resolution/ruby/ruby-resolution-config.test.ts'
object_kind: { head: 'absent', index: 'absent', worktree: 'absent', untracked: 'absent' }
state: 'absent'
rename_from: null
rename_to: null
head_digest: 'absent'
index_digest: 'absent'
worktree_digest: 'absent'
untracked_digest: 'absent'
files_to_modify:
- file: 'gitnexus/src/core/ingestion/languages/ruby/resolution-config.ts'
symbols: ['RubyResolutionConfig', 'loadRubyResolutionConfig']
intended_change: 'Statically collect declared and locked gem names with bounded traversal; return null without Gemfile/gemspec evidence.'
- file: 'gitnexus/src/core/ingestion/languages/ruby/import-target.ts'
symbols: ['resolveRubyImportTarget']
intended_change: 'Reject declared external-gem roots before bare suffix resolution.'
- file: 'gitnexus/src/core/ingestion/languages/ruby/scope-resolver.ts'
symbols: ['rubyScopeResolver']
intended_change: 'Load Ruby resolution config once per workspace.'
- file: 'gitnexus/test/unit/scope-resolution/external-import-conformance.test.ts'
symbols: ['CASES', 'KNOWN_GAPS']
intended_change: 'Provide rails dependency evidence and remove the Ruby gap.'
- file: 'gitnexus/test/unit/scope-resolution/ruby/ruby-resolution-config.test.ts'
symbols: []
intended_change: 'Cover manifest parsing, gate behavior, local positives, relative behavior, and fail-open mode.'
tests:
- file: 'gitnexus/test/unit/scope-resolution/external-import-conformance.test.ts'
scenarios:
- "declared rails + require rails/generators + local generators.rb decoy -> null"
- "same workspace + require generators -> lib/generators.rb"
- file: 'gitnexus/test/unit/scope-resolution/ruby/ruby-resolution-config.test.ts'
scenarios:
- 'Gemfile/gemspec direct dependency and Gemfile.lock transitive name -> collected roots'
- 'no Gemfile/gemspec -> null config and legacy bare resolution remains available'
- 'declared dependency target -> null; local app/models/user -> local file'
- 'require_relative target -> importer-relative file despite matching dependency name'
verification_commands:
- 'cd gitnexus && npx vitest run test/unit/scope-resolution/external-import-conformance.test.ts test/unit/scope-resolution/ruby/ruby-resolution-config.test.ts test/unit/scope-resolution/import-target-index-parity.test.ts'
- 'cd gitnexus && npm test'
- 'cd gitnexus && npx tsc --noEmit'
- 'cd gitnexus && npx eslint src/core/ingestion/languages/ruby test/unit/scope-resolution/external-import-conformance.test.ts test/unit/scope-resolution/ruby/ruby-resolution-config.test.ts'
- 'cd gitnexus && npx prettier --check src/core/ingestion/languages/ruby/import-target.ts src/core/ingestion/languages/ruby/resolution-config.ts src/core/ingestion/languages/ruby/scope-resolver.ts test/unit/scope-resolution/external-import-conformance.test.ts test/unit/scope-resolution/ruby/ruby-resolution-config.test.ts'
- 'git diff --check'
assumptions:
- 'Recheck immediately before implementation that #2966 is open and has no overlapping PR.'
- 'Verify the loader returns null when no Gemfile/gemspec is found, even if a lockfile exists.'
open_questions: []
avoid:
- 'Do not evaluate Ruby manifest code.'
- 'Do not classify undeclared require roots as external.'
- 'Do not change suffix-matching order, WorkspaceFileIndex reuse, or require_relative semantics.'
- 'Do not expand this PR to conventional-load-root heuristics beyond the accepted dependency gate.'
```
## Assumptions and Open Questions (§12)
- [assumed] Literal dependency declarations cover the accepted gate; dynamic Gemfile/gemspec logic remains fail-open and is explicitly deferred.
- [assumed] A lockfile contributes transitive names only when at least one Gemfile/gemspec proves the repository is Bundler/RubyGems-managed.
- No blocking open question remains; first-segment aliases for gems whose require path differs from their package name are deferred because metadata alone cannot prove them safely.
## Definition of Done (§13)
- `rails/generators` is unresolved with declared `rails`; local bare and relative fixtures still resolve.
- Ruby is removed from `KNOWN_GAPS`; focused, full, typecheck, lint, formatting, diff, and graph-change gates pass.
- No Ruby code is executed while reading manifests, and no-config workspaces retain existing behavior.

View file

@ -10,6 +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 {
findRubyResolutionScope,
type RubyResolutionConfig,
type RubyResolutionScope,
} from './resolution-config.js';
export interface RubyResolveContext {
readonly fromFile: string;
@ -35,7 +40,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;
@ -51,7 +56,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 !== null) return localGemTarget;
if (matchesRequirePrefix(targetRaw, scope.externalRequirePrefixes)) return null;
}
return resolveBare(targetRaw, allFilePaths);
}
@ -95,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<string>): 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>,
): 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.

View file

@ -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<string>;
/** Require prefix -> repository-relative Ruby load roots for local gems. */
readonly localLoadRootsByPrefix: ReadonlyMap<string, readonly string[]>;
}
export interface RubyResolutionConfig {
/** Manifest directory (repository-relative POSIX path) -> dependency scope. */
readonly scopesByDirectory: ReadonlyMap<string, RubyResolutionScope>;
}
interface MutableRubyResolutionScope {
readonly externalRequirePrefixes: Set<string>;
readonly localLoadRootsByPrefix: Map<string, Set<string>>;
}
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(?<options>.*)$/;
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<string>(),
localLoadRootsByPrefix: new Map<string, Set<string>>(),
};
}
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<string>([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<string>();
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<string, readonly string[]>();
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<string, 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'))) {
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<string, MutableRubyResolutionScope>();
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<string, RubyResolutionScope>();
for (const [directory, scope] of mutableScopes) {
scopesByDirectory.set(directory, freezeScope(scope));
}
return { scopesByDirectory };
}

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

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

View file

@ -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<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',
@ -389,7 +401,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,193 @@
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(
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', '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');
});
});