From b98a510534fafcf44ab7265c1c73692c81c79ee0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20V=C3=A1squez?= Date: Tue, 9 Jun 2026 12:33:35 -0600 Subject: [PATCH 1/2] Detect locally-sourced gems in bundle_report outdated A gem pulled in via `path:` (e.g. a private engine) could share its name with an unrelated public gem on rubygems. `bundle_report outdated` looked up the latest version by name, matched the public gem, and reported a bogus upgrade. Add `GemInfo#sourced_locally?`, which reads the Bundler source type from the lockfile (path source, excluding git which is already reported separately). `outdated` now excludes locally-sourced gems from the out-of-date check and reports them in a separate count, mirroring how git-sourced gems are handled. Closes #29 --- lib/next_rails/bundle_report.rb | 21 ++++++++++----- lib/next_rails/gem_info.rb | 8 ++++++ spec/next_rails/bundle_report_spec.rb | 39 +++++++++++++++++++++++---- spec/next_rails/gem_info_spec.rb | 34 +++++++++++++++++++++++ 4 files changed, 90 insertions(+), 12 deletions(-) diff --git a/lib/next_rails/bundle_report.rb b/lib/next_rails/bundle_report.rb index 1ec958f..26f09f2 100644 --- a/lib/next_rails/bundle_report.rb +++ b/lib/next_rails/bundle_report.rb @@ -63,22 +63,27 @@ def compatible_ruby_version(rails_version) def outdated(format = nil) gems = NextRails::GemInfo.all - out_of_date_gems = gems.reject(&:up_to_date?).sort_by(&:created_at) + sourced_locally = gems.select(&:sourced_locally?) sourced_from_git = gems.select(&:sourced_from_git?) + # Locally-sourced gems (e.g. `path:` engines) are excluded from the + # out-of-date check: looking them up by name on rubygems can match an + # unrelated public gem with the same name and report a bogus upgrade. + out_of_date_gems = (gems - sourced_locally).reject(&:up_to_date?).sort_by(&:created_at) + if format == 'json' - output_to_json(out_of_date_gems, gems.count, sourced_from_git.count) + output_to_json(out_of_date_gems, gems.count, sourced_from_git.count, sourced_locally.count) else - output_to_stdout(out_of_date_gems, gems.count, sourced_from_git.count) + output_to_stdout(out_of_date_gems, gems.count, sourced_from_git.count, sourced_locally.count) end end - def output_to_json(out_of_date_gems, total_gem_count, sourced_from_git_count) - obj = build_json(out_of_date_gems, total_gem_count, sourced_from_git_count) + def output_to_json(out_of_date_gems, total_gem_count, sourced_from_git_count, sourced_locally_count) + obj = build_json(out_of_date_gems, total_gem_count, sourced_from_git_count, sourced_locally_count) puts JSON.pretty_generate(obj) end - def build_json(out_of_date_gems, total_gem_count, sourced_from_git_count) + def build_json(out_of_date_gems, total_gem_count, sourced_from_git_count, sourced_locally_count) output = Hash.new { [] } out_of_date_gems.each do |gem| output[:outdated_gems] += [ @@ -95,12 +100,13 @@ def build_json(out_of_date_gems, total_gem_count, sourced_from_git_count) output.merge( { sourced_from_git_count: sourced_from_git_count, + sourced_locally_count: sourced_locally_count, total_gem_count: total_gem_count } ) end - def output_to_stdout(out_of_date_gems, total_gem_count, sourced_from_git_count) + def output_to_stdout(out_of_date_gems, total_gem_count, sourced_from_git_count, sourced_locally_count) out_of_date_gems.each do |gem| header = "#{gem.name} #{gem.version}" @@ -112,6 +118,7 @@ def output_to_stdout(out_of_date_gems, total_gem_count, sourced_from_git_count) percentage_out_of_date = ((out_of_date_gems.count / total_gem_count.to_f) * 100).round footer = <<-MESSAGE #{NextRails::Tint(sourced_from_git_count.to_s).yellow} gems are sourced from git + #{NextRails::Tint(sourced_locally_count.to_s).yellow} gems are sourced from a local path #{NextRails::Tint(out_of_date_gems.count.to_s).red} of the #{total_gem_count} gems are out-of-date (#{percentage_out_of_date}%) MESSAGE diff --git a/lib/next_rails/gem_info.rb b/lib/next_rails/gem_info.rb index 1f9897f..acc6741 100644 --- a/lib/next_rails/gem_info.rb +++ b/lib/next_rails/gem_info.rb @@ -66,6 +66,14 @@ def sourced_from_git? !!gem_specification.git_version end + def sourced_locally? + return false unless defined?(Bundler::Source::Path) + + source = gem_specification.source + # Git sources subclass Path, so exclude them; they are reported via #sourced_from_git?. + source.is_a?(Bundler::Source::Path) && !source.is_a?(Bundler::Source::Git) + end + def created_at @created_at ||= gem_specification.date end diff --git a/spec/next_rails/bundle_report_spec.rb b/spec/next_rails/bundle_report_spec.rb index d33b6dd..04b1968 100644 --- a/spec/next_rails/bundle_report_spec.rb +++ b/spec/next_rails/bundle_report_spec.rb @@ -6,7 +6,7 @@ RSpec.describe NextRails::BundleReport do describe '.outdated' do let(:mock_version) { Struct.new(:version, :age) } - let(:mock_gem) { Struct.new(:name, :version, :age, :latest_version, :up_to_date?, :created_at, :sourced_from_git?) } + let(:mock_gem) { Struct.new(:name, :version, :age, :latest_version, :up_to_date?, :created_at, :sourced_from_git?, :sourced_locally?) } let(:format_str) { '%b %e, %Y' } let(:alpha_date) { Date.parse('2022-01-01') } let(:alpha_age) { alpha_date.strftime(format_str) } @@ -18,8 +18,8 @@ before do allow(NextRails::GemInfo).to receive(:all).and_return( [ - mock_gem.new('alpha', '0.0.1', alpha_age, mock_version.new('0.0.2', bravo_age), false, alpha_date, false), - mock_gem.new('bravo', '0.2.0', bravo_age, mock_version.new('0.2.2', charlie_age), false, bravo_date, true) + mock_gem.new('alpha', '0.0.1', alpha_age, mock_version.new('0.0.2', bravo_age), false, alpha_date, false, false), + mock_gem.new('bravo', '0.2.0', bravo_age, mock_version.new('0.2.2', charlie_age), false, bravo_date, true, false) ] ) end @@ -37,6 +37,7 @@ allow($stdout).to receive(:puts).with('') allow($stdout).to receive(:puts).with(<<-EO_MULTLINE_STRING) #{NextRails::Tint('1').yellow} gems are sourced from git + #{NextRails::Tint('0').yellow} gems are sourced from a local path #{NextRails::Tint('2').red} of the 2 gems are out-of-date (100%) EO_MULTLINE_STRING end @@ -45,10 +46,11 @@ context 'when writing JSON output' do it 'JSON is correctly formatted' do gems = NextRails::GemInfo.all - out_of_date_gems = gems.reject(&:up_to_date?).sort_by(&:created_at) + sourced_locally = gems.select(&:sourced_locally?) + out_of_date_gems = (gems - sourced_locally).reject(&:up_to_date?).sort_by(&:created_at) sourced_from_git = gems.select(&:sourced_from_git?) - expect(NextRails::BundleReport.build_json(out_of_date_gems, gems.count, sourced_from_git.count)).to eq( + expect(NextRails::BundleReport.build_json(out_of_date_gems, gems.count, sourced_from_git.count, sourced_locally.count)).to eq( { outdated_gems: [ { name: 'alpha', installed_version: '0.0.1', installed_age: alpha_age, latest_version: '0.0.2', @@ -57,11 +59,38 @@ latest_age: charlie_age } ], sourced_from_git_count: sourced_from_git.count, + sourced_locally_count: sourced_locally.count, total_gem_count: gems.count } ) end end + + context 'when a gem is sourced from a local path' do + let(:delta_date) { Date.parse('2022-04-04') } + let(:delta_age) { delta_date.strftime(format_str) } + + before do + allow(NextRails::GemInfo).to receive(:all).and_return( + [ + mock_gem.new('alpha', '0.0.1', alpha_age, mock_version.new('0.0.2', bravo_age), false, alpha_date, false, false), + # same name as a public gem, but sourced locally and out-of-date + mock_gem.new('delta', '0.1.0', delta_age, mock_version.new('0.1.2', charlie_age), false, delta_date, false, true) + ] + ) + end + + it 'excludes the local gem from the out-of-date list and counts it separately' do + gems = NextRails::GemInfo.all + sourced_locally = gems.select(&:sourced_locally?) + out_of_date_gems = (gems - sourced_locally).reject(&:up_to_date?).sort_by(&:created_at) + + result = NextRails::BundleReport.build_json(out_of_date_gems, gems.count, 0, sourced_locally.count) + + expect(result[:outdated_gems].map { |g| g[:name] }).to eq(['alpha']) + expect(result[:sourced_locally_count]).to eq(1) + end + end end describe ".rails_compatibility" do diff --git a/spec/next_rails/gem_info_spec.rb b/spec/next_rails/gem_info_spec.rb index 98a7191..8d7a954 100644 --- a/spec/next_rails/gem_info_spec.rb +++ b/spec/next_rails/gem_info_spec.rb @@ -74,6 +74,40 @@ end end + describe "#sourced_locally?" do + let(:source) { nil } + let(:spec) do + Gem::Specification.new do |s| + s.date = release_date + s.version = "1.0.0" + end.tap { |s| s.source = source } + end + + context "when the gem is sourced from a local path" do + let(:source) { Bundler::Source::Path.new("path" => "engines/foo") } + + it "is true" do + expect(subject.sourced_locally?).to be(true) + end + end + + context "when the gem is sourced from git" do + let(:source) { Bundler::Source::Git.new("uri" => "https://example.com/foo.git") } + + it "is false (git is reported separately)" do + expect(subject.sourced_locally?).to be(false) + end + end + + context "when the gem is sourced from rubygems" do + let(:source) { Bundler::Source::Rubygems.new } + + it "is false" do + expect(subject.sourced_locally?).to be(false) + end + end + end + describe "#find_latest_compatible" do let(:mock_gem) { Struct.new(:name, :version) } From 56bfbd97cdaba700aad7a4bbdb5ee4fcddbfb380 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20V=C3=A1squez?= Date: Tue, 9 Jun 2026 12:33:35 -0600 Subject: [PATCH 2/2] Add CHANGELOG entry for local gem source detection --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c1510f6..414fd21 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ - [BUGFIX: example](https://github.com/fastruby/next_rails/pull/) - [FEATURE: Validate the DeprecationTracker mode at initialization, treating a blank mode as the default `save`](https://github.com/fastruby/next_rails/pull/186) +- [BUGFIX: `bundle_report outdated` no longer confuses a locally-sourced (`path:`) gem with a same-named public gem on rubygems; local gems are excluded from the out-of-date check and counted separately](https://github.com/fastruby/next_rails/pull/188) * Your changes/patches go here.