mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-17 23:52:36 +00:00
* 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>
220 lines
8.7 KiB
TypeScript
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');
|
|
});
|
|
});
|