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]') 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 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..422b726980d6fa 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,24 @@ 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) + excluded = {} + specs.each do |spec| + next unless cooldown_excluded?(spec) + excluded[[spec.name, spec.version]] = true + end + excluded 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..ed864604fe1649 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,31 @@ 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 + # 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) + index.each do |spec| + next unless spec.respond_to?(:created_at=) + next if spec.created_at + remote_created_at, remote = snapshot[[spec.name, spec.version]] + next unless remote_created_at + spec.created_at = remote_created_at + spec.remote ||= remote if remote && spec.respond_to?(:remote=) + end + end + def lockfile_remotes @lockfile_remotes || credless_remotes end 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/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..e00a70c66249a3 100644 --- a/lib/rubygems/exceptions.rb +++ b/lib/rubygems/exceptions.rb @@ -33,22 +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 - a, b = conflicting_dependencies + @explanation = conflict.explanation + super @explanation + end - super "conflicting dependencies #{a} and #{b}\n#{@conflict.explanation}" + def explanation + @explanation + end + + def conflict + nil end def conflicting_dependencies - @conflict.conflicting_dependencies + [] end end @@ -126,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/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/lib/rubygems/resolver.rb b/lib/rubygems/resolver.rb index bc4fef893ead65..788206c0566fd5 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,219 +99,449 @@ def initialize(needed, set = nil) @ignore_dependencies = false @skip_gems = {} @soft_missing = false - @stats = Gem::Resolver::Stats.new - end - def explain(stage, *data) # :nodoc: - return unless DEBUG_RESOLVER + @root_package = RootPackage.new + @root_version = Gem::PubGrub::Package.root_version + + @packages = {} - d = data.map(&:pretty_inspect).join(", ") - $stderr.printf "%10s %s\n", stage.to_s.upcase, d + @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] : @all_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 = Hash.new {|h, pkg| h[pkg] = {} } + @spec_for_cache = Hash.new {|h, name| h[name] = build_spec_for_cache(name) } end - def explain_list(stage) # :nodoc: - return unless DEBUG_RESOLVER + ## + # Proceed with resolution! Returns an array of ActivationRequest objects. + + def resolve + # 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) + all = @set.find_all(dep_request) + matching = select_local_platforms(all) + + next unless matching.empty? + + exc = Gem::UnsatisfiableDependencyError.new(dep_request, all) + exc.errors = @set.errors + raise exc + end - data = yield - $stderr.printf "%10s (%d entries)\n", stage.to_s.upcase, data.size - unless data.empty? - require "pp" - PP.pp data, $stderr + solver = Gem::PubGrub::VersionSolver.new( + source: self, + root: @root_package, + strategy: Gem::Resolver::Strategy.new(self), + logger: make_logger + ) + 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 = 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 + 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 - ## - # Creates an ActivationRequest for the given +dep+ and the last +possible+ - # specification. - # - # Returns the Specification and the ActivationRequest + # PubGrub source interface methods + + def all_versions_for(package) + versions = @sorted_versions[package].reverse # highest first + 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 + 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 + + def versions_for(package, range = Gem::PubGrub::VersionRange.any) + @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 activation_request(dep, possible) # :nodoc: - spec = possible.pop + def no_versions_incompatibility_for(_package, unsatisfied_term) + cause = Gem::PubGrub::Incompatibility::NoVersions.new(unsatisfied_term) - explain :activate, [spec.full_name, possible.size] - explain :possible, possible + name = unsatisfied_term.package.to_s + constraint = unsatisfied_term.constraint + extended_explanation = build_extended_explanation(name, constraint) - activation_request = - Gem::Resolver::ActivationRequest.new spec, dep, possible + custom_explanation = if extended_explanation + "#{constraint} could not be found in any repository" + end - [spec, activation_request] + Gem::Resolver::Incompatibility.new( + [unsatisfied_term], + cause: cause, + custom_explanation: custom_explanation, + extended_explanation: extended_explanation + ) end - def requests(s, act, reqs = []) # :nodoc: - return reqs if @ignore_dependencies + 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 - s.fetch_development_dependencies if @development + low = high = @version_to_index[package][version] - 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 + # 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) + + # 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) + 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( + [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 - reqs << Gem::Resolver::DependencyRequest.new(d, act) - @stats.requirement! + # An empty range means the requirement is self-contradictory (e.g. `> 2, < 1`). + if dep_constraint.range.empty? + 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( + [Gem::PubGrub::Term.new(self_constraint, true), Gem::PubGrub::Term.new(dep_constraint, false)], + cause: :dependency + ) end + end + + ## + # Returns the gems in +specs+ that match the local platform. - @set.prefetch reqs + def select_local_platforms(specs) # :nodoc: + specs.select do |spec| + Gem::Platform.installable? spec + end + end - @stats.record_requirements reqs + private - reqs + def package_for(name) + @packages[name] ||= Gem::PubGrub::Package.new(name) end - include Gem::Molinillo::UI + def root_dependencies + deps = {} + @needed.each do |dep| + 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 + end - def output - @output ||= debug? ? $stdout : File.open(IO::NULL, "w") + # 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 - def debug? - DEBUG_RESOLVER + def find_unfiltered_specs_for(name) + dep = Gem::Dependency.new(name, ">= 0.a") + dep_request = DependencyRequest.new(dep, nil) + @set.find_all(dep_request) end - include Gem::Molinillo::SpecificationProvider + def filter_specs(specs) + filtered = select_local_platforms(specs) - ## - # Proceed with resolution! Returns an array of ActivationRequest objects. + unless @soft_missing + filtered = filtered.select do |s| + s.required_ruby_version.satisfied_by?(Gem.ruby_version) && + s.required_rubygems_version.satisfied_by?(Gem.rubygems_version) + rescue StandardError + true + end + 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? + filtered 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 spec_for(name, version) + @spec_for_cache[name][version] + end - def find_possible(dependency) # :nodoc: - all = @set.find_all dependency + 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 - 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_specs[name].group_by(&:version).transform_values do |candidates| + next candidates.first if candidates.length == 1 + + # Prefer already-installed specs to avoid unnecessary downloads + installed = candidates.select {|s| s.is_a?(Gem::Resolver::InstalledSpecification) } + next installed.first if installed.length == 1 + candidates = installed if installed.any? - all = matching unless matching.empty? + # 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 - matching_platform = select_local_platforms all + def compute_dependencies(package, version) + spec = spec_for(package.to_s, version) + return {} unless spec + return {} if @ignore_dependencies - [matching_platform, all] - end + spec.fetch_development_dependencies if @development && spec.respond_to?(:fetch_development_dependencies) - ## - # Returns the gems in +specs+ that match the local platform. + deps = {} + root_names = @needed.map(&:name) - def select_local_platforms(specs) # :nodoc: - specs.select do |spec| - Gem::Platform.installable? spec + 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) + + dep_package = package_for(d.name) + + # 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) end + + deps 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 + def build_extended_explanation(name, constraint) + unfiltered = @unfiltered_specs[name] + return if unfiltered.empty? + + filtered = @all_specs[name] + pkg = package_for(name) - groups = Hash.new {|hash, key| hash[key] = [] } + # 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?) - # create groups & sources in the same loop - sources = possibles.map do |spec| - source = spec.source - groups[source] << spec - source - end.uniq.reverse + return if filtered.length == unfiltered.length && !has_prerelease_candidate - activation_requests = [] + hints = [] - 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 } + # 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 - activation_requests - 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 + + # Check for specs filtered by prerelease status + 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." + end + end - def dependencies_for(specification) - return [] if @ignore_dependencies - spec = specification.spec - requests(spec, specification) + hints.empty? ? nil : hints.join("\n") end - def requirement_satisfied_by?(requirement, activated, spec) - matches_spec = requirement.matches_spec? spec - return matches_spec if @soft_missing + def extract_extended_explanation(incompatibility) + while incompatibility.cause.is_a?(Gem::PubGrub::Incompatibility::ConflictCause) + cause = incompatibility.cause - matches_spec && - spec.spec.required_ruby_version.satisfied_by?(Gem.ruby_version) && - spec.spec.required_rubygems_version.satisfied_by?(Gem.rubygems_version) - end + [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 - def name_for(dependency) - dependency.name + nil end - def allow_missing?(dependency) - @soft_missing + def make_logger + DEBUG_RESOLVER ? Gem::PubGrub::StderrLogger.new : Gem::PubGrub::NullLogger.new end - 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 - ] + # 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 - end - SINGLE_POSSIBILITY_CONSTRAINT_PENALTY = 1_000_000 - private_constant :SINGLE_POSSIBILITY_CONSTRAINT_PENALTY if defined?(private_constant) + def root? + true + end - # 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 - end + def to_s + "your request" end end - private :amount_constrained end require_relative "resolver/activation_request" -require_relative "resolver/conflict" require_relative "resolver/dependency_request" +require_relative "resolver/incompatibility" +require_relative "resolver/strategy" require_relative "resolver/requirement_list" -require_relative "resolver/stats" - require_relative "resolver/set" require_relative "resolver/api_set" require_relative "resolver/composed_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/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/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/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/resolver/strategy.rb b/lib/rubygems/resolver/strategy.rb new file mode 100644 index 00000000000000..bf0dbb6adc3580 --- /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) + @source = source + @package_priority_cache = Hash.new {|h, pkg| h[pkg] = {} } + + @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/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/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" 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 diff --git a/test/rubygems/test_gem.rb b/test/rubygems/test_gem.rb index b9a4cf1ce032ce..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" @@ -331,13 +331,11 @@ 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 + # 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") - 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 + assert_equal %w[a-1 b-1 c-1], loaded_spec_names end def test_activate_bin_path_in_debug_mode 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 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..c2fb6f264b92a1 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 @@ -793,13 +812,12 @@ 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 >= 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_dependency_resolution_error.rb b/test/rubygems/test_gem_dependency_resolution_error.rb index 98a6b6b8fd0e08..d8fa96a2602ac4 100644 --- a/test/rubygems/test_gem_dependency_resolution_error.rb +++ b/test/rubygems/test_gem_dependency_resolution_error.rb @@ -6,20 +6,23 @@ 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 + failure = Struct.new(:explanation).new("a depends on b (= 1.0) but no versions match") + @error = Gem::DependencyResolutionError.new failure + end - @activated = Gem::Resolver::ActivationRequest.new @spec, @a2_req + def test_message + assert_equal "a depends on b (= 1.0) but no versions match", @error.message + end - @conflict = Gem::Resolver::Conflict.new @a1_req, @activated + def test_explanation + assert_equal "a depends on b (= 1.0) but no versions match", @error.explanation + end - @error = Gem::DependencyResolutionError.new @conflict + def test_conflict + assert_nil @error.conflict end - def test_message - assert_match(/^conflicting dependencies a \(= 1\) and a \(= 2\)$/, - @error.message) + def test_conflicting_dependencies + assert_equal [], @error.conflicting_dependencies end end 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_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 diff --git a/test/rubygems/test_gem_resolver.rb b/test/rubygems/test_gem_resolver.rb index 4990d5d2dd1339..84ede36b6c85d7 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 @@ -197,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" @@ -511,19 +482,10 @@ 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_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 def test_raises_when_a_gem_is_missing @@ -578,12 +540,11 @@ 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 = 2 which could not be found in any repository/, e.message) end def test_raises_when_possibles_are_exhausted @@ -605,18 +566,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_nil 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 +724,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 +747,43 @@ 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_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 @@ -850,4 +838,338 @@ 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 + + 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_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" + 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(/could not be found in any repository/, e.message) + 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 + + 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" + end + + s = set(a) + ad = make_dep "a" + r = Gem::Resolver.new([ad], s) + + 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 + + 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 + + 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 zzz >= 1 which could not be found in any repository/, 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 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 diff --git a/test/rubygems/test_gem_resolver_strategy.rb b/test/rubygems/test_gem_resolver_strategy.rb new file mode 100644 index 00000000000000..57c9aadde8ab9f --- /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) + 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) + + 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) + + 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) + + 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) + + 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 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