GitNexus/gitnexus/test/unit/scope-resolution/ruby/ruby-resolution-config.test.ts
Yahoo 0edf9ce0ff
fix(ruby): guard gem requires with dependency metadata (#3096)
* docs(plans): add ruby gem require boundary plan

* fix(ruby): guard gem requires with dependency metadata

* fix(ruby): scope gem sources by manifest

* test(ruby): model resolved lockfile specs

* fix(ruby): stop local gem suffix fallthrough

* docs: remove Ruby resolution plan

* test(ruby): gate gem resolution correctness and scaling

* test(ruby): align gem benchmark baseline with ratio gates

* Address PR review feedback (#3096)

- Strip a trailing .rb so local and external gem prefix matching follows Ruby's optional-suffix require.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-10 16:04:11 +01:00

220 lines
8.7 KiB
TypeScript

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(resolveRubyImportTarget('rails.rb', 'lib/main.rb', files, config)).toBeNull();
expect(resolveRubyImportTarget('rails/generators.rb', '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.rb', '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',
'engines/my_engine/lib/my_engine/feature.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(resolveRubyImportTarget('my_engine.rb', 'lib/main.rb', files, config)).toBe(
'engines/my_engine/lib/my_engine.rb',
);
expect(resolveRubyImportTarget('my_engine/feature.rb', 'lib/main.rb', files, config)).toBe(
'engines/my_engine/lib/my_engine/feature.rb',
);
expect(config?.scopesByDirectory.get('')?.externalRequirePrefixes).not.toContain('my_engine');
});
it('does not suffix-fallback when a local gem has no in-repository load root', () => {
const repo = makeRepo();
writeFileSync(
join(repo, 'local_widget.gemspec'),
["spec.name = 'local_widget'", "spec.require_paths = ['../outside']"].join('\n'),
);
const files = new Set(['lib/feature.rb', 'lib/main.rb']);
const config = loadRubyResolutionConfig(repo);
expect(config?.scopesByDirectory.get('')?.localLoadRootsByPrefix.get('local_widget')).toEqual(
[],
);
expect(
resolveRubyImportTarget('local_widget/feature', 'lib/main.rb', files, config),
).toBeNull();
});
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');
});
});