/** * Production-path regression guard for the Ruby import-resolution index (#2880). * * Ruby's bare `require` leg reads the shared `getWorkspaceFileIndex` * (`import-resolvers/workspace-file-index.ts`), memoized on the `allFilePaths` * Set identity via a WeakMap. Before #2880 every single `require` materialized * two arrays AND built a whole `buildSuffixIndex` over the repo, then threw it * away — the most expensive of the four resolvers hoisted. * * Resolution reaches that index through `rubyScopeResolver.resolveImportTarget` * — the orchestrator adapter — not by calling `resolveRubyImportTarget` * directly the way the unit parity test does. The adapter must therefore pass * the Set THROUGH; a defensive copy (`new Set(allFilePaths)`) would hand a * fresh WeakMap key per call and restore the per-require rebuild. Python hit * exactly that (PR #1918 review P1), and the parity test cannot see it: it * never crosses the adapter. * * The traversal-count assertions are the perf guard. They are paired with * result assertions on purpose: a count of 1 is equally true of an adapter that * has stopped resolving anything at all, so counting alone would stay green * while every Ruby IMPORTS edge disappeared. */ import { describe, it, expect } from 'vitest'; import { rubyScopeResolver } from '../../src/core/ingestion/languages/ruby/scope-resolver.js'; import { CountingSet, expectDistinctFileSetsGetOwnIndex } from '../helpers/counting-file-set.js'; const { resolveImportTarget } = rubyScopeResolver; const FROM_FILE = 'lib/main.rb'; /** * A synthetic Ruby app: many service files plus the two requires below — * `app/models/user` (a multi-segment suffix hit) and `util` (a single-segment * one). `index.rb` covers the `require_relative` directory form. */ function buildWorkspace(fileCount: number): CountingSet { const files: string[] = []; for (let i = 0; i < fileCount; i++) { files.push(`lib/app/services/service${String(i).padStart(5, '0')}.rb`); } files.push('lib/app/models/user.rb'); files.push('lib/util.rb'); files.push('lib/support/index.rb'); files.push('lib/main.rb'); return new CountingSet(files); } describe('Ruby import resolution — index reuse across requires (#2880)', () => { it('builds the workspace index once for many requires over a stable file set', () => { const files = buildWorkspace(300); const resolved: (string | readonly string[] | null)[] = []; for (let i = 0; i < 200; i++) { // A resolvable bare require, and a gem-shaped one that misses. The miss // is the expensive case: it walks every suffix × every extension before // returning null, and used to rebuild the suffix index first. resolved.push(resolveImportTarget('app/models/user', FROM_FILE, files)); resolved.push(resolveImportTarget(`gem${i}/missing`, FROM_FILE, files)); } expect(files.scans).toBe(1); // Paired result assertion — a count of 1 must not be the count of an // adapter that resolves nothing. expect(resolved[0]).toBe('lib/app/models/user.rb'); expect(resolved[1]).toBeNull(); }); it('a distinct file set gets its own index (no stale cross-run reuse)', () => { expectDistinctFileSetsGetOwnIndex({ resolveImportTarget, buildWorkspace: () => buildWorkspace(20), targetRaw: 'app/models/user', fromFile: FROM_FILE, resolutionConfig: undefined, expected: 'lib/app/models/user.rb', expectedScans: 1, }); }); it('still resolves real requires correctly (the perf test is not vacuous)', () => { const files = buildWorkspace(5); // Bare `require`: multi-segment and single-segment suffix matches. expect(resolveImportTarget('app/models/user', FROM_FILE, files)).toBe('lib/app/models/user.rb'); expect(resolveImportTarget('util', FROM_FILE, files)).toBe('lib/util.rb'); // `require_relative`: resolved against the importer's directory, `.rb` // first and then `/index.rb`. This leg answers from `Set.has` and // never touches the index, which is why the counting arm drives bare // requires instead. expect(resolveImportTarget('./util', FROM_FILE, files)).toBe('lib/util.rb'); expect(resolveImportTarget('./support', FROM_FILE, files)).toBe('lib/support/index.rb'); expect(resolveImportTarget('../models/user', 'lib/app/services/service00000.rb', files)).toBe( 'lib/app/models/user.rb', ); // Gems with no matching file in the repo resolve to nothing. expect(resolveImportTarget('net/http', FROM_FILE, files)).toBeNull(); }); });