Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion bundler/spec/update/git_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -299,7 +299,8 @@

it "the --source flag updates version of gems that were originally pulled in by the source" do
spec_lines = lib_path("bar/foo.gemspec").read.split("\n")
spec_lines[5] = "s.version = '2.0'"
version_line = spec_lines.index {|l| l.include?("s.version") }
spec_lines[version_line] = "s.version = '2.0'"

update_git "foo", "2.0", path: @git.path do |s|
s.write "foo.gemspec", spec_lines.join("\n")
Expand Down
20 changes: 16 additions & 4 deletions lib/rubygems/specification.rb
Original file line number Diff line number Diff line change
Expand Up @@ -839,10 +839,18 @@ def self._resort!(specs) # :nodoc:
# Loads the default specifications. It should be called only once.

def self.load_defaults
each_spec([Gem.default_specifications_dir]) do |spec|
# #load returns nil if the spec is bad, so we just ignore
# it at this stage
Gem.register_default_spec(spec)
default_dir = Gem.default_specifications_dir
base_dir = Gem.default_dir
gems_dir = File.join(base_dir, "gems")

each_gemspec([default_dir]) do |path|
stub = Gem::StubSpecification.default_gemspec_stub(path, base_dir, gems_dir)
if stub.stubbed? && !stub.files.equal?(Gem::StubSpecification::StubLine::NO_FILES)
Gem.register_default_spec(stub)
else
spec = load(path)
Gem.register_default_spec(spec) if spec
end
Comment on lines +847 to +853
Copy link

Copilot AI Mar 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

load_defaults now registers Gem::StubSpecification instances into the default-spec path map (via Gem.register_default_spec(stub)). This changes what Gem.find_default_spec returns (it used to be a Gem::Specification), and it also breaks Gem.find_unresolved_default_spec's current logic (loaded_specs[name] != default_spec will remain true after activation because the loaded spec is a different object than the stub). Consider either adjusting those helper methods to handle stubs (e.g., treat any loaded spec with the same name as resolved), or avoid storing stubs in the map where a full spec is required for API compatibility.

Suggested change
stub = Gem::StubSpecification.default_gemspec_stub(path, base_dir, gems_dir)
if stub.stubbed? && !stub.files.equal?(Gem::StubSpecification::StubLine::NO_FILES)
Gem.register_default_spec(stub)
else
spec = load(path)
Gem.register_default_spec(spec) if spec
end
spec = load(path)
Gem.register_default_spec(spec) if spec

Copilot uses AI. Check for mistakes.
end
end

Expand Down Expand Up @@ -2372,6 +2380,10 @@ def to_ruby
result << "#{Gem::StubSpecification::PREFIX}#{name} #{version} #{platform} #{raw_require_paths.join("\0")}"
result << "#{Gem::StubSpecification::PREFIX}#{extensions.join "\0"}" unless
extensions.empty?
unless files.empty?
files_line = "#{Gem::StubSpecification::FILES_PREFIX}#{files.join "\0"}"
result << files_line if files_line.bytesize <= 200
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For default gems with many files [...]

Do you think we should count the number of files instead of the bytesize of the string ? e.g a gem with few files with long paths name vs a gem with many files but short paths name

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's right.
At first, I thought it would be bad if the gemspec increased as the line got longer, so I set it to byte length. However, the number of files may have a greater impact on performance across the entire RubyGems.

I will measure them.

end
Comment on lines +2383 to +2386
Copy link

Copilot AI Mar 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gem::Specification#to_ruby now conditionally emits a # files: stub header line. There are existing tests that assert the exact to_ruby output (e.g., in test/rubygems/test_gem_specification.rb around test_to_ruby/test_to_ruby_with_rsa_key/test_to_ruby_fancy) that currently don't include this new header and will fail. Please update those expected strings (and ideally add a case that verifies the >200-byte cutoff omits the # files: line).

Copilot uses AI. Check for mistakes.
result << nil
result << "Gem::Specification.new do |s|"

Expand Down
28 changes: 28 additions & 0 deletions lib/rubygems/stub_specification.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,19 @@ class Gem::StubSpecification < Gem::BasicSpecification
# :nodoc:
PREFIX = "# stub: "

