diff --git a/docs/plans/2026-08-30-gitnexus-plan-ruby-gem-require-boundary.md b/docs/plans/2026-08-30-gitnexus-plan-ruby-gem-require-boundary.md new file mode 100644 index 000000000..a57b8b1dd --- /dev/null +++ b/docs/plans/2026-08-30-gitnexus-plan-ruby-gem-require-boundary.md @@ -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 (§2–3) + +- [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 (§4–5) + +- [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. diff --git a/gitnexus/src/core/ingestion/languages/ruby/import-target.ts b/gitnexus/src/core/ingestion/languages/ruby/import-target.ts index 62d6fca3d..462a47e08 100644 --- a/gitnexus/src/core/ingestion/languages/ruby/import-target.ts +++ b/gitnexus/src/core/ingestion/languages/ruby/import-target.ts @@ -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, - _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): 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 new file mode 100644 index 000000000..52c213af3 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/ruby/resolution-config.ts @@ -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; + /** Require prefix -> repository-relative Ruby load roots for local gems. */ + readonly localLoadRootsByPrefix: ReadonlyMap; +} + +export interface RubyResolutionConfig { + /** 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; +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 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(), + 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 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(); + 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(); + 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(); + 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'))) { + 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(); + 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(); + for (const [directory, scope] of mutableScopes) { + scopesByDirectory.set(directory, freezeScope(scope)); + } + return { scopesByDirectory }; +} 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/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 dca6f187d..7ba1e5efa 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,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 = 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 = 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..b61b8b393 --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/ruby/ruby-resolution-config.test.ts @@ -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'); + }); +});