From 1ee42d290dedc1430c1befd80ed9757c8b68fd2d Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Tue, 2 Jun 2026 14:45:46 +0900 Subject: [PATCH 01/34] [ruby/rubygems] Apply cooldown to locally installed gem versions `Source::Rubygems#specs` merges `installed_specs` on top of `remote_specs`, so a `Bundler::StubSpecification` for an already-installed gem overwrites the matching `EndpointSpecification` and erases its `created_at`. The cooldown filter then short-circuited on `spec.respond_to?(:created_at)` and let the local stub through, which made `bundle install --cooldown N` keep selecting a brand-new version that happened to be on disk already. Snapshot the remote `created_at` per `[name, version]` before merging and back-fill it onto stubs that lack one, attaching the source's first remote so `effective_cooldown` is reachable. The filter now runs ahead of `filter_remote_specs` and rejects every spec that shares an `[name, version]` flagged by `cooldown_excluded?`, so a stub and the endpoint that carries its date drop together. `RemoteSpecification` gains `attr_accessor :created_at` so any subclass without an explicit setter participates. `spec/bundler/resolver/cooldown_spec.rb` gets `name`/`version` on the shared spec helper, plus dedicated coverage for the version-grouped exclusion and stub-only fallback. `spec/install/cooldown_spec.rb` adds two end-to-end cases that pre-install `ripe_gem-2.0.0` and verify the in-cooldown copy is excluded while `--cooldown 0` continues to bypass the filter. https://github.com/ruby/rubygems/commit/3920a092da Co-Authored-By: Claude Opus 4.7 --- lib/bundler/remote_specification.rb | 2 +- lib/bundler/resolver.rb | 18 ++++---- lib/bundler/source/rubygems.rb | 37 +++++++++++++++ .../bundler/bundler/resolver/cooldown_spec.rb | 45 +++++++++++++++---- spec/bundler/install/cooldown_spec.rb | 26 +++++++++++ 5 files changed, 110 insertions(+), 18 deletions(-) diff --git a/lib/bundler/remote_specification.rb b/lib/bundler/remote_specification.rb index ab163e2b046acd..dcaaf6af2e61ed 100644 --- a/lib/bundler/remote_specification.rb +++ b/lib/bundler/remote_specification.rb @@ -12,7 +12,7 @@ class RemoteSpecification attr_reader :name, :version, :platform attr_writer :dependencies - attr_accessor :source, :remote, :locked_platform + attr_accessor :source, :remote, :locked_platform, :created_at def initialize(name, version, platform, spec_fetcher) @name = name diff --git a/lib/bundler/resolver.rb b/lib/bundler/resolver.rb index 7e2ce321b6452a..43b19440e642fb 100644 --- a/lib/bundler/resolver.rb +++ b/lib/bundler/resolver.rb @@ -422,7 +422,7 @@ def filter_matching_specs(specs, requirements) end def filter_specs(specs, package) - filter_cooldown(filter_remote_specs(filter_prereleases(specs, package), package)) + filter_remote_specs(filter_cooldown(filter_prereleases(specs, package)), package) end def filter_prereleases(specs, package) @@ -433,19 +433,19 @@ def filter_prereleases(specs, package) def filter_cooldown(specs) return specs if specs.empty? - excluded = cooldown_excluded_specs(specs) - return specs if excluded.empty? - specs - excluded + excluded_versions = cooldown_excluded_versions(specs) + return specs if excluded_versions.empty? + specs.reject {|s| excluded_versions.include?([s.name, s.version]) } end - def cooldown_excluded_specs(specs) - specs.select {|spec| cooldown_excluded?(spec) } + def cooldown_excluded_versions(specs) + specs.select {|spec| cooldown_excluded?(spec) }.map {|spec| [spec.name, spec.version] }.uniq end def cooldown_hint(specs) - excluded = cooldown_excluded_specs(specs) - return nil if excluded.empty? - "#{excluded.size} version#{"s" if excluded.size > 1} excluded by the cooldown setting; pass `--cooldown 0` to bypass" + excluded_versions = cooldown_excluded_versions(specs) + return nil if excluded_versions.empty? + "#{excluded_versions.size} version#{"s" if excluded_versions.size > 1} excluded by the cooldown setting; pass `--cooldown 0` to bypass" end def cooldown_excluded?(spec) diff --git a/lib/bundler/source/rubygems.rb b/lib/bundler/source/rubygems.rb index 66b36645199e85..9dde405e2c740b 100644 --- a/lib/bundler/source/rubygems.rb +++ b/lib/bundler/source/rubygems.rb @@ -150,6 +150,13 @@ def specs # sources, and large_idx.merge! small_idx is way faster than # small_idx.merge! large_idx. index = @allow_remote ? remote_specs.dup : Index.new + + # Snapshot per-version `created_at` from the remote info before installed + # / cached specs overwrite the EndpointSpecification objects that carry + # it. The cooldown filter consults `created_at` on every candidate, so + # local stubs need the published date back-filled to participate. + remote_created_at = collect_remote_created_at(index) + index.merge!(cached_specs) if @allow_cached index.merge!(installed_specs) if @allow_local @@ -163,6 +170,8 @@ def specs end end + backfill_created_at(index, remote_created_at) unless remote_created_at.empty? + index end end @@ -470,6 +479,34 @@ def cache_path private + def collect_remote_created_at(index) + return {} unless @allow_remote + + snapshot = {} + index.each do |spec| + next unless spec.respond_to?(:created_at) && spec.created_at + snapshot[[spec.name, spec.version]] = spec.created_at + end + snapshot + end + + def backfill_created_at(index, snapshot) + first_remote = remote_fetchers.keys.first + index.each do |spec| + next unless spec.respond_to?(:created_at=) + next if spec.created_at + remote_created_at = snapshot[[spec.name, spec.version]] + next unless remote_created_at + spec.created_at = remote_created_at + # The cooldown filter consults `spec.remote.effective_cooldown`, so a + # backfilled stub also needs a Source::Rubygems::Remote reference. Any + # remote on the source carries the right `effective_cooldown` because + # the setting is source-wide and `Bundler.settings[:cooldown]` + # overrides per-source. + spec.remote ||= first_remote if first_remote && spec.respond_to?(:remote=) + end + end + def lockfile_remotes @lockfile_remotes || credless_remotes end diff --git a/spec/bundler/bundler/resolver/cooldown_spec.rb b/spec/bundler/bundler/resolver/cooldown_spec.rb index 834842145fa615..37ec158cba4fcf 100644 --- a/spec/bundler/bundler/resolver/cooldown_spec.rb +++ b/spec/bundler/bundler/resolver/cooldown_spec.rb @@ -7,8 +7,8 @@ def remote(cooldown:) instance_double(Bundler::Source::Rubygems::Remote, effective_cooldown: cooldown) end - def spec(created_at:, remote:) - Struct.new(:created_at, :remote).new(created_at, remote) + def spec(created_at:, remote:, name: "myrack", version: "1.0.0") + Struct.new(:name, :version, :created_at, :remote).new(name, Gem::Version.new(version), created_at, remote) end describe "#filter_cooldown" do @@ -18,8 +18,8 @@ def spec(created_at:, remote:) let(:r) { remote(cooldown: 7) } it "rejects versions published within the window" do - recent = spec(created_at: now - (2 * 86_400), remote: r) - old = spec(created_at: now - (30 * 86_400), remote: r) + recent = spec(version: "1.1.0", created_at: now - (2 * 86_400), remote: r) + old = spec(version: "1.0.0", created_at: now - (30 * 86_400), remote: r) expect(resolver.send(:filter_cooldown, [recent, old])).to eq([old]) end @@ -32,14 +32,37 @@ def spec(created_at:, remote:) it "leaves rolling-delay history intact" do # 7-day cooldown with frequent releases must still expose an older candidate. - in_cooldown = spec(created_at: now - 86_400, remote: r) - also_in_cooldown = spec(created_at: now - (3 * 86_400), remote: r) - eligible = spec(created_at: now - (10 * 86_400), remote: r) + in_cooldown = spec(version: "1.2.0", created_at: now - 86_400, remote: r) + also_in_cooldown = spec(version: "1.1.0", created_at: now - (3 * 86_400), remote: r) + eligible = spec(version: "1.0.0", created_at: now - (10 * 86_400), remote: r) result = resolver.send(:filter_cooldown, [in_cooldown, also_in_cooldown, eligible]) expect(result).to eq([eligible]) end + + it "drops every spec sharing an excluded [name, version] tuple" do + # The cooldown check is by version, not per-spec: a StubSpecification for an + # in-cooldown release would otherwise slip through on local install paths. + endpoint = spec(version: "2.0.0", created_at: now - 86_400, remote: r) + local_stub = Struct.new(:name, :version).new("myrack", Gem::Version.new("2.0.0")) + eligible = spec(version: "1.0.0", created_at: now - (30 * 86_400), remote: r) + + result = resolver.send(:filter_cooldown, [endpoint, local_stub, eligible]) + + expect(result).to eq([eligible]) + end + + it "keeps stub-only versions that no endpoint marks as in cooldown" do + # If no remote spec carries created_at for a version, cooldown cannot judge it; + # the stub stays in. + local_only = Struct.new(:name, :version).new("myrack", Gem::Version.new("2.0.0")) + eligible = spec(version: "1.0.0", created_at: now - (30 * 86_400), remote: r) + + result = resolver.send(:filter_cooldown, [local_only, eligible]) + + expect(result).to eq([local_only, eligible]) + end end context "when created_at is missing (blank metadata)" do @@ -111,9 +134,15 @@ def spec(created_at:, remote:) end it "uses plural wording when multiple versions are excluded" do - excluded = Array.new(3) { spec(created_at: now - 86_400, remote: r) } + excluded = %w[1.0.0 1.1.0 1.2.0].map {|v| spec(version: v, created_at: now - 86_400, remote: r) } expect(resolver.send(:cooldown_hint, excluded)).to match(/3 versions excluded/) end + + it "counts each unique version once even when multiple spec instances share it" do + duplicates = Array.new(3) { spec(created_at: now - 86_400, remote: r) } + + expect(resolver.send(:cooldown_hint, duplicates)).to match(/1 version excluded/) + end end end diff --git a/spec/bundler/install/cooldown_spec.rb b/spec/bundler/install/cooldown_spec.rb index 1368726c2c7722..b3f57d93ccf5c8 100644 --- a/spec/bundler/install/cooldown_spec.rb +++ b/spec/bundler/install/cooldown_spec.rb @@ -215,6 +215,32 @@ expect(out).to match(/ripe_gem.*in cooldown for \d+ more day/) end + it "excludes a locally-installed version that is still within the cooldown window" do + system_gems "ripe_gem-2.0.0", gem_repo: gem_repo3 + + gemfile <<-G + source "https://gem.repo3" + gem "ripe_gem" + G + + bundle "install --cooldown 7", artifice: "compact_index_cooldown" + + expect(the_bundle).to include_gems("ripe_gem 1.0.0") + end + + it "selects a locally-installed in-cooldown version when --cooldown 0 bypasses the filter" do + system_gems "ripe_gem-2.0.0", gem_repo: gem_repo3 + + gemfile <<-G + source "https://gem.repo3" + gem "ripe_gem" + G + + bundle "install --cooldown 0", artifice: "compact_index_cooldown" + + expect(the_bundle).to include_gems("ripe_gem 2.0.0") + end + it "surfaces a cooldown hint when bundle update filters every candidate" do gemfile <<-G source "https://gem.repo3" From 4af3a57bb69752cc79ee853f3ddb70f119732ac4 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Tue, 2 Jun 2026 15:30:51 +0900 Subject: [PATCH 02/34] [ruby/rubygems] Address PR review on cooldown local stub bypass Snapshot the remote alongside `created_at` and restore it during backfill so per-remote cooldown settings (`source ..., cooldown: N`) survive into installed stubs instead of being replaced by whichever remote happens to be first. Build `cooldown_excluded_versions` as a hash so `filter_cooldown`'s membership check is O(1) per spec; large Gemfiles with many candidate versions no longer pay an O(n*m) scan. https://github.com/ruby/rubygems/commit/58669f2890 Co-Authored-By: Claude Opus 4.7 --- lib/bundler/resolver.rb | 7 ++++++- lib/bundler/source/rubygems.rb | 15 ++++++--------- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/lib/bundler/resolver.rb b/lib/bundler/resolver.rb index 43b19440e642fb..422b726980d6fa 100644 --- a/lib/bundler/resolver.rb +++ b/lib/bundler/resolver.rb @@ -439,7 +439,12 @@ def filter_cooldown(specs) end def cooldown_excluded_versions(specs) - specs.select {|spec| cooldown_excluded?(spec) }.map {|spec| [spec.name, spec.version] }.uniq + excluded = {} + specs.each do |spec| + next unless cooldown_excluded?(spec) + excluded[[spec.name, spec.version]] = true + end + excluded end def cooldown_hint(specs) diff --git a/lib/bundler/source/rubygems.rb b/lib/bundler/source/rubygems.rb index 9dde405e2c740b..ed864604fe1649 100644 --- a/lib/bundler/source/rubygems.rb +++ b/lib/bundler/source/rubygems.rb @@ -485,25 +485,22 @@ def collect_remote_created_at(index) snapshot = {} index.each do |spec| next unless spec.respond_to?(:created_at) && spec.created_at - snapshot[[spec.name, spec.version]] = spec.created_at + # Remember the remote that supplied the date too: when a source has + # several remotes with different per-URI cooldown settings we must + # restore the same one during backfill so `effective_cooldown` agrees. + snapshot[[spec.name, spec.version]] = [spec.created_at, spec.remote] end snapshot end def backfill_created_at(index, snapshot) - first_remote = remote_fetchers.keys.first index.each do |spec| next unless spec.respond_to?(:created_at=) next if spec.created_at - remote_created_at = snapshot[[spec.name, spec.version]] + remote_created_at, remote = snapshot[[spec.name, spec.version]] next unless remote_created_at spec.created_at = remote_created_at - # The cooldown filter consults `spec.remote.effective_cooldown`, so a - # backfilled stub also needs a Source::Rubygems::Remote reference. Any - # remote on the source carries the right `effective_cooldown` because - # the setting is source-wide and `Bundler.settings[:cooldown]` - # overrides per-source. - spec.remote ||= first_remote if first_remote && spec.respond_to?(:remote=) + spec.remote ||= remote if remote && spec.respond_to?(:remote=) end end From 3220428912b020e23caea42271ff030d195457b9 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Tue, 2 Jun 2026 15:49:36 +0900 Subject: [PATCH 03/34] Inline the skip-detection run command Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/tarball-test.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/tarball-test.yml b/.github/workflows/tarball-test.yml index 52c4d31fc81f2b..c862ab76cc1886 100644 --- a/.github/workflows/tarball-test.yml +++ b/.github/workflows/tarball-test.yml @@ -38,8 +38,7 @@ jobs: skip: ${{ steps.skipping.outputs.skip }} steps: - id: skipping - run: - echo 'skip=true' >> $GITHUB_OUTPUT + run: echo 'skip=true' >> $GITHUB_OUTPUT if: >- ${{(false || contains(github.event.head_commit.message, '[DOC]') From 220f66a33eae1370aa964e7c07efdfdaf7bccddf Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Tue, 2 Jun 2026 15:50:06 +0900 Subject: [PATCH 04/34] Build full path for HOME stat snapshot Dir.each_child yields entry names, so stat/digest/children ran against the working directory instead of HOME. Join dir and name first. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/tarball-ubuntu.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/tarball-ubuntu.yml b/.github/workflows/tarball-ubuntu.yml index 03f2f946b5a630..e227dc29aa59bd 100644 --- a/.github/workflows/tarball-ubuntu.yml +++ b/.github/workflows/tarball-ubuntu.yml @@ -77,7 +77,8 @@ jobs: [ Dir.home, ].each do |dir| - Dir.each_child(dir) do |pn| + Dir.each_child(dir) do |name| + pn = File.join(dir, name) st = File.stat(pn) if st.file? content = Digest::SHA1.file(pn).hexdigest From 6da78caea71b78e9ae4e108f43c7d5e02c950acf Mon Sep 17 00:00:00 2001 From: Matt Larraz Date: Mon, 16 Mar 2026 18:14:29 -0400 Subject: [PATCH 05/34] [ruby/rubygems] Replace Molinillo with PubGrub for dependency resolution Molinillo (a backtracking resolver) is replaced by PubGrub (a CDCL SAT-based solver) which provides better conflict explanations, smarter backjumping, and provable completeness. PubGrub is already used by Bundler; this vendors the same library re-namespaced under Gem::PubGrub. Key changes: - Vendor PubGrub from bundler/lib/bundler/vendor/pub_grub/, re-namespaced Bundler::PubGrub -> Gem::PubGrub - Rewrite Gem::Resolver to implement PubGrub's source interface (all_versions_for, versions_for, incompatibilities_for, etc.) - Add Gem::Resolver::PubGrubFailure for error reporting via PubGrub's superior conflict explanation format - Add Source::Local#find_all_gems to expose all local gem versions (PubGrub needs complete version information upfront) - Prefer installed specs in version ordering to avoid unnecessary upgrades - Delete Molinillo (21 files, ~2400 lines) and resolver/stats.rb https://github.com/ruby/rubygems/commit/df02d4110c Co-Authored-By: Claude Opus 4.6 --- lib/rubygems/commands/install_command.rb | 3 + lib/rubygems/exceptions.rb | 8 +- lib/rubygems/resolver.rb | 319 ++++--- lib/rubygems/resolver/installer_set.rb | 2 +- lib/rubygems/resolver/pub_grub_failure.rb | 18 + lib/rubygems/resolver/stats.rb | 46 - lib/rubygems/source/local.rb | 6 +- .../vendor/molinillo/lib/molinillo.rb | 11 - .../molinillo/delegates/resolution_state.rb | 57 -- .../delegates/specification_provider.rb | 88 -- .../lib/molinillo/dependency_graph.rb | 255 ------ .../lib/molinillo/dependency_graph/action.rb | 36 - .../dependency_graph/add_edge_no_circular.rb | 66 -- .../molinillo/dependency_graph/add_vertex.rb | 62 -- .../molinillo/dependency_graph/delete_edge.rb | 63 -- .../dependency_graph/detach_vertex_named.rb | 61 -- .../lib/molinillo/dependency_graph/log.rb | 126 --- .../molinillo/dependency_graph/set_payload.rb | 46 - .../lib/molinillo/dependency_graph/tag.rb | 36 - .../lib/molinillo/dependency_graph/vertex.rb | 164 ---- .../vendor/molinillo/lib/molinillo/errors.rb | 149 ---- .../molinillo/lib/molinillo/gem_metadata.rb | 6 - .../modules/specification_provider.rb | 112 --- .../molinillo/lib/molinillo/modules/ui.rb | 67 -- .../molinillo/lib/molinillo/resolution.rb | 839 ------------------ .../molinillo/lib/molinillo/resolver.rb | 46 - .../vendor/molinillo/lib/molinillo/state.rb | 58 -- lib/rubygems/vendor/pub_grub/lib/pub_grub.rb | 53 ++ .../pub_grub/lib/pub_grub/assignment.rb | 20 + .../lib/pub_grub/basic_package_source.rb | 169 ++++ .../pub_grub/lib/pub_grub/failure_writer.rb | 182 ++++ .../pub_grub/lib/pub_grub/incompatibility.rb | 150 ++++ .../vendor/pub_grub/lib/pub_grub/package.rb | 43 + .../pub_grub/lib/pub_grub/partial_solution.rb | 121 +++ .../vendor/pub_grub/lib/pub_grub/rubygems.rb | 45 + .../pub_grub/lib/pub_grub/solve_failure.rb | 19 + .../lib/pub_grub/static_package_source.rb | 61 ++ .../vendor/pub_grub/lib/pub_grub/strategy.rb | 42 + .../vendor/pub_grub/lib/pub_grub/term.rb | 105 +++ .../vendor/pub_grub/lib/pub_grub/version.rb | 3 + .../lib/pub_grub/version_constraint.rb | 129 +++ .../pub_grub/lib/pub_grub/version_range.rb | 423 +++++++++ .../pub_grub/lib/pub_grub/version_solver.rb | 236 +++++ .../pub_grub/lib/pub_grub/version_union.rb | 178 ++++ lib/rubygems/vendored_molinillo.rb | 3 - lib/rubygems/vendored_pub_grub.rb | 3 + .../test_gem_commands_install_command.rb | 12 +- .../rubygems/test_gem_dependency_installer.rb | 6 +- test/rubygems/test_gem_resolver.rb | 98 +- 49 files changed, 2228 insertions(+), 2623 deletions(-) create mode 100644 lib/rubygems/resolver/pub_grub_failure.rb delete mode 100644 lib/rubygems/resolver/stats.rb delete mode 100644 lib/rubygems/vendor/molinillo/lib/molinillo.rb delete mode 100644 lib/rubygems/vendor/molinillo/lib/molinillo/delegates/resolution_state.rb delete mode 100644 lib/rubygems/vendor/molinillo/lib/molinillo/delegates/specification_provider.rb delete mode 100644 lib/rubygems/vendor/molinillo/lib/molinillo/dependency_graph.rb delete mode 100644 lib/rubygems/vendor/molinillo/lib/molinillo/dependency_graph/action.rb delete mode 100644 lib/rubygems/vendor/molinillo/lib/molinillo/dependency_graph/add_edge_no_circular.rb delete mode 100644 lib/rubygems/vendor/molinillo/lib/molinillo/dependency_graph/add_vertex.rb delete mode 100644 lib/rubygems/vendor/molinillo/lib/molinillo/dependency_graph/delete_edge.rb delete mode 100644 lib/rubygems/vendor/molinillo/lib/molinillo/dependency_graph/detach_vertex_named.rb delete mode 100644 lib/rubygems/vendor/molinillo/lib/molinillo/dependency_graph/log.rb delete mode 100644 lib/rubygems/vendor/molinillo/lib/molinillo/dependency_graph/set_payload.rb delete mode 100644 lib/rubygems/vendor/molinillo/lib/molinillo/dependency_graph/tag.rb delete mode 100644 lib/rubygems/vendor/molinillo/lib/molinillo/dependency_graph/vertex.rb delete mode 100644 lib/rubygems/vendor/molinillo/lib/molinillo/errors.rb delete mode 100644 lib/rubygems/vendor/molinillo/lib/molinillo/gem_metadata.rb delete mode 100644 lib/rubygems/vendor/molinillo/lib/molinillo/modules/specification_provider.rb delete mode 100644 lib/rubygems/vendor/molinillo/lib/molinillo/modules/ui.rb delete mode 100644 lib/rubygems/vendor/molinillo/lib/molinillo/resolution.rb delete mode 100644 lib/rubygems/vendor/molinillo/lib/molinillo/resolver.rb delete mode 100644 lib/rubygems/vendor/molinillo/lib/molinillo/state.rb create mode 100644 lib/rubygems/vendor/pub_grub/lib/pub_grub.rb create mode 100644 lib/rubygems/vendor/pub_grub/lib/pub_grub/assignment.rb create mode 100644 lib/rubygems/vendor/pub_grub/lib/pub_grub/basic_package_source.rb create mode 100644 lib/rubygems/vendor/pub_grub/lib/pub_grub/failure_writer.rb create mode 100644 lib/rubygems/vendor/pub_grub/lib/pub_grub/incompatibility.rb create mode 100644 lib/rubygems/vendor/pub_grub/lib/pub_grub/package.rb create mode 100644 lib/rubygems/vendor/pub_grub/lib/pub_grub/partial_solution.rb create mode 100644 lib/rubygems/vendor/pub_grub/lib/pub_grub/rubygems.rb create mode 100644 lib/rubygems/vendor/pub_grub/lib/pub_grub/solve_failure.rb create mode 100644 lib/rubygems/vendor/pub_grub/lib/pub_grub/static_package_source.rb create mode 100644 lib/rubygems/vendor/pub_grub/lib/pub_grub/strategy.rb create mode 100644 lib/rubygems/vendor/pub_grub/lib/pub_grub/term.rb create mode 100644 lib/rubygems/vendor/pub_grub/lib/pub_grub/version.rb create mode 100644 lib/rubygems/vendor/pub_grub/lib/pub_grub/version_constraint.rb create mode 100644 lib/rubygems/vendor/pub_grub/lib/pub_grub/version_range.rb create mode 100644 lib/rubygems/vendor/pub_grub/lib/pub_grub/version_solver.rb create mode 100644 lib/rubygems/vendor/pub_grub/lib/pub_grub/version_union.rb delete mode 100644 lib/rubygems/vendored_molinillo.rb create mode 100644 lib/rubygems/vendored_pub_grub.rb diff --git a/lib/rubygems/commands/install_command.rb b/lib/rubygems/commands/install_command.rb index 28a2fb7c71de40..6d3beec0b43261 100644 --- a/lib/rubygems/commands/install_command.rb +++ b/lib/rubygems/commands/install_command.rb @@ -224,6 +224,9 @@ def install_gems # :nodoc: rescue Gem::InstallError => e alert_error "Error installing #{gem_name}:\n\t#{e.message}" exit_code |= 1 + rescue Gem::DependencyResolutionError => e + alert_error "Error installing #{gem_name}:\n\t#{e.message}" + exit_code |= 2 rescue Gem::UnsatisfiableDependencyError => e show_lookup_failure e.name, e.version, e.errors, suppress_suggestions, "'#{gem_name}' (#{gem_version})" diff --git a/lib/rubygems/exceptions.rb b/lib/rubygems/exceptions.rb index 40485bbadff81a..1bba014245a8d2 100644 --- a/lib/rubygems/exceptions.rb +++ b/lib/rubygems/exceptions.rb @@ -42,9 +42,13 @@ class Gem::DependencyResolutionError < Gem::DependencyError def initialize(conflict) @conflict = conflict - a, b = conflicting_dependencies - super "conflicting dependencies #{a} and #{b}\n#{@conflict.explanation}" + if conflict.respond_to?(:solve_failure) + super conflict.explanation + else + a, b = conflicting_dependencies + super "conflicting dependencies #{a} and #{b}\n#{@conflict.explanation}" + end end def conflicting_dependencies diff --git a/lib/rubygems/resolver.rb b/lib/rubygems/resolver.rb index bc4fef893ead65..5578135a83e81f 100644 --- a/lib/rubygems/resolver.rb +++ b/lib/rubygems/resolver.rb @@ -10,7 +10,7 @@ # all the requirements. class Gem::Resolver - require_relative "vendored_molinillo" + require_relative "vendored_pub_grub" ## # If the DEBUG_RESOLVER environment variable is set then debugging mode is @@ -34,11 +34,6 @@ class Gem::Resolver attr_accessor :ignore_dependencies - ## - # List of dependencies that could not be found in the configured sources. - - attr_reader :stats - ## # Hash of gems to skip resolution. Keyed by gem name, with arrays of # gem specifications as values. @@ -104,7 +99,15 @@ def initialize(needed, set = nil) @ignore_dependencies = false @skip_gems = {} @soft_missing = false - @stats = Gem::Resolver::Stats.new + + @root_package = Gem::PubGrub::Package.root + @root_version = Gem::PubGrub::Package.root_version + + @packages = {} + + @all_specs = Hash.new {|h, name| h[name] = find_all_specs_for(name) } + @sorted_versions = Hash.new {|h, pkg| h[pkg] = @all_specs[pkg.to_s].map(&:version).uniq.sort } + @cached_dependencies = Hash.new {|h, pkg| h[pkg] = Hash.new {|v, ver| v[ver] = compute_dependencies(pkg, ver) } } end def explain(stage, *data) # :nodoc: @@ -126,68 +129,154 @@ def explain_list(stage) # :nodoc: end ## - # Creates an ActivationRequest for the given +dep+ and the last +possible+ - # specification. - # - # Returns the Specification and the ActivationRequest + # Proceed with resolution! Returns an array of ActivationRequest objects. - def activation_request(dep, possible) # :nodoc: - spec = possible.pop + def resolve + # Pre-check: raise UnsatisfiableDependencyError for root deps with no matches + @needed.each do |dep| + next if @soft_missing + dep_request = DependencyRequest.new(dep, nil) + all = @set.find_all(dep_request) + matching = select_local_platforms(all) + + if matching.empty? + exc = Gem::UnsatisfiableDependencyError.new(dep_request, all) + exc.errors = @set.errors + raise exc + end - explain :activate, [spec.full_name, possible.size] - explain :possible, possible + specs_matching_requirement = matching.select {|s| dep.requirement.satisfied_by?(s.version) } + next unless specs_matching_requirement.empty? + exc = Gem::UnsatisfiableDependencyError.new(dep_request, all) + exc.errors = @set.errors + raise exc + end - activation_request = - Gem::Resolver::ActivationRequest.new spec, dep, possible + # Build root deps from @needed + root_deps = {} + @needed.each do |dep| + range = Gem::PubGrub::RubyGems.requirement_to_range(dep.requirement) + constraint = Gem::PubGrub::VersionConstraint.new(package_for(dep.name), range: range) + root_deps[dep.name] = root_deps.key?(dep.name) ? root_deps[dep.name].intersect(constraint) : constraint + end - [spec, activation_request] + @sorted_versions[@root_package] = [@root_version] + @cached_dependencies[@root_package] = { @root_version => root_deps } + + solver = Gem::PubGrub::VersionSolver.new( + source: self, + root: @root_package, + logger: make_logger + ) + result = solver.solve + + # Convert to Array + result.filter_map do |package, version| + next if Gem::PubGrub::Package.root?(package) + spec = spec_for(package.to_s, version) + dep_request = DependencyRequest.new(Gem::Dependency.new(package.to_s), nil) + ActivationRequest.new(spec, dep_request) + end + rescue Gem::PubGrub::SolveFailure => e + failure = Gem::Resolver::PubGrubFailure.new(e) + raise Gem::DependencyResolutionError, failure end - def requests(s, act, reqs = []) # :nodoc: - return reqs if @ignore_dependencies + # PubGrub source interface methods - s.fetch_development_dependencies if @development + def all_versions_for(package) + return [@root_version] if package == @root_package - s.dependencies.reverse_each do |d| - next if d.type == :development && !@development - next if d.type == :development && @development_shallow && - act.development? - next if d.type == :development && @development_shallow && - act.parent + all_versions = @sorted_versions[package] - reqs << Gem::Resolver::DependencyRequest.new(d, act) - @stats.requirement! + # Exclude prerelease versions unless the set has prerelease enabled. + # Prereleases are still available via versions_for when a range + # specifically includes them (e.g., "= 2.a"), with low priority + # in the Strategy. + if @set.respond_to?(:prerelease) && @set.prerelease + versions = all_versions + else + stable = all_versions.reject(&:prerelease?) + versions = stable.empty? ? all_versions : stable end - @set.prefetch reqs + versions = versions.reverse # highest first + name = package.to_s - @stats.record_requirements reqs - - reqs + if (skip_dep_gems = skip_gems[name]) && !skip_dep_gems.empty? + skip_versions = skip_dep_gems.map(&:version) + preferred, rest = versions.partition {|v| skip_versions.include?(v) } + preferred + rest + else + # Prefer already-installed versions to avoid unnecessary upgrades + installed_versions = @all_specs[name]. + select {|s| s.is_a?(Gem::Resolver::InstalledSpecification) }. + map(&:version) + if installed_versions.any? + preferred, rest = versions.partition {|v| installed_versions.include?(v) } + preferred + rest + else + versions + end + end end - include Gem::Molinillo::UI - - def output - @output ||= debug? ? $stdout : File.open(IO::NULL, "w") + def versions_for(package, range = Gem::PubGrub::VersionRange.any) + range.select_versions(@sorted_versions[package]) end - def debug? - DEBUG_RESOLVER + def no_versions_incompatibility_for(_package, unsatisfied_term) + cause = Gem::PubGrub::Incompatibility::NoVersions.new(unsatisfied_term) + Gem::PubGrub::Incompatibility.new([unsatisfied_term], cause: cause) end - include Gem::Molinillo::SpecificationProvider - - ## - # Proceed with resolution! Returns an array of ActivationRequest objects. + def incompatibilities_for(package, version) + package_deps = @cached_dependencies[package] + sorted_versions = @sorted_versions[package] + package_deps[version].filter_map do |dep_package_name, dep_constraint| + dep_package = dep_constraint.package + low = high = sorted_versions.index(version) + + # find version low such that all >= low share the same dep + while low > 0 && + package_deps[sorted_versions[low - 1]][dep_package_name] == dep_constraint + low -= 1 + end + low = + if low == 0 + nil + else + sorted_versions[low] + end + + # find version high such that all < high share the same dep + while high < sorted_versions.length && + package_deps[sorted_versions[high]][dep_package_name] == dep_constraint + high += 1 + end + high = + if high == sorted_versions.length + nil + else + sorted_versions[high] + end + + range = Gem::PubGrub::VersionRange.new(min: low, max: high, include_min: !low.nil?) + self_constraint = Gem::PubGrub::VersionConstraint.new(package, range: range) + + if dep_constraint.range.empty? + cause = Gem::PubGrub::Incompatibility::InvalidDependency.new(dep_package, dep_constraint) + next Gem::PubGrub::Incompatibility.new( + [Gem::PubGrub::Term.new(self_constraint, true)], + cause: cause + ) + end - def resolve - Gem::Molinillo::Resolver.new(self, self).resolve(@needed.map {|d| DependencyRequest.new d, nil }).tsort.filter_map(&:payload) - rescue Gem::Molinillo::VersionConflict => e - conflict = e.conflicts.values.first - raise Gem::DependencyResolutionError, Conflict.new(conflict.requirement_trees.first.first, conflict.existing, conflict.requirement) - ensure - @output.close if defined?(@output) && !debug? + Gem::PubGrub::Incompatibility.new( + [Gem::PubGrub::Term.new(self_constraint, true), Gem::PubGrub::Term.new(dep_constraint, false)], + cause: :dependency + ) + end end ## @@ -219,103 +308,89 @@ def select_local_platforms(specs) # :nodoc: end end - def search_for(dependency) - possibles, all = find_possible(dependency) - if !@soft_missing && possibles.empty? - exc = Gem::UnsatisfiableDependencyError.new dependency, all - exc.errors = @set.errors - raise exc - end + private - groups = Hash.new {|hash, key| hash[key] = [] } + def package_for(name) + @packages[name] ||= Gem::PubGrub::Package.new(name) + end - # create groups & sources in the same loop - sources = possibles.map do |spec| - source = spec.source - groups[source] << spec - source - end.uniq.reverse + def find_all_specs_for(name) + dep = Gem::Dependency.new(name, ">= 0.a") + dep_request = DependencyRequest.new(dep, nil) + all = @set.find_all(dep_request) - activation_requests = [] + specs = select_local_platforms(all) - sources.each do |source| - groups[source]. - sort_by {|spec| [spec.version, -Gem::Platform.platform_specificity_match(spec.platform, Gem::Platform.local)] }. - map {|spec| ActivationRequest.new spec, dependency }. - each {|activation_request| activation_requests << activation_request } + unless @soft_missing + specs = specs.select do |s| + actual = s.respond_to?(:spec) ? s.spec : s + actual.required_ruby_version.satisfied_by?(Gem.ruby_version) && + actual.required_rubygems_version.satisfied_by?(Gem.rubygems_version) + rescue StandardError + true + end end - activation_requests + specs end - def dependencies_for(specification) - return [] if @ignore_dependencies - spec = specification.spec - requests(spec, specification) - end + def spec_for(name, version) + candidates = @all_specs[name].select {|s| s.version == version } + candidates = @all_specs[name].select {|s| s.version.to_s == version.to_s } if candidates.empty? - def requirement_satisfied_by?(requirement, activated, spec) - matches_spec = requirement.matches_spec? spec - return matches_spec if @soft_missing + if candidates.length > 1 + # Prefer already-installed specs to avoid unnecessary downloads + installed = candidates.select {|s| s.is_a?(Gem::Resolver::InstalledSpecification) } + return installed.first if installed.length == 1 + candidates = installed if installed.any? - matches_spec && - spec.spec.required_ruby_version.satisfied_by?(Gem.ruby_version) && - spec.spec.required_rubygems_version.satisfied_by?(Gem.rubygems_version) + # Among remaining candidates, prefer the most specific platform + candidates.min_by {|s| Gem::Platform.platform_specificity_match(s.platform, Gem::Platform.local) } + else + candidates.first + end end - def name_for(dependency) - dependency.name - end + def compute_dependencies(package, version) + return {} if package == @root_package - def allow_missing?(dependency) - @soft_missing - end + spec = spec_for(package.to_s, version) + return {} unless spec + return {} if @ignore_dependencies - def sort_dependencies(dependencies, activated, conflicts) - dependencies.sort_by.with_index do |dependency, i| - name = name_for(dependency) - [ - activated.vertex_named(name).payload ? 0 : 1, - amount_constrained(dependency), - conflicts[name] ? 0 : 1, - activated.vertex_named(name).payload ? 0 : search_for(dependency).count, - i, # for stable sort - ] - end - end + deps = {} + root_names = @needed.map(&:name) - SINGLE_POSSIBILITY_CONSTRAINT_PENALTY = 1_000_000 - private_constant :SINGLE_POSSIBILITY_CONSTRAINT_PENALTY if defined?(private_constant) + actual_spec = spec.respond_to?(:spec) ? spec.spec : spec + actual_spec.dependencies.each do |d| + next if d.type == :development && !@development + next if d.type == :development && @development_shallow && !root_names.include?(package.to_s) - # returns an integer \in (-\infty, 0] - # a number closer to 0 means the dependency is less constraining - # - # dependencies w/ 0 or 1 possibilities (ignoring version requirements) - # are given very negative values, so they _always_ sort first, - # before dependencies that are unconstrained - def amount_constrained(dependency) - @amount_constrained ||= {} - @amount_constrained[dependency.name] ||= begin - name_dependency = Gem::Dependency.new(dependency.name) - dependency_request_for_name = Gem::Resolver::DependencyRequest.new(name_dependency, dependency.requester) - all = @set.find_all(dependency_request_for_name).size - - if all <= 1 - all - SINGLE_POSSIBILITY_CONSTRAINT_PENALTY - else - search = search_for(dependency).size - search - all + dep_package = package_for(d.name) + + # Check if the dependency has any available versions + dep_specs = @all_specs[d.name] + if dep_specs.empty? && @soft_missing + next end + + range = Gem::PubGrub::RubyGems.requirement_to_range(d.requirement) + deps[d.name] = Gem::PubGrub::VersionConstraint.new(dep_package, range: range) end + + deps + end + + def make_logger + DEBUG_RESOLVER ? Gem::PubGrub::StderrLogger.new : Gem::PubGrub::NullLogger.new end - private :amount_constrained end require_relative "resolver/activation_request" require_relative "resolver/conflict" require_relative "resolver/dependency_request" require_relative "resolver/requirement_list" -require_relative "resolver/stats" +require_relative "resolver/pub_grub_failure" require_relative "resolver/set" require_relative "resolver/api_set" diff --git a/lib/rubygems/resolver/installer_set.rb b/lib/rubygems/resolver/installer_set.rb index d9fe36c589a54a..42ce0890e2b634 100644 --- a/lib/rubygems/resolver/installer_set.rb +++ b/lib/rubygems/resolver/installer_set.rb @@ -160,7 +160,7 @@ def find_all(req) res.concat matching_local begin - if local_spec = @local_source.find_gem(name, dep.requirement) + @local_source.find_all_gems(name, dep.requirement).each do |local_spec| res << Gem::Resolver::IndexSpecification.new( self, local_spec.name, local_spec.version, @local_source, local_spec.platform diff --git a/lib/rubygems/resolver/pub_grub_failure.rb b/lib/rubygems/resolver/pub_grub_failure.rb new file mode 100644 index 00000000000000..3ace7ef51590a6 --- /dev/null +++ b/lib/rubygems/resolver/pub_grub_failure.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +class Gem::Resolver::PubGrubFailure + attr_reader :solve_failure + + def initialize(solve_failure) + @solve_failure = solve_failure + end + + def explanation + @solve_failure.explanation + end + + def conflicting_dependencies + terms = @solve_failure.incompatibility.terms + terms.map {|t| t.package.to_s } + end +end diff --git a/lib/rubygems/resolver/stats.rb b/lib/rubygems/resolver/stats.rb deleted file mode 100644 index 9920976b2a0950..00000000000000 --- a/lib/rubygems/resolver/stats.rb +++ /dev/null @@ -1,46 +0,0 @@ -# frozen_string_literal: true - -class Gem::Resolver::Stats - def initialize - @max_depth = 0 - @max_requirements = 0 - @requirements = 0 - @backtracking = 0 - @iterations = 0 - end - - def record_depth(stack) - if stack.size > @max_depth - @max_depth = stack.size - end - end - - def record_requirements(reqs) - if reqs.size > @max_requirements - @max_requirements = reqs.size - end - end - - def requirement! - @requirements += 1 - end - - def backtracking! - @backtracking += 1 - end - - def iteration! - @iterations += 1 - end - - PATTERN = "%20s: %d\n" - - def display - $stdout.puts "=== Resolver Statistics ===" - $stdout.printf PATTERN, "Max Depth", @max_depth - $stdout.printf PATTERN, "Total Requirements", @requirements - $stdout.printf PATTERN, "Max Requirements", @max_requirements - $stdout.printf PATTERN, "Backtracking #", @backtracking - $stdout.printf PATTERN, "Iteration #", @iterations - end -end diff --git a/lib/rubygems/source/local.rb b/lib/rubygems/source/local.rb index ba6eea1f9aa5c7..4bef31a2655fa6 100644 --- a/lib/rubygems/source/local.rb +++ b/lib/rubygems/source/local.rb @@ -76,6 +76,10 @@ def load_specs(type) # :nodoc: end def find_gem(gem_name, version = Gem::Requirement.default, prerelease = false) # :nodoc: + find_all_gems(gem_name, version, prerelease).max_by(&:version) + end + + def find_all_gems(gem_name, version = Gem::Requirement.default, prerelease = false) # :nodoc: load_specs :complete found = [] @@ -93,7 +97,7 @@ def find_gem(gem_name, version = Gem::Requirement.default, prerelease = false) # end end - found.max_by(&:version) + found end def fetch_spec(name) # :nodoc: diff --git a/lib/rubygems/vendor/molinillo/lib/molinillo.rb b/lib/rubygems/vendor/molinillo/lib/molinillo.rb deleted file mode 100644 index dd5600c9e38c89..00000000000000 --- a/lib/rubygems/vendor/molinillo/lib/molinillo.rb +++ /dev/null @@ -1,11 +0,0 @@ -# frozen_string_literal: true - -require_relative 'molinillo/gem_metadata' -require_relative 'molinillo/errors' -require_relative 'molinillo/resolver' -require_relative 'molinillo/modules/ui' -require_relative 'molinillo/modules/specification_provider' - -# Gem::Molinillo is a generic dependency resolution algorithm. -module Gem::Molinillo -end diff --git a/lib/rubygems/vendor/molinillo/lib/molinillo/delegates/resolution_state.rb b/lib/rubygems/vendor/molinillo/lib/molinillo/delegates/resolution_state.rb deleted file mode 100644 index 34842d46d5f9e4..00000000000000 --- a/lib/rubygems/vendor/molinillo/lib/molinillo/delegates/resolution_state.rb +++ /dev/null @@ -1,57 +0,0 @@ -# frozen_string_literal: true - -module Gem::Molinillo - # @!visibility private - module Delegates - # Delegates all {Gem::Molinillo::ResolutionState} methods to a `#state` property. - module ResolutionState - # (see Gem::Molinillo::ResolutionState#name) - def name - current_state = state || Gem::Molinillo::ResolutionState.empty - current_state.name - end - - # (see Gem::Molinillo::ResolutionState#requirements) - def requirements - current_state = state || Gem::Molinillo::ResolutionState.empty - current_state.requirements - end - - # (see Gem::Molinillo::ResolutionState#activated) - def activated - current_state = state || Gem::Molinillo::ResolutionState.empty - current_state.activated - end - - # (see Gem::Molinillo::ResolutionState#requirement) - def requirement - current_state = state || Gem::Molinillo::ResolutionState.empty - current_state.requirement - end - - # (see Gem::Molinillo::ResolutionState#possibilities) - def possibilities - current_state = state || Gem::Molinillo::ResolutionState.empty - current_state.possibilities - end - - # (see Gem::Molinillo::ResolutionState#depth) - def depth - current_state = state || Gem::Molinillo::ResolutionState.empty - current_state.depth - end - - # (see Gem::Molinillo::ResolutionState#conflicts) - def conflicts - current_state = state || Gem::Molinillo::ResolutionState.empty - current_state.conflicts - end - - # (see Gem::Molinillo::ResolutionState#unused_unwind_options) - def unused_unwind_options - current_state = state || Gem::Molinillo::ResolutionState.empty - current_state.unused_unwind_options - end - end - end -end diff --git a/lib/rubygems/vendor/molinillo/lib/molinillo/delegates/specification_provider.rb b/lib/rubygems/vendor/molinillo/lib/molinillo/delegates/specification_provider.rb deleted file mode 100644 index 8417721537219d..00000000000000 --- a/lib/rubygems/vendor/molinillo/lib/molinillo/delegates/specification_provider.rb +++ /dev/null @@ -1,88 +0,0 @@ -# frozen_string_literal: true - -module Gem::Molinillo - module Delegates - # Delegates all {Gem::Molinillo::SpecificationProvider} methods to a - # `#specification_provider` property. - module SpecificationProvider - # (see Gem::Molinillo::SpecificationProvider#search_for) - def search_for(dependency) - with_no_such_dependency_error_handling do - specification_provider.search_for(dependency) - end - end - - # (see Gem::Molinillo::SpecificationProvider#dependencies_for) - def dependencies_for(specification) - with_no_such_dependency_error_handling do - specification_provider.dependencies_for(specification) - end - end - - # (see Gem::Molinillo::SpecificationProvider#requirement_satisfied_by?) - def requirement_satisfied_by?(requirement, activated, spec) - with_no_such_dependency_error_handling do - specification_provider.requirement_satisfied_by?(requirement, activated, spec) - end - end - - # (see Gem::Molinillo::SpecificationProvider#dependencies_equal?) - def dependencies_equal?(dependencies, other_dependencies) - with_no_such_dependency_error_handling do - specification_provider.dependencies_equal?(dependencies, other_dependencies) - end - end - - # (see Gem::Molinillo::SpecificationProvider#name_for) - def name_for(dependency) - with_no_such_dependency_error_handling do - specification_provider.name_for(dependency) - end - end - - # (see Gem::Molinillo::SpecificationProvider#name_for_explicit_dependency_source) - def name_for_explicit_dependency_source - with_no_such_dependency_error_handling do - specification_provider.name_for_explicit_dependency_source - end - end - - # (see Gem::Molinillo::SpecificationProvider#name_for_locking_dependency_source) - def name_for_locking_dependency_source - with_no_such_dependency_error_handling do - specification_provider.name_for_locking_dependency_source - end - end - - # (see Gem::Molinillo::SpecificationProvider#sort_dependencies) - def sort_dependencies(dependencies, activated, conflicts) - with_no_such_dependency_error_handling do - specification_provider.sort_dependencies(dependencies, activated, conflicts) - end - end - - # (see Gem::Molinillo::SpecificationProvider#allow_missing?) - def allow_missing?(dependency) - with_no_such_dependency_error_handling do - specification_provider.allow_missing?(dependency) - end - end - - private - - # Ensures any raised {NoSuchDependencyError} has its - # {NoSuchDependencyError#required_by} set. - # @yield - def with_no_such_dependency_error_handling - yield - rescue NoSuchDependencyError => error - if state - vertex = activated.vertex_named(name_for(error.dependency)) - error.required_by += vertex.incoming_edges.map { |e| e.origin.name } - error.required_by << name_for_explicit_dependency_source unless vertex.explicit_requirements.empty? - end - raise - end - end - end -end diff --git a/lib/rubygems/vendor/molinillo/lib/molinillo/dependency_graph.rb b/lib/rubygems/vendor/molinillo/lib/molinillo/dependency_graph.rb deleted file mode 100644 index 2dbbc589dc7d5e..00000000000000 --- a/lib/rubygems/vendor/molinillo/lib/molinillo/dependency_graph.rb +++ /dev/null @@ -1,255 +0,0 @@ -# frozen_string_literal: true - -require_relative '../../../../vendored_tsort' - -require_relative 'dependency_graph/log' -require_relative 'dependency_graph/vertex' - -module Gem::Molinillo - # A directed acyclic graph that is tuned to hold named dependencies - class DependencyGraph - include Enumerable - - # Enumerates through the vertices of the graph. - # @return [Array] The graph's vertices. - def each - return vertices.values.each unless block_given? - vertices.values.each { |v| yield v } - end - - include Gem::TSort - - # @!visibility private - alias tsort_each_node each - - # @!visibility private - def tsort_each_child(vertex, &block) - vertex.successors.each(&block) - end - - # Topologically sorts the given vertices. - # @param [Enumerable] vertices the vertices to be sorted, which must - # all belong to the same graph. - # @return [Array] The sorted vertices. - def self.tsort(vertices) - Gem::TSort.tsort( - lambda { |b| vertices.each(&b) }, - lambda { |v, &b| (v.successors & vertices).each(&b) } - ) - end - - # A directed edge of a {DependencyGraph} - # @attr [Vertex] origin The origin of the directed edge - # @attr [Vertex] destination The destination of the directed edge - # @attr [Object] requirement The requirement the directed edge represents - Edge = Struct.new(:origin, :destination, :requirement) - - # @return [{String => Vertex}] the vertices of the dependency graph, keyed - # by {Vertex#name} - attr_reader :vertices - - # @return [Log] the op log for this graph - attr_reader :log - - # Initializes an empty dependency graph - def initialize - @vertices = {} - @log = Log.new - end - - # Tags the current state of the dependency as the given tag - # @param [Object] tag an opaque tag for the current state of the graph - # @return [Void] - def tag(tag) - log.tag(self, tag) - end - - # Rewinds the graph to the state tagged as `tag` - # @param [Object] tag the tag to rewind to - # @return [Void] - def rewind_to(tag) - log.rewind_to(self, tag) - end - - # Initializes a copy of a {DependencyGraph}, ensuring that all {#vertices} - # are properly copied. - # @param [DependencyGraph] other the graph to copy. - def initialize_copy(other) - super - @vertices = {} - @log = other.log.dup - traverse = lambda do |new_v, old_v| - return if new_v.outgoing_edges.size == old_v.outgoing_edges.size - old_v.outgoing_edges.each do |edge| - destination = add_vertex(edge.destination.name, edge.destination.payload) - add_edge_no_circular(new_v, destination, edge.requirement) - traverse.call(destination, edge.destination) - end - end - other.vertices.each do |name, vertex| - new_vertex = add_vertex(name, vertex.payload, vertex.root?) - new_vertex.explicit_requirements.replace(vertex.explicit_requirements) - traverse.call(new_vertex, vertex) - end - end - - # @return [String] a string suitable for debugging - def inspect - "#{self.class}:#{vertices.values.inspect}" - end - - # @param [Hash] options options for dot output. - # @return [String] Returns a dot format representation of the graph - def to_dot(options = {}) - edge_label = options.delete(:edge_label) - raise ArgumentError, "Unknown options: #{options.keys}" unless options.empty? - - dot_vertices = [] - dot_edges = [] - vertices.each do |n, v| - dot_vertices << " #{n} [label=\"{#{n}|#{v.payload}}\"]" - v.outgoing_edges.each do |e| - label = edge_label ? edge_label.call(e) : e.requirement - dot_edges << " #{e.origin.name} -> #{e.destination.name} [label=#{label.to_s.dump}]" - end - end - - dot_vertices.uniq! - dot_vertices.sort! - dot_edges.uniq! - dot_edges.sort! - - dot = dot_vertices.unshift('digraph G {').push('') + dot_edges.push('}') - dot.join("\n") - end - - # @param [DependencyGraph] other - # @return [Boolean] whether the two dependency graphs are equal, determined - # by a recursive traversal of each {#root_vertices} and its - # {Vertex#successors} - def ==(other) - return false unless other - return true if equal?(other) - vertices.each do |name, vertex| - other_vertex = other.vertex_named(name) - return false unless other_vertex - return false unless vertex.payload == other_vertex.payload - return false unless other_vertex.successors.to_set == vertex.successors.to_set - end - end - - # @param [String] name - # @param [Object] payload - # @param [Array] parent_names - # @param [Object] requirement the requirement that is requiring the child - # @return [void] - def add_child_vertex(name, payload, parent_names, requirement) - root = !parent_names.delete(nil) { true } - vertex = add_vertex(name, payload, root) - vertex.explicit_requirements << requirement if root - parent_names.each do |parent_name| - parent_vertex = vertex_named(parent_name) - add_edge(parent_vertex, vertex, requirement) - end - vertex - end - - # Adds a vertex with the given name, or updates the existing one. - # @param [String] name - # @param [Object] payload - # @return [Vertex] the vertex that was added to `self` - def add_vertex(name, payload, root = false) - log.add_vertex(self, name, payload, root) - end - - # Detaches the {#vertex_named} `name` {Vertex} from the graph, recursively - # removing any non-root vertices that were orphaned in the process - # @param [String] name - # @return [Array] the vertices which have been detached - def detach_vertex_named(name) - log.detach_vertex_named(self, name) - end - - # @param [String] name - # @return [Vertex,nil] the vertex with the given name - def vertex_named(name) - vertices[name] - end - - # @param [String] name - # @return [Vertex,nil] the root vertex with the given name - def root_vertex_named(name) - vertex = vertex_named(name) - vertex if vertex && vertex.root? - end - - # Adds a new {Edge} to the dependency graph - # @param [Vertex] origin - # @param [Vertex] destination - # @param [Object] requirement the requirement that this edge represents - # @return [Edge] the added edge - def add_edge(origin, destination, requirement) - if destination.path_to?(origin) - raise CircularDependencyError.new(path(destination, origin)) - end - add_edge_no_circular(origin, destination, requirement) - end - - # Deletes an {Edge} from the dependency graph - # @param [Edge] edge - # @return [Void] - def delete_edge(edge) - log.delete_edge(self, edge.origin.name, edge.destination.name, edge.requirement) - end - - # Sets the payload of the vertex with the given name - # @param [String] name the name of the vertex - # @param [Object] payload the payload - # @return [Void] - def set_payload(name, payload) - log.set_payload(self, name, payload) - end - - private - - # Adds a new {Edge} to the dependency graph without checking for - # circularity. - # @param (see #add_edge) - # @return (see #add_edge) - def add_edge_no_circular(origin, destination, requirement) - log.add_edge_no_circular(self, origin.name, destination.name, requirement) - end - - # Returns the path between two vertices - # @raise [ArgumentError] if there is no path between the vertices - # @param [Vertex] from - # @param [Vertex] to - # @return [Array] the shortest path from `from` to `to` - def path(from, to) - distances = Hash.new(vertices.size + 1) - distances[from.name] = 0 - predecessors = {} - each do |vertex| - vertex.successors.each do |successor| - if distances[successor.name] > distances[vertex.name] + 1 - distances[successor.name] = distances[vertex.name] + 1 - predecessors[successor] = vertex - end - end - end - - path = [to] - while before = predecessors[to] - path << before - to = before - break if to == from - end - - unless path.last.equal?(from) - raise ArgumentError, "There is no path from #{from.name} to #{to.name}" - end - - path.reverse - end - end -end diff --git a/lib/rubygems/vendor/molinillo/lib/molinillo/dependency_graph/action.rb b/lib/rubygems/vendor/molinillo/lib/molinillo/dependency_graph/action.rb deleted file mode 100644 index 8707ec451db997..00000000000000 --- a/lib/rubygems/vendor/molinillo/lib/molinillo/dependency_graph/action.rb +++ /dev/null @@ -1,36 +0,0 @@ -# frozen_string_literal: true - -module Gem::Molinillo - class DependencyGraph - # An action that modifies a {DependencyGraph} that is reversible. - # @abstract - class Action - # rubocop:disable Lint/UnusedMethodArgument - - # @return [Symbol] The name of the action. - def self.action_name - raise 'Abstract' - end - - # Performs the action on the given graph. - # @param [DependencyGraph] graph the graph to perform the action on. - # @return [Void] - def up(graph) - raise 'Abstract' - end - - # Reverses the action on the given graph. - # @param [DependencyGraph] graph the graph to reverse the action on. - # @return [Void] - def down(graph) - raise 'Abstract' - end - - # @return [Action,Nil] The previous action - attr_accessor :previous - - # @return [Action,Nil] The next action - attr_accessor :next - end - end -end diff --git a/lib/rubygems/vendor/molinillo/lib/molinillo/dependency_graph/add_edge_no_circular.rb b/lib/rubygems/vendor/molinillo/lib/molinillo/dependency_graph/add_edge_no_circular.rb deleted file mode 100644 index aa9815c5ae8d8d..00000000000000 --- a/lib/rubygems/vendor/molinillo/lib/molinillo/dependency_graph/add_edge_no_circular.rb +++ /dev/null @@ -1,66 +0,0 @@ -# frozen_string_literal: true - -require_relative 'action' -module Gem::Molinillo - class DependencyGraph - # @!visibility private - # (see DependencyGraph#add_edge_no_circular) - class AddEdgeNoCircular < Action - # @!group Action - - # (see Action.action_name) - def self.action_name - :add_vertex - end - - # (see Action#up) - def up(graph) - edge = make_edge(graph) - edge.origin.outgoing_edges << edge - edge.destination.incoming_edges << edge - edge - end - - # (see Action#down) - def down(graph) - edge = make_edge(graph) - delete_first(edge.origin.outgoing_edges, edge) - delete_first(edge.destination.incoming_edges, edge) - end - - # @!group AddEdgeNoCircular - - # @return [String] the name of the origin of the edge - attr_reader :origin - - # @return [String] the name of the destination of the edge - attr_reader :destination - - # @return [Object] the requirement that the edge represents - attr_reader :requirement - - # @param [DependencyGraph] graph the graph to find vertices from - # @return [Edge] The edge this action adds - def make_edge(graph) - Edge.new(graph.vertex_named(origin), graph.vertex_named(destination), requirement) - end - - # Initialize an action to add an edge to a dependency graph - # @param [String] origin the name of the origin of the edge - # @param [String] destination the name of the destination of the edge - # @param [Object] requirement the requirement that the edge represents - def initialize(origin, destination, requirement) - @origin = origin - @destination = destination - @requirement = requirement - end - - private - - def delete_first(array, item) - return unless index = array.index(item) - array.delete_at(index) - end - end - end -end diff --git a/lib/rubygems/vendor/molinillo/lib/molinillo/dependency_graph/add_vertex.rb b/lib/rubygems/vendor/molinillo/lib/molinillo/dependency_graph/add_vertex.rb deleted file mode 100644 index 9c7066a669a799..00000000000000 --- a/lib/rubygems/vendor/molinillo/lib/molinillo/dependency_graph/add_vertex.rb +++ /dev/null @@ -1,62 +0,0 @@ -# frozen_string_literal: true - -require_relative 'action' -module Gem::Molinillo - class DependencyGraph - # @!visibility private - # (see DependencyGraph#add_vertex) - class AddVertex < Action # :nodoc: - # @!group Action - - # (see Action.action_name) - def self.action_name - :add_vertex - end - - # (see Action#up) - def up(graph) - if existing = graph.vertices[name] - @existing_payload = existing.payload - @existing_root = existing.root - end - vertex = existing || Vertex.new(name, payload) - graph.vertices[vertex.name] = vertex - vertex.payload ||= payload - vertex.root ||= root - vertex - end - - # (see Action#down) - def down(graph) - if defined?(@existing_payload) - vertex = graph.vertices[name] - vertex.payload = @existing_payload - vertex.root = @existing_root - else - graph.vertices.delete(name) - end - end - - # @!group AddVertex - - # @return [String] the name of the vertex - attr_reader :name - - # @return [Object] the payload for the vertex - attr_reader :payload - - # @return [Boolean] whether the vertex is root or not - attr_reader :root - - # Initialize an action to add a vertex to a dependency graph - # @param [String] name the name of the vertex - # @param [Object] payload the payload for the vertex - # @param [Boolean] root whether the vertex is root or not - def initialize(name, payload, root) - @name = name - @payload = payload - @root = root - end - end - end -end diff --git a/lib/rubygems/vendor/molinillo/lib/molinillo/dependency_graph/delete_edge.rb b/lib/rubygems/vendor/molinillo/lib/molinillo/dependency_graph/delete_edge.rb deleted file mode 100644 index 1e62c0a0b6442b..00000000000000 --- a/lib/rubygems/vendor/molinillo/lib/molinillo/dependency_graph/delete_edge.rb +++ /dev/null @@ -1,63 +0,0 @@ -# frozen_string_literal: true - -require_relative 'action' -module Gem::Molinillo - class DependencyGraph - # @!visibility private - # (see DependencyGraph#delete_edge) - class DeleteEdge < Action - # @!group Action - - # (see Action.action_name) - def self.action_name - :delete_edge - end - - # (see Action#up) - def up(graph) - edge = make_edge(graph) - edge.origin.outgoing_edges.delete(edge) - edge.destination.incoming_edges.delete(edge) - end - - # (see Action#down) - def down(graph) - edge = make_edge(graph) - edge.origin.outgoing_edges << edge - edge.destination.incoming_edges << edge - edge - end - - # @!group DeleteEdge - - # @return [String] the name of the origin of the edge - attr_reader :origin_name - - # @return [String] the name of the destination of the edge - attr_reader :destination_name - - # @return [Object] the requirement that the edge represents - attr_reader :requirement - - # @param [DependencyGraph] graph the graph to find vertices from - # @return [Edge] The edge this action adds - def make_edge(graph) - Edge.new( - graph.vertex_named(origin_name), - graph.vertex_named(destination_name), - requirement - ) - end - - # Initialize an action to add an edge to a dependency graph - # @param [String] origin_name the name of the origin of the edge - # @param [String] destination_name the name of the destination of the edge - # @param [Object] requirement the requirement that the edge represents - def initialize(origin_name, destination_name, requirement) - @origin_name = origin_name - @destination_name = destination_name - @requirement = requirement - end - end - end -end diff --git a/lib/rubygems/vendor/molinillo/lib/molinillo/dependency_graph/detach_vertex_named.rb b/lib/rubygems/vendor/molinillo/lib/molinillo/dependency_graph/detach_vertex_named.rb deleted file mode 100644 index 6132f969b99308..00000000000000 --- a/lib/rubygems/vendor/molinillo/lib/molinillo/dependency_graph/detach_vertex_named.rb +++ /dev/null @@ -1,61 +0,0 @@ -# frozen_string_literal: true - -require_relative 'action' -module Gem::Molinillo - class DependencyGraph - # @!visibility private - # @see DependencyGraph#detach_vertex_named - class DetachVertexNamed < Action - # @!group Action - - # (see Action#name) - def self.action_name - :add_vertex - end - - # (see Action#up) - def up(graph) - return [] unless @vertex = graph.vertices.delete(name) - - removed_vertices = [@vertex] - @vertex.outgoing_edges.each do |e| - v = e.destination - v.incoming_edges.delete(e) - if !v.root? && v.incoming_edges.empty? - removed_vertices.concat graph.detach_vertex_named(v.name) - end - end - - @vertex.incoming_edges.each do |e| - v = e.origin - v.outgoing_edges.delete(e) - end - - removed_vertices - end - - # (see Action#down) - def down(graph) - return unless @vertex - graph.vertices[@vertex.name] = @vertex - @vertex.outgoing_edges.each do |e| - e.destination.incoming_edges << e - end - @vertex.incoming_edges.each do |e| - e.origin.outgoing_edges << e - end - end - - # @!group DetachVertexNamed - - # @return [String] the name of the vertex to detach - attr_reader :name - - # Initialize an action to detach a vertex from a dependency graph - # @param [String] name the name of the vertex to detach - def initialize(name) - @name = name - end - end - end -end diff --git a/lib/rubygems/vendor/molinillo/lib/molinillo/dependency_graph/log.rb b/lib/rubygems/vendor/molinillo/lib/molinillo/dependency_graph/log.rb deleted file mode 100644 index 6954c4b1f8cace..00000000000000 --- a/lib/rubygems/vendor/molinillo/lib/molinillo/dependency_graph/log.rb +++ /dev/null @@ -1,126 +0,0 @@ -# frozen_string_literal: true - -require_relative 'add_edge_no_circular' -require_relative 'add_vertex' -require_relative 'delete_edge' -require_relative 'detach_vertex_named' -require_relative 'set_payload' -require_relative 'tag' - -module Gem::Molinillo - class DependencyGraph - # A log for dependency graph actions - class Log - # Initializes an empty log - def initialize - @current_action = @first_action = nil - end - - # @!macro [new] action - # {include:DependencyGraph#$0} - # @param [Graph] graph the graph to perform the action on - # @param (see DependencyGraph#$0) - # @return (see DependencyGraph#$0) - - # @macro action - def tag(graph, tag) - push_action(graph, Tag.new(tag)) - end - - # @macro action - def add_vertex(graph, name, payload, root) - push_action(graph, AddVertex.new(name, payload, root)) - end - - # @macro action - def detach_vertex_named(graph, name) - push_action(graph, DetachVertexNamed.new(name)) - end - - # @macro action - def add_edge_no_circular(graph, origin, destination, requirement) - push_action(graph, AddEdgeNoCircular.new(origin, destination, requirement)) - end - - # {include:DependencyGraph#delete_edge} - # @param [Graph] graph the graph to perform the action on - # @param [String] origin_name - # @param [String] destination_name - # @param [Object] requirement - # @return (see DependencyGraph#delete_edge) - def delete_edge(graph, origin_name, destination_name, requirement) - push_action(graph, DeleteEdge.new(origin_name, destination_name, requirement)) - end - - # @macro action - def set_payload(graph, name, payload) - push_action(graph, SetPayload.new(name, payload)) - end - - # Pops the most recent action from the log and undoes the action - # @param [DependencyGraph] graph - # @return [Action] the action that was popped off the log - def pop!(graph) - return unless action = @current_action - unless @current_action = action.previous - @first_action = nil - end - action.down(graph) - action - end - - extend Enumerable - - # @!visibility private - # Enumerates each action in the log - # @yield [Action] - def each - return enum_for unless block_given? - action = @first_action - loop do - break unless action - yield action - action = action.next - end - self - end - - # @!visibility private - # Enumerates each action in the log in reverse order - # @yield [Action] - def reverse_each - return enum_for(:reverse_each) unless block_given? - action = @current_action - loop do - break unless action - yield action - action = action.previous - end - self - end - - # @macro action - def rewind_to(graph, tag) - loop do - action = pop!(graph) - raise "No tag #{tag.inspect} found" unless action - break if action.class.action_name == :tag && action.tag == tag - end - end - - private - - # Adds the given action to the log, running the action - # @param [DependencyGraph] graph - # @param [Action] action - # @return The value returned by `action.up` - def push_action(graph, action) - action.previous = @current_action - @current_action.next = action if @current_action - @current_action = action - @first_action ||= action - action.up(graph) - end - end - end -end diff --git a/lib/rubygems/vendor/molinillo/lib/molinillo/dependency_graph/set_payload.rb b/lib/rubygems/vendor/molinillo/lib/molinillo/dependency_graph/set_payload.rb deleted file mode 100644 index 9bcaaae0f97a96..00000000000000 --- a/lib/rubygems/vendor/molinillo/lib/molinillo/dependency_graph/set_payload.rb +++ /dev/null @@ -1,46 +0,0 @@ -# frozen_string_literal: true - -require_relative 'action' -module Gem::Molinillo - class DependencyGraph - # @!visibility private - # @see DependencyGraph#set_payload - class SetPayload < Action # :nodoc: - # @!group Action - - # (see Action.action_name) - def self.action_name - :set_payload - end - - # (see Action#up) - def up(graph) - vertex = graph.vertex_named(name) - @old_payload = vertex.payload - vertex.payload = payload - end - - # (see Action#down) - def down(graph) - graph.vertex_named(name).payload = @old_payload - end - - # @!group SetPayload - - # @return [String] the name of the vertex - attr_reader :name - - # @return [Object] the payload for the vertex - attr_reader :payload - - # Initialize an action to add set the payload for a vertex in a dependency - # graph - # @param [String] name the name of the vertex - # @param [Object] payload the payload for the vertex - def initialize(name, payload) - @name = name - @payload = payload - end - end - end -end diff --git a/lib/rubygems/vendor/molinillo/lib/molinillo/dependency_graph/tag.rb b/lib/rubygems/vendor/molinillo/lib/molinillo/dependency_graph/tag.rb deleted file mode 100644 index 62f243a2aff63d..00000000000000 --- a/lib/rubygems/vendor/molinillo/lib/molinillo/dependency_graph/tag.rb +++ /dev/null @@ -1,36 +0,0 @@ -# frozen_string_literal: true - -require_relative 'action' -module Gem::Molinillo - class DependencyGraph - # @!visibility private - # @see DependencyGraph#tag - class Tag < Action - # @!group Action - - # (see Action.action_name) - def self.action_name - :tag - end - - # (see Action#up) - def up(graph) - end - - # (see Action#down) - def down(graph) - end - - # @!group Tag - - # @return [Object] An opaque tag - attr_reader :tag - - # Initialize an action to tag a state of a dependency graph - # @param [Object] tag an opaque tag - def initialize(tag) - @tag = tag - end - end - end -end diff --git a/lib/rubygems/vendor/molinillo/lib/molinillo/dependency_graph/vertex.rb b/lib/rubygems/vendor/molinillo/lib/molinillo/dependency_graph/vertex.rb deleted file mode 100644 index 074de369bed89b..00000000000000 --- a/lib/rubygems/vendor/molinillo/lib/molinillo/dependency_graph/vertex.rb +++ /dev/null @@ -1,164 +0,0 @@ -# frozen_string_literal: true - -module Gem::Molinillo - class DependencyGraph - # A vertex in a {DependencyGraph} that encapsulates a {#name} and a - # {#payload} - class Vertex - # @return [String] the name of the vertex - attr_accessor :name - - # @return [Object] the payload the vertex holds - attr_accessor :payload - - # @return [Array] the explicit requirements that required - # this vertex - attr_reader :explicit_requirements - - # @return [Boolean] whether the vertex is considered a root vertex - attr_accessor :root - alias root? root - - # Initializes a vertex with the given name and payload. - # @param [String] name see {#name} - # @param [Object] payload see {#payload} - def initialize(name, payload) - @name = name.frozen? ? name : name.dup.freeze - @payload = payload - @explicit_requirements = [] - @outgoing_edges = [] - @incoming_edges = [] - end - - # @return [Array] all of the requirements that required - # this vertex - def requirements - (incoming_edges.map(&:requirement) + explicit_requirements).uniq - end - - # @return [Array] the edges of {#graph} that have `self` as their - # {Edge#origin} - attr_accessor :outgoing_edges - - # @return [Array] the edges of {#graph} that have `self` as their - # {Edge#destination} - attr_accessor :incoming_edges - - # @return [Array] the vertices of {#graph} that have an edge with - # `self` as their {Edge#destination} - def predecessors - incoming_edges.map(&:origin) - end - - # @return [Set] the vertices of {#graph} where `self` is a - # {#descendent?} - def recursive_predecessors - _recursive_predecessors - end - - # @param [Set] vertices the set to add the predecessors to - # @return [Set] the vertices of {#graph} where `self` is a - # {#descendent?} - def _recursive_predecessors(vertices = new_vertex_set) - incoming_edges.each do |edge| - vertex = edge.origin - next unless vertices.add?(vertex) - vertex._recursive_predecessors(vertices) - end - - vertices - end - protected :_recursive_predecessors - - # @return [Array] the vertices of {#graph} that have an edge with - # `self` as their {Edge#origin} - def successors - outgoing_edges.map(&:destination) - end - - # @return [Set] the vertices of {#graph} where `self` is an - # {#ancestor?} - def recursive_successors - _recursive_successors - end - - # @param [Set] vertices the set to add the successors to - # @return [Set] the vertices of {#graph} where `self` is an - # {#ancestor?} - def _recursive_successors(vertices = new_vertex_set) - outgoing_edges.each do |edge| - vertex = edge.destination - next unless vertices.add?(vertex) - vertex._recursive_successors(vertices) - end - - vertices - end - protected :_recursive_successors - - # @return [String] a string suitable for debugging - def inspect - "#{self.class}:#{name}(#{payload.inspect})" - end - - # @return [Boolean] whether the two vertices are equal, determined - # by a recursive traversal of each {Vertex#successors} - def ==(other) - return true if equal?(other) - shallow_eql?(other) && - successors.to_set == other.successors.to_set - end - - # @param [Vertex] other the other vertex to compare to - # @return [Boolean] whether the two vertices are equal, determined - # solely by {#name} and {#payload} equality - def shallow_eql?(other) - return true if equal?(other) - other && - name == other.name && - payload == other.payload - end - - alias eql? == - - # @return [Fixnum] a hash for the vertex based upon its {#name} - def hash - name.hash - end - - # Is there a path from `self` to `other` following edges in the - # dependency graph? - # @return whether there is a path following edges within this {#graph} - def path_to?(other) - _path_to?(other) - end - - alias descendent? path_to? - - # @param [Vertex] other the vertex to check if there's a path to - # @param [Set] visited the vertices of {#graph} that have been visited - # @return [Boolean] whether there is a path to `other` from `self` - def _path_to?(other, visited = new_vertex_set) - return false unless visited.add?(self) - return true if equal?(other) - successors.any? { |v| v._path_to?(other, visited) } - end - protected :_path_to? - - # Is there a path from `other` to `self` following edges in the - # dependency graph? - # @return whether there is a path following edges within this {#graph} - def ancestor?(other) - other.path_to?(self) - end - - alias is_reachable_from? ancestor? - - def new_vertex_set - require 'set' - Set.new - end - private :new_vertex_set - end - end -end diff --git a/lib/rubygems/vendor/molinillo/lib/molinillo/errors.rb b/lib/rubygems/vendor/molinillo/lib/molinillo/errors.rb deleted file mode 100644 index 07ea5fdf3746c3..00000000000000 --- a/lib/rubygems/vendor/molinillo/lib/molinillo/errors.rb +++ /dev/null @@ -1,149 +0,0 @@ -# frozen_string_literal: true - -module Gem::Molinillo - # An error that occurred during the resolution process - class ResolverError < StandardError; end - - # An error caused by searching for a dependency that is completely unknown, - # i.e. has no versions available whatsoever. - class NoSuchDependencyError < ResolverError - # @return [Object] the dependency that could not be found - attr_accessor :dependency - - # @return [Array] the specifications that depended upon {#dependency} - attr_accessor :required_by - - # Initializes a new error with the given missing dependency. - # @param [Object] dependency @see {#dependency} - # @param [Array] required_by @see {#required_by} - def initialize(dependency, required_by = []) - @dependency = dependency - @required_by = required_by.uniq - super() - end - - # The error message for the missing dependency, including the specifications - # that had this dependency. - def message - sources = required_by.map { |r| "`#{r}`" }.join(' and ') - message = "Unable to find a specification for `#{dependency}`" - message += " depended upon by #{sources}" unless sources.empty? - message - end - end - - # An error caused by attempting to fulfil a dependency that was circular - # - # @note This exception will be thrown if and only if a {Vertex} is added to a - # {DependencyGraph} that has a {DependencyGraph::Vertex#path_to?} an - # existing {DependencyGraph::Vertex} - class CircularDependencyError < ResolverError - # [Set] the dependencies responsible for causing the error - attr_reader :dependencies - - # Initializes a new error with the given circular vertices. - # @param [Array] vertices the vertices in the dependency - # that caused the error - def initialize(vertices) - super "There is a circular dependency between #{vertices.map(&:name).join(' and ')}" - @dependencies = vertices.map { |vertex| vertex.payload.possibilities.last }.to_set - end - end - - # An error caused by conflicts in version - class VersionConflict < ResolverError - # @return [{String => Resolution::Conflict}] the conflicts that caused - # resolution to fail - attr_reader :conflicts - - # @return [SpecificationProvider] the specification provider used during - # resolution - attr_reader :specification_provider - - # Initializes a new error with the given version conflicts. - # @param [{String => Resolution::Conflict}] conflicts see {#conflicts} - # @param [SpecificationProvider] specification_provider see {#specification_provider} - def initialize(conflicts, specification_provider) - pairs = [] - conflicts.values.flat_map(&:requirements).each do |conflicting| - conflicting.each do |source, conflict_requirements| - conflict_requirements.each do |c| - pairs << [c, source] - end - end - end - - super "Unable to satisfy the following requirements:\n\n" \ - "#{pairs.map { |r, d| "- `#{r}` required by `#{d}`" }.join("\n")}" - - @conflicts = conflicts - @specification_provider = specification_provider - end - - require_relative 'delegates/specification_provider' - include Delegates::SpecificationProvider - - # @return [String] An error message that includes requirement trees, - # which is much more detailed & customizable than the default message - # @param [Hash] opts the options to create a message with. - # @option opts [String] :solver_name The user-facing name of the solver - # @option opts [String] :possibility_type The generic name of a possibility - # @option opts [Proc] :reduce_trees A proc that reduced the list of requirement trees - # @option opts [Proc] :printable_requirement A proc that pretty-prints requirements - # @option opts [Proc] :additional_message_for_conflict A proc that appends additional - # messages for each conflict - # @option opts [Proc] :version_for_spec A proc that returns the version number for a - # possibility - def message_with_trees(opts = {}) - solver_name = opts.delete(:solver_name) { self.class.name.split('::').first } - possibility_type = opts.delete(:possibility_type) { 'possibility named' } - reduce_trees = opts.delete(:reduce_trees) { proc { |trees| trees.uniq.sort_by(&:to_s) } } - printable_requirement = opts.delete(:printable_requirement) { proc { |req| req.to_s } } - additional_message_for_conflict = opts.delete(:additional_message_for_conflict) { proc {} } - version_for_spec = opts.delete(:version_for_spec) { proc(&:to_s) } - incompatible_version_message_for_conflict = opts.delete(:incompatible_version_message_for_conflict) do - proc do |name, _conflict| - %(#{solver_name} could not find compatible versions for #{possibility_type} "#{name}":) - end - end - - full_message_for_conflict = opts.delete(:full_message_for_conflict) do - proc do |name, conflict| - o = "\n".dup << incompatible_version_message_for_conflict.call(name, conflict) << "\n" - if conflict.locked_requirement - o << %( In snapshot (#{name_for_locking_dependency_source}):\n) - o << %( #{printable_requirement.call(conflict.locked_requirement)}\n) - o << %(\n) - end - o << %( In #{name_for_explicit_dependency_source}:\n) - trees = reduce_trees.call(conflict.requirement_trees) - - o << trees.map do |tree| - t = ''.dup - depth = 2 - tree.each do |req| - t << ' ' * depth << printable_requirement.call(req) - unless tree.last == req - if spec = conflict.activated_by_name[name_for(req)] - t << %( was resolved to #{version_for_spec.call(spec)}, which) - end - t << %( depends on) - end - t << %(\n) - depth += 1 - end - t - end.join("\n") - - additional_message_for_conflict.call(o, name, conflict) - - o - end - end - - conflicts.sort.reduce(''.dup) do |o, (name, conflict)| - o << full_message_for_conflict.call(name, conflict) - end.strip - end - end -end diff --git a/lib/rubygems/vendor/molinillo/lib/molinillo/gem_metadata.rb b/lib/rubygems/vendor/molinillo/lib/molinillo/gem_metadata.rb deleted file mode 100644 index 8ed3a920a2f386..00000000000000 --- a/lib/rubygems/vendor/molinillo/lib/molinillo/gem_metadata.rb +++ /dev/null @@ -1,6 +0,0 @@ -# frozen_string_literal: true - -module Gem::Molinillo - # The version of Gem::Molinillo. - VERSION = '0.8.0'.freeze -end diff --git a/lib/rubygems/vendor/molinillo/lib/molinillo/modules/specification_provider.rb b/lib/rubygems/vendor/molinillo/lib/molinillo/modules/specification_provider.rb deleted file mode 100644 index 85860902fca563..00000000000000 --- a/lib/rubygems/vendor/molinillo/lib/molinillo/modules/specification_provider.rb +++ /dev/null @@ -1,112 +0,0 @@ -# frozen_string_literal: true - -module Gem::Molinillo - # Provides information about specifications and dependencies to the resolver, - # allowing the {Resolver} class to remain generic while still providing power - # and flexibility. - # - # This module contains the methods that users of Gem::Molinillo must to implement, - # using knowledge of their own model classes. - module SpecificationProvider - # Search for the specifications that match the given dependency. - # The specifications in the returned array will be considered in reverse - # order, so the latest version ought to be last. - # @note This method should be 'pure', i.e. the return value should depend - # only on the `dependency` parameter. - # - # @param [Object] dependency - # @return [Array] the specifications that satisfy the given - # `dependency`. - def search_for(dependency) - [] - end - - # Returns the dependencies of `specification`. - # @note This method should be 'pure', i.e. the return value should depend - # only on the `specification` parameter. - # - # @param [Object] specification - # @return [Array] the dependencies that are required by the given - # `specification`. - def dependencies_for(specification) - [] - end - - # Determines whether the given `requirement` is satisfied by the given - # `spec`, in the context of the current `activated` dependency graph. - # - # @param [Object] requirement - # @param [DependencyGraph] activated the current dependency graph in the - # resolution process. - # @param [Object] spec - # @return [Boolean] whether `requirement` is satisfied by `spec` in the - # context of the current `activated` dependency graph. - def requirement_satisfied_by?(requirement, activated, spec) - true - end - - # Determines whether two arrays of dependencies are equal, and thus can be - # grouped. - # - # @param [Array] dependencies - # @param [Array] other_dependencies - # @return [Boolean] whether `dependencies` and `other_dependencies` should - # be considered equal. - def dependencies_equal?(dependencies, other_dependencies) - dependencies == other_dependencies - end - - # Returns the name for the given `dependency`. - # @note This method should be 'pure', i.e. the return value should depend - # only on the `dependency` parameter. - # - # @param [Object] dependency - # @return [String] the name for the given `dependency`. - def name_for(dependency) - dependency.to_s - end - - # @return [String] the name of the source of explicit dependencies, i.e. - # those passed to {Resolver#resolve} directly. - def name_for_explicit_dependency_source - 'user-specified dependency' - end - - # @return [String] the name of the source of 'locked' dependencies, i.e. - # those passed to {Resolver#resolve} directly as the `base` - def name_for_locking_dependency_source - 'Lockfile' - end - - # Sort dependencies so that the ones that are easiest to resolve are first. - # Easiest to resolve is (usually) defined by: - # 1) Is this dependency already activated? - # 2) How relaxed are the requirements? - # 3) Are there any conflicts for this dependency? - # 4) How many possibilities are there to satisfy this dependency? - # - # @param [Array] dependencies - # @param [DependencyGraph] activated the current dependency graph in the - # resolution process. - # @param [{String => Array}] conflicts - # @return [Array] a sorted copy of `dependencies`. - def sort_dependencies(dependencies, activated, conflicts) - dependencies.sort_by do |dependency| - name = name_for(dependency) - [ - activated.vertex_named(name).payload ? 0 : 1, - conflicts[name] ? 0 : 1, - ] - end - end - - # Returns whether this dependency, which has no possible matching - # specifications, can safely be ignored. - # - # @param [Object] dependency - # @return [Boolean] whether this dependency can safely be skipped. - def allow_missing?(dependency) - false - end - end -end diff --git a/lib/rubygems/vendor/molinillo/lib/molinillo/modules/ui.rb b/lib/rubygems/vendor/molinillo/lib/molinillo/modules/ui.rb deleted file mode 100644 index 464722902e24d0..00000000000000 --- a/lib/rubygems/vendor/molinillo/lib/molinillo/modules/ui.rb +++ /dev/null @@ -1,67 +0,0 @@ -# frozen_string_literal: true - -module Gem::Molinillo - # Conveys information about the resolution process to a user. - module UI - # The {IO} object that should be used to print output. `STDOUT`, by default. - # - # @return [IO] - def output - STDOUT - end - - # Called roughly every {#progress_rate}, this method should convey progress - # to the user. - # - # @return [void] - def indicate_progress - output.print '.' unless debug? - end - - # How often progress should be conveyed to the user via - # {#indicate_progress}, in seconds. A third of a second, by default. - # - # @return [Float] - def progress_rate - 0.33 - end - - # Called before resolution begins. - # - # @return [void] - def before_resolution - output.print 'Resolving dependencies...' - end - - # Called after resolution ends (either successfully or with an error). - # By default, prints a newline. - # - # @return [void] - def after_resolution - output.puts - end - - # Conveys debug information to the user. - # - # @param [Integer] depth the current depth of the resolution process. - # @return [void] - def debug(depth = 0) - if debug? - debug_info = yield - debug_info = debug_info.inspect unless debug_info.is_a?(String) - debug_info = debug_info.split("\n").map { |s| ":#{depth.to_s.rjust 4}: #{s}" } - output.puts debug_info - end - end - - # Whether or not debug messages should be printed. - # By default, whether or not the `MOLINILLO_DEBUG` environment variable is - # set. - # - # @return [Boolean] - def debug? - return @debug_mode if defined?(@debug_mode) - @debug_mode = ENV['MOLINILLO_DEBUG'] - end - end -end diff --git a/lib/rubygems/vendor/molinillo/lib/molinillo/resolution.rb b/lib/rubygems/vendor/molinillo/lib/molinillo/resolution.rb deleted file mode 100644 index 84ec6cb095977b..00000000000000 --- a/lib/rubygems/vendor/molinillo/lib/molinillo/resolution.rb +++ /dev/null @@ -1,839 +0,0 @@ -# frozen_string_literal: true - -module Gem::Molinillo - class Resolver - # A specific resolution from a given {Resolver} - class Resolution - # A conflict that the resolution process encountered - # @attr [Object] requirement the requirement that immediately led to the conflict - # @attr [{String,Nil=>[Object]}] requirements the requirements that caused the conflict - # @attr [Object, nil] existing the existing spec that was in conflict with - # the {#possibility} - # @attr [Object] possibility_set the set of specs that was unable to be - # activated due to a conflict. - # @attr [Object] locked_requirement the relevant locking requirement. - # @attr [Array>] requirement_trees the different requirement - # trees that led to every requirement for the conflicting name. - # @attr [{String=>Object}] activated_by_name the already-activated specs. - # @attr [Object] underlying_error an error that has occurred during resolution, and - # will be raised at the end of it if no resolution is found. - Conflict = Struct.new( - :requirement, - :requirements, - :existing, - :possibility_set, - :locked_requirement, - :requirement_trees, - :activated_by_name, - :underlying_error - ) - - class Conflict - # @return [Object] a spec that was unable to be activated due to a conflict - def possibility - possibility_set && possibility_set.latest_version - end - end - - # A collection of possibility states that share the same dependencies - # @attr [Array] dependencies the dependencies for this set of possibilities - # @attr [Array] possibilities the possibilities - PossibilitySet = Struct.new(:dependencies, :possibilities) - - class PossibilitySet - # String representation of the possibility set, for debugging - def to_s - "[#{possibilities.join(', ')}]" - end - - # @return [Object] most up-to-date dependency in the possibility set - def latest_version - possibilities.last - end - end - - # Details of the state to unwind to when a conflict occurs, and the cause of the unwind - # @attr [Integer] state_index the index of the state to unwind to - # @attr [Object] state_requirement the requirement of the state we're unwinding to - # @attr [Array] requirement_tree for the requirement we're relaxing - # @attr [Array] conflicting_requirements the requirements that combined to cause the conflict - # @attr [Array] requirement_trees for the conflict - # @attr [Array] requirements_unwound_to_instead array of unwind requirements that were chosen over this unwind - UnwindDetails = Struct.new( - :state_index, - :state_requirement, - :requirement_tree, - :conflicting_requirements, - :requirement_trees, - :requirements_unwound_to_instead - ) - - class UnwindDetails - include Comparable - - # We compare UnwindDetails when choosing which state to unwind to. If - # two options have the same state_index we prefer the one most - # removed from a requirement that caused the conflict. Both options - # would unwind to the same state, but a `grandparent` option will - # filter out fewer of its possibilities after doing so - where a state - # is both a `parent` and a `grandparent` to requirements that have - # caused a conflict this is the correct behaviour. - # @param [UnwindDetail] other UnwindDetail to be compared - # @return [Integer] integer specifying ordering - def <=>(other) - if state_index > other.state_index - 1 - elsif state_index == other.state_index - reversed_requirement_tree_index <=> other.reversed_requirement_tree_index - else - -1 - end - end - - # @return [Integer] index of state requirement in reversed requirement tree - # (the conflicting requirement itself will be at position 0) - def reversed_requirement_tree_index - @reversed_requirement_tree_index ||= - if state_requirement - requirement_tree.reverse.index(state_requirement) - else - 999_999 - end - end - - # @return [Boolean] where the requirement of the state we're unwinding - # to directly caused the conflict. Note: in this case, it is - # impossible for the state we're unwinding to be a parent of - # any of the other conflicting requirements (or we would have - # circularity) - def unwinding_to_primary_requirement? - requirement_tree.last == state_requirement - end - - # @return [Array] array of sub-dependencies to avoid when choosing a - # new possibility for the state we've unwound to. Only relevant for - # non-primary unwinds - def sub_dependencies_to_avoid - @requirements_to_avoid ||= - requirement_trees.map do |tree| - index = tree.index(state_requirement) - tree[index + 1] if index - end.compact - end - - # @return [Array] array of all the requirements that led to the need for - # this unwind - def all_requirements - @all_requirements ||= requirement_trees.flatten(1) - end - end - - # @return [SpecificationProvider] the provider that knows about - # dependencies, requirements, specifications, versions, etc. - attr_reader :specification_provider - - # @return [UI] the UI that knows how to communicate feedback about the - # resolution process back to the user - attr_reader :resolver_ui - - # @return [DependencyGraph] the base dependency graph to which - # dependencies should be 'locked' - attr_reader :base - - # @return [Array] the dependencies that were explicitly required - attr_reader :original_requested - - # Initializes a new resolution. - # @param [SpecificationProvider] specification_provider - # see {#specification_provider} - # @param [UI] resolver_ui see {#resolver_ui} - # @param [Array] requested see {#original_requested} - # @param [DependencyGraph] base see {#base} - def initialize(specification_provider, resolver_ui, requested, base) - @specification_provider = specification_provider - @resolver_ui = resolver_ui - @original_requested = requested - @base = base - @states = [] - @iteration_counter = 0 - @parents_of = Hash.new { |h, k| h[k] = [] } - end - - # Resolves the {#original_requested} dependencies into a full dependency - # graph - # @raise [ResolverError] if successful resolution is impossible - # @return [DependencyGraph] the dependency graph of successfully resolved - # dependencies - def resolve - start_resolution - - while state - break if !state.requirement && state.requirements.empty? - indicate_progress - if state.respond_to?(:pop_possibility_state) # DependencyState - debug(depth) { "Creating possibility state for #{requirement} (#{possibilities.count} remaining)" } - state.pop_possibility_state.tap do |s| - if s - states.push(s) - activated.tag(s) - end - end - end - process_topmost_state - end - - resolve_activated_specs - ensure - end_resolution - end - - # @return [Integer] the number of resolver iterations in between calls to - # {#resolver_ui}'s {UI#indicate_progress} method - attr_accessor :iteration_rate - private :iteration_rate - - # @return [Time] the time at which resolution began - attr_accessor :started_at - private :started_at - - # @return [Array] the stack of states for the resolution - attr_accessor :states - private :states - - private - - # Sets up the resolution process - # @return [void] - def start_resolution - @started_at = Time.now - - push_initial_state - - debug { "Starting resolution (#{@started_at})\nUser-requested dependencies: #{original_requested}" } - resolver_ui.before_resolution - end - - def resolve_activated_specs - activated.vertices.each do |_, vertex| - next unless vertex.payload - - latest_version = vertex.payload.possibilities.reverse_each.find do |possibility| - vertex.requirements.all? { |req| requirement_satisfied_by?(req, activated, possibility) } - end - - activated.set_payload(vertex.name, latest_version) - end - activated.freeze - end - - # Ends the resolution process - # @return [void] - def end_resolution - resolver_ui.after_resolution - debug do - "Finished resolution (#{@iteration_counter} steps) " \ - "(Took #{(ended_at = Time.now) - @started_at} seconds) (#{ended_at})" - end - debug { 'Unactivated: ' + Hash[activated.vertices.reject { |_n, v| v.payload }].keys.join(', ') } if state - debug { 'Activated: ' + Hash[activated.vertices.select { |_n, v| v.payload }].keys.join(', ') } if state - end - - require_relative 'state' - require_relative 'modules/specification_provider' - - require_relative 'delegates/resolution_state' - require_relative 'delegates/specification_provider' - - include Gem::Molinillo::Delegates::ResolutionState - include Gem::Molinillo::Delegates::SpecificationProvider - - # Processes the topmost available {RequirementState} on the stack - # @return [void] - def process_topmost_state - if possibility - attempt_to_activate - else - create_conflict - unwind_for_conflict - end - rescue CircularDependencyError => underlying_error - create_conflict(underlying_error) - unwind_for_conflict - end - - # @return [Object] the current possibility that the resolution is trying - # to activate - def possibility - possibilities.last - end - - # @return [RequirementState] the current state the resolution is - # operating upon - def state - states.last - end - - # Creates and pushes the initial state for the resolution, based upon the - # {#requested} dependencies - # @return [void] - def push_initial_state - graph = DependencyGraph.new.tap do |dg| - original_requested.each do |requested| - vertex = dg.add_vertex(name_for(requested), nil, true) - vertex.explicit_requirements << requested - end - dg.tag(:initial_state) - end - - push_state_for_requirements(original_requested, true, graph) - end - - # Unwinds the states stack because a conflict has been encountered - # @return [void] - def unwind_for_conflict - details_for_unwind = build_details_for_unwind - unwind_options = unused_unwind_options - debug(depth) { "Unwinding for conflict: #{requirement} to #{details_for_unwind.state_index / 2}" } - conflicts.tap do |c| - sliced_states = states.slice!((details_for_unwind.state_index + 1)..-1) - raise_error_unless_state(c) - activated.rewind_to(sliced_states.first || :initial_state) if sliced_states - state.conflicts = c - state.unused_unwind_options = unwind_options - filter_possibilities_after_unwind(details_for_unwind) - index = states.size - 1 - @parents_of.each { |_, a| a.reject! { |i| i >= index } } - state.unused_unwind_options.reject! { |uw| uw.state_index >= index } - end - end - - # Raises a VersionConflict error, or any underlying error, if there is no - # current state - # @return [void] - def raise_error_unless_state(conflicts) - return if state - - error = conflicts.values.map(&:underlying_error).compact.first - raise error || VersionConflict.new(conflicts, specification_provider) - end - - # @return [UnwindDetails] Details of the nearest index to which we could unwind - def build_details_for_unwind - # Get the possible unwinds for the current conflict - current_conflict = conflicts[name] - binding_requirements = binding_requirements_for_conflict(current_conflict) - unwind_details = unwind_options_for_requirements(binding_requirements) - - last_detail_for_current_unwind = unwind_details.sort.last - current_detail = last_detail_for_current_unwind - - # Look for past conflicts that could be unwound to affect the - # requirement tree for the current conflict - all_reqs = last_detail_for_current_unwind.all_requirements - all_reqs_size = all_reqs.size - relevant_unused_unwinds = unused_unwind_options.select do |alternative| - diff_reqs = all_reqs - alternative.requirements_unwound_to_instead - next if diff_reqs.size == all_reqs_size - # Find the highest index unwind whilst looping through - current_detail = alternative if alternative > current_detail - alternative - end - - # Add the current unwind options to the `unused_unwind_options` array. - # The "used" option will be filtered out during `unwind_for_conflict`. - state.unused_unwind_options += unwind_details.reject { |detail| detail.state_index == -1 } - - # Update the requirements_unwound_to_instead on any relevant unused unwinds - relevant_unused_unwinds.each do |d| - (d.requirements_unwound_to_instead << current_detail.state_requirement).uniq! - end - unwind_details.each do |d| - (d.requirements_unwound_to_instead << current_detail.state_requirement).uniq! - end - - current_detail - end - - # @param [Array] binding_requirements array of requirements that combine to create a conflict - # @return [Array] array of UnwindDetails that have a chance - # of resolving the passed requirements - def unwind_options_for_requirements(binding_requirements) - unwind_details = [] - - trees = [] - binding_requirements.reverse_each do |r| - partial_tree = [r] - trees << partial_tree - unwind_details << UnwindDetails.new(-1, nil, partial_tree, binding_requirements, trees, []) - - # If this requirement has alternative possibilities, check if any would - # satisfy the other requirements that created this conflict - requirement_state = find_state_for(r) - if conflict_fixing_possibilities?(requirement_state, binding_requirements) - unwind_details << UnwindDetails.new( - states.index(requirement_state), - r, - partial_tree, - binding_requirements, - trees, - [] - ) - end - - # Next, look at the parent of this requirement, and check if the requirement - # could have been avoided if an alternative PossibilitySet had been chosen - parent_r = parent_of(r) - next if parent_r.nil? - partial_tree.unshift(parent_r) - requirement_state = find_state_for(parent_r) - if requirement_state.possibilities.any? { |set| !set.dependencies.include?(r) } - unwind_details << UnwindDetails.new( - states.index(requirement_state), - parent_r, - partial_tree, - binding_requirements, - trees, - [] - ) - end - - # Finally, look at the grandparent and up of this requirement, looking - # for any possibilities that wouldn't create their parent requirement - grandparent_r = parent_of(parent_r) - until grandparent_r.nil? - partial_tree.unshift(grandparent_r) - requirement_state = find_state_for(grandparent_r) - if requirement_state.possibilities.any? { |set| !set.dependencies.include?(parent_r) } - unwind_details << UnwindDetails.new( - states.index(requirement_state), - grandparent_r, - partial_tree, - binding_requirements, - trees, - [] - ) - end - parent_r = grandparent_r - grandparent_r = parent_of(parent_r) - end - end - - unwind_details - end - - # @param [DependencyState] state - # @param [Array] binding_requirements array of requirements - # @return [Boolean] whether or not the given state has any possibilities - # that could satisfy the given requirements - def conflict_fixing_possibilities?(state, binding_requirements) - return false unless state - - state.possibilities.any? do |possibility_set| - possibility_set.possibilities.any? do |poss| - possibility_satisfies_requirements?(poss, binding_requirements) - end - end - end - - # Filter's a state's possibilities to remove any that would not fix the - # conflict we've just rewound from - # @param [UnwindDetails] unwind_details details of the conflict just - # unwound from - # @return [void] - def filter_possibilities_after_unwind(unwind_details) - return unless state && !state.possibilities.empty? - - if unwind_details.unwinding_to_primary_requirement? - filter_possibilities_for_primary_unwind(unwind_details) - else - filter_possibilities_for_parent_unwind(unwind_details) - end - end - - # Filter's a state's possibilities to remove any that would not satisfy - # the requirements in the conflict we've just rewound from - # @param [UnwindDetails] unwind_details details of the conflict just unwound from - # @return [void] - def filter_possibilities_for_primary_unwind(unwind_details) - unwinds_to_state = unused_unwind_options.select { |uw| uw.state_index == unwind_details.state_index } - unwinds_to_state << unwind_details - unwind_requirement_sets = unwinds_to_state.map(&:conflicting_requirements) - - state.possibilities.reject! do |possibility_set| - possibility_set.possibilities.none? do |poss| - unwind_requirement_sets.any? do |requirements| - possibility_satisfies_requirements?(poss, requirements) - end - end - end - end - - # @param [Object] possibility a single possibility - # @param [Array] requirements an array of requirements - # @return [Boolean] whether the possibility satisfies all of the - # given requirements - def possibility_satisfies_requirements?(possibility, requirements) - name = name_for(possibility) - - activated.tag(:swap) - activated.set_payload(name, possibility) if activated.vertex_named(name) - satisfied = requirements.all? { |r| requirement_satisfied_by?(r, activated, possibility) } - activated.rewind_to(:swap) - - satisfied - end - - # Filter's a state's possibilities to remove any that would (eventually) - # create a requirement in the conflict we've just rewound from - # @param [UnwindDetails] unwind_details details of the conflict just unwound from - # @return [void] - def filter_possibilities_for_parent_unwind(unwind_details) - unwinds_to_state = unused_unwind_options.select { |uw| uw.state_index == unwind_details.state_index } - unwinds_to_state << unwind_details - - primary_unwinds = unwinds_to_state.select(&:unwinding_to_primary_requirement?).uniq - parent_unwinds = unwinds_to_state.uniq - primary_unwinds - - allowed_possibility_sets = primary_unwinds.flat_map do |unwind| - states[unwind.state_index].possibilities.select do |possibility_set| - possibility_set.possibilities.any? do |poss| - possibility_satisfies_requirements?(poss, unwind.conflicting_requirements) - end - end - end - - requirements_to_avoid = parent_unwinds.flat_map(&:sub_dependencies_to_avoid) - - state.possibilities.reject! do |possibility_set| - !allowed_possibility_sets.include?(possibility_set) && - (requirements_to_avoid - possibility_set.dependencies).empty? - end - end - - # @param [Conflict] conflict - # @return [Array] minimal array of requirements that would cause the passed - # conflict to occur. - def binding_requirements_for_conflict(conflict) - return [conflict.requirement] if conflict.possibility.nil? - - possible_binding_requirements = conflict.requirements.values.flatten(1).uniq - - # When there's a `CircularDependency` error the conflicting requirement - # (the one causing the circular) won't be `conflict.requirement` - # (which won't be for the right state, because we won't have created it, - # because it's circular). - # We need to make sure we have that requirement in the conflict's list, - # otherwise we won't be able to unwind properly, so we just return all - # the requirements for the conflict. - return possible_binding_requirements if conflict.underlying_error - - possibilities = search_for(conflict.requirement) - - # If all the requirements together don't filter out all possibilities, - # then the only two requirements we need to consider are the initial one - # (where the dependency's version was first chosen) and the last - if binding_requirement_in_set?(nil, possible_binding_requirements, possibilities) - return [conflict.requirement, requirement_for_existing_name(name_for(conflict.requirement))].compact - end - - # Loop through the possible binding requirements, removing each one - # that doesn't bind. Use a `reverse_each` as we want the earliest set of - # binding requirements, and don't use `reject!` as we wish to refine the - # array *on each iteration*. - binding_requirements = possible_binding_requirements.dup - possible_binding_requirements.reverse_each do |req| - next if req == conflict.requirement - unless binding_requirement_in_set?(req, binding_requirements, possibilities) - binding_requirements -= [req] - end - end - - binding_requirements - end - - # @param [Object] requirement we wish to check - # @param [Array] possible_binding_requirements array of requirements - # @param [Array] possibilities array of possibilities the requirements will be used to filter - # @return [Boolean] whether or not the given requirement is required to filter - # out all elements of the array of possibilities. - def binding_requirement_in_set?(requirement, possible_binding_requirements, possibilities) - possibilities.any? do |poss| - possibility_satisfies_requirements?(poss, possible_binding_requirements - [requirement]) - end - end - - # @param [Object] requirement - # @return [Object] the requirement that led to `requirement` being added - # to the list of requirements. - def parent_of(requirement) - return unless requirement - return unless index = @parents_of[requirement].last - return unless parent_state = @states[index] - parent_state.requirement - end - - # @param [String] name - # @return [Object] the requirement that led to a version of a possibility - # with the given name being activated. - def requirement_for_existing_name(name) - return nil unless vertex = activated.vertex_named(name) - return nil unless vertex.payload - states.find { |s| s.name == name }.requirement - end - - # @param [Object] requirement - # @return [ResolutionState] the state whose `requirement` is the given - # `requirement`. - def find_state_for(requirement) - return nil unless requirement - states.find { |i| requirement == i.requirement } - end - - # @param [Object] underlying_error - # @return [Conflict] a {Conflict} that reflects the failure to activate - # the {#possibility} in conjunction with the current {#state} - def create_conflict(underlying_error = nil) - vertex = activated.vertex_named(name) - locked_requirement = locked_requirement_named(name) - - requirements = {} - unless vertex.explicit_requirements.empty? - requirements[name_for_explicit_dependency_source] = vertex.explicit_requirements - end - requirements[name_for_locking_dependency_source] = [locked_requirement] if locked_requirement - vertex.incoming_edges.each do |edge| - (requirements[edge.origin.payload.latest_version] ||= []).unshift(edge.requirement) - end - - activated_by_name = {} - activated.each { |v| activated_by_name[v.name] = v.payload.latest_version if v.payload } - conflicts[name] = Conflict.new( - requirement, - requirements, - vertex.payload && vertex.payload.latest_version, - possibility, - locked_requirement, - requirement_trees, - activated_by_name, - underlying_error - ) - end - - # @return [Array>] The different requirement - # trees that led to every requirement for the current spec. - def requirement_trees - vertex = activated.vertex_named(name) - vertex.requirements.map { |r| requirement_tree_for(r) } - end - - # @param [Object] requirement - # @return [Array] the list of requirements that led to - # `requirement` being required. - def requirement_tree_for(requirement) - tree = [] - while requirement - tree.unshift(requirement) - requirement = parent_of(requirement) - end - tree - end - - # Indicates progress roughly once every second - # @return [void] - def indicate_progress - @iteration_counter += 1 - @progress_rate ||= resolver_ui.progress_rate - if iteration_rate.nil? - if Time.now - started_at >= @progress_rate - self.iteration_rate = @iteration_counter - end - end - - if iteration_rate && (@iteration_counter % iteration_rate) == 0 - resolver_ui.indicate_progress - end - end - - # Calls the {#resolver_ui}'s {UI#debug} method - # @param [Integer] depth the depth of the {#states} stack - # @param [Proc] block a block that yields a {#to_s} - # @return [void] - def debug(depth = 0, &block) - resolver_ui.debug(depth, &block) - end - - # Attempts to activate the current {#possibility} - # @return [void] - def attempt_to_activate - debug(depth) { 'Attempting to activate ' + possibility.to_s } - existing_vertex = activated.vertex_named(name) - if existing_vertex.payload - debug(depth) { "Found existing spec (#{existing_vertex.payload})" } - attempt_to_filter_existing_spec(existing_vertex) - else - latest = possibility.latest_version - possibility.possibilities.select! do |possibility| - requirement_satisfied_by?(requirement, activated, possibility) - end - if possibility.latest_version.nil? - # ensure there's a possibility for better error messages - possibility.possibilities << latest if latest - create_conflict - unwind_for_conflict - else - activate_new_spec - end - end - end - - # Attempts to update the existing vertex's `PossibilitySet` with a filtered version - # @return [void] - def attempt_to_filter_existing_spec(vertex) - filtered_set = filtered_possibility_set(vertex) - if !filtered_set.possibilities.empty? - activated.set_payload(name, filtered_set) - new_requirements = requirements.dup - push_state_for_requirements(new_requirements, false) - else - create_conflict - debug(depth) { "Unsatisfied by existing spec (#{vertex.payload})" } - unwind_for_conflict - end - end - - # Generates a filtered version of the existing vertex's `PossibilitySet` using the - # current state's `requirement` - # @param [Object] vertex existing vertex - # @return [PossibilitySet] filtered possibility set - def filtered_possibility_set(vertex) - PossibilitySet.new(vertex.payload.dependencies, vertex.payload.possibilities & possibility.possibilities) - end - - # @param [String] requirement_name the spec name to search for - # @return [Object] the locked spec named `requirement_name`, if one - # is found on {#base} - def locked_requirement_named(requirement_name) - vertex = base.vertex_named(requirement_name) - vertex && vertex.payload - end - - # Add the current {#possibility} to the dependency graph of the current - # {#state} - # @return [void] - def activate_new_spec - conflicts.delete(name) - debug(depth) { "Activated #{name} at #{possibility}" } - activated.set_payload(name, possibility) - require_nested_dependencies_for(possibility) - end - - # Requires the dependencies that the recently activated spec has - # @param [Object] possibility_set the PossibilitySet that has just been - # activated - # @return [void] - def require_nested_dependencies_for(possibility_set) - nested_dependencies = dependencies_for(possibility_set.latest_version) - debug(depth) { "Requiring nested dependencies (#{nested_dependencies.join(', ')})" } - nested_dependencies.each do |d| - activated.add_child_vertex(name_for(d), nil, [name_for(possibility_set.latest_version)], d) - parent_index = states.size - 1 - parents = @parents_of[d] - parents << parent_index if parents.empty? - end - - push_state_for_requirements(requirements + nested_dependencies, !nested_dependencies.empty?) - end - - # Pushes a new {DependencyState} that encapsulates both existing and new - # requirements - # @param [Array] new_requirements - # @param [Boolean] requires_sort - # @param [Object] new_activated - # @return [void] - def push_state_for_requirements(new_requirements, requires_sort = true, new_activated = activated) - new_requirements = sort_dependencies(new_requirements.uniq, new_activated, conflicts) if requires_sort - new_requirement = nil - loop do - new_requirement = new_requirements.shift - break if new_requirement.nil? || states.none? { |s| s.requirement == new_requirement } - end - new_name = new_requirement ? name_for(new_requirement) : ''.freeze - possibilities = possibilities_for_requirement(new_requirement) - handle_missing_or_push_dependency_state DependencyState.new( - new_name, new_requirements, new_activated, - new_requirement, possibilities, depth, conflicts.dup, unused_unwind_options.dup - ) - end - - # Checks a proposed requirement with any existing locked requirement - # before generating an array of possibilities for it. - # @param [Object] requirement the proposed requirement - # @param [Object] activated - # @return [Array] possibilities - def possibilities_for_requirement(requirement, activated = self.activated) - return [] unless requirement - if locked_requirement_named(name_for(requirement)) - return locked_requirement_possibility_set(requirement, activated) - end - - group_possibilities(search_for(requirement)) - end - - # @param [Object] requirement the proposed requirement - # @param [Object] activated - # @return [Array] possibility set containing only the locked requirement, if any - def locked_requirement_possibility_set(requirement, activated = self.activated) - all_possibilities = search_for(requirement) - locked_requirement = locked_requirement_named(name_for(requirement)) - - # Longwinded way to build a possibilities array with either the locked - # requirement or nothing in it. Required, since the API for - # locked_requirement isn't guaranteed. - locked_possibilities = all_possibilities.select do |possibility| - requirement_satisfied_by?(locked_requirement, activated, possibility) - end - - group_possibilities(locked_possibilities) - end - - # Build an array of PossibilitySets, with each element representing a group of - # dependency versions that all have the same sub-dependency version constraints - # and are contiguous. - # @param [Array] possibilities an array of possibilities - # @return [Array] an array of possibility sets - def group_possibilities(possibilities) - possibility_sets = [] - current_possibility_set = nil - - possibilities.reverse_each do |possibility| - dependencies = dependencies_for(possibility) - if current_possibility_set && dependencies_equal?(current_possibility_set.dependencies, dependencies) - current_possibility_set.possibilities.unshift(possibility) - else - possibility_sets.unshift(PossibilitySet.new(dependencies, [possibility])) - current_possibility_set = possibility_sets.first - end - end - - possibility_sets - end - - # Pushes a new {DependencyState}. - # If the {#specification_provider} says to - # {SpecificationProvider#allow_missing?} that particular requirement, and - # there are no possibilities for that requirement, then `state` is not - # pushed, and the vertex in {#activated} is removed, and we continue - # resolving the remaining requirements. - # @param [DependencyState] state - # @return [void] - def handle_missing_or_push_dependency_state(state) - if state.requirement && state.possibilities.empty? && allow_missing?(state.requirement) - state.activated.detach_vertex_named(state.name) - push_state_for_requirements(state.requirements.dup, false, state.activated) - else - states.push(state).tap { activated.tag(state) } - end - end - end - end -end diff --git a/lib/rubygems/vendor/molinillo/lib/molinillo/resolver.rb b/lib/rubygems/vendor/molinillo/lib/molinillo/resolver.rb deleted file mode 100644 index 86229c3fa12046..00000000000000 --- a/lib/rubygems/vendor/molinillo/lib/molinillo/resolver.rb +++ /dev/null @@ -1,46 +0,0 @@ -# frozen_string_literal: true - -require_relative 'dependency_graph' - -module Gem::Molinillo - # This class encapsulates a dependency resolver. - # The resolver is responsible for determining which set of dependencies to - # activate, with feedback from the {#specification_provider} - # - # - class Resolver - require_relative 'resolution' - - # @return [SpecificationProvider] the specification provider used - # in the resolution process - attr_reader :specification_provider - - # @return [UI] the UI module used to communicate back to the user - # during the resolution process - attr_reader :resolver_ui - - # Initializes a new resolver. - # @param [SpecificationProvider] specification_provider - # see {#specification_provider} - # @param [UI] resolver_ui - # see {#resolver_ui} - def initialize(specification_provider, resolver_ui) - @specification_provider = specification_provider - @resolver_ui = resolver_ui - end - - # Resolves the requested dependencies into a {DependencyGraph}, - # locking to the base dependency graph (if specified) - # @param [Array] requested an array of 'requested' dependencies that the - # {#specification_provider} can understand - # @param [DependencyGraph,nil] base the base dependency graph to which - # dependencies should be 'locked' - def resolve(requested, base = DependencyGraph.new) - Resolution.new(specification_provider, - resolver_ui, - requested, - base). - resolve - end - end -end diff --git a/lib/rubygems/vendor/molinillo/lib/molinillo/state.rb b/lib/rubygems/vendor/molinillo/lib/molinillo/state.rb deleted file mode 100644 index c48ec6af9c1234..00000000000000 --- a/lib/rubygems/vendor/molinillo/lib/molinillo/state.rb +++ /dev/null @@ -1,58 +0,0 @@ -# frozen_string_literal: true - -module Gem::Molinillo - # A state that a {Resolution} can be in - # @attr [String] name the name of the current requirement - # @attr [Array] requirements currently unsatisfied requirements - # @attr [DependencyGraph] activated the graph of activated dependencies - # @attr [Object] requirement the current requirement - # @attr [Object] possibilities the possibilities to satisfy the current requirement - # @attr [Integer] depth the depth of the resolution - # @attr [Hash] conflicts unresolved conflicts, indexed by dependency name - # @attr [Array] unused_unwind_options unwinds for previous conflicts that weren't explored - ResolutionState = Struct.new( - :name, - :requirements, - :activated, - :requirement, - :possibilities, - :depth, - :conflicts, - :unused_unwind_options - ) - - class ResolutionState - # Returns an empty resolution state - # @return [ResolutionState] an empty state - def self.empty - new(nil, [], DependencyGraph.new, nil, nil, 0, {}, []) - end - end - - # A state that encapsulates a set of {#requirements} with an {Array} of - # possibilities - class DependencyState < ResolutionState - # Removes a possibility from `self` - # @return [PossibilityState] a state with a single possibility, - # the possibility that was removed from `self` - def pop_possibility_state - PossibilityState.new( - name, - requirements.dup, - activated, - requirement, - [possibilities.pop], - depth + 1, - conflicts.dup, - unused_unwind_options.dup - ).tap do |state| - state.activated.tag(state) - end - end - end - - # A state that encapsulates a single possibility to fulfill the given - # {#requirement} - class PossibilityState < ResolutionState - end -end diff --git a/lib/rubygems/vendor/pub_grub/lib/pub_grub.rb b/lib/rubygems/vendor/pub_grub/lib/pub_grub.rb new file mode 100644 index 00000000000000..818e947477cf2c --- /dev/null +++ b/lib/rubygems/vendor/pub_grub/lib/pub_grub.rb @@ -0,0 +1,53 @@ +require_relative "pub_grub/package" +require_relative "pub_grub/static_package_source" +require_relative "pub_grub/term" +require_relative "pub_grub/version_range" +require_relative "pub_grub/version_constraint" +require_relative "pub_grub/version_union" +require_relative "pub_grub/version_solver" +require_relative "pub_grub/incompatibility" +require_relative 'pub_grub/solve_failure' +require_relative 'pub_grub/failure_writer' +require_relative 'pub_grub/version' + +module Gem::PubGrub + # Minimal logger that doesn't require the 'logger' gem + class NullLogger + def info(&block); end + def debug(&block); end + def warn(&block); end + def error(&block); end + end + + class StderrLogger + def info(&block) + $stderr.puts "INFO: #{block.call}" if block + end + + def debug(&block) + $stderr.puts "DEBUG: #{block.call}" if block + end + + def warn(&block) + $stderr.puts "WARN: #{block.call}" if block + end + + def error(&block) + $stderr.puts "ERROR: #{block.call}" if block + end + end + + class << self + attr_writer :logger + + def logger + @logger || default_logger + end + + private + + def default_logger + @logger = $DEBUG ? StderrLogger.new : NullLogger.new + end + end +end diff --git a/lib/rubygems/vendor/pub_grub/lib/pub_grub/assignment.rb b/lib/rubygems/vendor/pub_grub/lib/pub_grub/assignment.rb new file mode 100644 index 00000000000000..7a11cf0933c9be --- /dev/null +++ b/lib/rubygems/vendor/pub_grub/lib/pub_grub/assignment.rb @@ -0,0 +1,20 @@ +module Gem::PubGrub + class Assignment + attr_reader :term, :cause, :decision_level, :index + def initialize(term, cause, decision_level, index) + @term = term + @cause = cause + @decision_level = decision_level + @index = index + end + + def self.decision(package, version, decision_level, index) + term = Term.new(VersionConstraint.exact(package, version), true) + new(term, :decision, decision_level, index) + end + + def decision? + cause == :decision + end + end +end diff --git a/lib/rubygems/vendor/pub_grub/lib/pub_grub/basic_package_source.rb b/lib/rubygems/vendor/pub_grub/lib/pub_grub/basic_package_source.rb new file mode 100644 index 00000000000000..c8dbf2a5ab15ec --- /dev/null +++ b/lib/rubygems/vendor/pub_grub/lib/pub_grub/basic_package_source.rb @@ -0,0 +1,169 @@ +require_relative 'version_constraint' +require_relative 'incompatibility' + +module Gem::PubGrub + # Types: + # + # Where possible, Gem::PubGrub will accept user-defined types, so long as they quack. + # + # ## "Package": + # + # This class will be used to represent the various packages being solved for. + # .to_s will be called when displaying errors and debugging info, it should + # probably return the package's name. + # It must also have a reasonable definition of #== and #hash + # + # Example classes: String ("rails") + # + # + # ## "Version": + # + # This class will be used to represent a single version number. + # + # Versions don't need to store their associated package, however they will + # only be compared against other versions of the same package. + # + # It must be Comparible (and implement <=> reasonably) + # + # Example classes: Gem::Version, Integer + # + # + # ## "Dependency" + # + # This class represents the requirement one package has on another. It is + # returned by dependencies_for(package, version) and will be passed to + # parse_dependency to convert it to a format Gem::PubGrub understands. + # + # It must also have a reasonable definition of #== + # + # Example classes: String ("~> 1.0"), Gem::Requirement + # + class BasicPackageSource + # Override me! + # + # This is called per package to find all possible versions of a package. + # + # It is called at most once per-package + # + # Returns: Array of versions for a package, in preferred order of selection + def all_versions_for(package) + raise NotImplementedError + end + + # Override me! + # + # Returns: Hash in the form of { package => requirement, ... } + def dependencies_for(package, version) + raise NotImplementedError + end + + # Override me! + # + # Convert a (user-defined) dependency into a format Gem::PubGrub understands. + # + # Package is passed to this method but for many implementations is not + # needed. + # + # Returns: either a Gem::PubGrub::VersionRange, Gem::PubGrub::VersionUnion, or a + # Gem::PubGrub::VersionConstraint + def parse_dependency(package, dependency) + raise NotImplementedError + end + + # Override me! + # + # If not overridden, this will call dependencies_for with the root package. + # + # Returns: Hash in the form of { package => requirement, ... } (see dependencies_for) + def root_dependencies + dependencies_for(@root_package, @root_version) + end + + def initialize + @root_package = Package.root + @root_version = Package.root_version + + @sorted_versions = Hash.new do |h,k| + if k == @root_package + h[k] = [@root_version] + else + h[k] = all_versions_for(k).sort + end + end + + @cached_dependencies = Hash.new do |packages, package| + if package == @root_package + packages[package] = { + @root_version => root_dependencies + } + else + packages[package] = Hash.new do |versions, version| + versions[version] = dependencies_for(package, version) + end + end + end + end + + def versions_for(package, range=VersionRange.any) + range.select_versions(@sorted_versions[package]) + end + + def no_versions_incompatibility_for(_package, unsatisfied_term) + cause = Incompatibility::NoVersions.new(unsatisfied_term) + + Incompatibility.new([unsatisfied_term], cause: cause) + end + + def incompatibilities_for(package, version) + package_deps = @cached_dependencies[package] + sorted_versions = @sorted_versions[package] + package_deps[version].map do |dep_package, dep_constraint_name| + low = high = sorted_versions.index(version) + + # find version low such that all >= low share the same dep + while low > 0 && + package_deps[sorted_versions[low - 1]][dep_package] == dep_constraint_name + low -= 1 + end + low = + if low == 0 + nil + else + sorted_versions[low] + end + + # find version high such that all < high share the same dep + while high < sorted_versions.length && + package_deps[sorted_versions[high]][dep_package] == dep_constraint_name + high += 1 + end + high = + if high == sorted_versions.length + nil + else + sorted_versions[high] + end + + range = VersionRange.new(min: low, max: high, include_min: !low.nil?) + + self_constraint = VersionConstraint.new(package, range: range) + + if !@packages.include?(dep_package) + # no such package -> this version is invalid + end + + dep_constraint = parse_dependency(dep_package, dep_constraint_name) + if !dep_constraint + # falsey indicates this dependency was invalid + cause = Gem::PubGrub::Incompatibility::InvalidDependency.new(dep_package, dep_constraint_name) + return [Incompatibility.new([Term.new(self_constraint, true)], cause: cause)] + elsif !dep_constraint.is_a?(VersionConstraint) + # Upgrade range/union to VersionConstraint + dep_constraint = VersionConstraint.new(dep_package, range: dep_constraint) + end + + Incompatibility.new([Term.new(self_constraint, true), Term.new(dep_constraint, false)], cause: :dependency) + end + end + end +end diff --git a/lib/rubygems/vendor/pub_grub/lib/pub_grub/failure_writer.rb b/lib/rubygems/vendor/pub_grub/lib/pub_grub/failure_writer.rb new file mode 100644 index 00000000000000..d8bfde0286224c --- /dev/null +++ b/lib/rubygems/vendor/pub_grub/lib/pub_grub/failure_writer.rb @@ -0,0 +1,182 @@ +module Gem::PubGrub + class FailureWriter + def initialize(root) + @root = root + + # { Incompatibility => Integer } + @derivations = {} + + # [ [ String, Integer or nil ] ] + @lines = [] + + # { Incompatibility => Integer } + @line_numbers = {} + + count_derivations(root) + end + + def write + return @root.to_s unless @root.conflict? + + visit(@root) + + padding = @line_numbers.empty? ? 0 : "(#{@line_numbers.values.last}) ".length + + @lines.map do |message, number| + next "" if message.empty? + + lead = number ? "(#{number}) " : "" + lead = lead.ljust(padding) + message = message.gsub("\n", "\n" + " " * (padding + 2)) + "#{lead}#{message}" + end.join("\n") + end + + private + + def write_line(incompatibility, message, numbered:) + if numbered + number = @line_numbers.length + 1 + @line_numbers[incompatibility] = number + end + + @lines << [message, number] + end + + def visit(incompatibility, conclusion: false) + raise unless incompatibility.conflict? + + numbered = conclusion || @derivations[incompatibility] > 1; + conjunction = conclusion || incompatibility == @root ? "So," : "And" + + cause = incompatibility.cause + + if cause.conflict.conflict? && cause.other.conflict? + conflict_line = @line_numbers[cause.conflict] + other_line = @line_numbers[cause.other] + + if conflict_line && other_line + write_line( + incompatibility, + "Because #{cause.conflict} (#{conflict_line})\nand #{cause.other} (#{other_line}),\n#{incompatibility}.", + numbered: numbered + ) + elsif conflict_line || other_line + with_line = conflict_line ? cause.conflict : cause.other + without_line = conflict_line ? cause.other : cause.conflict + line = @line_numbers[with_line] + + visit(without_line); + write_line( + incompatibility, + "#{conjunction} because #{with_line} (#{line}),\n#{incompatibility}.", + numbered: numbered + ) + else + single_line_conflict = single_line?(cause.conflict.cause) + single_line_other = single_line?(cause.other.cause) + + if single_line_conflict || single_line_other + first = single_line_other ? cause.conflict : cause.other + second = single_line_other ? cause.other : cause.conflict + visit(first) + visit(second) + write_line( + incompatibility, + "Thus, #{incompatibility}.", + numbered: numbered + ) + else + visit(cause.conflict, conclusion: true) + @lines << ["", nil] + visit(cause.other) + + write_line( + incompatibility, + "#{conjunction} because #{cause.conflict} (#{@line_numbers[cause.conflict]}),\n#{incompatibility}.", + numbered: numbered + ) + end + end + elsif cause.conflict.conflict? || cause.other.conflict? + derived = cause.conflict.conflict? ? cause.conflict : cause.other + ext = cause.conflict.conflict? ? cause.other : cause.conflict + + derived_line = @line_numbers[derived] + if derived_line + write_line( + incompatibility, + "Because #{ext}\nand #{derived} (#{derived_line}),\n#{incompatibility}.", + numbered: numbered + ) + elsif collapsible?(derived) + derived_cause = derived.cause + if derived_cause.conflict.conflict? + collapsed_derived = derived_cause.conflict + collapsed_ext = derived_cause.other + else + collapsed_derived = derived_cause.other + collapsed_ext = derived_cause.conflict + end + + visit(collapsed_derived) + + write_line( + incompatibility, + "#{conjunction} because #{collapsed_ext}\nand #{ext},\n#{incompatibility}.", + numbered: numbered + ) + else + visit(derived) + write_line( + incompatibility, + "#{conjunction} because #{ext},\n#{incompatibility}.", + numbered: numbered + ) + end + else + write_line( + incompatibility, + "Because #{cause.conflict}\nand #{cause.other},\n#{incompatibility}.", + numbered: numbered + ) + end + end + + def single_line?(cause) + !cause.conflict.conflict? && !cause.other.conflict? + end + + def collapsible?(incompatibility) + return false if @derivations[incompatibility] > 1 + + cause = incompatibility.cause + # If incompatibility is derived from two derived incompatibilities, + # there are too many transitive causes to display concisely. + return false if cause.conflict.conflict? && cause.other.conflict? + + # If incompatibility is derived from two external incompatibilities, it + # tends to be confusing to collapse it. + return false unless cause.conflict.conflict? || cause.other.conflict? + + # If incompatibility's internal cause is numbered, collapsing it would + # get too noisy. + complex = cause.conflict.conflict? ? cause.conflict : cause.other + + !@line_numbers.has_key?(complex) + end + + def count_derivations(incompatibility) + if @derivations.has_key?(incompatibility) + @derivations[incompatibility] += 1 + else + @derivations[incompatibility] = 1 + if incompatibility.conflict? + cause = incompatibility.cause + count_derivations(cause.conflict) + count_derivations(cause.other) + end + end + end + end +end diff --git a/lib/rubygems/vendor/pub_grub/lib/pub_grub/incompatibility.rb b/lib/rubygems/vendor/pub_grub/lib/pub_grub/incompatibility.rb new file mode 100644 index 00000000000000..b5652b5e01226c --- /dev/null +++ b/lib/rubygems/vendor/pub_grub/lib/pub_grub/incompatibility.rb @@ -0,0 +1,150 @@ +module Gem::PubGrub + class Incompatibility + ConflictCause = Struct.new(:incompatibility, :satisfier) do + alias_method :conflict, :incompatibility + alias_method :other, :satisfier + end + + InvalidDependency = Struct.new(:package, :constraint) do + end + + NoVersions = Struct.new(:constraint) do + end + + attr_reader :terms, :cause + + def initialize(terms, cause:, custom_explanation: nil) + @cause = cause + @terms = cleanup_terms(terms) + @custom_explanation = custom_explanation + + if cause == :dependency && @terms.length != 2 + raise ArgumentError, "a dependency Incompatibility must have exactly two terms. Got #{@terms.inspect}" + end + end + + def hash + cause.hash ^ terms.hash + end + + def eql?(other) + cause.eql?(other.cause) && + terms.eql?(other.terms) + end + + def failure? + terms.empty? || (terms.length == 1 && Package.root?(terms[0].package) && terms[0].positive?) + end + + def conflict? + ConflictCause === cause + end + + # Returns all external incompatibilities in this incompatibility's + # derivation graph + def external_incompatibilities + if conflict? + [ + cause.conflict, + cause.other + ].flat_map(&:external_incompatibilities) + else + [this] + end + end + + def to_s + return @custom_explanation if @custom_explanation + + case cause + when :root + "(root dependency)" + when :dependency + "#{terms[0].to_s(allow_every: true)} depends on #{terms[1].invert}" + when Gem::PubGrub::Incompatibility::InvalidDependency + "#{terms[0].to_s(allow_every: true)} depends on unknown package #{cause.package}" + when Gem::PubGrub::Incompatibility::NoVersions + "no versions satisfy #{cause.constraint}" + when Gem::PubGrub::Incompatibility::ConflictCause + if failure? + "version solving has failed" + elsif terms.length == 1 + term = terms[0] + if term.positive? + if term.constraint.any? + "#{term.package} cannot be used" + else + "#{term.to_s(allow_every: true)} cannot be used" + end + else + "#{term.invert} is required" + end + else + if terms.all?(&:positive?) + if terms.length == 2 + "#{terms[0].to_s(allow_every: true)} is incompatible with #{terms[1]}" + else + "one of #{terms.map(&:to_s).join(" or ")} must be false" + end + elsif terms.all?(&:negative?) + if terms.length == 2 + "either #{terms[0].invert} or #{terms[1].invert}" + else + "one of #{terms.map(&:invert).join(" or ")} must be true"; + end + else + positive = terms.select(&:positive?) + negative = terms.select(&:negative?).map(&:invert) + + if positive.length == 1 + "#{positive[0].to_s(allow_every: true)} requires #{negative.join(" or ")}" + else + "if #{positive.join(" and ")} then #{negative.join(" or ")}" + end + end + end + else + raise "unhandled cause: #{cause.inspect}" + end + end + + def inspect + "#<#{self.class} #{to_s}>" + end + + def pretty_print(q) + q.group 2, "#<#{self.class}", ">" do + q.breakable + q.text to_s + + q.breakable + q.text " caused by " + q.pp @cause + end + end + + private + + def cleanup_terms(terms) + terms.each do |term| + raise "#{term.inspect} must be a term" unless term.is_a?(Term) + end + + if terms.length != 1 && ConflictCause === cause + terms = terms.reject do |term| + term.positive? && Package.root?(term.package) + end + end + + # Optimized simple cases + return terms if terms.length <= 1 + return terms if terms.length == 2 && terms[0].package != terms[1].package + + terms.group_by(&:package).map do |package, common_terms| + common_terms.inject do |acc, term| + acc.intersect(term) + end + end + end + end +end diff --git a/lib/rubygems/vendor/pub_grub/lib/pub_grub/package.rb b/lib/rubygems/vendor/pub_grub/lib/pub_grub/package.rb new file mode 100644 index 00000000000000..6baa908f60a2af --- /dev/null +++ b/lib/rubygems/vendor/pub_grub/lib/pub_grub/package.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +module Gem::PubGrub + class Package + + attr_reader :name + + def initialize(name) + @name = name + end + + def inspect + "#<#{self.class} #{name.inspect}>" + end + + def <=>(other) + name <=> other.name + end + + ROOT = Package.new(:root) + ROOT_VERSION = 0 + + def self.root + ROOT + end + + def self.root_version + ROOT_VERSION + end + + def self.root?(package) + if package.respond_to?(:root?) + package.root? + else + package == root + end + end + + def to_s + name.to_s + end + end +end diff --git a/lib/rubygems/vendor/pub_grub/lib/pub_grub/partial_solution.rb b/lib/rubygems/vendor/pub_grub/lib/pub_grub/partial_solution.rb new file mode 100644 index 00000000000000..f6a6ae6964f7fa --- /dev/null +++ b/lib/rubygems/vendor/pub_grub/lib/pub_grub/partial_solution.rb @@ -0,0 +1,121 @@ +require_relative 'assignment' + +module Gem::PubGrub + class PartialSolution + attr_reader :assignments, :decisions + attr_reader :attempted_solutions + + def initialize + reset! + + @attempted_solutions = 1 + @backtracking = false + end + + def decision_level + @decisions.length + end + + def relation(term) + package = term.package + return :overlap if !@terms.key?(package) + + @relation_cache[package][term] ||= + @terms[package].relation(term) + end + + def satisfies?(term) + relation(term) == :subset + end + + def derive(term, cause) + add_assignment(Assignment.new(term, cause, decision_level, assignments.length)) + end + + def satisfier(term) + assignment = + @assignments_by[term.package].bsearch do |assignment_by| + @cumulative_assignments[assignment_by].satisfies?(term) + end + + assignment || raise("#{term} unsatisfied") + end + + # A list of unsatisfied terms + def unsatisfied + @required.keys.reject do |package| + @decisions.key?(package) + end.map do |package| + @terms[package] + end + end + + def decide(package, version) + @attempted_solutions += 1 if @backtracking + @backtracking = false; + + decisions[package] = version + assignment = Assignment.decision(package, version, decision_level, assignments.length) + add_assignment(assignment) + end + + def backtrack(previous_level) + @backtracking = true + + new_assignments = assignments.select do |assignment| + assignment.decision_level <= previous_level + end + + new_decisions = Hash[decisions.first(previous_level)] + + reset! + + @decisions = new_decisions + + new_assignments.each do |assignment| + add_assignment(assignment) + end + end + + private + + def reset! + # { Array } + @assignments = [] + + # { Package => Array } + @assignments_by = Hash.new { |h,k| h[k] = [] } + @cumulative_assignments = {}.compare_by_identity + + # { Package => Package::Version } + @decisions = {} + + # { Package => Term } + @terms = {} + @relation_cache = Hash.new { |h,k| h[k] = {} } + + # { Package => Boolean } + @required = {} + end + + def add_assignment(assignment) + term = assignment.term + package = term.package + + @assignments << assignment + @assignments_by[package] << assignment + + @required[package] = true if term.positive? + + if @terms.key?(package) + old_term = @terms[package] + @terms[package] = old_term.intersect(term) + else + @terms[package] = term + end + @relation_cache[package].clear + + @cumulative_assignments[assignment] = @terms[package] + end + end +end diff --git a/lib/rubygems/vendor/pub_grub/lib/pub_grub/rubygems.rb b/lib/rubygems/vendor/pub_grub/lib/pub_grub/rubygems.rb new file mode 100644 index 00000000000000..60ca3ca2eacbcd --- /dev/null +++ b/lib/rubygems/vendor/pub_grub/lib/pub_grub/rubygems.rb @@ -0,0 +1,45 @@ +module Gem::PubGrub + module RubyGems + extend self + + def requirement_to_range(requirement) + ranges = requirement.requirements.map do |(op, ver)| + case op + when "~>" + name = "~> #{ver}" + bump = ver.class.new(ver.bump.to_s + ".A") + VersionRange.new(name: name, min: ver, max: bump, include_min: true) + when ">" + VersionRange.new(min: ver) + when ">=" + VersionRange.new(min: ver, include_min: true) + when "<" + VersionRange.new(max: ver) + when "<=" + VersionRange.new(max: ver, include_max: true) + when "=" + VersionRange.new(min: ver, max: ver, include_min: true, include_max: true) + when "!=" + VersionRange.new(min: ver, max: ver, include_min: true, include_max: true).invert + else + raise "bad version specifier: #{op}" + end + end + + ranges.inject(&:intersect) + end + + def requirement_to_constraint(package, requirement) + Gem::PubGrub::VersionConstraint.new(package, range: requirement_to_range(requirement)) + end + + def parse_range(dep) + requirement_to_range(Gem::Requirement.new(dep)) + end + + def parse_constraint(package, dep) + range = parse_range(dep) + Gem::PubGrub::VersionConstraint.new(package, range: range) + end + end +end diff --git a/lib/rubygems/vendor/pub_grub/lib/pub_grub/solve_failure.rb b/lib/rubygems/vendor/pub_grub/lib/pub_grub/solve_failure.rb new file mode 100644 index 00000000000000..c4181d2b2551ad --- /dev/null +++ b/lib/rubygems/vendor/pub_grub/lib/pub_grub/solve_failure.rb @@ -0,0 +1,19 @@ +require_relative 'failure_writer' + +module Gem::PubGrub + class SolveFailure < StandardError + attr_reader :incompatibility + + def initialize(incompatibility) + @incompatibility = incompatibility + end + + def to_s + "Could not find compatible versions\n\n#{explanation}" + end + + def explanation + @explanation ||= FailureWriter.new(@incompatibility).write + end + end +end diff --git a/lib/rubygems/vendor/pub_grub/lib/pub_grub/static_package_source.rb b/lib/rubygems/vendor/pub_grub/lib/pub_grub/static_package_source.rb new file mode 100644 index 00000000000000..9e1de7d7a1d11b --- /dev/null +++ b/lib/rubygems/vendor/pub_grub/lib/pub_grub/static_package_source.rb @@ -0,0 +1,61 @@ +require_relative 'package' +require_relative 'rubygems' +require_relative 'version_constraint' +require_relative 'incompatibility' +require_relative 'basic_package_source' + +module Gem::PubGrub + class StaticPackageSource < BasicPackageSource + class DSL + def initialize(packages, root_deps) + @packages = packages + @root_deps = root_deps + end + + def root(deps:) + @root_deps.update(deps) + end + + def add(name, version, deps: {}) + version = Gem::Version.new(version) + @packages[name] ||= {} + raise ArgumentError, "#{name} #{version} declared twice" if @packages[name].key?(version) + @packages[name][version] = clean_deps(name, version, deps) + end + + private + + # Exclude redundant self-referencing dependencies + def clean_deps(name, version, deps) + deps.reject {|dep_name, req| name == dep_name && Gem::PubGrub::RubyGems.parse_range(req).include?(version) } + end + end + + def initialize + @root_deps = {} + @packages = {} + + yield DSL.new(@packages, @root_deps) + + super() + end + + def all_versions_for(package) + @packages[package].keys + end + + def root_dependencies + @root_deps + end + + def dependencies_for(package, version) + @packages[package][version] + end + + def parse_dependency(package, dependency) + return false unless @packages.key?(package) + + Gem::PubGrub::RubyGems.parse_constraint(package, dependency) + end + end +end diff --git a/lib/rubygems/vendor/pub_grub/lib/pub_grub/strategy.rb b/lib/rubygems/vendor/pub_grub/lib/pub_grub/strategy.rb new file mode 100644 index 00000000000000..b9874cdece5f47 --- /dev/null +++ b/lib/rubygems/vendor/pub_grub/lib/pub_grub/strategy.rb @@ -0,0 +1,42 @@ +module Gem::PubGrub + class Strategy + def initialize(source) + @source = source + + @root_package = Package.root + @root_version = Package.root_version + + @version_indexes = Hash.new do |h,k| + if k == @root_package + h[k] = { @root_version => 0 } + else + h[k] = @source.all_versions_for(k).each.with_index.to_h + end + end + end + + def next_package_and_version(unsatisfied) + package, range = next_term_to_try_from(unsatisfied) + + [package, most_preferred_version_of(package, range)] + end + + private + + def most_preferred_version_of(package, range) + versions = @source.versions_for(package, range) + + indexes = @version_indexes[package] + versions.min_by { |version| indexes[version] || Float::INFINITY } + end + + def next_term_to_try_from(unsatisfied) + unsatisfied.min_by do |package, range| + matching_versions = @source.versions_for(package, range) + higher_versions = @source.versions_for(package, range.upper_invert) + + [matching_versions.count <= 1 ? 0 : 1, higher_versions.count] + end + end + end +end diff --git a/lib/rubygems/vendor/pub_grub/lib/pub_grub/term.rb b/lib/rubygems/vendor/pub_grub/lib/pub_grub/term.rb new file mode 100644 index 00000000000000..bb26bdc911782a --- /dev/null +++ b/lib/rubygems/vendor/pub_grub/lib/pub_grub/term.rb @@ -0,0 +1,105 @@ +module Gem::PubGrub + class Term + attr_reader :package, :constraint, :positive + + def initialize(constraint, positive) + @constraint = constraint + @package = @constraint.package + @positive = positive + end + + def to_s(allow_every: false) + if positive + @constraint.to_s(allow_every: allow_every) + else + "not #{@constraint}" + end + end + + def hash + constraint.hash ^ positive.hash + end + + def eql?(other) + positive == other.positive && + constraint.eql?(other.constraint) + end + + def invert + self.class.new(@constraint, !@positive) + end + alias_method :inverse, :invert + + def intersect(other) + raise ArgumentError, "packages must match" if package != other.package + + if positive? && other.positive? + self.class.new(constraint.intersect(other.constraint), true) + elsif negative? && other.negative? + self.class.new(constraint.union(other.constraint), false) + else + positive = positive? ? self : other + negative = negative? ? self : other + self.class.new(positive.constraint.intersect(negative.constraint.invert), true) + end + end + + def difference(other) + intersect(other.invert) + end + + def relation(other) + if positive? && other.positive? + constraint.relation(other.constraint) + elsif negative? && other.positive? + if constraint.allows_all?(other.constraint) + :disjoint + else + :overlap + end + elsif positive? && other.negative? + if !other.constraint.allows_any?(constraint) + :subset + elsif other.constraint.allows_all?(constraint) + :disjoint + else + :overlap + end + elsif negative? && other.negative? + if constraint.allows_all?(other.constraint) + :subset + else + :overlap + end + else + raise + end + end + + def normalized_constraint + @normalized_constraint ||= positive ? constraint : constraint.invert + end + + def satisfies?(other) + raise ArgumentError, "packages must match" unless package == other.package + + relation(other) == :subset + end + + def positive? + @positive + end + + def negative? + !positive? + end + + def empty? + @empty ||= normalized_constraint.empty? + end + + def inspect + "#<#{self.class} #{self}>" + end + end +end diff --git a/lib/rubygems/vendor/pub_grub/lib/pub_grub/version.rb b/lib/rubygems/vendor/pub_grub/lib/pub_grub/version.rb new file mode 100644 index 00000000000000..5701bf0656f840 --- /dev/null +++ b/lib/rubygems/vendor/pub_grub/lib/pub_grub/version.rb @@ -0,0 +1,3 @@ +module Gem::PubGrub + VERSION = "0.5.0" +end diff --git a/lib/rubygems/vendor/pub_grub/lib/pub_grub/version_constraint.rb b/lib/rubygems/vendor/pub_grub/lib/pub_grub/version_constraint.rb new file mode 100644 index 00000000000000..ee998b32711019 --- /dev/null +++ b/lib/rubygems/vendor/pub_grub/lib/pub_grub/version_constraint.rb @@ -0,0 +1,129 @@ +require_relative 'version_range' + +module Gem::PubGrub + class VersionConstraint + attr_reader :package, :range + + # @param package [Gem::PubGrub::Package] + # @param range [Gem::PubGrub::VersionRange] + def initialize(package, range: nil) + @package = package + @range = range + end + + def hash + package.hash ^ range.hash + end + + def ==(other) + package == other.package && + range == other.range + end + + def eql?(other) + package.eql?(other.package) && + range.eql?(other.range) + end + + class << self + def exact(package, version) + range = VersionRange.new(min: version, max: version, include_min: true, include_max: true) + new(package, range: range) + end + + def any(package) + new(package, range: VersionRange.any) + end + + def empty(package) + new(package, range: VersionRange.empty) + end + end + + def intersect(other) + unless package == other.package + raise ArgumentError, "Can only intersect between VersionConstraint of the same package" + end + + self.class.new(package, range: range.intersect(other.range)) + end + + def union(other) + unless package == other.package + raise ArgumentError, "Can only intersect between VersionConstraint of the same package" + end + + self.class.new(package, range: range.union(other.range)) + end + + def invert + new_range = range.invert + self.class.new(package, range: new_range) + end + + def difference(other) + intersect(other.invert) + end + + def allows_all?(other) + range.allows_all?(other.range) + end + + def allows_any?(other) + range.intersects?(other.range) + end + + def subset?(other) + other.allows_all?(self) + end + + def overlap?(other) + other.allows_any?(self) + end + + def disjoint?(other) + !overlap?(other) + end + + def relation(other) + if subset?(other) + :subset + elsif overlap?(other) + :overlap + else + :disjoint + end + end + + def to_s(allow_every: false) + if Package.root?(package) + package.to_s + elsif allow_every && any? + "every version of #{package}" + else + "#{package} #{constraint_string}" + end + end + + def constraint_string + if any? + ">= 0" + else + range.to_s + end + end + + def empty? + range.empty? + end + + # Does this match every version of the package + def any? + range.any? + end + + def inspect + "#<#{self.class} #{self}>" + end + end +end diff --git a/lib/rubygems/vendor/pub_grub/lib/pub_grub/version_range.rb b/lib/rubygems/vendor/pub_grub/lib/pub_grub/version_range.rb new file mode 100644 index 00000000000000..fa0e2d5742b07e --- /dev/null +++ b/lib/rubygems/vendor/pub_grub/lib/pub_grub/version_range.rb @@ -0,0 +1,423 @@ +# frozen_string_literal: true + +module Gem::PubGrub + class VersionRange + attr_reader :min, :max, :include_min, :include_max + + alias_method :include_min?, :include_min + alias_method :include_max?, :include_max + + class Empty < VersionRange + undef_method :min, :max + undef_method :include_min, :include_min? + undef_method :include_max, :include_max? + + def initialize + end + + def empty? + true + end + + def eql?(other) + other.empty? + end + + def hash + [].hash + end + + def intersects?(_) + false + end + + def intersect(other) + self + end + + def allows_all?(other) + other.empty? + end + + def include?(_) + false + end + + def any? + false + end + + def to_s + "(no versions)" + end + + def ==(other) + other.class == self.class + end + + def invert + VersionRange.any + end + + def select_versions(_) + [] + end + end + + EMPTY = Empty.new + Empty.singleton_class.undef_method(:new) + + def self.empty + EMPTY + end + + def self.any + new + end + + def initialize(min: nil, max: nil, include_min: false, include_max: false, name: nil) + raise ArgumentError, "Ranges without a lower bound cannot have include_min == true" if !min && include_min == true + raise ArgumentError, "Ranges without an upper bound cannot have include_max == true" if !max && include_max == true + + @min = min + @max = max + @include_min = include_min + @include_max = include_max + @name = name + end + + def hash + @hash ||= min.hash ^ max.hash ^ include_min.hash ^ include_max.hash + end + + def eql?(other) + if other.is_a?(VersionRange) + !other.empty? && + min.eql?(other.min) && + max.eql?(other.max) && + include_min.eql?(other.include_min) && + include_max.eql?(other.include_max) + else + ranges.eql?(other.ranges) + end + end + + def ranges + [self] + end + + def include?(version) + compare_version(version) == 0 + end + + # Partitions passed versions into [lower, within, higher] + # + # versions must be sorted + def partition_versions(versions) + min_index = + if !min || versions.empty? + 0 + elsif include_min? + (0..versions.size).bsearch { |i| versions[i].nil? || versions[i] >= min } + else + (0..versions.size).bsearch { |i| versions[i].nil? || versions[i] > min } + end + + lower = versions.slice(0, min_index) + versions = versions.slice(min_index, versions.size) + + max_index = + if !max || versions.empty? + versions.size + elsif include_max? + (0..versions.size).bsearch { |i| versions[i].nil? || versions[i] > max } + else + (0..versions.size).bsearch { |i| versions[i].nil? || versions[i] >= max } + end + + [ + lower, + versions.slice(0, max_index), + versions.slice(max_index, versions.size) + ] + end + + # Returns versions which are included by this range. + # + # versions must be sorted + def select_versions(versions) + return versions if any? + + partition_versions(versions)[1] + end + + def compare_version(version) + if min + case version <=> min + when -1 + return -1 + when 0 + return -1 if !include_min + when 1 + end + end + + if max + case version <=> max + when -1 + when 0 + return 1 if !include_max + when 1 + return 1 + end + end + + 0 + end + + def strictly_lower?(other) + return false if !max || !other.min + + case max <=> other.min + when 0 + !include_max || !other.include_min + when -1 + true + when 1 + false + end + end + + def strictly_higher?(other) + other.strictly_lower?(self) + end + + def intersects?(other) + return false if other.empty? + return other.intersects?(self) if other.is_a?(VersionUnion) + !strictly_lower?(other) && !strictly_higher?(other) + end + alias_method :allows_any?, :intersects? + + def intersect(other) + return other if other.empty? + return other.intersect(self) if other.is_a?(VersionUnion) + + min_range = + if !min + other + elsif !other.min + self + else + case min <=> other.min + when 0 + include_min ? other : self + when -1 + other + when 1 + self + end + end + + max_range = + if !max + other + elsif !other.max + self + else + case max <=> other.max + when 0 + include_max ? other : self + when -1 + self + when 1 + other + end + end + + if !min_range.equal?(max_range) && min_range.min && max_range.max + case min_range.min <=> max_range.max + when -1 + when 0 + if !min_range.include_min || !max_range.include_max + return EMPTY + end + when 1 + return EMPTY + end + end + + VersionRange.new( + min: min_range.min, + include_min: min_range.include_min, + max: max_range.max, + include_max: max_range.include_max + ) + end + + # The span covered by two ranges + # + # If self and other are contiguous, this builds a union of the two ranges. + # (if they aren't you are probably calling the wrong method) + def span(other) + return self if other.empty? + + min_range = + if !min + self + elsif !other.min + other + else + case min <=> other.min + when 0 + include_min ? self : other + when -1 + self + when 1 + other + end + end + + max_range = + if !max + self + elsif !other.max + other + else + case max <=> other.max + when 0 + include_max ? self : other + when -1 + other + when 1 + self + end + end + + VersionRange.new( + min: min_range.min, + include_min: min_range.include_min, + max: max_range.max, + include_max: max_range.include_max + ) + end + + def union(other) + return other.union(self) if other.is_a?(VersionUnion) + + if contiguous_to?(other) + span(other) + else + VersionUnion.union([self, other]) + end + end + + def contiguous_to?(other) + return false if other.empty? + return true if any? + + intersects?(other) || contiguous_below?(other) || contiguous_above?(other) + end + + def contiguous_below?(other) + return false if !max || !other.min + + max == other.min && (include_max || other.include_min) + end + + def contiguous_above?(other) + other.contiguous_below?(self) + end + + def allows_all?(other) + return true if other.empty? + + if other.is_a?(VersionUnion) + return VersionUnion.new([self]).allows_all?(other) + end + + return false if max && !other.max + return false if min && !other.min + + if min + case min <=> other.min + when -1 + when 0 + return false if !include_min && other.include_min + when 1 + return false + end + end + + if max + case max <=> other.max + when -1 + return false + when 0 + return false if !include_max && other.include_max + when 1 + end + end + + true + end + + def any? + !min && !max + end + + def empty? + false + end + + def to_s + @name ||= constraints.join(", ") + end + + def inspect + "#<#{self.class} #{to_s}>" + end + + def upper_invert + return self.class.empty unless max + + VersionRange.new(min: max, include_min: !include_max) + end + + def invert + return self.class.empty if any? + + low = -> { VersionRange.new(max: min, include_max: !include_min) } + high = -> { VersionRange.new(min: max, include_min: !include_max) } + + if !min + high.call + elsif !max + low.call + else + low.call.union(high.call) + end + end + + def ==(other) + self.class == other.class && + min == other.min && + max == other.max && + include_min == other.include_min && + include_max == other.include_max + end + + private + + def constraints + return ["any"] if any? + return ["= #{min}"] if min.to_s == max.to_s + + c = [] + c << "#{include_min ? ">=" : ">"} #{min}" if min + c << "#{include_max ? "<=" : "<"} #{max}" if max + c + end + + end +end diff --git a/lib/rubygems/vendor/pub_grub/lib/pub_grub/version_solver.rb b/lib/rubygems/vendor/pub_grub/lib/pub_grub/version_solver.rb new file mode 100644 index 00000000000000..3341d8fe3b0076 --- /dev/null +++ b/lib/rubygems/vendor/pub_grub/lib/pub_grub/version_solver.rb @@ -0,0 +1,236 @@ +require_relative 'partial_solution' +require_relative 'term' +require_relative 'incompatibility' +require_relative 'solve_failure' +require_relative 'strategy' + +module Gem::PubGrub + class VersionSolver + attr_reader :logger + attr_reader :source + attr_reader :solution + attr_reader :strategy + + def initialize(source:, root: Package.root, strategy: Strategy.new(source), logger: Gem::PubGrub.logger) + @logger = logger + + @source = source + @strategy = strategy + + # { package => [incompatibility, ...]} + @incompatibilities = Hash.new do |h, k| + h[k] = [] + end + + @seen_incompatibilities = {} + + @solution = PartialSolution.new + + add_incompatibility Incompatibility.new([ + Term.new(VersionConstraint.any(root), false) + ], cause: :root) + + propagate(root) + end + + def solved? + solution.unsatisfied.empty? + end + + # Returns true if there is more work to be done, false otherwise + def work + unsatisfied_terms = solution.unsatisfied + if unsatisfied_terms.empty? + logger.info { "Solution found after #{solution.attempted_solutions} attempts:" } + solution.decisions.each do |package, version| + next if Package.root?(package) + logger.info { "* #{package} #{version}" } + end + + return false + end + + next_package = choose_package_version_from(unsatisfied_terms) + propagate(next_package) + + true + end + + def solve + while work; end + + solution.decisions + end + + alias_method :result, :solve + + private + + def propagate(initial_package) + changed = [initial_package] + while package = changed.shift + @incompatibilities[package].reverse_each do |incompatibility| + result = propagate_incompatibility(incompatibility) + if result == :conflict + root_cause = resolve_conflict(incompatibility) + changed.clear + changed << propagate_incompatibility(root_cause) + elsif result # should be a Package + changed << result + end + end + changed.uniq! + end + end + + def propagate_incompatibility(incompatibility) + unsatisfied = nil + incompatibility.terms.each do |term| + relation = solution.relation(term) + if relation == :disjoint + return nil + elsif relation == :overlap + # If more than one term is inconclusive, we can't deduce anything + return nil if unsatisfied + unsatisfied = term + end + end + + if !unsatisfied + return :conflict + end + + logger.debug { "derived: #{unsatisfied.invert}" } + + solution.derive(unsatisfied.invert, incompatibility) + + unsatisfied.package + end + + def choose_package_version_from(unsatisfied_terms) + remaining = unsatisfied_terms.map { |t| [t.package, t.constraint.range] }.to_h + + package, version = strategy.next_package_and_version(remaining) + + logger.debug { "attempting #{package} #{version}" } + + if version.nil? + unsatisfied_term = unsatisfied_terms.find { |t| t.package == package } + add_incompatibility source.no_versions_incompatibility_for(package, unsatisfied_term) + return package + end + + conflict = false + + source.incompatibilities_for(package, version).each do |incompatibility| + if @seen_incompatibilities.include?(incompatibility) + logger.debug { "knew: #{incompatibility}" } + next + end + @seen_incompatibilities[incompatibility] = true + + add_incompatibility incompatibility + + conflict ||= incompatibility.terms.all? do |term| + term.package == package || solution.satisfies?(term) + end + end + + unless conflict + logger.info { "selected #{package} #{version}" } + + solution.decide(package, version) + else + logger.info { "conflict: #{conflict.inspect}" } + end + + package + end + + def resolve_conflict(incompatibility) + logger.info { "conflict: #{incompatibility}" } + + new_incompatibility = nil + + while !incompatibility.failure? + most_recent_term = nil + most_recent_satisfier = nil + difference = nil + + previous_level = 1 + + incompatibility.terms.each do |term| + satisfier = solution.satisfier(term) + + if most_recent_satisfier.nil? + most_recent_term = term + most_recent_satisfier = satisfier + elsif most_recent_satisfier.index < satisfier.index + previous_level = [previous_level, most_recent_satisfier.decision_level].max + most_recent_term = term + most_recent_satisfier = satisfier + difference = nil + else + previous_level = [previous_level, satisfier.decision_level].max + end + + if most_recent_term == term + difference = most_recent_satisfier.term.difference(most_recent_term) + if difference.empty? + difference = nil + else + difference_satisfier = solution.satisfier(difference.inverse) + previous_level = [previous_level, difference_satisfier.decision_level].max + end + end + end + + if previous_level < most_recent_satisfier.decision_level || + most_recent_satisfier.decision? + + logger.info { "backtracking to #{previous_level}" } + solution.backtrack(previous_level) + + if new_incompatibility + add_incompatibility(new_incompatibility) + end + + return incompatibility + end + + new_terms = [] + new_terms += incompatibility.terms - [most_recent_term] + new_terms += most_recent_satisfier.cause.terms.reject { |term| + term.package == most_recent_satisfier.term.package + } + if difference + new_terms << difference.invert + end + + new_incompatibility = Incompatibility.new(new_terms, cause: Incompatibility::ConflictCause.new(incompatibility, most_recent_satisfier.cause)) + + if incompatibility.to_s == new_incompatibility.to_s + logger.info { "!! failed to resolve conflicts, this shouldn't have happened" } + break + end + + incompatibility = new_incompatibility + + partially = difference ? " partially" : "" + logger.info { "! #{most_recent_term} is#{partially} satisfied by #{most_recent_satisfier.term}" } + logger.info { "! which is caused by #{most_recent_satisfier.cause}" } + logger.info { "! thus #{incompatibility}" } + end + + raise SolveFailure.new(incompatibility) + end + + def add_incompatibility(incompatibility) + logger.debug { "fact: #{incompatibility}" } + incompatibility.terms.each do |term| + package = term.package + @incompatibilities[package] << incompatibility + end + end + end +end diff --git a/lib/rubygems/vendor/pub_grub/lib/pub_grub/version_union.rb b/lib/rubygems/vendor/pub_grub/lib/pub_grub/version_union.rb new file mode 100644 index 00000000000000..4166318a98930a --- /dev/null +++ b/lib/rubygems/vendor/pub_grub/lib/pub_grub/version_union.rb @@ -0,0 +1,178 @@ +# frozen_string_literal: true + +module Gem::PubGrub + class VersionUnion + attr_reader :ranges + + def self.normalize_ranges(ranges) + ranges = ranges.flat_map do |range| + range.ranges + end + + ranges.reject!(&:empty?) + + return [] if ranges.empty? + + mins, ranges = ranges.partition { |r| !r.min } + original_ranges = mins + ranges.sort_by { |r| [r.min, r.include_min ? 0 : 1] } + ranges = [original_ranges.shift] + original_ranges.each do |range| + if ranges.last.contiguous_to?(range) + ranges << ranges.pop.span(range) + else + ranges << range + end + end + + ranges + end + + def self.union(ranges, normalize: true) + ranges = normalize_ranges(ranges) if normalize + + if ranges.size == 0 + VersionRange.empty + elsif ranges.size == 1 + ranges[0] + else + new(ranges) + end + end + + def initialize(ranges) + raise ArgumentError unless ranges.all? { |r| r.instance_of?(VersionRange) } + @ranges = ranges + end + + def hash + ranges.hash + end + + def eql?(other) + ranges.eql?(other.ranges) + end + + def include?(version) + !!ranges.bsearch {|r| r.compare_version(version) } + end + + def select_versions(all_versions) + versions = [] + ranges.inject(all_versions) do |acc, range| + _, matching, higher = range.partition_versions(acc) + versions.concat matching + higher + end + versions + end + + def intersects?(other) + my_ranges = ranges.dup + other_ranges = other.ranges.dup + + my_range = my_ranges.shift + other_range = other_ranges.shift + while my_range && other_range + if my_range.intersects?(other_range) + return true + end + + if !my_range.max || other_range.empty? || (other_range.max && other_range.max < my_range.max) + other_range = other_ranges.shift + else + my_range = my_ranges.shift + end + end + end + alias_method :allows_any?, :intersects? + + def allows_all?(other) + my_ranges = ranges.dup + + my_range = my_ranges.shift + + other.ranges.all? do |other_range| + while my_range + break if my_range.allows_all?(other_range) + my_range = my_ranges.shift + end + + !!my_range + end + end + + def empty? + false + end + + def any? + false + end + + def intersect(other) + my_ranges = ranges.dup + other_ranges = other.ranges.dup + new_ranges = [] + + my_range = my_ranges.shift + other_range = other_ranges.shift + while my_range && other_range + new_ranges << my_range.intersect(other_range) + + if !my_range.max || other_range.empty? || (other_range.max && other_range.max < my_range.max) + other_range = other_ranges.shift + else + my_range = my_ranges.shift + end + end + new_ranges.reject!(&:empty?) + VersionUnion.union(new_ranges, normalize: false) + end + + def upper_invert + ranges.last.upper_invert + end + + def invert + ranges.map(&:invert).inject(:intersect) + end + + def union(other) + VersionUnion.union([self, other]) + end + + def to_s + output = [] + + ranges = self.ranges.dup + while !ranges.empty? + ne = [] + range = ranges.shift + while !ranges.empty? && ranges[0].min.to_s == range.max.to_s + ne << range.max + range = range.span(ranges.shift) + end + + ne.map! {|x| "!= #{x}" } + if ne.empty? + output << range.to_s + elsif range.any? + output << ne.join(', ') + else + output << "#{range}, #{ne.join(', ')}" + end + end + + output.join(" OR ") + end + + def inspect + "#<#{self.class} #{to_s}>" + end + + def ==(other) + self.class == other.class && + self.ranges == other.ranges + end + end +end diff --git a/lib/rubygems/vendored_molinillo.rb b/lib/rubygems/vendored_molinillo.rb deleted file mode 100644 index 45906c0e5c71b4..00000000000000 --- a/lib/rubygems/vendored_molinillo.rb +++ /dev/null @@ -1,3 +0,0 @@ -# frozen_string_literal: true - -require_relative "vendor/molinillo/lib/molinillo" diff --git a/lib/rubygems/vendored_pub_grub.rb b/lib/rubygems/vendored_pub_grub.rb new file mode 100644 index 00000000000000..844d243ab320fb --- /dev/null +++ b/lib/rubygems/vendored_pub_grub.rb @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +require_relative "vendor/pub_grub/lib/pub_grub" diff --git a/test/rubygems/test_gem_commands_install_command.rb b/test/rubygems/test_gem_commands_install_command.rb index cc58d7d105df85..d75ba349f96ada 100644 --- a/test/rubygems/test_gem_commands_install_command.rb +++ b/test/rubygems/test_gem_commands_install_command.rb @@ -119,11 +119,7 @@ def test_execute_local_dependency_nonexistent end end - expected = <<-EXPECTED -ERROR: Could not find a valid gem 'bar' (= 0.5) (required by 'foo' (>= 0)) in any repository - EXPECTED - - assert_equal expected, @ui.error + assert_match(/ERROR:.*foo.*bar/m, @ui.error) end def test_execute_local_dependency_nonexistent_ignore_dependencies @@ -303,11 +299,7 @@ def test_execute_dependency_nonexistent assert_equal 2, e.exit_code end - expected = <<-EXPECTED -ERROR: Could not find a valid gem 'bar' (= 0.5) (required by 'foo' (>= 0)) in any repository - EXPECTED - - assert_equal expected, @ui.error + assert_match(/ERROR:.*foo.*bar/m, @ui.error) end def test_execute_http_proxy diff --git a/test/rubygems/test_gem_dependency_installer.rb b/test/rubygems/test_gem_dependency_installer.rb index 8d9caf7d90b9a4..bb52df4271fee6 100644 --- a/test/rubygems/test_gem_dependency_installer.rb +++ b/test/rubygems/test_gem_dependency_installer.rb @@ -793,13 +793,13 @@ def test_install_domain_local inst = nil Dir.chdir @tempdir do - e = assert_raise Gem::UnsatisfiableDependencyError do + e = assert_raise Gem::DependencyResolutionError do inst = Gem::DependencyInstaller.new domain: :local inst.install "b" end - expected = "Unable to resolve dependency: 'b (>= 0)' requires 'a (>= 0)'" - assert_equal expected, e.message + assert_match(/depends on a/, e.message) + assert_match(/no versions satisfy a/, e.message) end assert_equal [], inst.installed_gems.map(&:full_name) diff --git a/test/rubygems/test_gem_resolver.rb b/test/rubygems/test_gem_resolver.rb index 4990d5d2dd1339..b9b005f70ea7e6 100644 --- a/test/rubygems/test_gem_resolver.rb +++ b/test/rubygems/test_gem_resolver.rb @@ -86,63 +86,6 @@ def test_self_compose_sets_single assert_same index_set, composed end - def test_requests - a1 = util_spec "a", 1, "b" => 2 - - r1 = Gem::Resolver::DependencyRequest.new dep("a", "= 1"), nil - - act = Gem::Resolver::ActivationRequest.new a1, r1 - - res = Gem::Resolver.new [a1] - - reqs = [] - - res.requests a1, act, reqs - - assert_equal ["b (= 2)"], reqs.map(&:to_s) - end - - def test_requests_development - a1 = util_spec "a", 1, "b" => 2 - - spec = Gem::Resolver::SpecSpecification.new nil, a1 - def spec.fetch_development_dependencies - @called = true - end - - r1 = Gem::Resolver::DependencyRequest.new dep("a", "= 1"), nil - - act = Gem::Resolver::ActivationRequest.new spec, r1 - - res = Gem::Resolver.new [act] - res.development = true - - reqs = [] - - res.requests spec, act, reqs - - assert_equal ["b (= 2)"], reqs.map(&:to_s) - - assert spec.instance_variable_defined? :@called - end - - def test_requests_ignore_dependencies - a1 = util_spec "a", 1, "b" => 2 - - r1 = Gem::Resolver::DependencyRequest.new dep("a", "= 1"), nil - - act = Gem::Resolver::ActivationRequest.new a1, r1 - - res = Gem::Resolver.new [a1] - res.ignore_dependencies = true - - reqs = [] - - res.requests a1, act, reqs - - assert_empty reqs - end - def test_resolve_conservative a1_spec = util_spec "a", 1 @@ -511,19 +454,9 @@ def test_raises_dependency_error r.resolve end - deps = [make_dep("c", "= 2"), make_dep("c", "= 1")] - assert_equal deps, e.conflicting_dependencies - - con = e.conflict - - act = con.activated - assert_equal "c-1", act.spec.full_name - - parent = act.parent - assert_equal "a-1", parent.spec.full_name - - act = con.requester - assert_equal "b-1", act.spec.full_name + assert_kind_of Gem::Resolver::PubGrubFailure, e.conflict + assert_match(/a depends on c/, e.message) + assert_match(/b depends on c/, e.message) end def test_raises_when_a_gem_is_missing @@ -578,12 +511,12 @@ def test_raises_and_reports_an_implicit_request_properly r = Gem::Resolver.new([ad], set(a1)) - e = assert_raise Gem::UnsatisfiableDependencyError do + e = assert_raise Gem::DependencyResolutionError do r.resolve end - assert_equal "Unable to resolve dependency: 'a (= 1)' requires 'b (= 2)'", - e.message + assert_match(/depends on b/, e.message) + assert_match(/no versions satisfy b/, e.message) end def test_raises_when_possibles_are_exhausted @@ -605,18 +538,9 @@ def test_raises_when_possibles_are_exhausted r.resolve end - dependency = e.conflict.dependency - - assert_includes %w[a b], dependency.name - assert_equal req(">= 0"), dependency.requirement - - activated = e.conflict.activated - assert_equal "c-1", activated.full_name - - assert_equal dep("c", "= 1"), activated.request.dependency - - assert_equal [dep("c", ">= 2"), dep("c", "= 1")], - e.conflict.conflicting_dependencies + assert_kind_of Gem::Resolver::PubGrubFailure, e.conflict + assert_match(/a depends on c/, e.message) + assert_match(/b depends on c/, e.message) end def test_keeps_resolving_after_seeing_satisfied_dep @@ -772,7 +696,7 @@ def test_second_level_backout assert_resolves_to [b1, c1, d2], r end - def test_sorts_by_source_then_version + def test_picks_highest_version_across_sources source_a = Gem::Source.new "http://example.com/a" source_b = Gem::Source.new "http://example.com/b" source_c = Gem::Source.new "http://example.com/c" @@ -795,7 +719,7 @@ def test_sorts_by_source_then_version resolver = Gem::Resolver.new [dependency], set - assert_resolves_to [spec_b_2], resolver + assert_resolves_to [spec_a_2], resolver end def test_select_local_platforms From 456a2dbdd3ed58f7d16b06c945ce382dbf7aec2c Mon Sep 17 00:00:00 2001 From: Matt Larraz Date: Mon, 16 Mar 2026 19:26:47 -0400 Subject: [PATCH 06/34] [ruby/rubygems] Fix lockfile requirement preservation and orphaned deps test Preserve the original dependency requirement from root deps when building ActivationRequests, so lockfiles correctly record constraints like "a (>= 1)" instead of bare "a". Update the orphaned dependencies test: PubGrub correctly backtracks from b-2 (missing c-2) to b-1 (has c-1), finding a valid solution that Molinillo's simpler backtracking missed. https://github.com/ruby/rubygems/commit/ff04cd9d90 Co-Authored-By: Claude Opus 4.6 --- lib/rubygems/resolver.rb | 4 +++- test/rubygems/test_gem.rb | 10 +++------- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/lib/rubygems/resolver.rb b/lib/rubygems/resolver.rb index 5578135a83e81f..e0643dd249e6fd 100644 --- a/lib/rubygems/resolver.rb +++ b/lib/rubygems/resolver.rb @@ -171,10 +171,12 @@ def resolve result = solver.solve # Convert to Array + needed_by_name = @needed.group_by(&:name) result.filter_map do |package, version| next if Gem::PubGrub::Package.root?(package) spec = spec_for(package.to_s, version) - dep_request = DependencyRequest.new(Gem::Dependency.new(package.to_s), nil) + dep = needed_by_name[package.to_s]&.first || Gem::Dependency.new(package.to_s) + dep_request = DependencyRequest.new(dep, nil) ActivationRequest.new(spec, dep_request) end rescue Gem::PubGrub::SolveFailure => e diff --git a/test/rubygems/test_gem.rb b/test/rubygems/test_gem.rb index b9a4cf1ce032ce..bce50a1d369bc2 100644 --- a/test/rubygems/test_gem.rb +++ b/test/rubygems/test_gem.rb @@ -331,13 +331,9 @@ def test_activate_bin_path_raises_a_meaningful_error_if_a_gem_thats_finally_acti install_specs c1, b1, b2, a1 - # c2 is missing, and b2 which has it as a dependency will be activated, so we should get an error about the orphaned dependency - - e = assert_raise Gem::UnsatisfiableDependencyError do - load Gem.activate_bin_path("a", "exec", ">= 0") - end - - assert_equal "Unable to resolve dependency: 'b (>= 0)' requires 'c (= 2)'", e.message + # c2 is missing, but the resolver backtracks from b2 to b1 which + # works with c1, finding a valid solution despite partial installation + load Gem.activate_bin_path("a", "exec", ">= 0") end def test_activate_bin_path_in_debug_mode From 600240d6852416021ad6d435837316b18f1902ba Mon Sep 17 00:00:00 2001 From: Colby Swandale Date: Sun, 12 Apr 2026 12:15:08 +1000 Subject: [PATCH 07/34] [ruby/rubygems] Extend catching Gem::DependencyResolutionError error to exec command https://github.com/ruby/rubygems/commit/486bbca187 --- lib/rubygems/commands/exec_command.rb | 3 ++ .../test_gem_commands_exec_command.rb | 29 +++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/lib/rubygems/commands/exec_command.rb b/lib/rubygems/commands/exec_command.rb index c24ebbf711c2d7..1feafbdd358316 100644 --- a/lib/rubygems/commands/exec_command.rb +++ b/lib/rubygems/commands/exec_command.rb @@ -173,6 +173,9 @@ def install rescue Gem::InstallError => e alert_error "Error installing #{gem_name}:\n\t#{e.message}" terminate_interaction 1 + rescue Gem::DependencyResolutionError => e + alert_error "Error installing #{gem_name}:\n\t#{e.message}" + terminate_interaction 2 rescue Gem::GemNotFoundException => e show_lookup_failure e.name, e.version, e.errors, false diff --git a/test/rubygems/test_gem_commands_exec_command.rb b/test/rubygems/test_gem_commands_exec_command.rb index db738b5e9f58e0..b949cd34a67b3a 100644 --- a/test/rubygems/test_gem_commands_exec_command.rb +++ b/test/rubygems/test_gem_commands_exec_command.rb @@ -856,4 +856,33 @@ def test_newer_prerelease_available assert_equal %w[a-1.1.a], @installed_specs.map(&:full_name) end end + + def test_install_dependency_resolution_error + spec_fetcher do |fetcher| + fetcher.gem "a", 2 do |s| + s.executables = %w[a] + s.add_dependency "b", "~> 1.0" + s.add_dependency "c", "~> 1.0" + end + fetcher.gem "b", 1 do |s| + s.add_dependency "d", "= 1.0" + end + fetcher.gem "c", 1 do |s| + s.add_dependency "d", "= 2.0" + end + fetcher.gem "d", 1 + fetcher.gem "d", 2 + end + + util_clear_gems + + use_ui @ui do + e = assert_raise Gem::MockGemUi::TermError do + @cmd.invoke "a:2" + end + assert_equal 2, e.exit_code + end + + assert_match(/ERROR:.*Error installing a:/, @ui.error) + end end From 14298299455ba3f83f3a638808fc872a699c810e Mon Sep 17 00:00:00 2001 From: Colby Swandale Date: Sun, 12 Apr 2026 12:33:55 +1000 Subject: [PATCH 08/34] [ruby/rubygems] Simplify DependencyResolutionError by removing PubGrubFailure wrapper https://github.com/ruby/rubygems/commit/ca268e2042 --- lib/rubygems/exceptions.rb | 24 +++++++++---------- lib/rubygems/resolver.rb | 5 +--- lib/rubygems/resolver/pub_grub_failure.rb | 18 -------------- .../test_gem_dependency_resolution_error.rb | 12 ++++++++-- test/rubygems/test_gem_resolver.rb | 4 ++-- 5 files changed, 24 insertions(+), 39 deletions(-) delete mode 100644 lib/rubygems/resolver/pub_grub_failure.rb diff --git a/lib/rubygems/exceptions.rb b/lib/rubygems/exceptions.rb index 1bba014245a8d2..2c0c38617f045e 100644 --- a/lib/rubygems/exceptions.rb +++ b/lib/rubygems/exceptions.rb @@ -33,26 +33,24 @@ class Gem::DependencyError < Gem::Exception; end class Gem::DependencyRemovalException < Gem::Exception; end ## -# Raised by Gem::Resolver when a Gem::Dependency::Conflict reaches the -# toplevel. Indicates which dependencies were incompatible through #conflict -# and #conflicting_dependencies +# Raised by Gem::Resolver when dependency resolution fails. class Gem::DependencyResolutionError < Gem::DependencyError - attr_reader :conflict - def initialize(conflict) - @conflict = conflict + @explanation = conflict.explanation + super @explanation + end - if conflict.respond_to?(:solve_failure) - super conflict.explanation - else - a, b = conflicting_dependencies - super "conflicting dependencies #{a} and #{b}\n#{@conflict.explanation}" - end + def explanation + @explanation + end + + def conflict + nil end def conflicting_dependencies - @conflict.conflicting_dependencies + [] end end diff --git a/lib/rubygems/resolver.rb b/lib/rubygems/resolver.rb index e0643dd249e6fd..5e4b29579a682a 100644 --- a/lib/rubygems/resolver.rb +++ b/lib/rubygems/resolver.rb @@ -180,8 +180,7 @@ def resolve ActivationRequest.new(spec, dep_request) end rescue Gem::PubGrub::SolveFailure => e - failure = Gem::Resolver::PubGrubFailure.new(e) - raise Gem::DependencyResolutionError, failure + raise Gem::DependencyResolutionError, e end # PubGrub source interface methods @@ -392,8 +391,6 @@ def make_logger require_relative "resolver/conflict" require_relative "resolver/dependency_request" require_relative "resolver/requirement_list" -require_relative "resolver/pub_grub_failure" - require_relative "resolver/set" require_relative "resolver/api_set" require_relative "resolver/composed_set" diff --git a/lib/rubygems/resolver/pub_grub_failure.rb b/lib/rubygems/resolver/pub_grub_failure.rb deleted file mode 100644 index 3ace7ef51590a6..00000000000000 --- a/lib/rubygems/resolver/pub_grub_failure.rb +++ /dev/null @@ -1,18 +0,0 @@ -# frozen_string_literal: true - -class Gem::Resolver::PubGrubFailure - attr_reader :solve_failure - - def initialize(solve_failure) - @solve_failure = solve_failure - end - - def explanation - @solve_failure.explanation - end - - def conflicting_dependencies - terms = @solve_failure.incompatibility.terms - terms.map {|t| t.package.to_s } - end -end diff --git a/test/rubygems/test_gem_dependency_resolution_error.rb b/test/rubygems/test_gem_dependency_resolution_error.rb index 98a6b6b8fd0e08..e67ade5939ff73 100644 --- a/test/rubygems/test_gem_dependency_resolution_error.rb +++ b/test/rubygems/test_gem_dependency_resolution_error.rb @@ -19,7 +19,15 @@ def setup end def test_message - assert_match(/^conflicting dependencies a \(= 1\) and a \(= 2\)$/, - @error.message) + assert_match(/Activated a-2/, @error.message) + assert_match(/conflicting dependency/, @error.message) + end + + def test_conflict + assert_nil @error.conflict + end + + def test_conflicting_dependencies + assert_equal [], @error.conflicting_dependencies end end diff --git a/test/rubygems/test_gem_resolver.rb b/test/rubygems/test_gem_resolver.rb index b9b005f70ea7e6..8c032bf73a30c4 100644 --- a/test/rubygems/test_gem_resolver.rb +++ b/test/rubygems/test_gem_resolver.rb @@ -454,7 +454,7 @@ def test_raises_dependency_error r.resolve end - assert_kind_of Gem::Resolver::PubGrubFailure, e.conflict + assert_nil e.conflict assert_match(/a depends on c/, e.message) assert_match(/b depends on c/, e.message) end @@ -538,7 +538,7 @@ def test_raises_when_possibles_are_exhausted r.resolve end - assert_kind_of Gem::Resolver::PubGrubFailure, e.conflict + assert_nil e.conflict assert_match(/a depends on c/, e.message) assert_match(/b depends on c/, e.message) end From f07eabd6c7e133647ef9f0527db0d6325b0ac7e8 Mon Sep 17 00:00:00 2001 From: Colby Swandale Date: Sun, 12 Apr 2026 12:42:29 +1000 Subject: [PATCH 09/34] [ruby/rubygems] find_all_gems test coverage https://github.com/ruby/rubygems/commit/8cb62e54a6 --- test/rubygems/test_gem_source_local.rb | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/test/rubygems/test_gem_source_local.rb b/test/rubygems/test_gem_source_local.rb index e9d7f454820a3e..60621736296766 100644 --- a/test/rubygems/test_gem_source_local.rb +++ b/test/rubygems/test_gem_source_local.rb @@ -63,6 +63,30 @@ def test_find_gem_prerelease assert_equal "a-2.a", @sl.find_gem("a", req, true).full_name end + def test_find_all_gems + _, a2_gem = util_gem "a", "2" + FileUtils.mv a2_gem, @tempdir + + results = @sl.find_all_gems("a") + assert_equal ["a-1", "a-2"], results.map(&:full_name).sort + end + + def test_find_all_gems_excludes_prerelease_by_default + results = @sl.find_all_gems("a") + assert_equal ["a-1"], results.map(&:full_name) + end + + def test_find_all_gems_includes_prerelease_when_requested + results = @sl.find_all_gems("a", Gem::Requirement.create(">= 0"), true) + assert_equal ["a-1", "a-2.a"], results.map(&:full_name).sort + end + + def test_find_all_gems_includes_prerelease_when_requirement_is_prerelease + req = Gem::Requirement.create("= 2.a") + results = @sl.find_all_gems("a", req) + assert_equal ["a-2.a"], results.map(&:full_name) + end + def test_fetch_spec s = @sl.fetch_spec @a.name_tuple assert_equal s, @a From b1b53a2ff581db3b2ab828ef8f1fe61d7f611d8a Mon Sep 17 00:00:00 2001 From: Colby Swandale Date: Sun, 12 Apr 2026 12:51:33 +1000 Subject: [PATCH 10/34] [ruby/rubygems] Remove dead Molinillo-era error classes and simplify DependencyResolutionError https://github.com/ruby/rubygems/commit/6592033131 --- lib/rubygems/exceptions.rb | 34 ---- lib/rubygems/resolver.rb | 1 - lib/rubygems/resolver/conflict.rb | 146 ------------------ .../test_gem_dependency_resolution_error.rb | 19 +-- .../test_gem_impossible_dependencies_error.rb | 60 ------- test/rubygems/test_gem_resolver_conflict.rb | 80 ---------- 6 files changed, 7 insertions(+), 333 deletions(-) delete mode 100644 lib/rubygems/resolver/conflict.rb delete mode 100644 test/rubygems/test_gem_impossible_dependencies_error.rb delete mode 100644 test/rubygems/test_gem_resolver_conflict.rb diff --git a/lib/rubygems/exceptions.rb b/lib/rubygems/exceptions.rb index 2c0c38617f045e..e00a70c66249a3 100644 --- a/lib/rubygems/exceptions.rb +++ b/lib/rubygems/exceptions.rb @@ -128,40 +128,6 @@ def initialize(name, version, errors = nil) Gem.deprecate_constant :SpecificGemNotFoundException -## -# Raised by Gem::Resolver when dependencies conflict and create the -# inability to find a valid possible spec for a request. - -class Gem::ImpossibleDependenciesError < Gem::Exception - attr_reader :conflicts - attr_reader :request - - def initialize(request, conflicts) - @request = request - @conflicts = conflicts - - super build_message - end - - def build_message # :nodoc: - requester = @request.requester - requester = requester ? requester.spec.full_name : "The user" - dependency = @request.dependency - - message = "#{requester} requires #{dependency} but it conflicted:\n".dup - - @conflicts.each do |_, conflict| - message << conflict.explanation - end - - message - end - - def dependency - @request.dependency - end -end - class Gem::InstallError < Gem::Exception; end class Gem::RuntimeRequirementNotMetError < Gem::InstallError diff --git a/lib/rubygems/resolver.rb b/lib/rubygems/resolver.rb index 5e4b29579a682a..1f63a7b17b5e23 100644 --- a/lib/rubygems/resolver.rb +++ b/lib/rubygems/resolver.rb @@ -388,7 +388,6 @@ def make_logger end require_relative "resolver/activation_request" -require_relative "resolver/conflict" require_relative "resolver/dependency_request" require_relative "resolver/requirement_list" require_relative "resolver/set" diff --git a/lib/rubygems/resolver/conflict.rb b/lib/rubygems/resolver/conflict.rb deleted file mode 100644 index 77c3add4b32d21..00000000000000 --- a/lib/rubygems/resolver/conflict.rb +++ /dev/null @@ -1,146 +0,0 @@ -# frozen_string_literal: true - -## -# Used internally to indicate that a dependency conflicted -# with a spec that would be activated. - -class Gem::Resolver::Conflict - ## - # The specification that was activated prior to the conflict - - attr_reader :activated - - ## - # The dependency that is in conflict with the activated gem. - - attr_reader :dependency - - attr_reader :failed_dep # :nodoc: - - ## - # Creates a new resolver conflict when +dependency+ is in conflict with an - # already +activated+ specification. - - def initialize(dependency, activated, failed_dep = dependency) - @dependency = dependency - @activated = activated - @failed_dep = failed_dep - end - - def ==(other) # :nodoc: - self.class === other && - @dependency == other.dependency && - @activated == other.activated && - @failed_dep == other.failed_dep - end - - ## - # A string explanation of the conflict. - - def explain - "" - end - - ## - # Return the 2 dependency objects that conflicted - - def conflicting_dependencies - [@failed_dep.dependency, @activated.request.dependency] - end - - ## - # Explanation of the conflict used by exceptions to print useful messages - - def explanation - activated = @activated.spec.full_name - dependency = @failed_dep.dependency - requirement = dependency.requirement - alternates = dependency.matching_specs.map(&:full_name) - - unless alternates.empty? - matching = <<-MATCHING.chomp - - Gems matching %s: - %s - MATCHING - - matching = format(matching, dependency, alternates.join(", ")) - end - - explanation = <<-EXPLANATION - Activated %s - which does not match conflicting dependency (%s) - - Conflicting dependency chains: - %s - - versus: - %s -%s - EXPLANATION - - format(explanation, activated, requirement, request_path(@activated).reverse.join(", depends on\n "), request_path(@failed_dep).reverse.join(", depends on\n "), matching) - end - - ## - # Returns true if the conflicting dependency's name matches +spec+. - - def for_spec?(spec) - @dependency.name == spec.name - end - - def pretty_print(q) # :nodoc: - q.group 2, "[Dependency conflict: ", "]" do - q.breakable - - q.text "activated " - q.pp @activated - - q.breakable - q.text " dependency " - q.pp @dependency - - q.breakable - if @dependency == @failed_dep - q.text " failed" - else - q.text " failed dependency " - q.pp @failed_dep - end - end - end - - ## - # Path of activations from the +current+ list. - - def request_path(current) - path = [] - - while current do - case current - when Gem::Resolver::ActivationRequest then - path << - "#{current.request.dependency}, #{current.spec.version} activated" - - current = current.parent - when Gem::Resolver::DependencyRequest then - path << current.dependency.to_s - - current = current.requester - else - raise Gem::Exception, "[BUG] unknown request class #{current.class}" - end - end - - path = ["user request (gem command or Gemfile)"] if path.empty? - - path - end - - ## - # Return the Specification that listed the dependency - - def requester - @failed_dep.requester - end -end diff --git a/test/rubygems/test_gem_dependency_resolution_error.rb b/test/rubygems/test_gem_dependency_resolution_error.rb index e67ade5939ff73..d8fa96a2602ac4 100644 --- a/test/rubygems/test_gem_dependency_resolution_error.rb +++ b/test/rubygems/test_gem_dependency_resolution_error.rb @@ -6,21 +6,16 @@ class TestGemDependencyResolutionError < Gem::TestCase def setup super - @spec = util_spec "a", 2 - - @a1_req = Gem::Resolver::DependencyRequest.new dep("a", "= 1"), nil - @a2_req = Gem::Resolver::DependencyRequest.new dep("a", "= 2"), nil - - @activated = Gem::Resolver::ActivationRequest.new @spec, @a2_req - - @conflict = Gem::Resolver::Conflict.new @a1_req, @activated - - @error = Gem::DependencyResolutionError.new @conflict + failure = Struct.new(:explanation).new("a depends on b (= 1.0) but no versions match") + @error = Gem::DependencyResolutionError.new failure end def test_message - assert_match(/Activated a-2/, @error.message) - assert_match(/conflicting dependency/, @error.message) + assert_equal "a depends on b (= 1.0) but no versions match", @error.message + end + + def test_explanation + assert_equal "a depends on b (= 1.0) but no versions match", @error.explanation end def test_conflict diff --git a/test/rubygems/test_gem_impossible_dependencies_error.rb b/test/rubygems/test_gem_impossible_dependencies_error.rb deleted file mode 100644 index 94c0290ea1e79c..00000000000000 --- a/test/rubygems/test_gem_impossible_dependencies_error.rb +++ /dev/null @@ -1,60 +0,0 @@ -# frozen_string_literal: true - -require_relative "helper" - -class TestGemImpossibleDependenciesError < Gem::TestCase - def test_message_conflict - request = dependency_request dep("net-ssh", ">= 2.0.13"), "rye", "0.9.8" - - conflicts = [] - - # These conflicts are lies as their dependencies does not have the correct - # requested-by entries, but they are suitable for testing the message. - # See #485 to construct a correct conflict. - net_ssh_2_2_2 = - dependency_request dep("net-ssh", ">= 2.6.5"), "net-ssh", "2.2.2", request - net_ssh_2_6_5 = - dependency_request dep("net-ssh", "~> 2.2.2"), "net-ssh", "2.6.5", request - - conflict1 = Gem::Resolver::Conflict.new \ - net_ssh_2_6_5, net_ssh_2_6_5.requester - - conflict2 = Gem::Resolver::Conflict.new \ - net_ssh_2_2_2, net_ssh_2_2_2.requester - - conflicts << [net_ssh_2_6_5.requester.spec, conflict1] - conflicts << [net_ssh_2_2_2.requester.spec, conflict2] - - error = Gem::ImpossibleDependenciesError.new request, conflicts - - expected = <<-EXPECTED -rye-0.9.8 requires net-ssh (>= 2.0.13) but it conflicted: - Activated net-ssh-2.6.5 - which does not match conflicting dependency (~> 2.2.2) - - Conflicting dependency chains: - rye (= 0.9.8), 0.9.8 activated, depends on - net-ssh (>= 2.0.13), 2.6.5 activated - - versus: - rye (= 0.9.8), 0.9.8 activated, depends on - net-ssh (>= 2.0.13), 2.6.5 activated, depends on - net-ssh (~> 2.2.2) - - Activated net-ssh-2.2.2 - which does not match conflicting dependency (>= 2.6.5) - - Conflicting dependency chains: - rye (= 0.9.8), 0.9.8 activated, depends on - net-ssh (>= 2.0.13), 2.2.2 activated - - versus: - rye (= 0.9.8), 0.9.8 activated, depends on - net-ssh (>= 2.0.13), 2.2.2 activated, depends on - net-ssh (>= 2.6.5) - - EXPECTED - - assert_equal expected, error.message - end -end diff --git a/test/rubygems/test_gem_resolver_conflict.rb b/test/rubygems/test_gem_resolver_conflict.rb deleted file mode 100644 index 5696ff266d0023..00000000000000 --- a/test/rubygems/test_gem_resolver_conflict.rb +++ /dev/null @@ -1,80 +0,0 @@ -# frozen_string_literal: true - -require_relative "helper" - -class TestGemResolverConflict < Gem::TestCase - def test_explanation - root = - dependency_request dep("net-ssh", ">= 2.0.13"), "rye", "0.9.8" - child = - dependency_request dep("net-ssh", ">= 2.6.5"), "net-ssh", "2.2.2", root - - dep = Gem::Resolver::DependencyRequest.new dep("net-ssh", ">= 2.0.13"), nil - - spec = util_spec "net-ssh", "2.2.2" - active = - Gem::Resolver::ActivationRequest.new spec, dep - - conflict = - Gem::Resolver::Conflict.new child, active - - expected = <<-EXPECTED - Activated net-ssh-2.2.2 - which does not match conflicting dependency (>= 2.6.5) - - Conflicting dependency chains: - net-ssh (>= 2.0.13), 2.2.2 activated - - versus: - rye (= 0.9.8), 0.9.8 activated, depends on - net-ssh (>= 2.0.13), 2.2.2 activated, depends on - net-ssh (>= 2.6.5) - - EXPECTED - - assert_equal expected, conflict.explanation - end - - def test_explanation_user_request - spec = util_spec "a", 2 - - a1_req = Gem::Resolver::DependencyRequest.new dep("a", "= 1"), nil - a2_req = Gem::Resolver::DependencyRequest.new dep("a", "= 2"), nil - - activated = Gem::Resolver::ActivationRequest.new spec, a2_req - - conflict = Gem::Resolver::Conflict.new a1_req, activated - - expected = <<-EXPECTED - Activated a-2 - which does not match conflicting dependency (= 1) - - Conflicting dependency chains: - a (= 2), 2 activated - - versus: - a (= 1) - - EXPECTED - - assert_equal expected, conflict.explanation - end - - def test_request_path - root = - dependency_request dep("net-ssh", ">= 2.0.13"), "rye", "0.9.8" - - child = - dependency_request dep("other", ">= 1.0"), "net-ssh", "2.2.2", root - - conflict = - Gem::Resolver::Conflict.new nil, nil - - expected = [ - "net-ssh (>= 2.0.13), 2.2.2 activated", - "rye (= 0.9.8), 0.9.8 activated", - ] - - assert_equal expected, conflict.request_path(child.requester) - end -end From c131a631f0a94d29891c55d5a1f770a1e8c710f7 Mon Sep 17 00:00:00 2001 From: Colby Swandale Date: Sun, 12 Apr 2026 13:46:09 +1000 Subject: [PATCH 11/34] [ruby/rubygems] Rename orphaned deps test to reflect PubGrub backtracking behavior https://github.com/ruby/rubygems/commit/081245a511 --- test/rubygems/test_gem.rb | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/test/rubygems/test_gem.rb b/test/rubygems/test_gem.rb index bce50a1d369bc2..c81b0b0547aeca 100644 --- a/test/rubygems/test_gem.rb +++ b/test/rubygems/test_gem.rb @@ -313,7 +313,7 @@ def test_activate_bin_path_does_not_error_if_a_gem_thats_not_finally_activated_h assert_equal %w[a-1 b-2 c-2], loaded_spec_names end - def test_activate_bin_path_raises_a_meaningful_error_if_a_gem_thats_finally_activated_has_orphaned_dependencies + def test_activate_bin_path_backtracks_when_highest_version_has_orphaned_dependencies a1 = util_spec "a", "1" do |s| s.executables = ["exec"] s.add_dependency "b" @@ -334,6 +334,8 @@ def test_activate_bin_path_raises_a_meaningful_error_if_a_gem_thats_finally_acti # c2 is missing, but the resolver backtracks from b2 to b1 which # works with c1, finding a valid solution despite partial installation load Gem.activate_bin_path("a", "exec", ">= 0") + + assert_equal %w[a-1 b-1 c-1], loaded_spec_names end def test_activate_bin_path_in_debug_mode From c6682793b2e18875e90111f1d67baa49e87bbf2e Mon Sep 17 00:00:00 2001 From: Colby Swandale Date: Sun, 12 Apr 2026 14:31:29 +1000 Subject: [PATCH 12/34] [ruby/rubygems] Fix prerelease version inconsistency between all_versions_for and versions_for PubGrub requires both methods to agree on the version universe. https://github.com/ruby/rubygems/commit/23b9505131 --- lib/rubygems/resolver.rb | 31 ++++++++--------- test/rubygems/test_gem_resolver.rb | 53 ++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 15 deletions(-) diff --git a/lib/rubygems/resolver.rb b/lib/rubygems/resolver.rb index 1f63a7b17b5e23..224526116f5800 100644 --- a/lib/rubygems/resolver.rb +++ b/lib/rubygems/resolver.rb @@ -106,7 +106,8 @@ def initialize(needed, set = nil) @packages = {} @all_specs = Hash.new {|h, name| h[name] = find_all_specs_for(name) } - @sorted_versions = Hash.new {|h, pkg| h[pkg] = @all_specs[pkg.to_s].map(&:version).uniq.sort } + @all_versions = Hash.new {|h, pkg| h[pkg] = @all_specs[pkg.to_s].map(&:version).uniq.sort } + @sorted_versions = Hash.new {|h, pkg| h[pkg] = filter_versions(pkg) } @cached_dependencies = Hash.new {|h, pkg| h[pkg] = Hash.new {|v, ver| v[ver] = compute_dependencies(pkg, ver) } } end @@ -188,20 +189,7 @@ def resolve def all_versions_for(package) return [@root_version] if package == @root_package - all_versions = @sorted_versions[package] - - # Exclude prerelease versions unless the set has prerelease enabled. - # Prereleases are still available via versions_for when a range - # specifically includes them (e.g., "= 2.a"), with low priority - # in the Strategy. - if @set.respond_to?(:prerelease) && @set.prerelease - versions = all_versions - else - stable = all_versions.reject(&:prerelease?) - versions = stable.empty? ? all_versions : stable - end - - versions = versions.reverse # highest first + versions = @sorted_versions[package].reverse # highest first name = package.to_s if (skip_dep_gems = skip_gems[name]) && !skip_dep_gems.empty? @@ -315,6 +303,19 @@ def package_for(name) @packages[name] ||= Gem::PubGrub::Package.new(name) end + # Filter versions to exclude prereleases unless prerelease is enabled. + # Both all_versions_for and versions_for use this filtered set to ensure + # PubGrub's constraint propagation and version selection are consistent. + def filter_versions(package) + all_versions = @all_versions[package] + if @set.respond_to?(:prerelease) && @set.prerelease + all_versions + else + stable = all_versions.reject(&:prerelease?) + stable.empty? ? all_versions : stable + end + end + def find_all_specs_for(name) dep = Gem::Dependency.new(name, ">= 0.a") dep_request = DependencyRequest.new(dep, nil) diff --git a/test/rubygems/test_gem_resolver.rb b/test/rubygems/test_gem_resolver.rb index 8c032bf73a30c4..5cdf2fae3596a7 100644 --- a/test/rubygems/test_gem_resolver.rb +++ b/test/rubygems/test_gem_resolver.rb @@ -774,4 +774,57 @@ def test_raises_and_explains_when_platform_prevents_install assert_match "No match for 'a (= 1)' on this platform. Found: c-p-1", e.message end + + def test_resolve_prerelease_not_considered_when_stable_exists + # a-1.0 depends on b ~> 2.0 — only b-2.0.pre satisfies that, but + # b also has a stable version (1.0), so prereleases are filtered out. + # The resolver must fail, not silently use b-2.0.pre during propagation. + a_stable = util_spec "a", "1.0" do |s| + s.add_dependency "b", "~> 2.0" + end + + b_stable = util_spec "b", "1.0" + b_pre = util_spec "b", "2.0.pre" + + s = set(a_stable, b_stable, b_pre) + + ad = make_dep "a" + r = Gem::Resolver.new([ad], s) + + assert_raise Gem::DependencyResolutionError do + r.resolve + end + end + + def test_resolve_prerelease_considered_when_enabled + a_stable = util_spec "a", "1.0" do |s| + s.add_dependency "b", ">= 1.0" + end + + b_pre = util_spec "b", "2.0.pre" + + s = set(a_stable, b_pre) + s.prerelease = true + + ad = make_dep "a" + r = Gem::Resolver.new([ad], s) + + assert_resolves_to [a_stable, b_pre], r + end + + def test_resolve_prerelease_used_when_no_stable_versions_exist + a_stable = util_spec "a", "1.0" do |s| + s.add_dependency "b", ">= 1.0" + end + + b_pre = util_spec "b", "2.0.pre" + b_other_pre = util_spec "b", "1.0.pre" + + s = set(a_stable, b_pre, b_other_pre) + + ad = make_dep "a" + r = Gem::Resolver.new([ad], s) + + assert_resolves_to [a_stable, b_pre], r + end end From 87607bf6b57ac9d0a2b4b455653190f1c0106bdd Mon Sep 17 00:00:00 2001 From: Colby Swandale Date: Sun, 12 Apr 2026 16:39:13 +1000 Subject: [PATCH 13/34] [ruby/rubygems] Remove unreachable spec_for string fallback https://github.com/ruby/rubygems/commit/fa30c4d95e --- lib/rubygems/resolver.rb | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/rubygems/resolver.rb b/lib/rubygems/resolver.rb index 224526116f5800..ae6dd2c65ab725 100644 --- a/lib/rubygems/resolver.rb +++ b/lib/rubygems/resolver.rb @@ -338,7 +338,6 @@ def find_all_specs_for(name) def spec_for(name, version) candidates = @all_specs[name].select {|s| s.version == version } - candidates = @all_specs[name].select {|s| s.version.to_s == version.to_s } if candidates.empty? if candidates.length > 1 # Prefer already-installed specs to avoid unnecessary downloads From 270f7de108926d3cf558dc334b8f708d465f7e91 Mon Sep 17 00:00:00 2001 From: Colby Swandale Date: Sun, 12 Apr 2026 16:50:25 +1000 Subject: [PATCH 14/34] [ruby/rubygems] Remove dead resolver methods https://github.com/ruby/rubygems/commit/5fd0abb214 --- lib/rubygems/resolver.rb | 38 -------------------------------------- 1 file changed, 38 deletions(-) diff --git a/lib/rubygems/resolver.rb b/lib/rubygems/resolver.rb index ae6dd2c65ab725..253e02659cd339 100644 --- a/lib/rubygems/resolver.rb +++ b/lib/rubygems/resolver.rb @@ -111,24 +111,6 @@ def initialize(needed, set = nil) @cached_dependencies = Hash.new {|h, pkg| h[pkg] = Hash.new {|v, ver| v[ver] = compute_dependencies(pkg, ver) } } end - def explain(stage, *data) # :nodoc: - return unless DEBUG_RESOLVER - - d = data.map(&:pretty_inspect).join(", ") - $stderr.printf "%10s %s\n", stage.to_s.upcase, d - end - - def explain_list(stage) # :nodoc: - return unless DEBUG_RESOLVER - - data = yield - $stderr.printf "%10s (%d entries)\n", stage.to_s.upcase, data.size - unless data.empty? - require "pp" - PP.pp data, $stderr - end - end - ## # Proceed with resolution! Returns an array of ActivationRequest objects. @@ -268,26 +250,6 @@ def incompatibilities_for(package, version) end end - ## - # Extracts the specifications that may be able to fulfill +dependency+ and - # returns those that match the local platform and all those that match. - - def find_possible(dependency) # :nodoc: - all = @set.find_all dependency - - if (skip_dep_gems = skip_gems[dependency.name]) && !skip_dep_gems.empty? - matching = all.select do |api_spec| - skip_dep_gems.any? {|s| api_spec.version == s.version } - end - - all = matching unless matching.empty? - end - - matching_platform = select_local_platforms all - - [matching_platform, all] - end - ## # Returns the gems in +specs+ that match the local platform. From c7fc867b7af058e52811fa3ba77acb16f1e0b3f6 Mon Sep 17 00:00:00 2001 From: Colby Swandale Date: Mon, 13 Apr 2026 09:13:43 +1000 Subject: [PATCH 15/34] [ruby/rubygems] Define custom PubGrub Root Package to customise error https://github.com/ruby/rubygems/commit/302e5c83a0 --- lib/rubygems/resolver.rb | 18 +++++++++++++++++- test/rubygems/test_gem_resolver.rb | 1 + 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/lib/rubygems/resolver.rb b/lib/rubygems/resolver.rb index 253e02659cd339..4a04d41d932687 100644 --- a/lib/rubygems/resolver.rb +++ b/lib/rubygems/resolver.rb @@ -100,7 +100,7 @@ def initialize(needed, set = nil) @skip_gems = {} @soft_missing = false - @root_package = Gem::PubGrub::Package.root + @root_package = RootPackage.new @root_version = Gem::PubGrub::Package.root_version @packages = {} @@ -347,6 +347,22 @@ def compute_dependencies(package, version) def make_logger DEBUG_RESOLVER ? Gem::PubGrub::StderrLogger.new : Gem::PubGrub::NullLogger.new end + + # Custom root package so error messages say "your request depends on..." + # instead of PubGrub's default "root depends on...". + class RootPackage < Gem::PubGrub::Package + def initialize + super(:root) + end + + def root? + true + end + + def to_s + "your request" + end + end end require_relative "resolver/activation_request" diff --git a/test/rubygems/test_gem_resolver.rb b/test/rubygems/test_gem_resolver.rb index 5cdf2fae3596a7..5869a1d75eb8c1 100644 --- a/test/rubygems/test_gem_resolver.rb +++ b/test/rubygems/test_gem_resolver.rb @@ -455,6 +455,7 @@ def test_raises_dependency_error end assert_nil e.conflict + assert_match(/your request/, e.message) assert_match(/a depends on c/, e.message) assert_match(/b depends on c/, e.message) end From 91ee03fd6310b8cee8ea635547c1dc2be639f946 Mon Sep 17 00:00:00 2001 From: Colby Swandale Date: Mon, 13 Apr 2026 09:41:28 +1000 Subject: [PATCH 16/34] [ruby/rubygems] Add extended explanations for platform and Ruby version hints https://github.com/ruby/rubygems/commit/b3e24fd709 --- lib/rubygems/resolver.rb | 82 +++++++++++++++++++++++- lib/rubygems/resolver/incompatibility.rb | 10 +++ test/rubygems/test_gem_resolver.rb | 43 +++++++++++++ 3 files changed, 133 insertions(+), 2 deletions(-) create mode 100644 lib/rubygems/resolver/incompatibility.rb diff --git a/lib/rubygems/resolver.rb b/lib/rubygems/resolver.rb index 4a04d41d932687..9147027d56dd05 100644 --- a/lib/rubygems/resolver.rb +++ b/lib/rubygems/resolver.rb @@ -163,7 +163,13 @@ def resolve ActivationRequest.new(spec, dep_request) end rescue Gem::PubGrub::SolveFailure => e - raise Gem::DependencyResolutionError, e + extended = extract_extended_explanation(e.incompatibility) + if extended + message = "#{e.explanation}\n\n#{extended}" + raise Gem::DependencyResolutionError, Struct.new(:explanation).new(message) + else + raise Gem::DependencyResolutionError, e + end end # PubGrub source interface methods @@ -198,7 +204,16 @@ def versions_for(package, range = Gem::PubGrub::VersionRange.any) def no_versions_incompatibility_for(_package, unsatisfied_term) cause = Gem::PubGrub::Incompatibility::NoVersions.new(unsatisfied_term) - Gem::PubGrub::Incompatibility.new([unsatisfied_term], cause: cause) + + name = unsatisfied_term.package.to_s + constraint = unsatisfied_term.constraint + extended_explanation = build_extended_explanation(name, constraint) + + Gem::Resolver::Incompatibility.new( + [unsatisfied_term], + cause: cause, + extended_explanation: extended_explanation + ) end def incompatibilities_for(package, version) @@ -344,6 +359,68 @@ def compute_dependencies(package, version) deps end + def build_extended_explanation(name, constraint) + dep = Gem::Dependency.new(name, ">= 0.a") + dep_request = DependencyRequest.new(dep, nil) + unfiltered = @set.find_all(dep_request) + return if unfiltered.empty? + + filtered = @all_specs[name] + return if filtered.length == unfiltered.length + + hints = [] + + # Check for specs that exist for other platforms + platform_specs = unfiltered.select do |s| + !Gem::Platform.installable?(s) && constraint.range.include?(s.version) + end + if platform_specs.any? + label = "#{name} (#{constraint.constraint_string})" + hints << "The source contains the following gems matching '#{label}':" + platform_specs.each do |s| + actual = s.respond_to?(:spec) ? s.spec : s + hints << " * #{actual.full_name}" + end + end + + # Check for specs filtered by Ruby version + installable = select_local_platforms(unfiltered) + ruby_specs = installable.select do |s| + actual = s.respond_to?(:spec) ? s.spec : s + constraint.range.include?(s.version) && + !actual.required_ruby_version.satisfied_by?(Gem.ruby_version) + rescue StandardError + false + end + if ruby_specs.any? + versions = ruby_specs.map(&:version).uniq.sort.reverse.first(3) + sample = ruby_specs.find {|s| s.version == versions.first } + actual = sample.respond_to?(:spec) ? sample.spec : sample + ruby_req = actual.required_ruby_version + hints << "#{name} #{versions.join(", ")} requires Ruby #{ruby_req} (you have #{Gem.ruby_version})" + end + + hints.empty? ? nil : hints.join("\n") + end + + def extract_extended_explanation(incompatibility) + while incompatibility.cause.is_a?(Gem::PubGrub::Incompatibility::ConflictCause) + cause = incompatibility.cause + + [cause.conflict, cause.other].each do |incompat| + if incompat.cause.is_a?(Gem::PubGrub::Incompatibility::NoVersions) && + incompat.respond_to?(:extended_explanation) && + incompat.extended_explanation + return incompat.extended_explanation + end + end + + incompatibility = cause.conflict + end + + nil + end + def make_logger DEBUG_RESOLVER ? Gem::PubGrub::StderrLogger.new : Gem::PubGrub::NullLogger.new end @@ -367,6 +444,7 @@ def to_s require_relative "resolver/activation_request" require_relative "resolver/dependency_request" +require_relative "resolver/incompatibility" require_relative "resolver/requirement_list" require_relative "resolver/set" require_relative "resolver/api_set" diff --git a/lib/rubygems/resolver/incompatibility.rb b/lib/rubygems/resolver/incompatibility.rb new file mode 100644 index 00000000000000..57a60affb47392 --- /dev/null +++ b/lib/rubygems/resolver/incompatibility.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class Gem::Resolver::Incompatibility < Gem::PubGrub::Incompatibility + attr_reader :extended_explanation + + def initialize(terms, cause:, custom_explanation: nil, extended_explanation: nil) + @extended_explanation = extended_explanation + super(terms, cause: cause, custom_explanation: custom_explanation) + end +end diff --git a/test/rubygems/test_gem_resolver.rb b/test/rubygems/test_gem_resolver.rb index 5869a1d75eb8c1..e6edc7553e9cd2 100644 --- a/test/rubygems/test_gem_resolver.rb +++ b/test/rubygems/test_gem_resolver.rb @@ -828,4 +828,47 @@ def test_resolve_prerelease_used_when_no_stable_versions_exist assert_resolves_to [a_stable, b_pre], r end + + def test_error_includes_platform_hint_when_specs_exist_for_other_platforms + a = util_spec "a", "1.0" do |s| + s.add_dependency "b", ">= 1.0" + end + + b_foreign = util_spec "b", "1.0" do |s| + s.platform = "java" + end + + s = set(a, b_foreign) + + ad = make_dep "a" + r = Gem::Resolver.new([ad], s) + + e = assert_raise Gem::DependencyResolutionError do + r.resolve + end + + assert_match(/b-1.0-java/, e.message) + end + + def test_error_includes_ruby_version_hint_when_filtered + a = util_spec "a", "1.0" do |s| + s.add_dependency "b", ">= 1.0" + end + + b = util_spec "b", "1.0" do |s| + s.required_ruby_version = ">= 999.0" + end + + s = set(a, b) + + ad = make_dep "a" + r = Gem::Resolver.new([ad], s) + + e = assert_raise Gem::DependencyResolutionError do + r.resolve + end + + assert_match(/requires Ruby/, e.message) + assert_match(/you have/, e.message) + end end From 56a402c0afd533dda80ca67f6bdc1fe318bd3fa4 Mon Sep 17 00:00:00 2001 From: Colby Swandale Date: Mon, 13 Apr 2026 09:50:18 +1000 Subject: [PATCH 17/34] [ruby/rubygems] Add custom inline explanation when gems are filtered from resolution https://github.com/ruby/rubygems/commit/353ecaa178 --- lib/rubygems/resolver.rb | 5 +++++ test/rubygems/test_gem_resolver.rb | 1 + 2 files changed, 6 insertions(+) diff --git a/lib/rubygems/resolver.rb b/lib/rubygems/resolver.rb index 9147027d56dd05..15d023976505c5 100644 --- a/lib/rubygems/resolver.rb +++ b/lib/rubygems/resolver.rb @@ -209,9 +209,14 @@ def no_versions_incompatibility_for(_package, unsatisfied_term) constraint = unsatisfied_term.constraint extended_explanation = build_extended_explanation(name, constraint) + custom_explanation = if extended_explanation + "#{constraint} could not be found in any repository" + end + Gem::Resolver::Incompatibility.new( [unsatisfied_term], cause: cause, + custom_explanation: custom_explanation, extended_explanation: extended_explanation ) end diff --git a/test/rubygems/test_gem_resolver.rb b/test/rubygems/test_gem_resolver.rb index e6edc7553e9cd2..d76738f92b2cb2 100644 --- a/test/rubygems/test_gem_resolver.rb +++ b/test/rubygems/test_gem_resolver.rb @@ -847,6 +847,7 @@ def test_error_includes_platform_hint_when_specs_exist_for_other_platforms r.resolve end + assert_match(/could not be found in any repository/, e.message) assert_match(/b-1.0-java/, e.message) end From c7d6a21f5ba5c2fe145cf6bc6822b39399cd895b Mon Sep 17 00:00:00 2001 From: Colby Swandale Date: Mon, 13 Apr 2026 10:37:56 +1000 Subject: [PATCH 18/34] [ruby/rubygems] Optimize PubGrub resolver performance https://github.com/ruby/rubygems/commit/56738545e9 --- lib/rubygems/resolver.rb | 8 +- lib/rubygems/resolver/strategy.rb | 44 ++++++ test/rubygems/test_gem_resolver_strategy.rb | 163 ++++++++++++++++++++ 3 files changed, 213 insertions(+), 2 deletions(-) create mode 100644 lib/rubygems/resolver/strategy.rb create mode 100644 test/rubygems/test_gem_resolver_strategy.rb diff --git a/lib/rubygems/resolver.rb b/lib/rubygems/resolver.rb index 15d023976505c5..01372c0cd27e0d 100644 --- a/lib/rubygems/resolver.rb +++ b/lib/rubygems/resolver.rb @@ -109,6 +109,8 @@ def initialize(needed, set = nil) @all_versions = Hash.new {|h, pkg| h[pkg] = @all_specs[pkg.to_s].map(&:version).uniq.sort } @sorted_versions = Hash.new {|h, pkg| h[pkg] = filter_versions(pkg) } @cached_dependencies = Hash.new {|h, pkg| h[pkg] = Hash.new {|v, ver| v[ver] = compute_dependencies(pkg, ver) } } + @version_to_index = Hash.new {|h, pkg| h[pkg] = @sorted_versions[pkg].each_with_index.to_h } + @versions_for_cache = {} end ## @@ -149,6 +151,7 @@ def resolve solver = Gem::PubGrub::VersionSolver.new( source: self, root: @root_package, + strategy: Gem::Resolver::Strategy.new(self, @root_package), logger: make_logger ) result = solver.solve @@ -199,7 +202,7 @@ def all_versions_for(package) end def versions_for(package, range = Gem::PubGrub::VersionRange.any) - range.select_versions(@sorted_versions[package]) + @versions_for_cache[[package, range]] ||= range.select_versions(@sorted_versions[package]) end def no_versions_incompatibility_for(_package, unsatisfied_term) @@ -226,7 +229,7 @@ def incompatibilities_for(package, version) sorted_versions = @sorted_versions[package] package_deps[version].filter_map do |dep_package_name, dep_constraint| dep_package = dep_constraint.package - low = high = sorted_versions.index(version) + low = high = @version_to_index[package][version] # find version low such that all >= low share the same dep while low > 0 && @@ -450,6 +453,7 @@ def to_s require_relative "resolver/activation_request" require_relative "resolver/dependency_request" require_relative "resolver/incompatibility" +require_relative "resolver/strategy" require_relative "resolver/requirement_list" require_relative "resolver/set" require_relative "resolver/api_set" diff --git a/lib/rubygems/resolver/strategy.rb b/lib/rubygems/resolver/strategy.rb new file mode 100644 index 00000000000000..1a1c16261906f5 --- /dev/null +++ b/lib/rubygems/resolver/strategy.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +# Custom PubGrub strategy with caching for version selection. +# Modeled after Bundler's strategy to avoid redundant versions_for +# calls during the solver's package selection loop. + +class Gem::Resolver::Strategy + def initialize(source, root_package) + @source = source + @package_priority_cache = {} + + @version_indexes = Hash.new do |h, k| + if Gem::PubGrub::Package.root?(k) + h[k] = { Gem::PubGrub::Package.root_version => 0 } + else + h[k] = @source.all_versions_for(k).each.with_index.to_h + end + end + end + + def next_package_and_version(unsatisfied) + package, range = next_term_to_try_from(unsatisfied) + [package, most_preferred_version_of(package, range)] + end + + private + + def most_preferred_version_of(package, range) + versions = @source.versions_for(package, range) + indexes = @version_indexes[package] + versions.min_by {|version| indexes[version] || Float::INFINITY } + end + + def next_term_to_try_from(unsatisfied) + unsatisfied.min_by do |package, range| + @package_priority_cache[[package, range]] ||= begin + matching_versions = @source.versions_for(package, range) + higher_versions = @source.versions_for(package, range.upper_invert) + + [matching_versions.count <= 1 ? 0 : 1, higher_versions.count] + end + end + end +end diff --git a/test/rubygems/test_gem_resolver_strategy.rb b/test/rubygems/test_gem_resolver_strategy.rb new file mode 100644 index 00000000000000..f84e8b6fdaa312 --- /dev/null +++ b/test/rubygems/test_gem_resolver_strategy.rb @@ -0,0 +1,163 @@ +# frozen_string_literal: true + +require_relative "helper" + +class TestGemResolverStrategy < Gem::TestCase + # Minimal source that implements the two methods Strategy calls: + # all_versions_for(package) — returns versions in preference order + # versions_for(package, range) — returns versions matching a range + # + # Tracks call counts so we can assert on caching behavior. + class StubSource + attr_reader :versions_for_calls + + def initialize(versions_by_package) + @versions_by_package = versions_by_package + @versions_for_calls = 0 + end + + def all_versions_for(package) + @versions_by_package.fetch(package.to_s, []) + end + + def versions_for(package, range) + @versions_for_calls += 1 + all = @versions_by_package.fetch(package.to_s, []) + all.select {|v| range.include?(v) } + end + end + + def v(version_string) + Gem::Version.new(version_string) + end + + def make_package(name) + Gem::PubGrub::Package.new(name) + end + + def make_range_any + Gem::PubGrub::VersionRange.any + end + + # A range >= min (unbounded above) + def make_range_gte(version) + Gem::PubGrub::VersionRange.new(min: version, include_min: true) + end + + # A range >= min AND < max + def make_range_between(min, max) + Gem::PubGrub::VersionRange.new( + min: min, max: max, + include_min: true, include_max: false + ) + end + + def test_most_preferred_version_respects_all_versions_for_ordering + # all_versions_for returns [2.0, 1.0, 3.0] — so 2.0 is most preferred + # even though 3.0 is numerically highest. + pkg = make_package("a") + source = StubSource.new("a" => [v("2.0"), v("1.0"), v("3.0")]) + + strategy = Gem::Resolver::Strategy.new(source, Gem::PubGrub::Package.root) + unsatisfied = { pkg => make_range_any } + + _package, version = strategy.next_package_and_version(unsatisfied) + + assert_equal v("2.0"), version + end + + def test_picks_most_constrained_package + # "a" has 3 matching versions, "b" has 1 matching version. + # Strategy should pick "b" because it's more constrained. + pkg_a = make_package("a") + pkg_b = make_package("b") + + source = StubSource.new( + "a" => [v("3.0"), v("2.0"), v("1.0")], + "b" => [v("1.0")] + ) + + strategy = Gem::Resolver::Strategy.new(source, Gem::PubGrub::Package.root) + + unsatisfied = { + pkg_a => make_range_any, + pkg_b => make_range_any, + } + + package, _version = strategy.next_package_and_version(unsatisfied) + + assert_equal pkg_b, package + end + + def test_picks_package_with_fewer_higher_versions_as_tiebreaker + # Both "a" and "b" have 2 matching versions (so both get priority [1, ...]). + # "a" has matching [2.0, 1.0] with higher (above range) = [] (0 higher) + # "b" has matching [2.0, 1.0] with higher [3.0] (1 higher) + # Tiebreaker: fewer higher versions wins, so "a" is picked. + pkg_a = make_package("a") + pkg_b = make_package("b") + + range = make_range_between(v("0.5"), v("2.5")) + + source = StubSource.new( + "a" => [v("2.0"), v("1.0")], + "b" => [v("3.0"), v("2.0"), v("1.0")] + ) + + strategy = Gem::Resolver::Strategy.new(source, Gem::PubGrub::Package.root) + + unsatisfied = { + pkg_a => range, + pkg_b => range, + } + + package, _version = strategy.next_package_and_version(unsatisfied) + + assert_equal pkg_a, package + end + + def test_cache_prevents_redundant_versions_for_calls + pkg = make_package("a") + source = StubSource.new("a" => [v("2.0"), v("1.0")]) + + strategy = Gem::Resolver::Strategy.new(source, Gem::PubGrub::Package.root) + + range = make_range_any + unsatisfied = { pkg => range } + + # First call: should call versions_for for matching + upper_invert + most_preferred + strategy.next_package_and_version(unsatisfied) + calls_after_first = source.versions_for_calls + + # Second call with same package+range: next_term_to_try_from should + # hit the cache, so only most_preferred_version_of adds a call. + strategy.next_package_and_version(unsatisfied) + calls_after_second = source.versions_for_calls + + # The cached path saves the 2 calls in next_term_to_try_from, + # so only the 1 call from most_preferred_version_of is added. + assert_equal 1, calls_after_second - calls_after_first + end + + def test_cache_is_keyed_by_package_and_range + pkg = make_package("a") + source = StubSource.new("a" => [v("3.0"), v("2.0"), v("1.0")]) + + strategy = Gem::Resolver::Strategy.new(source, Gem::PubGrub::Package.root) + + range_any = make_range_any + range_gte = make_range_gte(v("2.0")) + + # First call with range_any + strategy.next_package_and_version({ pkg => range_any }) + calls_after_first = source.versions_for_calls + + # Second call with different range — cache miss, so versions_for is called again + strategy.next_package_and_version({ pkg => range_gte }) + calls_after_second = source.versions_for_calls + + # A cache miss means 2 new versions_for calls (matching + upper_invert) + # plus 1 from most_preferred_version_of = 3 total new calls + assert_equal 3, calls_after_second - calls_after_first + end +end From f609e7e60a58e5b0d97b9c206b1f95170e679a14 Mon Sep 17 00:00:00 2001 From: Colby Swandale Date: Mon, 13 Apr 2026 11:12:19 +1000 Subject: [PATCH 19/34] [ruby/rubygems] Skip self-dependencies in compute_dependencies https://github.com/ruby/rubygems/commit/310263f948 --- lib/rubygems/resolver.rb | 1 + test/rubygems/test_gem_resolver.rb | 13 +++++++++++++ 2 files changed, 14 insertions(+) diff --git a/lib/rubygems/resolver.rb b/lib/rubygems/resolver.rb index 01372c0cd27e0d..6538a6c270f7a2 100644 --- a/lib/rubygems/resolver.rb +++ b/lib/rubygems/resolver.rb @@ -349,6 +349,7 @@ def compute_dependencies(package, version) actual_spec = spec.respond_to?(:spec) ? spec.spec : spec actual_spec.dependencies.each do |d| + next if d.name == package.to_s next if d.type == :development && !@development next if d.type == :development && @development_shallow && !root_names.include?(package.to_s) diff --git a/test/rubygems/test_gem_resolver.rb b/test/rubygems/test_gem_resolver.rb index d76738f92b2cb2..b7d3aeda3f9047 100644 --- a/test/rubygems/test_gem_resolver.rb +++ b/test/rubygems/test_gem_resolver.rb @@ -872,4 +872,17 @@ def test_error_includes_ruby_version_hint_when_filtered assert_match(/requires Ruby/, e.message) assert_match(/you have/, e.message) end + + def test_self_dependency_does_not_crash + a = util_spec "a", "1.0" do |s| + s.add_dependency "a" + end + + s = set(a) + ad = make_dep "a" + r = Gem::Resolver.new([ad], s) + + assert_resolves_to [a], r + end + end From 6b3ae73f1824dbfe573934750766561919a17974 Mon Sep 17 00:00:00 2001 From: Colby Swandale Date: Mon, 13 Apr 2026 11:18:47 +1000 Subject: [PATCH 20/34] [ruby/rubygems] Use PubGrub's intended root dependency pattern https://github.com/ruby/rubygems/commit/b70c071e98 --- lib/rubygems/resolver.rb | 37 ++++++++++++++++++++----------------- 1 file changed, 20 insertions(+), 17 deletions(-) diff --git a/lib/rubygems/resolver.rb b/lib/rubygems/resolver.rb index 6538a6c270f7a2..7ddffb3901a405 100644 --- a/lib/rubygems/resolver.rb +++ b/lib/rubygems/resolver.rb @@ -107,8 +107,16 @@ def initialize(needed, set = nil) @all_specs = Hash.new {|h, name| h[name] = find_all_specs_for(name) } @all_versions = Hash.new {|h, pkg| h[pkg] = @all_specs[pkg.to_s].map(&:version).uniq.sort } - @sorted_versions = Hash.new {|h, pkg| h[pkg] = filter_versions(pkg) } - @cached_dependencies = Hash.new {|h, pkg| h[pkg] = Hash.new {|v, ver| v[ver] = compute_dependencies(pkg, ver) } } + @sorted_versions = Hash.new do |h, pkg| + h[pkg] = Gem::PubGrub::Package.root?(pkg) ? [@root_version] : filter_versions(pkg) + end + @cached_dependencies = Hash.new do |h, pkg| + h[pkg] = if Gem::PubGrub::Package.root?(pkg) + { @root_version => root_dependencies } + else + Hash.new {|v, ver| v[ver] = compute_dependencies(pkg, ver) } + end + end @version_to_index = Hash.new {|h, pkg| h[pkg] = @sorted_versions[pkg].each_with_index.to_h } @versions_for_cache = {} end @@ -137,17 +145,6 @@ def resolve raise exc end - # Build root deps from @needed - root_deps = {} - @needed.each do |dep| - range = Gem::PubGrub::RubyGems.requirement_to_range(dep.requirement) - constraint = Gem::PubGrub::VersionConstraint.new(package_for(dep.name), range: range) - root_deps[dep.name] = root_deps.key?(dep.name) ? root_deps[dep.name].intersect(constraint) : constraint - end - - @sorted_versions[@root_package] = [@root_version] - @cached_dependencies[@root_package] = { @root_version => root_deps } - solver = Gem::PubGrub::VersionSolver.new( source: self, root: @root_package, @@ -178,8 +175,6 @@ def resolve # PubGrub source interface methods def all_versions_for(package) - return [@root_version] if package == @root_package - versions = @sorted_versions[package].reverse # highest first name = package.to_s @@ -291,6 +286,16 @@ def package_for(name) # Filter versions to exclude prereleases unless prerelease is enabled. # Both all_versions_for and versions_for use this filtered set to ensure # PubGrub's constraint propagation and version selection are consistent. + def root_dependencies + deps = {} + @needed.each do |dep| + range = Gem::PubGrub::RubyGems.requirement_to_range(dep.requirement) + constraint = Gem::PubGrub::VersionConstraint.new(package_for(dep.name), range: range) + deps[dep.name] = deps.key?(dep.name) ? deps[dep.name].intersect(constraint) : constraint + end + deps + end + def filter_versions(package) all_versions = @all_versions[package] if @set.respond_to?(:prerelease) && @set.prerelease @@ -338,8 +343,6 @@ def spec_for(name, version) end def compute_dependencies(package, version) - return {} if package == @root_package - spec = spec_for(package.to_s, version) return {} unless spec return {} if @ignore_dependencies From a687a6b954184b7786e6f4e372a2de211d82a77f Mon Sep 17 00:00:00 2001 From: Colby Swandale Date: Mon, 13 Apr 2026 11:27:20 +1000 Subject: [PATCH 21/34] [ruby/rubygems] Fetch development dependencies for API-sourced specs https://github.com/ruby/rubygems/commit/aa9d86ee22 --- lib/rubygems/resolver.rb | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/rubygems/resolver.rb b/lib/rubygems/resolver.rb index 7ddffb3901a405..9fd1cd0f357467 100644 --- a/lib/rubygems/resolver.rb +++ b/lib/rubygems/resolver.rb @@ -347,6 +347,8 @@ def compute_dependencies(package, version) return {} unless spec return {} if @ignore_dependencies + spec.fetch_development_dependencies if @development && spec.respond_to?(:fetch_development_dependencies) + deps = {} root_names = @needed.map(&:name) From 43128a82773b5db719c407c376ad365de64b15dc Mon Sep 17 00:00:00 2001 From: Colby Swandale Date: Mon, 13 Apr 2026 11:40:13 +1000 Subject: [PATCH 22/34] [ruby/rubygems] Align incompatibilities_for with BasicPackageSource design https://github.com/ruby/rubygems/commit/390d035339 --- lib/rubygems/resolver.rb | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/lib/rubygems/resolver.rb b/lib/rubygems/resolver.rb index 9fd1cd0f357467..f96d794eefcaa5 100644 --- a/lib/rubygems/resolver.rb +++ b/lib/rubygems/resolver.rb @@ -224,6 +224,7 @@ def incompatibilities_for(package, version) sorted_versions = @sorted_versions[package] package_deps[version].filter_map do |dep_package_name, dep_constraint| dep_package = dep_constraint.package + low = high = @version_to_index[package][version] # find version low such that all >= low share the same dep @@ -255,10 +256,10 @@ def incompatibilities_for(package, version) if dep_constraint.range.empty? cause = Gem::PubGrub::Incompatibility::InvalidDependency.new(dep_package, dep_constraint) - next Gem::PubGrub::Incompatibility.new( + return [Gem::PubGrub::Incompatibility.new( [Gem::PubGrub::Term.new(self_constraint, true)], cause: cause - ) + )] end Gem::PubGrub::Incompatibility.new( @@ -289,8 +290,7 @@ def package_for(name) def root_dependencies deps = {} @needed.each do |dep| - range = Gem::PubGrub::RubyGems.requirement_to_range(dep.requirement) - constraint = Gem::PubGrub::VersionConstraint.new(package_for(dep.name), range: range) + constraint = Gem::PubGrub::RubyGems.requirement_to_constraint(package_for(dep.name), dep.requirement) deps[dep.name] = deps.key?(dep.name) ? deps[dep.name].intersect(constraint) : constraint end deps @@ -366,8 +366,7 @@ def compute_dependencies(package, version) next end - range = Gem::PubGrub::RubyGems.requirement_to_range(d.requirement) - deps[d.name] = Gem::PubGrub::VersionConstraint.new(dep_package, range: range) + deps[d.name] = Gem::PubGrub::RubyGems.requirement_to_constraint(dep_package, d.requirement) end deps From d6a233c5d30385c2cb6f5972233f0428f8d0627f Mon Sep 17 00:00:00 2001 From: Colby Swandale Date: Mon, 13 Apr 2026 11:44:38 +1000 Subject: [PATCH 23/34] [ruby/rubygems] Distinguish unknown packages from filtered packages in error messages https://github.com/ruby/rubygems/commit/638af500a2 --- lib/rubygems/resolver.rb | 32 +++++++++++++++++++++--------- test/rubygems/test_gem_resolver.rb | 3 +-- 2 files changed, 24 insertions(+), 11 deletions(-) diff --git a/lib/rubygems/resolver.rb b/lib/rubygems/resolver.rb index f96d794eefcaa5..eeda6ec30e5bef 100644 --- a/lib/rubygems/resolver.rb +++ b/lib/rubygems/resolver.rb @@ -105,7 +105,8 @@ def initialize(needed, set = nil) @packages = {} - @all_specs = Hash.new {|h, name| h[name] = find_all_specs_for(name) } + @unfiltered_specs = Hash.new {|h, name| h[name] = find_unfiltered_specs_for(name) } + @all_specs = Hash.new {|h, name| h[name] = filter_specs(@unfiltered_specs[name]) } @all_versions = Hash.new {|h, pkg| h[pkg] = @all_specs[pkg.to_s].map(&:version).uniq.sort } @sorted_versions = Hash.new do |h, pkg| h[pkg] = Gem::PubGrub::Package.root?(pkg) ? [@root_version] : filter_versions(pkg) @@ -225,6 +226,19 @@ def incompatibilities_for(package, version) package_deps[version].filter_map do |dep_package_name, dep_constraint| dep_package = dep_constraint.package + # If no specs exist at all for this dependency (not even for other + # platforms or Ruby versions), mark this version as invalid. + # When specs exist but were filtered out, let PubGrub discover it + # via NoVersions so platform/ruby hints are generated. + if @unfiltered_specs[dep_package_name].empty? + self_constraint = Gem::PubGrub::VersionConstraint.new(package, range: Gem::PubGrub::VersionRange.any) + cause = Gem::PubGrub::Incompatibility::InvalidDependency.new(dep_package, dep_constraint) + return [Gem::PubGrub::Incompatibility.new( + [Gem::PubGrub::Term.new(self_constraint, true)], + cause: cause + )] + end + low = high = @version_to_index[package][version] # find version low such that all >= low share the same dep @@ -306,15 +320,17 @@ def filter_versions(package) end end - def find_all_specs_for(name) + def find_unfiltered_specs_for(name) dep = Gem::Dependency.new(name, ">= 0.a") dep_request = DependencyRequest.new(dep, nil) - all = @set.find_all(dep_request) + @set.find_all(dep_request) + end - specs = select_local_platforms(all) + def filter_specs(specs) + filtered = select_local_platforms(specs) unless @soft_missing - specs = specs.select do |s| + filtered = filtered.select do |s| actual = s.respond_to?(:spec) ? s.spec : s actual.required_ruby_version.satisfied_by?(Gem.ruby_version) && actual.required_rubygems_version.satisfied_by?(Gem.rubygems_version) @@ -323,7 +339,7 @@ def find_all_specs_for(name) end end - specs + filtered end def spec_for(name, version) @@ -373,9 +389,7 @@ def compute_dependencies(package, version) end def build_extended_explanation(name, constraint) - dep = Gem::Dependency.new(name, ">= 0.a") - dep_request = DependencyRequest.new(dep, nil) - unfiltered = @set.find_all(dep_request) + unfiltered = @unfiltered_specs[name] return if unfiltered.empty? filtered = @all_specs[name] diff --git a/test/rubygems/test_gem_resolver.rb b/test/rubygems/test_gem_resolver.rb index b7d3aeda3f9047..85ebd8fabe105b 100644 --- a/test/rubygems/test_gem_resolver.rb +++ b/test/rubygems/test_gem_resolver.rb @@ -516,8 +516,7 @@ def test_raises_and_reports_an_implicit_request_properly r.resolve end - assert_match(/depends on b/, e.message) - assert_match(/no versions satisfy b/, e.message) + assert_match(/depends on unknown package b/, e.message) end def test_raises_when_possibles_are_exhausted From ffde879a64d04aa6ee41eef0d3859ba639ba03ad Mon Sep 17 00:00:00 2001 From: Colby Swandale Date: Mon, 13 Apr 2026 12:01:02 +1000 Subject: [PATCH 24/34] [ruby/rubygems] Improve error messages for contradictory requirements and prereleases https://github.com/ruby/rubygems/commit/4d131aa532 --- lib/rubygems/resolver.rb | 34 +++++++++++++++---- test/rubygems/test_gem_resolver.rb | 54 ++++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+), 6 deletions(-) diff --git a/lib/rubygems/resolver.rb b/lib/rubygems/resolver.rb index eeda6ec30e5bef..39873ebd89dfa1 100644 --- a/lib/rubygems/resolver.rb +++ b/lib/rubygems/resolver.rb @@ -269,11 +269,20 @@ def incompatibilities_for(package, version) self_constraint = Gem::PubGrub::VersionConstraint.new(package, range: range) if dep_constraint.range.empty? - cause = Gem::PubGrub::Incompatibility::InvalidDependency.new(dep_package, dep_constraint) - return [Gem::PubGrub::Incompatibility.new( - [Gem::PubGrub::Term.new(self_constraint, true)], - cause: cause - )] + if @unfiltered_specs[dep_package_name].any? + # Package exists but requirement is self-contradictory + return [Gem::Resolver::Incompatibility.new( + [Gem::PubGrub::Term.new(self_constraint, true)], + cause: Gem::PubGrub::Incompatibility::NoVersions.new(dep_constraint), + custom_explanation: "#{dep_package_name} cannot satisfy contradictory requirements #{dep_constraint.constraint_string}" + )] + else + cause = Gem::PubGrub::Incompatibility::InvalidDependency.new(dep_package, dep_constraint) + return [Gem::PubGrub::Incompatibility.new( + [Gem::PubGrub::Term.new(self_constraint, true)], + cause: cause + )] + end end Gem::PubGrub::Incompatibility.new( @@ -393,7 +402,10 @@ def build_extended_explanation(name, constraint) return if unfiltered.empty? filtered = @all_specs[name] - return if filtered.length == unfiltered.length + pkg = package_for(name) + has_prerelease_filtering = @all_versions[pkg].length > @sorted_versions[pkg].length + + return if filtered.length == unfiltered.length && !has_prerelease_filtering hints = [] @@ -427,6 +439,16 @@ def build_extended_explanation(name, constraint) hints << "#{name} #{versions.join(", ")} requires Ruby #{ruby_req} (you have #{Gem.ruby_version})" end + # Check for specs filtered by prerelease status + unless @set.respond_to?(:prerelease) && @set.prerelease + pkg = package_for(name) + prerelease_versions = @all_versions[pkg].select(&:prerelease?) - @sorted_versions[pkg] + if prerelease_versions.any? + versions = prerelease_versions.sort.reverse.first(3) # limit to avoid cluttering error output + hints << "#{name} #{versions.join(", ")} are pre-release versions. Use --prerelease to allow pre-release gems." + end + end + hints.empty? ? nil : hints.join("\n") end diff --git a/test/rubygems/test_gem_resolver.rb b/test/rubygems/test_gem_resolver.rb index 85ebd8fabe105b..384d8ca40327ca 100644 --- a/test/rubygems/test_gem_resolver.rb +++ b/test/rubygems/test_gem_resolver.rb @@ -884,4 +884,58 @@ def test_self_dependency_does_not_crash assert_resolves_to [a], r end + def test_contradictory_root_requirements_give_clear_error + a1 = util_spec "a", "1" + a2 = util_spec "a", "2" + + s = set(a1, a2) + r = Gem::Resolver.new([make_dep("a", "= 1"), make_dep("a", "= 2")], s) + + e = assert_raise Gem::DependencyResolutionError do + r.resolve + end + + assert_match(/contradictory/, e.message) + refute_match(/unknown package/, e.message) + end + + def test_empty_range_transitive_dep_does_not_say_unknown + a = util_spec "a", "1.0" do |s| + s.add_dependency "b", "> 2", "< 1" + end + + b = util_spec "b", "1.5" + + s = set(a, b) + ad = make_dep "a" + r = Gem::Resolver.new([ad], s) + + e = assert_raise Gem::DependencyResolutionError do + r.resolve + end + + assert_match(/contradictory/, e.message) + refute_match(/unknown package/, e.message) + end + + def test_error_hints_about_prerelease_when_filtered + a = util_spec "a", "1.0" do |s| + s.add_dependency "b", "~> 2.0" + end + + b_stable = util_spec "b", "1.0" + b_pre = util_spec "b", "2.0.pre" + + s = set(a, b_stable, b_pre) + ad = make_dep "a" + r = Gem::Resolver.new([ad], s) + + e = assert_raise Gem::DependencyResolutionError do + r.resolve + end + + assert_match(/pre-release/, e.message) + assert_match(/--prerelease/, e.message) + end + end From 488ac14e0ebd48260ba79c2ae685847bd9497827 Mon Sep 17 00:00:00 2001 From: Colby Swandale Date: Mon, 13 Apr 2026 12:29:11 +1000 Subject: [PATCH 25/34] [ruby/rubygems] Fix --force to skip unsatisfiable version constraints https://github.com/ruby/rubygems/commit/24c69d4769 --- lib/rubygems/resolver.rb | 10 +++++---- .../rubygems/test_gem_dependency_installer.rb | 22 +++++++++++++++++-- test/rubygems/test_gem_resolver.rb | 17 ++++++++++++++ 3 files changed, 43 insertions(+), 6 deletions(-) diff --git a/lib/rubygems/resolver.rb b/lib/rubygems/resolver.rb index 39873ebd89dfa1..da2413befb27e7 100644 --- a/lib/rubygems/resolver.rb +++ b/lib/rubygems/resolver.rb @@ -385,10 +385,12 @@ def compute_dependencies(package, version) dep_package = package_for(d.name) - # Check if the dependency has any available versions - dep_specs = @all_specs[d.name] - if dep_specs.empty? && @soft_missing - next + # In force mode, skip deps that can't be satisfied — either no + # specs at all, or no specs matching the version requirement. + if @soft_missing + dep_specs = @all_specs[d.name] + matching = dep_specs.select {|s| d.requirement.satisfied_by?(s.version) } + next if matching.empty? end deps[d.name] = Gem::PubGrub::RubyGems.requirement_to_constraint(dep_package, d.requirement) diff --git a/test/rubygems/test_gem_dependency_installer.rb b/test/rubygems/test_gem_dependency_installer.rb index bb52df4271fee6..e4da6cabd56f50 100644 --- a/test/rubygems/test_gem_dependency_installer.rb +++ b/test/rubygems/test_gem_dependency_installer.rb @@ -688,6 +688,25 @@ def test_install_force assert_equal %w[b-1], inst.installed_gems.map(&:full_name) end + def test_install_force_with_unsatisfiable_dep + # foo depends on bar >= 2.0, but only bar-1.0 exists. + # With --force, the unsatisfiable dep should be skipped. + _, foo_gem = util_gem "foo", "1" do |s| + s.add_dependency "bar", ">= 2.0" + end + + util_setup_spec_fetcher(util_spec("bar", "1.0")) + FileUtils.mv foo_gem, @tempdir + inst = nil + + Dir.chdir @tempdir do + inst = Gem::DependencyInstaller.new force: true + inst.install "foo" + end + + assert_equal %w[foo-1], inst.installed_gems.map(&:full_name) + end + def test_install_build_args util_setup_gems @@ -798,8 +817,7 @@ def test_install_domain_local inst.install "b" end - assert_match(/depends on a/, e.message) - assert_match(/no versions satisfy a/, e.message) + assert_match(/depends on unknown package a/, e.message) end assert_equal [], inst.installed_gems.map(&:full_name) diff --git a/test/rubygems/test_gem_resolver.rb b/test/rubygems/test_gem_resolver.rb index 384d8ca40327ca..18a0551448033a 100644 --- a/test/rubygems/test_gem_resolver.rb +++ b/test/rubygems/test_gem_resolver.rb @@ -938,4 +938,21 @@ def test_error_hints_about_prerelease_when_filtered assert_match(/--prerelease/, e.message) end + def test_soft_missing_skips_dep_with_wrong_version + a = util_spec "a", "1.0" do |s| + s.add_dependency "b", ">= 2.0" + end + + b = util_spec "b", "1.0" + + s = set(a, b) + ad = make_dep "a" + r = Gem::Resolver.new([ad], s) + r.soft_missing = true + + # b exists but only 1.0, which doesn't satisfy >= 2.0. + # With soft_missing (--force), the dep should be skipped. + assert_resolves_to [a], r + end + end From 759acb779390185e6840c4ab6c64c137d952fde0 Mon Sep 17 00:00:00 2001 From: Colby Swandale Date: Mon, 20 Apr 2026 15:27:30 +0900 Subject: [PATCH 26/34] [ruby/rubygems] Resolve issue with pre-release root dependencies being filtered incorrectly https://github.com/ruby/rubygems/commit/4c280ac057 --- lib/rubygems/resolver.rb | 16 ++++++++++++---- test/rubygems/test_gem_resolver.rb | 19 +++++++++++++++++-- test/rubygems/test_gem_resolver_strategy.rb | 8 ++++---- 3 files changed, 33 insertions(+), 10 deletions(-) diff --git a/lib/rubygems/resolver.rb b/lib/rubygems/resolver.rb index da2413befb27e7..67941b62cf1daa 100644 --- a/lib/rubygems/resolver.rb +++ b/lib/rubygems/resolver.rb @@ -321,7 +321,7 @@ def root_dependencies def filter_versions(package) all_versions = @all_versions[package] - if @set.respond_to?(:prerelease) && @set.prerelease + if (@set.respond_to?(:prerelease) && @set.prerelease) || root_requires_prerelease?(package) all_versions else stable = all_versions.reject(&:prerelease?) @@ -329,6 +329,14 @@ def filter_versions(package) end end + # Root deps with an explicit prerelease requirement (e.g. `= 4.1.0.dev`) + # must keep their prerelease versions in the filtered set; otherwise + # PubGrub cannot match them even though `@set.find_all` returned them. + def root_requires_prerelease?(package) + name = package.to_s + @needed.any? {|dep| dep.name == name && dep.requirement.prerelease? } + end + def find_unfiltered_specs_for(name) dep = Gem::Dependency.new(name, ">= 0.a") dep_request = DependencyRequest.new(dep, nil) @@ -385,7 +393,7 @@ def compute_dependencies(package, version) dep_package = package_for(d.name) - # In force mode, skip deps that can't be satisfied — either no + # In force mode, skip deps that can't be satisfied - either no # specs at all, or no specs matching the version requirement. if @soft_missing dep_specs = @all_specs[d.name] @@ -460,8 +468,8 @@ def extract_extended_explanation(incompatibility) [cause.conflict, cause.other].each do |incompat| if incompat.cause.is_a?(Gem::PubGrub::Incompatibility::NoVersions) && - incompat.respond_to?(:extended_explanation) && - incompat.extended_explanation + incompat.respond_to?(:extended_explanation) && + incompat.extended_explanation return incompat.extended_explanation end end diff --git a/test/rubygems/test_gem_resolver.rb b/test/rubygems/test_gem_resolver.rb index 18a0551448033a..18433c5e0f0739 100644 --- a/test/rubygems/test_gem_resolver.rb +++ b/test/rubygems/test_gem_resolver.rb @@ -776,7 +776,7 @@ def test_raises_and_explains_when_platform_prevents_install end def test_resolve_prerelease_not_considered_when_stable_exists - # a-1.0 depends on b ~> 2.0 — only b-2.0.pre satisfies that, but + # a-1.0 depends on b ~> 2.0 - only b-2.0.pre satisfies that, but # b also has a stable version (1.0), so prereleases are filtered out. # The resolver must fail, not silently use b-2.0.pre during propagation. a_stable = util_spec "a", "1.0" do |s| @@ -828,6 +828,22 @@ def test_resolve_prerelease_used_when_no_stable_versions_exist assert_resolves_to [a_stable, b_pre], r end + def test_resolve_prerelease_required_by_exact_requirement + # A root dep with an exact prerelease version must resolve to that + # version even when stable versions of the same gem are in the set. + # Gem.finish_resolve hits this: it imports loaded_specs as exact-version + # deps, so the currently-activated prerelease bundler becomes a root dep. + a_stable = util_spec "a", "1.0" + a_pre = util_spec "a", "2.0.pre" + + s = set(a_stable, a_pre) + + ad = make_dep "a", "= 2.0.pre" + r = Gem::Resolver.new([ad], s) + + assert_resolves_to [a_pre], r + end + def test_error_includes_platform_hint_when_specs_exist_for_other_platforms a = util_spec "a", "1.0" do |s| s.add_dependency "b", ">= 1.0" @@ -954,5 +970,4 @@ def test_soft_missing_skips_dep_with_wrong_version # With soft_missing (--force), the dep should be skipped. assert_resolves_to [a], r end - end diff --git a/test/rubygems/test_gem_resolver_strategy.rb b/test/rubygems/test_gem_resolver_strategy.rb index f84e8b6fdaa312..b8c6c86adfe321 100644 --- a/test/rubygems/test_gem_resolver_strategy.rb +++ b/test/rubygems/test_gem_resolver_strategy.rb @@ -4,8 +4,8 @@ class TestGemResolverStrategy < Gem::TestCase # Minimal source that implements the two methods Strategy calls: - # all_versions_for(package) — returns versions in preference order - # versions_for(package, range) — returns versions matching a range + # all_versions_for(package) - returns versions in preference order + # versions_for(package, range) - returns versions matching a range # # Tracks call counts so we can assert on caching behavior. class StubSource @@ -53,7 +53,7 @@ def make_range_between(min, max) end def test_most_preferred_version_respects_all_versions_for_ordering - # all_versions_for returns [2.0, 1.0, 3.0] — so 2.0 is most preferred + # all_versions_for returns [2.0, 1.0, 3.0] - so 2.0 is most preferred # even though 3.0 is numerically highest. pkg = make_package("a") source = StubSource.new("a" => [v("2.0"), v("1.0"), v("3.0")]) @@ -152,7 +152,7 @@ def test_cache_is_keyed_by_package_and_range strategy.next_package_and_version({ pkg => range_any }) calls_after_first = source.versions_for_calls - # Second call with different range — cache miss, so versions_for is called again + # Second call with different range - cache miss, so versions_for is called again strategy.next_package_and_version({ pkg => range_gte }) calls_after_second = source.versions_for_calls From a56f5b463582a92b5b4924f53a1c0229f04c43e4 Mon Sep 17 00:00:00 2001 From: Colby Swandale Date: Mon, 20 Apr 2026 23:56:35 +0900 Subject: [PATCH 27/34] [ruby/rubygems] Fix transitive prerelease filtering and clean up resolver internals https://github.com/ruby/rubygems/commit/a920da05ce --- lib/rubygems/resolver.rb | 70 +++++++++++---------- lib/rubygems/resolver/strategy.rb | 2 +- test/rubygems/test_gem_resolver.rb | 21 +++++++ test/rubygems/test_gem_resolver_strategy.rb | 10 +-- 4 files changed, 64 insertions(+), 39 deletions(-) diff --git a/lib/rubygems/resolver.rb b/lib/rubygems/resolver.rb index 67941b62cf1daa..bf75733d6bf640 100644 --- a/lib/rubygems/resolver.rb +++ b/lib/rubygems/resolver.rb @@ -109,7 +109,7 @@ def initialize(needed, set = nil) @all_specs = Hash.new {|h, name| h[name] = filter_specs(@unfiltered_specs[name]) } @all_versions = Hash.new {|h, pkg| h[pkg] = @all_specs[pkg.to_s].map(&:version).uniq.sort } @sorted_versions = Hash.new do |h, pkg| - h[pkg] = Gem::PubGrub::Package.root?(pkg) ? [@root_version] : filter_versions(pkg) + h[pkg] = Gem::PubGrub::Package.root?(pkg) ? [@root_version] : @all_versions[pkg] end @cached_dependencies = Hash.new do |h, pkg| h[pkg] = if Gem::PubGrub::Package.root?(pkg) @@ -133,14 +133,8 @@ def resolve all = @set.find_all(dep_request) matching = select_local_platforms(all) - if matching.empty? - exc = Gem::UnsatisfiableDependencyError.new(dep_request, all) - exc.errors = @set.errors - raise exc - end + next unless matching.empty? - specs_matching_requirement = matching.select {|s| dep.requirement.satisfied_by?(s.version) } - next unless specs_matching_requirement.empty? exc = Gem::UnsatisfiableDependencyError.new(dep_request, all) exc.errors = @set.errors raise exc @@ -149,7 +143,7 @@ def resolve solver = Gem::PubGrub::VersionSolver.new( source: self, root: @root_package, - strategy: Gem::Resolver::Strategy.new(self, @root_package), + strategy: Gem::Resolver::Strategy.new(self), logger: make_logger ) result = solver.solve @@ -198,7 +192,21 @@ def all_versions_for(package) end def versions_for(package, range = Gem::PubGrub::VersionRange.any) - @versions_for_cache[[package, range]] ||= range.select_versions(@sorted_versions[package]) + @versions_for_cache[[package, range]] ||= begin + candidates = range.select_versions(@sorted_versions[package]) + + if Gem::PubGrub::Package.root?(package) || + (@set.respond_to?(:prerelease) && @set.prerelease) || + range_admits_prerelease?(range) + candidates + elsif @all_versions[package].any? {|v| !v.prerelease? } + candidates.reject(&:prerelease?) + else + # Only prereleases exist for this gem; fall back to them so + # dependencies like `>= 1.0` can still be satisfied. + candidates + end + end end def no_versions_incompatibility_for(_package, unsatisfied_term) @@ -307,9 +315,6 @@ def package_for(name) @packages[name] ||= Gem::PubGrub::Package.new(name) end - # Filter versions to exclude prereleases unless prerelease is enabled. - # Both all_versions_for and versions_for use this filtered set to ensure - # PubGrub's constraint propagation and version selection are consistent. def root_dependencies deps = {} @needed.each do |dep| @@ -319,24 +324,16 @@ def root_dependencies deps end - def filter_versions(package) - all_versions = @all_versions[package] - if (@set.respond_to?(:prerelease) && @set.prerelease) || root_requires_prerelease?(package) - all_versions - else - stable = all_versions.reject(&:prerelease?) - stable.empty? ? all_versions : stable + # Only the min bound is inspected: `~>` synthesises a max like `X.A` + # whose suffix looks prerelease to Gem::Version but is not the user's + # intent, so checking max would mis-admit prereleases for every `~>`. + def range_admits_prerelease?(range) + range.ranges.any? do |r| + next false if r.empty? + r.min&.prerelease? end end - # Root deps with an explicit prerelease requirement (e.g. `= 4.1.0.dev`) - # must keep their prerelease versions in the filtered set; otherwise - # PubGrub cannot match them even though `@set.find_all` returned them. - def root_requires_prerelease?(package) - name = package.to_s - @needed.any? {|dep| dep.name == name && dep.requirement.prerelease? } - end - def find_unfiltered_specs_for(name) dep = Gem::Dependency.new(name, ">= 0.a") dep_request = DependencyRequest.new(dep, nil) @@ -413,9 +410,17 @@ def build_extended_explanation(name, constraint) filtered = @all_specs[name] pkg = package_for(name) - has_prerelease_filtering = @all_versions[pkg].length > @sorted_versions[pkg].length - return if filtered.length == unfiltered.length && !has_prerelease_filtering + # A prerelease hint applies when the source would strip prereleases for + # this constraint (global prerelease flag off and the constraint's range + # doesn't itself reach into prerelease territory) AND a prerelease of + # the gem exists somewhere. + prerelease_gated = !(@set.respond_to?(:prerelease) && @set.prerelease) && + !range_admits_prerelease?(constraint.range) + has_prerelease_candidate = prerelease_gated && + @all_versions[pkg].any?(&:prerelease?) + + return if filtered.length == unfiltered.length && !has_prerelease_candidate hints = [] @@ -450,9 +455,8 @@ def build_extended_explanation(name, constraint) end # Check for specs filtered by prerelease status - unless @set.respond_to?(:prerelease) && @set.prerelease - pkg = package_for(name) - prerelease_versions = @all_versions[pkg].select(&:prerelease?) - @sorted_versions[pkg] + if prerelease_gated + prerelease_versions = @all_versions[pkg].select(&:prerelease?) if prerelease_versions.any? versions = prerelease_versions.sort.reverse.first(3) # limit to avoid cluttering error output hints << "#{name} #{versions.join(", ")} are pre-release versions. Use --prerelease to allow pre-release gems." diff --git a/lib/rubygems/resolver/strategy.rb b/lib/rubygems/resolver/strategy.rb index 1a1c16261906f5..8f3bebc068f953 100644 --- a/lib/rubygems/resolver/strategy.rb +++ b/lib/rubygems/resolver/strategy.rb @@ -5,7 +5,7 @@ # calls during the solver's package selection loop. class Gem::Resolver::Strategy - def initialize(source, root_package) + def initialize(source) @source = source @package_priority_cache = {} diff --git a/test/rubygems/test_gem_resolver.rb b/test/rubygems/test_gem_resolver.rb index 18433c5e0f0739..d8ffc904738dfa 100644 --- a/test/rubygems/test_gem_resolver.rb +++ b/test/rubygems/test_gem_resolver.rb @@ -844,6 +844,27 @@ def test_resolve_prerelease_required_by_exact_requirement assert_resolves_to [a_pre], r end + def test_resolve_transitive_prerelease_required_by_exact_requirement + # A transitive dep with an exact prerelease version must resolve to that + # version even when stable versions of the same gem are in the set. + # The gate on prereleases lives in versions_for and is per-constraint: + # `= 2.0.pre` carries a prerelease bound, so prereleases are admitted for + # this range even though the global prerelease flag is off. + a = util_spec "a", "1.0" do |s| + s.add_dependency "b", "= 2.0.pre" + end + + b_stable = util_spec "b", "1.0" + b_pre = util_spec "b", "2.0.pre" + + s = set(a, b_stable, b_pre) + + ad = make_dep "a" + r = Gem::Resolver.new([ad], s) + + assert_resolves_to [a, b_pre], r + end + def test_error_includes_platform_hint_when_specs_exist_for_other_platforms a = util_spec "a", "1.0" do |s| s.add_dependency "b", ">= 1.0" diff --git a/test/rubygems/test_gem_resolver_strategy.rb b/test/rubygems/test_gem_resolver_strategy.rb index b8c6c86adfe321..57c9aadde8ab9f 100644 --- a/test/rubygems/test_gem_resolver_strategy.rb +++ b/test/rubygems/test_gem_resolver_strategy.rb @@ -58,7 +58,7 @@ def test_most_preferred_version_respects_all_versions_for_ordering pkg = make_package("a") source = StubSource.new("a" => [v("2.0"), v("1.0"), v("3.0")]) - strategy = Gem::Resolver::Strategy.new(source, Gem::PubGrub::Package.root) + strategy = Gem::Resolver::Strategy.new(source) unsatisfied = { pkg => make_range_any } _package, version = strategy.next_package_and_version(unsatisfied) @@ -77,7 +77,7 @@ def test_picks_most_constrained_package "b" => [v("1.0")] ) - strategy = Gem::Resolver::Strategy.new(source, Gem::PubGrub::Package.root) + strategy = Gem::Resolver::Strategy.new(source) unsatisfied = { pkg_a => make_range_any, @@ -104,7 +104,7 @@ def test_picks_package_with_fewer_higher_versions_as_tiebreaker "b" => [v("3.0"), v("2.0"), v("1.0")] ) - strategy = Gem::Resolver::Strategy.new(source, Gem::PubGrub::Package.root) + strategy = Gem::Resolver::Strategy.new(source) unsatisfied = { pkg_a => range, @@ -120,7 +120,7 @@ def test_cache_prevents_redundant_versions_for_calls pkg = make_package("a") source = StubSource.new("a" => [v("2.0"), v("1.0")]) - strategy = Gem::Resolver::Strategy.new(source, Gem::PubGrub::Package.root) + strategy = Gem::Resolver::Strategy.new(source) range = make_range_any unsatisfied = { pkg => range } @@ -143,7 +143,7 @@ def test_cache_is_keyed_by_package_and_range pkg = make_package("a") source = StubSource.new("a" => [v("3.0"), v("2.0"), v("1.0")]) - strategy = Gem::Resolver::Strategy.new(source, Gem::PubGrub::Package.root) + strategy = Gem::Resolver::Strategy.new(source) range_any = make_range_any range_gte = make_range_gte(v("2.0")) From d9fb42701a184f8218eaf5c2c3684c8148c1be33 Mon Sep 17 00:00:00 2001 From: Colby Swandale Date: Tue, 21 Apr 2026 00:29:58 +0900 Subject: [PATCH 28/34] [ruby/rubygems] Performance optimisations https://github.com/ruby/rubygems/commit/efdbd007b8 --- lib/rubygems/resolver.rb | 17 ++++++++++------- lib/rubygems/resolver/strategy.rb | 4 ++-- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/lib/rubygems/resolver.rb b/lib/rubygems/resolver.rb index bf75733d6bf640..fafc357c10a72a 100644 --- a/lib/rubygems/resolver.rb +++ b/lib/rubygems/resolver.rb @@ -119,7 +119,8 @@ def initialize(needed, set = nil) end end @version_to_index = Hash.new {|h, pkg| h[pkg] = @sorted_versions[pkg].each_with_index.to_h } - @versions_for_cache = {} + @versions_for_cache = Hash.new {|h, pkg| h[pkg] = {} } + @spec_for_cache = Hash.new {|h, name| h[name] = build_spec_for_cache(name) } end ## @@ -192,7 +193,7 @@ def all_versions_for(package) end def versions_for(package, range = Gem::PubGrub::VersionRange.any) - @versions_for_cache[[package, range]] ||= begin + @versions_for_cache[package][range] ||= begin candidates = range.select_versions(@sorted_versions[package]) if Gem::PubGrub::Package.root?(package) || @@ -357,18 +358,20 @@ def filter_specs(specs) end def spec_for(name, version) - candidates = @all_specs[name].select {|s| s.version == version } + @spec_for_cache[name][version] + end + + def build_spec_for_cache(name) + @all_specs[name].group_by(&:version).transform_values do |candidates| + next candidates.first if candidates.length == 1 - if candidates.length > 1 # Prefer already-installed specs to avoid unnecessary downloads installed = candidates.select {|s| s.is_a?(Gem::Resolver::InstalledSpecification) } - return installed.first if installed.length == 1 + next installed.first if installed.length == 1 candidates = installed if installed.any? # Among remaining candidates, prefer the most specific platform candidates.min_by {|s| Gem::Platform.platform_specificity_match(s.platform, Gem::Platform.local) } - else - candidates.first end end diff --git a/lib/rubygems/resolver/strategy.rb b/lib/rubygems/resolver/strategy.rb index 8f3bebc068f953..bf0dbb6adc3580 100644 --- a/lib/rubygems/resolver/strategy.rb +++ b/lib/rubygems/resolver/strategy.rb @@ -7,7 +7,7 @@ class Gem::Resolver::Strategy def initialize(source) @source = source - @package_priority_cache = {} + @package_priority_cache = Hash.new {|h, pkg| h[pkg] = {} } @version_indexes = Hash.new do |h, k| if Gem::PubGrub::Package.root?(k) @@ -33,7 +33,7 @@ def most_preferred_version_of(package, range) def next_term_to_try_from(unsatisfied) unsatisfied.min_by do |package, range| - @package_priority_cache[[package, range]] ||= begin + @package_priority_cache[package][range] ||= begin matching_versions = @source.versions_for(package, range) higher_versions = @source.versions_for(package, range.upper_invert) From df20297a3e6ab094f1bdc7160e252052cd7b1853 Mon Sep 17 00:00:00 2001 From: Colby Swandale Date: Tue, 26 May 2026 16:19:21 +1000 Subject: [PATCH 29/34] [ruby/rubygems] resolver performance optimisations https://github.com/ruby/rubygems/commit/9f585e6d0e --- lib/rubygems/resolver.rb | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/lib/rubygems/resolver.rb b/lib/rubygems/resolver.rb index fafc357c10a72a..350cfd964bb809 100644 --- a/lib/rubygems/resolver.rb +++ b/lib/rubygems/resolver.rb @@ -346,9 +346,8 @@ def filter_specs(specs) unless @soft_missing filtered = filtered.select do |s| - actual = s.respond_to?(:spec) ? s.spec : s - actual.required_ruby_version.satisfied_by?(Gem.ruby_version) && - actual.required_rubygems_version.satisfied_by?(Gem.rubygems_version) + s.required_ruby_version.satisfied_by?(Gem.ruby_version) && + s.required_rubygems_version.satisfied_by?(Gem.rubygems_version) rescue StandardError true end @@ -385,8 +384,7 @@ def compute_dependencies(package, version) deps = {} root_names = @needed.map(&:name) - actual_spec = spec.respond_to?(:spec) ? spec.spec : spec - actual_spec.dependencies.each do |d| + spec.dependencies.each do |d| next if d.name == package.to_s next if d.type == :development && !@development next if d.type == :development && @development_shallow && !root_names.include?(package.to_s) From 1a39445013e6fc8801e3b61ce7208f70d90825c5 Mon Sep 17 00:00:00 2001 From: Colby Swandale Date: Tue, 2 Jun 2026 13:50:48 +1000 Subject: [PATCH 30/34] [ruby/rubygems] Fix resolver eliminating all gem versions on a missing dependency https://github.com/ruby/rubygems/commit/22134cc22b --- lib/rubygems/resolver.rb | 45 ++++++-------- test/rubygems/test_gem_resolver.rb | 95 ++++++++++++++++++++++++++++++ 2 files changed, 113 insertions(+), 27 deletions(-) diff --git a/lib/rubygems/resolver.rb b/lib/rubygems/resolver.rb index 350cfd964bb809..c142017efb4498 100644 --- a/lib/rubygems/resolver.rb +++ b/lib/rubygems/resolver.rb @@ -235,19 +235,6 @@ def incompatibilities_for(package, version) package_deps[version].filter_map do |dep_package_name, dep_constraint| dep_package = dep_constraint.package - # If no specs exist at all for this dependency (not even for other - # platforms or Ruby versions), mark this version as invalid. - # When specs exist but were filtered out, let PubGrub discover it - # via NoVersions so platform/ruby hints are generated. - if @unfiltered_specs[dep_package_name].empty? - self_constraint = Gem::PubGrub::VersionConstraint.new(package, range: Gem::PubGrub::VersionRange.any) - cause = Gem::PubGrub::Incompatibility::InvalidDependency.new(dep_package, dep_constraint) - return [Gem::PubGrub::Incompatibility.new( - [Gem::PubGrub::Term.new(self_constraint, true)], - cause: cause - )] - end - low = high = @version_to_index[package][version] # find version low such that all >= low share the same dep @@ -277,21 +264,25 @@ def incompatibilities_for(package, version) range = Gem::PubGrub::VersionRange.new(min: low, max: high, include_min: !low.nil?) self_constraint = Gem::PubGrub::VersionConstraint.new(package, range: range) + # No specs anywhere means an unknown package. Check @unfiltered_specs, not + # the filtered set, so a dep filtered out by platform/Ruby/prerelease falls + # through to NoVersions for proper hints instead. The band-scoped + # self_constraint lets clean sibling versions still resolve via backtracking. + if @unfiltered_specs[dep_package_name].empty? + cause = Gem::PubGrub::Incompatibility::InvalidDependency.new(dep_package, dep_constraint) + return [Gem::PubGrub::Incompatibility.new( + [Gem::PubGrub::Term.new(self_constraint, true)], + cause: cause + )] + end + + # An empty range means the requirement is self-contradictory (e.g. `> 2, < 1`). if dep_constraint.range.empty? - if @unfiltered_specs[dep_package_name].any? - # Package exists but requirement is self-contradictory - return [Gem::Resolver::Incompatibility.new( - [Gem::PubGrub::Term.new(self_constraint, true)], - cause: Gem::PubGrub::Incompatibility::NoVersions.new(dep_constraint), - custom_explanation: "#{dep_package_name} cannot satisfy contradictory requirements #{dep_constraint.constraint_string}" - )] - else - cause = Gem::PubGrub::Incompatibility::InvalidDependency.new(dep_package, dep_constraint) - return [Gem::PubGrub::Incompatibility.new( - [Gem::PubGrub::Term.new(self_constraint, true)], - cause: cause - )] - end + return [Gem::Resolver::Incompatibility.new( + [Gem::PubGrub::Term.new(self_constraint, true)], + cause: Gem::PubGrub::Incompatibility::NoVersions.new(dep_constraint), + custom_explanation: "#{dep_package_name} cannot satisfy contradictory requirements #{dep_constraint.constraint_string}" + )] end Gem::PubGrub::Incompatibility.new( diff --git a/test/rubygems/test_gem_resolver.rb b/test/rubygems/test_gem_resolver.rb index d8ffc904738dfa..a0caa0cbc8f87a 100644 --- a/test/rubygems/test_gem_resolver.rb +++ b/test/rubygems/test_gem_resolver.rb @@ -991,4 +991,99 @@ def test_soft_missing_skips_dep_with_wrong_version # With soft_missing (--force), the dep should be skipped. assert_resolves_to [a], r end + + def test_backtracks_to_clean_sibling_when_higher_version_has_missing_dep + a1 = util_spec "a", "1" + a2 = util_spec "a", "2" do |s| + s.add_dependency "zzz", ">= 1" + end + + r = Gem::Resolver.new([make_dep("a")], set(a1, a2)) + + # 'zzz' has zero specs anywhere, so a-2 is unusable, but a-1 is clean + # and resolution must backtrack to it rather than declaring every + # version of 'a' invalid. + assert_resolves_to [a1], r + end + + def test_backtracks_over_band_of_bad_high_versions_to_clean_lower + a1 = util_spec "a", "1" + a2 = util_spec "a", "2" do |s| + s.add_dependency "zzz", ">= 1" + end + a3 = util_spec "a", "3" do |s| + s.add_dependency "zzz", ">= 1" + end + + r = Gem::Resolver.new([make_dep("a")], set(a1, a2, a3)) + + # Only the a-2..a-3 band shares the missing 'zzz' dep and should be + # eliminated; band scoping is load-bearing here, not just sibling + # presence. + assert_resolves_to [a1], r + end + + def test_backtracks_when_one_of_several_deps_is_missing + good = util_spec "good", "1" + a1 = util_spec "a", "1" do |s| + s.add_dependency "good", ">= 1" + end + a2 = util_spec "a", "2" do |s| + s.add_dependency "good", ">= 1" + s.add_dependency "zzz", ">= 1" + end + + r = Gem::Resolver.new([make_dep("a")], set(a1, a2, good)) + + # Only a-2, which carries the missing 'zzz' dep, is eliminated; the + # per-dep check inside a multi-dep version must not poison a-1. + assert_resolves_to [a1, good], r + end + + def test_fails_when_every_version_depends_on_missing_package + a1 = util_spec "a", "1" do |s| + s.add_dependency "zzz", ">= 1" + end + a2 = util_spec "a", "2" do |s| + s.add_dependency "zzz", ">= 1" + end + + r = Gem::Resolver.new([make_dep("a")], set(a1, a2)) + + e = assert_raise Gem::DependencyResolutionError do + r.resolve + end + + assert_match(/every version of a depends on unknown package zzz/, e.message) + end + + def test_resolves_when_only_lowest_version_has_missing_dep + a1 = util_spec "a", "1" do |s| + s.add_dependency "zzz", ">= 1" + end + a2 = util_spec "a", "2" + + r = Gem::Resolver.new([make_dep("a")], set(a1, a2)) + + # a-2 is preferred/tried first, so this is already green; it guards + # against the bug being re-introduced in an order-sensitive way. + assert_resolves_to [a2], r + end + + def test_filtered_platform_dep_lets_clean_sibling_backtrack + a1 = util_spec "a", "1" + a2 = util_spec "a", "2" do |s| + s.add_dependency "b", ">= 1.0" + end + b_java = util_spec "b", "1.0" do |s| + s.platform = "java" + end + + r = Gem::Resolver.new([make_dep("a")], set(a1, a2, b_java)) + + # 'b' EXISTS in the unfiltered specs but is platform-filtered, so a-2 + # is unusable via NoVersions (not InvalidDependency). Resolution must + # backtrack to the clean a-1 rather than eliminating it. + assert_resolves_to [a1], r + end end From 156935faa9f9ae65073ab0d08d9452919c63f87d Mon Sep 17 00:00:00 2001 From: Colby Swandale Date: Tue, 2 Jun 2026 14:09:46 +1000 Subject: [PATCH 31/34] [ruby/rubygems] Remove dangling resolver stats call from --explain path https://github.com/ruby/rubygems/commit/8bc787e863 --- lib/rubygems/request_set.rb | 4 ---- test/rubygems/test_gem_request_set.rb | 28 +++++++++++++++++++++++++++ 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/lib/rubygems/request_set.rb b/lib/rubygems/request_set.rb index c06ef32da9498a..eb8b4658f3b6c6 100644 --- a/lib/rubygems/request_set.rb +++ b/lib/rubygems/request_set.rb @@ -234,10 +234,6 @@ def install_from_gemdeps(options, &block) sorted_requests.each do |spec| puts " #{spec.full_name}" end - - if Gem.configuration.really_verbose - @resolver.stats.display - end else installed = install options, &block diff --git a/test/rubygems/test_gem_request_set.rb b/test/rubygems/test_gem_request_set.rb index 6ebc95ea20f218..33054aa8e50cc7 100644 --- a/test/rubygems/test_gem_request_set.rb +++ b/test/rubygems/test_gem_request_set.rb @@ -93,6 +93,34 @@ def test_install_from_gemdeps_explain end end + def test_install_from_gemdeps_explain_verbose + spec_fetcher do |fetcher| + fetcher.gem "a", 2 + end + + rs = Gem::RequestSet.new + + verbose = Gem.configuration.verbose + Gem.configuration.verbose = :really + + File.open "gem.deps.rb", "w" do |io| + io.puts 'gem "a"' + io.flush + + expected = <<-EXPECTED +Gems to install: + a-2 + EXPECTED + + actual, _ = capture_output do + rs.install_from_gemdeps gemdeps: io.path, explain: true + end + assert_equal(expected, actual) + end + ensure + Gem.configuration.verbose = verbose + end + def test_install_from_gemdeps_install_dir spec_fetcher do |fetcher| fetcher.gem "a", 2 From f7932a5318b233302ae3e21cf55c5f26ecc6b4f8 Mon Sep 17 00:00:00 2001 From: Colby Swandale Date: Tue, 2 Jun 2026 15:16:43 +1000 Subject: [PATCH 32/34] [ruby/rubygems] Prefer the earlier source for same-version dependency ties When version and platform match across sources, prefer the earlier source. This formalises existing behaviour and matches Bundler. Cross-version selection is unchanged. https://github.com/ruby/rubygems/commit/8f2cd72650 --- lib/rubygems/resolver.rb | 15 +++++++++++-- test/rubygems/test_gem_resolver.rb | 36 ++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/lib/rubygems/resolver.rb b/lib/rubygems/resolver.rb index c142017efb4498..208ee623a2709d 100644 --- a/lib/rubygems/resolver.rb +++ b/lib/rubygems/resolver.rb @@ -352,6 +352,13 @@ def spec_for(name, version) end def build_spec_for_cache(name) + # Rank sources by the order they were first supplied so that, when multiple + # sources offer the same version and platform, the earlier source wins. + source_rank = {} + @all_specs[name].each do |s| + source_rank[s.source] ||= source_rank.size + end + @all_specs[name].group_by(&:version).transform_values do |candidates| next candidates.first if candidates.length == 1 @@ -360,8 +367,12 @@ def build_spec_for_cache(name) next installed.first if installed.length == 1 candidates = installed if installed.any? - # Among remaining candidates, prefer the most specific platform - candidates.min_by {|s| Gem::Platform.platform_specificity_match(s.platform, Gem::Platform.local) } + # Among remaining candidates, prefer the most specific platform, then the + # earlier-supplied source. + candidates.min_by do |s| + [Gem::Platform.platform_specificity_match(s.platform, Gem::Platform.local), + source_rank[s.source]] + end end end diff --git a/test/rubygems/test_gem_resolver.rb b/test/rubygems/test_gem_resolver.rb index a0caa0cbc8f87a..a6319e4a65516a 100644 --- a/test/rubygems/test_gem_resolver.rb +++ b/test/rubygems/test_gem_resolver.rb @@ -722,6 +722,42 @@ def test_picks_highest_version_across_sources assert_resolves_to [spec_a_2], resolver end + def test_same_version_prefers_earlier_source + source_a = Gem::Source.new "http://example.com/a" + source_b = Gem::Source.new "http://example.com/b" + + spec_a = util_spec "some-dep", "1.0.0" + spec_b = util_spec "some-dep", "1.0.0" + + set = StaticSet.new [ + Gem::Resolver::SpecSpecification.new(nil, spec_a, source_a), + Gem::Resolver::SpecSpecification.new(nil, spec_b, source_b), + ] + + resolver = Gem::Resolver.new [make_dep("some-dep", "> 0")], set + result = resolver.resolve + + assert_equal source_a, result.first.spec.source + end + + def test_same_version_prefers_earlier_source_when_order_flipped + source_a = Gem::Source.new "http://example.com/a" + source_b = Gem::Source.new "http://example.com/b" + + spec_a = util_spec "some-dep", "1.0.0" + spec_b = util_spec "some-dep", "1.0.0" + + set = StaticSet.new [ + Gem::Resolver::SpecSpecification.new(nil, spec_b, source_b), + Gem::Resolver::SpecSpecification.new(nil, spec_a, source_a), + ] + + resolver = Gem::Resolver.new [make_dep("some-dep", "> 0")], set + result = resolver.resolve + + assert_equal source_b, result.first.spec.source + end + def test_select_local_platforms r = Gem::Resolver.new nil, nil From 5e3e2a4047415f59df59f6fd3fad7058d53773eb Mon Sep 17 00:00:00 2001 From: Colby Swandale Date: Tue, 2 Jun 2026 17:12:08 +1000 Subject: [PATCH 33/34] [ruby/rubygems] Include the missing dependency's version in resolver errors & test and document Bundler-aligned resolver behaviors https://github.com/ruby/rubygems/commit/3d5dfa91b9 --- lib/rubygems/resolver.rb | 35 ++++++++++-- .../rubygems/test_gem_dependency_installer.rb | 2 +- test/rubygems/test_gem_resolver.rb | 54 ++++++++++++++++++- 3 files changed, 85 insertions(+), 6 deletions(-) diff --git a/lib/rubygems/resolver.rb b/lib/rubygems/resolver.rb index 208ee623a2709d..788206c0566fd5 100644 --- a/lib/rubygems/resolver.rb +++ b/lib/rubygems/resolver.rb @@ -127,7 +127,14 @@ def initialize(needed, set = nil) # Proceed with resolution! Returns an array of ActivationRequest objects. def resolve - # Pre-check: raise UnsatisfiableDependencyError for root deps with no matches + # Pre-check: raise UnsatisfiableDependencyError for root deps with no + # platform match. We filter by platform ONLY here (not required_ruby_version + # / required_rubygems_version): a foreign-platform gem is genuinely "not + # found", but a gem that exists yet is incompatible with the running Ruby + # should flow through the solver to a DependencyResolutionError that names + # the Ruby requirement. That matches Bundler (which models Ruby as a + # synthetic dependency, so this surfaces as a solve failure) and gives a + # clearer message than the platform-oriented UnsatisfiableDependencyError. @needed.each do |dep| next if @soft_missing dep_request = DependencyRequest.new(dep, nil) @@ -175,6 +182,21 @@ def all_versions_for(package) name = package.to_s if (skip_dep_gems = skip_gems[name]) && !skip_dep_gems.empty? + # Conservative mode: float the already-installed (skip) versions to the + # front so the solver prefers them. This sets *preference* only (it feeds + # the strategy's version-index map); it does not restrict availability, so + # every version stays selectable via versions_for. When an installed + # version is made impossible by a downstream conflict, the solver + # backtracks to a newer version instead of failing. Molinillo instead + # hard-restricted the candidate set to skip versions and raised. + # + # This reaches the same outcome as Bundler (upgrade-over-raise) for the + # common single-blocked-gem case, though the mechanism differs: Bundler + # hard-pins locked gems and selectively unlocks + re-solves on conflict, + # whereas we float as a preference and let PubGrub backtrack in one solve. + # The float can therefore over-upgrade when several installed gems are + # jointly involved in a conflict; that outcome-level divergence is + # accepted (see test_conservative_upgrades_when_installed_blocked). skip_versions = skip_dep_gems.map(&:version) preferred, rest = versions.partition {|v| skip_versions.include?(v) } preferred + rest @@ -270,9 +292,16 @@ def incompatibilities_for(package, version) # self_constraint lets clean sibling versions still resolve via backtracking. if @unfiltered_specs[dep_package_name].empty? cause = Gem::PubGrub::Incompatibility::InvalidDependency.new(dep_package, dep_constraint) + self_term = Gem::PubGrub::Term.new(self_constraint, true) + # PubGrub's default InvalidDependency rendering drops the version + # requirement ("depends on unknown package bar"). Supply a custom + # explanation so the missing dependency's constraint is preserved + # ("depends on bar = 0.5 which could not be found in any repository"), + # matching Molinillo's diagnostics. return [Gem::PubGrub::Incompatibility.new( - [Gem::PubGrub::Term.new(self_constraint, true)], - cause: cause + [self_term], + cause: cause, + custom_explanation: "#{self_term.to_s(allow_every: true)} depends on #{dep_constraint} which could not be found in any repository" )] end diff --git a/test/rubygems/test_gem_dependency_installer.rb b/test/rubygems/test_gem_dependency_installer.rb index e4da6cabd56f50..c2fb6f264b92a1 100644 --- a/test/rubygems/test_gem_dependency_installer.rb +++ b/test/rubygems/test_gem_dependency_installer.rb @@ -817,7 +817,7 @@ def test_install_domain_local inst.install "b" end - assert_match(/depends on unknown package a/, e.message) + assert_match(/depends on a >= 0 which could not be found in any repository/, e.message) end assert_equal [], inst.installed_gems.map(&:full_name) diff --git a/test/rubygems/test_gem_resolver.rb b/test/rubygems/test_gem_resolver.rb index a6319e4a65516a..84ede36b6c85d7 100644 --- a/test/rubygems/test_gem_resolver.rb +++ b/test/rubygems/test_gem_resolver.rb @@ -140,6 +140,34 @@ def test_resolve_conservative assert_resolves_to [a2_spec, b2_spec, c1_spec, d2_spec, e1_spec], res end + def test_conservative_upgrades_when_installed_blocked + # Conservative mode floats the installed (skip) version to the front but + # keeps newer versions selectable. When the installed version cannot be + # used because its own dependency is unsatisfiable, the solver backtracks + # to a newer version instead of failing. This intentionally diverges from + # Molinillo (which hard-restricted to skip versions and raised) and reaches + # Bundler's upgrade-over-raise outcome. See the comment in + # Gem::Resolver#all_versions_for. + a1_spec = util_spec "a", 1 do |s| + s.add_dependency "b", ">= 2" + end + a2_spec = util_spec "a", 2 do |s| + s.add_dependency "b", ">= 1" + end + b1_spec = util_spec "b", 1 + + # b-2 is intentionally absent, so a-1's `b >= 2` cannot be satisfied. + deps = [make_dep("a", ">= 1")] + s = set a1_spec, a2_spec, b1_spec + + res = Gem::Resolver.new deps, s + # a-1 is already installed and satisfies `a >= 1`, so conservative mode + # prefers it - but it is blocked by the missing b-2, forcing an upgrade. + res.skip_gems = { "a" => [a1_spec] } + + assert_resolves_to [a2_spec, b1_spec], res + end + def test_resolve_development a_spec = util_spec "a", 1 do |s| s.add_development_dependency "b" @@ -516,7 +544,7 @@ def test_raises_and_reports_an_implicit_request_properly r.resolve end - assert_match(/depends on unknown package b/, e.message) + assert_match(/depends on b = 2 which could not be found in any repository/, e.message) end def test_raises_when_possibles_are_exhausted @@ -945,6 +973,28 @@ def test_error_includes_ruby_version_hint_when_filtered assert_match(/you have/, e.message) end + def test_root_gem_incompatible_ruby_version_names_ruby_requirement + # A requested (root) gem available only for an incompatible Ruby version + # flows through the solver to a DependencyResolutionError whose message + # names the Ruby requirement. This matches Bundler (which models Ruby as a + # synthetic dependency and reports a solve failure) and is clearer than the + # platform-oriented UnsatisfiableDependencyError. Contrast the foreign- + # *platform* case (test_raises_and_explains_when_platform_prevents_install), + # which is genuinely "not found" and does raise UnsatisfiableDependencyError. + a = util_spec "a", "1.0" do |s| + s.required_ruby_version = ">= 999.0" + end + + ad = make_dep "a", "= 1.0" + r = Gem::Resolver.new([ad], set(a)) + + e = assert_raise Gem::DependencyResolutionError do + r.resolve + end + + assert_match(/requires Ruby >= 999.0/, e.message) + end + def test_self_dependency_does_not_crash a = util_spec "a", "1.0" do |s| s.add_dependency "a" @@ -1090,7 +1140,7 @@ def test_fails_when_every_version_depends_on_missing_package r.resolve end - assert_match(/every version of a depends on unknown package zzz/, e.message) + assert_match(/every version of a depends on zzz >= 1 which could not be found in any repository/, e.message) end def test_resolves_when_only_lowest_version_has_missing_dep From f916141a2db972c39ad73ad584d505cc4dc63cb8 Mon Sep 17 00:00:00 2001 From: Peter Zhu Date: Tue, 2 Jun 2026 15:52:47 +0900 Subject: [PATCH 34/34] [DOC] Fix hash style for Struct --- struct.c | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/struct.c b/struct.c index 5ac67dad2c3eb0..7a592b42c548c8 100644 --- a/struct.c +++ b/struct.c @@ -628,7 +628,7 @@ rb_struct_define_under(VALUE outer, const char *name, ...) * PositionalOnly.new(0, 1) * # => # * PositionalOnly.new(bar: 1, foo: 0) - * # => #1, :bar=>2}, bar=nil> + * # => # * # Note that no error is raised, but arguments treated as one hash value * * # Same as not providing keyword_init: @@ -1076,14 +1076,14 @@ rb_struct_to_a(VALUE s) * Customer = Struct.new(:name, :address, :zip) * joe = Customer.new("Joe Smith", "123 Maple, Anytown NC", 12345) * h = joe.to_h - * h # => {:name=>"Joe Smith", :address=>"123 Maple, Anytown NC", :zip=>12345} + * h # => {name: "Joe Smith", address: "123 Maple, Anytown NC", zip: 12345} * * If a block is given, it is called with each name/value pair; * the block should return a 2-element array whose elements will become * a key/value pair in the returned hash: * * h = joe.to_h{|name, value| [name.upcase, value.to_s.upcase]} - * h # => {:NAME=>"JOE SMITH", :ADDRESS=>"123 MAPLE, ANYTOWN NC", :ZIP=>"12345"} + * h # => {NAME: "JOE SMITH", ADDRESS: "123 MAPLE, ANYTOWN NC", ZIP: "12345"} * * Raises ArgumentError if the block returns an inappropriate value. * @@ -1116,12 +1116,12 @@ rb_struct_to_h(VALUE s) * Customer = Struct.new(:name, :address, :zip) * joe = Customer.new("Joe Smith", "123 Maple, Anytown NC", 12345) * h = joe.deconstruct_keys([:zip, :address]) - * h # => {:zip=>12345, :address=>"123 Maple, Anytown NC"} + * h # => {zip: 12345, address: "123 Maple, Anytown NC"} * * Returns all names and values if +array_of_names+ is +nil+: * * h = joe.deconstruct_keys(nil) - * h # => {:name=>"Joseph Smith, Jr.", :address=>"123 Maple, Anytown NC", :zip=>12345} + * h # => {name: "Joseph Smith, Jr.", address: "123 Maple, Anytown NC", zip: 12345} * */ static VALUE @@ -1565,8 +1565,8 @@ rb_struct_size(VALUE s) * * Foo = Struct.new(:a) * f = Foo.new(Foo.new({b: [1, 2, 3]})) - * f.dig(:a) # => #[1, 2, 3]}> - * f.dig(:a, :a) # => {:b=>[1, 2, 3]} + * f.dig(:a) # => # + * f.dig(:a, :a) # => {b: [1, 2, 3]} * f.dig(:a, :a, :b) # => [1, 2, 3] * f.dig(:a, :a, :b, 0) # => 1 * f.dig(:b, 0) # => nil @@ -1574,8 +1574,8 @@ rb_struct_size(VALUE s) * Given integer argument +n+, * returns the object that is specified by +n+ and +identifiers+: * - * f.dig(0) # => #[1, 2, 3]}> - * f.dig(0, 0) # => {:b=>[1, 2, 3]} + * f.dig(0) # => # + * f.dig(0, 0) # => {b: [1, 2, 3]} * f.dig(0, 0, :b) # => [1, 2, 3] * f.dig(0, 0, :b, 0) # => 1 * f.dig(:b, 0) # => nil @@ -2034,7 +2034,7 @@ rb_data_inspect(VALUE s) * distance = Measure[10, 'km'] * * distance.to_h - * #=> {:amount=>10, :unit=>"km"} + * #=> {amount: 10, unit: "km"} * * Like Enumerable#to_h, if the block is provided, it is expected to * produce key-value pairs to construct a hash: @@ -2108,8 +2108,8 @@ rb_data_inspect(VALUE s) * Measure = Data.define(:amount, :unit) * * distance = Measure[10, 'km'] - * distance.deconstruct_keys(nil) #=> {:amount=>10, :unit=>"km"} - * distance.deconstruct_keys([:amount]) #=> {:amount=>10} + * distance.deconstruct_keys(nil) #=> {amount: 10, unit: "km"} + * distance.deconstruct_keys([:amount]) #=> {amount: 10} * * # usage * case distance