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/4] 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 71eb69d226924f422bbfb9ec2679eb77b836531b 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/4] 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..5ef91b8 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/189) * Your changes/patches go here. From f8053da4605042ab6008f948ff3f36fe766ced2a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20V=C3=A1squez?= Date: Fri, 12 Jun 2026 14:13:51 -0600 Subject: [PATCH 3/4] Clarify local-source guard, simplify spec setup Address review on #189. gem_info.rb: explain why sourced_locally? guards on defined?(Bundler::Source::Path). It is not a Bundler-version thing (Source::Path has existed since Bundler 1.0); the guard covers Bundler not being loaded. Verified: without Bundler, Gem::Specification#source returns Gem::Source::Installed and source= does not exist, so no path source can occur and false is correct rather than a NameError. gem_info_spec.rb: drop the redundant .tap; Gem::Specification.new yields self, so s.source = source works inside the block. Focused spec green (8 examples, 0 failures). --- lib/next_rails/gem_info.rb | 3 +++ spec/next_rails/gem_info_spec.rb | 3 ++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/lib/next_rails/gem_info.rb b/lib/next_rails/gem_info.rb index acc6741..b4728b8 100644 --- a/lib/next_rails/gem_info.rb +++ b/lib/next_rails/gem_info.rb @@ -67,6 +67,9 @@ def sourced_from_git? end def sourced_locally? + # Bundler defines Source::Path and patches Gem::Specification#source to return the + # gem's real source. Without Bundler loaded, #source is RubyGems' own and returns a + # Gem::Source::Installed, so no path source can exist; treat the gem as not local. return false unless defined?(Bundler::Source::Path) source = gem_specification.source diff --git a/spec/next_rails/gem_info_spec.rb b/spec/next_rails/gem_info_spec.rb index 8d7a954..caa422a 100644 --- a/spec/next_rails/gem_info_spec.rb +++ b/spec/next_rails/gem_info_spec.rb @@ -80,7 +80,8 @@ Gem::Specification.new do |s| s.date = release_date s.version = "1.0.0" - end.tap { |s| s.source = source } + s.source = source + end end context "when the gem is sourced from a local path" do From c380877388298020d6a648f7d38c08c787499fd2 Mon Sep 17 00:00:00 2001 From: Ernesto Tagwerker Date: Mon, 29 Jun 2026 12:21:32 -0400 Subject: [PATCH 4/4] Drive .outdated through real code path in specs The bundle_report specs either stubbed $stdout with `allow` and never called `.outdated` (so the human-readable example passed vacuously) or re-implemented the local/git filtering and called `build_json` directly, so they could not catch regressions in `.outdated` itself. Rewrite all three examples to drive `described_class.outdated` (and `outdated('json')`), capturing real stdout. This actually exercises the local-path exclusion and the new `sourced_locally_count` output. Co-Authored-By: Claude Opus 4.8 (1M context) --- spec/next_rails/bundle_report_spec.rb | 47 ++++++++++----------------- 1 file changed, 17 insertions(+), 30 deletions(-) diff --git a/spec/next_rails/bundle_report_spec.rb b/spec/next_rails/bundle_report_spec.rb index 04b1968..dfa530c 100644 --- a/spec/next_rails/bundle_report_spec.rb +++ b/spec/next_rails/bundle_report_spec.rb @@ -25,32 +25,22 @@ end context 'when writing human-readable output' do - #subject { described_class.outdated } - - it 'invokes $stdout.puts properly', :aggregate_failures do - allow($stdout) - .to receive(:puts) - .with("#{NextRails::Tint('alpha 0.0.1').bold.white}: released #{alpha_age} (latest version, 0.0.2, released #{bravo_age})\n") - allow($stdout) - .to receive(:puts) - .with("#{NextRails::Tint('bravo 0.2.0').bold.white}: released #{bravo_age} (latest version, 0.2.2, released #{charlie_age})\n") - 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 + it 'prints each out-of-date gem and a footer with the source counts', :aggregate_failures do + output = with_captured_stdout { described_class.outdated } + + expect(output).to include("#{NextRails::Tint('alpha 0.0.1').bold.white}: released #{alpha_age} (latest version, 0.0.2, released #{bravo_age})") + expect(output).to include("#{NextRails::Tint('bravo 0.2.0').bold.white}: released #{bravo_age} (latest version, 0.2.2, released #{charlie_age})") + expect(output).to include("#{NextRails::Tint('1').yellow} gems are sourced from git") + expect(output).to include("#{NextRails::Tint('0').yellow} gems are sourced from a local path") + expect(output).to include("#{NextRails::Tint('2').red} of the 2 gems are out-of-date (100%)") end end context 'when writing JSON output' do it 'JSON is correctly formatted' 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) - sourced_from_git = gems.select(&:sourced_from_git?) + output = with_captured_stdout { described_class.outdated('json') } - expect(NextRails::BundleReport.build_json(out_of_date_gems, gems.count, sourced_from_git.count, sourced_locally.count)).to eq( + expect(JSON.parse(output, symbolize_names: true)).to eq( { outdated_gems: [ { name: 'alpha', installed_version: '0.0.1', installed_age: alpha_age, latest_version: '0.0.2', @@ -58,9 +48,9 @@ { name: 'bravo', installed_version: '0.2.0', installed_age: bravo_age, latest_version: '0.2.2', latest_age: charlie_age } ], - sourced_from_git_count: sourced_from_git.count, - sourced_locally_count: sourced_locally.count, - total_gem_count: gems.count + sourced_from_git_count: 1, + sourced_locally_count: 0, + total_gem_count: 2 } ) end @@ -80,14 +70,11 @@ ) 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) + it 'excludes the local gem from the out-of-date list and counts it separately', :aggregate_failures do + output = with_captured_stdout { described_class.outdated('json') } + result = JSON.parse(output, symbolize_names: true) - 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[:outdated_gems].map { |gem| gem[:name] }).to eq(['alpha']) expect(result[:sourced_locally_count]).to eq(1) end end