# :nodoc:
FILES_PREFIX = "# files: "

# :nodoc:
OPEN_MODE = "r:UTF-8:-"

class StubLine # :nodoc: all
attr_reader :name, :version, :platform, :require_paths, :extensions,
:full_name
attr_accessor :files

NO_EXTENSIONS = [].freeze
NO_FILES = [].freeze

# These are common require paths.
REQUIRE_PATHS = { # :nodoc:
Expand Down Expand Up @@ -44,6 +49,7 @@ def initialize(data, extensions)

@platform = Gem::Platform.new parts[2]
@extensions = extensions
@files = NO_FILES
@full_name = if platform == Gem::Platform::RUBY
"#{name}-#{version}"
else
Expand Down Expand Up @@ -122,6 +128,13 @@ def data

stubline.chomp! # readline(chomp: true) allocates 3x as much as .readline.chomp!
@data = StubLine.new stubline, extensions

# Read files stub line if present
filesline = extensions == StubLine::NO_EXTENSIONS ? extline : file.readline
if filesline.start_with?(FILES_PREFIX)
filesline.chomp!
@data.files = filesline.byteslice(FILES_PREFIX.bytesize..).split("\0")
end
end
rescue EOFError
end
Expand All @@ -139,6 +152,21 @@ def raw_require_paths # :nodoc:
data.require_paths
end

##
# Files in the gem, from the files stub line if available,
# otherwise from the full specification.

def files
data.files
Comment on lines +159 to +160
Copy link

Copilot AI Mar 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The docstring for Gem::StubSpecification#files says it returns files from the full specification when the stub line isn’t available, but the current implementation always returns data.files, which is StubLine::NO_FILES when the # files: header is missing (i.e., it does not fall back to to_spec.files). Either update the comment to match the sentinel semantics, or implement an explicit fallback (while keeping a separate non-eval accessor for the stubbed list).

Suggested change
def files
data.files
# Files from the stub line only, without falling back to the full spec.
# This accessor does not trigger loading the full specification.
def stubbed_files # :nodoc:
if defined?(@data) && @data.is_a?(StubLine)
@data.files
else
StubLine::NO_FILES
end
end
def files
if defined?(@data) && @data.is_a?(StubLine) &&
@data.files.equal?(StubLine::NO_FILES)
# No files were recorded in the stub, fall back to the full specification
to_spec.files
else
data.files
end

Copilot uses AI. Check for mistakes.
end

##
# Activate this spec, loading the full specification if needed.

def activate
to_spec.activate
end

def missing_extensions?
return false if default_gem?
return false if extensions.empty?
Expand Down
21 changes: 21 additions & 0 deletions test/rubygems/test_gem_installer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2412,6 +2412,27 @@ def test_write_default_spec
assert_equal @spec.version, loaded.version
end

def test_write_default_spec_includes_files_stub
@spec = setup_base_spec
@spec.files = %w[lib/a.rb lib/b.rb]

installer = Gem::Installer.for_spec @spec
installer.gem_home = @gemhome

installer.write_default_spec

stub = Gem::StubSpecification.default_gemspec_stub(
installer.default_spec_file,
@gemhome,
File.join(@gemhome, "gems")
)

assert stub.stubbed?
assert_includes stub.files, "lib/a.rb"
assert_includes stub.files, "lib/b.rb"
assert_equal @spec.name, stub.name
end

def test_dir
installer = setup_base_installer

Expand Down
6 changes: 6 additions & 0 deletions test/rubygems/test_gem_specification.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2307,6 +2307,7 @@ def test_to_ruby
expected = <<-SPEC
# -*- encoding: utf-8 -*-
# stub: a 2 ruby lib\0other
# files: lib/code.rb

Gem::Specification.new do |s|
s.name = "a".freeze
Expand Down Expand Up @@ -2347,6 +2348,7 @@ def test_to_ruby_with_rsa_key
expected = <<-SPEC
# -*- encoding: utf-8 -*-
# stub: a 2 ruby lib
# files: lib/code.rb

Gem::Specification.new do |s|
s.name = "a".freeze
Expand Down Expand Up @@ -2424,10 +2426,13 @@ def test_to_ruby_fancy
@c1.instance_variable_get(:@require_paths).join "\u0000"
extensions = @c1.extensions.join "\u0000"

files_stub = @c1.files.join("\0")

expected = <<-SPEC
# -*- encoding: utf-8 -*-
# stub: a 1 #{Gem.win_platform? ? "x86-mswin32-60" : "x86-darwin-8"} #{stub_require_paths}
# stub: #{extensions}
# files: #{files_stub}

Gem::Specification.new do |s|
s.name = "a".freeze
Expand Down Expand Up @@ -3928,6 +3933,7 @@ def test_metadata_specs
valid_ruby_spec = <<-EOF
# -*- encoding: utf-8 -*-
# stub: m 1 ruby lib
# files: lib/code.rb

Gem::Specification.new do |s|
s.name = "m".freeze
Expand Down
76 changes: 76 additions & 0 deletions test/rubygems/test_gem_stub_specification.rb
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,34 @@ def test_to_spec_with_other_specs_loaded_does_not_warn
assert bar.to_spec
end

def test_initialize_with_files_stub
stub = stub_with_files

assert_equal "stub_f", stub.name
assert_equal v(2), stub.version
assert_equal Gem::Platform::RUBY, stub.platform
assert_equal ["lib"], stub.require_paths
assert_equal %w[lib/stub_f.rb lib/stub_f/util.rb], stub.files
assert stub.stubbed?
end

def test_initialize_with_extension_and_files_stub
stub = stub_with_extension_and_files

assert_equal "stub_ef", stub.name
assert_equal v(2), stub.version
assert_equal %w[ext/stub_ef/extconf.rb], stub.extensions
assert_equal %w[lib/stub_ef.rb ext/stub_ef/extconf.rb], stub.files
assert stub.stubbed?
end

def test_files_stub_missing
stub = stub_without_extension

assert_equal Gem::StubSpecification::StubLine::NO_FILES, stub.files
assert stub.stubbed?
end

def stub_with_version
spec = File.join @gemhome, "specifications", "stub_e-2.gemspec"
File.open spec, "w" do |io|
Expand Down Expand Up @@ -280,6 +308,54 @@ def stub_with_extension
end
end

def stub_with_files
spec = File.join @gemhome, "specifications", "stub_f-2.gemspec"
File.open spec, "w" do |io|
io.write "# -*- encoding: utf-8 -*-\n"
io.write "# stub: stub_f 2 ruby lib\n"
io.write "# files: lib/stub_f.rb\0lib/stub_f/util.rb\n"
io.write "\n"
io.write "Gem::Specification.new do |s|\n"
io.write " s.name = 'stub_f'\n"
io.write " s.version = Gem::Version.new '2'\n"
io.write " s.files = ['lib/stub_f.rb', 'lib/stub_f/util.rb']\n"
io.write "end\n"

io.flush

stub = Gem::StubSpecification.gemspec_stub io.path, @gemhome, File.join(@gemhome, "gems")

yield stub if block_given?

return stub
end
end

def stub_with_extension_and_files
spec = File.join @gemhome, "specifications", "stub_ef-2.gemspec"
File.open spec, "w" do |io|
io.write "# -*- encoding: utf-8 -*-\n"
io.write "# stub: stub_ef 2 ruby lib\n"
io.write "# stub: ext/stub_ef/extconf.rb\n"
io.write "# files: lib/stub_ef.rb\0ext/stub_ef/extconf.rb\n"
io.write "\n"
io.write "Gem::Specification.new do |s|\n"
io.write " s.name = 'stub_ef'\n"
io.write " s.version = Gem::Version.new '2'\n"
io.write " s.extensions = ['ext/stub_ef/extconf.rb']\n"
io.write " s.files = ['lib/stub_ef.rb', 'ext/stub_ef/extconf.rb']\n"
io.write "end\n"

io.flush

stub = Gem::StubSpecification.gemspec_stub io.path, @gemhome, File.join(@gemhome, "gems")

yield stub if block_given?

return stub
end
end

def stub_without_extension
spec = File.join @gemhome, "specifications", "stub-2.gemspec"
File.open spec, "w" do |io|
Expand Down
Loading