ruby/lib/bundler/gem_version_promoter.rb
David Rodríguez c77354157f [rubygems/rubygems] Fix locked gems being upgraded when locked dependencies are incorrect
Resolver had internal logic to prioritize locked versions when sorting
versions, however part of it was not being actually hit because of how
unlocking worked in the resolver: a package was allow to be unlocked
when that was explicit requested or when the list of unlocks was empty.
That did not make a lot of sense and other cases were working because
the explicit list of unlocks was getting "artificially filled".

Now we consider a package unlocked when explicitly requested (`bundle
update <package>`), or when everything is being unlocked (`bundle
install` with no lockfile or `bundle update`).

This makes things simpler and gets the edge case added as a test case
working as expected.

b8e55087f0
2025-02-18 12:12:51 +09:00

147 lines
5.1 KiB
Ruby

# frozen_string_literal: true
module Bundler
# This class contains all of the logic for determining the next version of a
# Gem to update to based on the requested level (patch, minor, major).
# Primarily designed to work with Resolver which will provide it the list of
# available dependency versions as found in its index, before returning it to
# to the resolution engine to select the best version.
class GemVersionPromoter
attr_reader :level
attr_accessor :pre
# By default, strict is false, meaning every available version of a gem
# is returned from sort_versions. The order gives preference to the
# requested level (:patch, :minor, :major) but in complicated requirement
# cases some gems will by necessity be promoted past the requested level,
# or even reverted to older versions.
#
# If strict is set to true, the results from sort_versions will be
# truncated, eliminating any version outside the current level scope.
# This can lead to unexpected outcomes or even VersionConflict exceptions
# that report a version of a gem not existing for versions that indeed do
# existing in the referenced source.
attr_accessor :strict
# Creates a GemVersionPromoter instance.
#
# @return [GemVersionPromoter]
def initialize
@level = :major
@strict = false
@pre = false
end
# @param value [Symbol] One of three Symbols: :major, :minor or :patch.
def level=(value)
v = case value
when String, Symbol
value.to_sym
end
raise ArgumentError, "Unexpected level #{v}. Must be :major, :minor or :patch" unless [:major, :minor, :patch].include?(v)
@level = v
end
# Given a Resolver::Package and an Array of Specifications of available
# versions for a gem, this method will return the Array of Specifications
# sorted in an order to give preference to the current level (:major, :minor
# or :patch) when resolution is deciding what versions best resolve all
# dependencies in the bundle.
# @param package [Resolver::Package] The package being resolved.
# @param specs [Specification] An array of Specifications for the package.
# @return [Specification] A new instance of the Specification Array sorted.
def sort_versions(package, specs)
locked_version = package.locked_version
result = specs.sort do |a, b|
unless package.prerelease_specified? || pre?
a_pre = a.prerelease?
b_pre = b.prerelease?
next 1 if a_pre && !b_pre
next -1 if b_pre && !a_pre
end
if major? || locked_version.nil?
b <=> a
elsif either_version_older_than_locked?(a, b, locked_version)
b <=> a
elsif segments_do_not_match?(a, b, :major)
a <=> b
elsif !minor? && segments_do_not_match?(a, b, :minor)
a <=> b
else
b <=> a
end
end
post_sort(result, package.unlock?, locked_version)
end
# @return [bool] Convenience method for testing value of level variable.
def major?
level == :major
end
# @return [bool] Convenience method for testing value of level variable.
def minor?
level == :minor
end
# @return [bool] Convenience method for testing value of pre variable.
def pre?
pre == true
end
# Given a Resolver::Package and an Array of Specifications of available
# versions for a gem, this method will truncate the Array if strict
# is true. That means filtering out downgrades from the version currently
# locked, and filtering out upgrades that go past the selected level (major,
# minor, or patch).
# @param package [Resolver::Package] The package being resolved.
# @param specs [Specification] An array of Specifications for the package.
# @return [Specification] A new instance of the Specification Array
# truncated.
def filter_versions(package, specs)
return specs unless strict
locked_version = package.locked_version
return specs if locked_version.nil? || major?
specs.select do |spec|
gsv = spec.version
must_match = minor? ? [0] : [0, 1]
all_match = must_match.all? {|idx| gsv.segments[idx] == locked_version.segments[idx] }
all_match && gsv >= locked_version
end
end
private
def either_version_older_than_locked?(a, b, locked_version)
a.version < locked_version || b.version < locked_version
end
def segments_do_not_match?(a, b, level)
index = [:major, :minor].index(level)
a.segments[index] != b.segments[index]
end
# Specific version moves can't always reliably be done during sorting
# as not all elements are compared against each other.
def post_sort(result, unlock, locked_version)
if unlock || locked_version.nil?
result
else
move_version_to_beginning(result, locked_version)
end
end
def move_version_to_beginning(result, version)
move, keep = result.partition {|s| s.version.to_s == version.to_s }
move.concat(keep)
end
end
end