Skip to content

Commit a920da0

Browse files
Fix transitive prerelease filtering and clean up resolver internals
1 parent 8abea2c commit a920da0

4 files changed

Lines changed: 64 additions & 39 deletions

File tree

lib/rubygems/resolver.rb

Lines changed: 37 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ def initialize(needed, set = nil)
109109
@all_specs = Hash.new {|h, name| h[name] = filter_specs(@unfiltered_specs[name]) }
110110
@all_versions = Hash.new {|h, pkg| h[pkg] = @all_specs[pkg.to_s].map(&:version).uniq.sort }
111111
@sorted_versions = Hash.new do |h, pkg|
112-
h[pkg] = Gem::PubGrub::Package.root?(pkg) ? [@root_version] : filter_versions(pkg)
112+
h[pkg] = Gem::PubGrub::Package.root?(pkg) ? [@root_version] : @all_versions[pkg]
113113
end
114114
@cached_dependencies = Hash.new do |h, pkg|
115115
h[pkg] = if Gem::PubGrub::Package.root?(pkg)
@@ -133,14 +133,8 @@ def resolve
133133
all = @set.find_all(dep_request)
134134
matching = select_local_platforms(all)
135135

136-
if matching.empty?
137-
exc = Gem::UnsatisfiableDependencyError.new(dep_request, all)
138-
exc.errors = @set.errors
139-
raise exc
140-
end
136+
next unless matching.empty?
141137

142-
specs_matching_requirement = matching.select {|s| dep.requirement.satisfied_by?(s.version) }
143-
next unless specs_matching_requirement.empty?
144138
exc = Gem::UnsatisfiableDependencyError.new(dep_request, all)
145139
exc.errors = @set.errors
146140
raise exc
@@ -149,7 +143,7 @@ def resolve
149143
solver = Gem::PubGrub::VersionSolver.new(
150144
source: self,
151145
root: @root_package,
152-
strategy: Gem::Resolver::Strategy.new(self, @root_package),
146+
strategy: Gem::Resolver::Strategy.new(self),
153147
logger: make_logger
154148
)
155149
result = solver.solve
@@ -198,7 +192,21 @@ def all_versions_for(package)
198192
end
199193

200194
def versions_for(package, range = Gem::PubGrub::VersionRange.any)
201-
@versions_for_cache[[package, range]] ||= range.select_versions(@sorted_versions[package])
195+
@versions_for_cache[[package, range]] ||= begin
196+
candidates = range.select_versions(@sorted_versions[package])
197+
198+
if Gem::PubGrub::Package.root?(package) ||
199+
(@set.respond_to?(:prerelease) && @set.prerelease) ||
200+
range_admits_prerelease?(range)
201+
candidates
202+
elsif @all_versions[package].any? {|v| !v.prerelease? }
203+
candidates.reject(&:prerelease?)
204+
else
205+
# Only prereleases exist for this gem; fall back to them so
206+
# dependencies like `>= 1.0` can still be satisfied.
207+
candidates
208+
end
209+
end
202210
end
203211

204212
def no_versions_incompatibility_for(_package, unsatisfied_term)
@@ -307,9 +315,6 @@ def package_for(name)
307315
@packages[name] ||= Gem::PubGrub::Package.new(name)
308316
end
309317

310-
# Filter versions to exclude prereleases unless prerelease is enabled.
311-
# Both all_versions_for and versions_for use this filtered set to ensure
312-
# PubGrub's constraint propagation and version selection are consistent.
313318
def root_dependencies
314319
deps = {}
315320
@needed.each do |dep|
@@ -319,24 +324,16 @@ def root_dependencies
319324
deps
320325
end
321326

322-
def filter_versions(package)
323-
all_versions = @all_versions[package]
324-
if (@set.respond_to?(:prerelease) && @set.prerelease) || root_requires_prerelease?(package)
325-
all_versions
326-
else
327-
stable = all_versions.reject(&:prerelease?)
328-
stable.empty? ? all_versions : stable
327+
# Only the min bound is inspected: `~>` synthesises a max like `X.A`
328+
# whose suffix looks prerelease to Gem::Version but is not the user's
329+
# intent, so checking max would mis-admit prereleases for every `~>`.
330+
def range_admits_prerelease?(range)
331+
range.ranges.any? do |r|
332+
next false if r.empty?
333+
r.min&.prerelease?
329334
end
330335
end
331336

332-
# Root deps with an explicit prerelease requirement (e.g. `= 4.1.0.dev`)
333-
# must keep their prerelease versions in the filtered set; otherwise
334-
# PubGrub cannot match them even though `@set.find_all` returned them.
335-
def root_requires_prerelease?(package)
336-
name = package.to_s
337-
@needed.any? {|dep| dep.name == name && dep.requirement.prerelease? }
338-
end
339-
340337
def find_unfiltered_specs_for(name)
341338
dep = Gem::Dependency.new(name, ">= 0.a")
342339
dep_request = DependencyRequest.new(dep, nil)
@@ -413,9 +410,17 @@ def build_extended_explanation(name, constraint)
413410

414411
filtered = @all_specs[name]
415412
pkg = package_for(name)
416-
has_prerelease_filtering = @all_versions[pkg].length > @sorted_versions[pkg].length
417413

418-
return if filtered.length == unfiltered.length && !has_prerelease_filtering
414+
# A prerelease hint applies when the source would strip prereleases for
415+
# this constraint (global prerelease flag off and the constraint's range
416+
# doesn't itself reach into prerelease territory) AND a prerelease of
417+
# the gem exists somewhere.
418+
prerelease_gated = !(@set.respond_to?(:prerelease) && @set.prerelease) &&
419+
!range_admits_prerelease?(constraint.range)
420+
has_prerelease_candidate = prerelease_gated &&
421+
@all_versions[pkg].any?(&:prerelease?)
422+
423+
return if filtered.length == unfiltered.length && !has_prerelease_candidate
419424

420425
hints = []
421426

@@ -450,9 +455,8 @@ def build_extended_explanation(name, constraint)
450455
end
451456

452457
# Check for specs filtered by prerelease status
453-
unless @set.respond_to?(:prerelease) && @set.prerelease
454-
pkg = package_for(name)
455-
prerelease_versions = @all_versions[pkg].select(&:prerelease?) - @sorted_versions[pkg]
458+
if prerelease_gated
459+
prerelease_versions = @all_versions[pkg].select(&:prerelease?)
456460
if prerelease_versions.any?
457461
versions = prerelease_versions.sort.reverse.first(3) # limit to avoid cluttering error output
458462
hints << "#{name} #{versions.join(", ")} are pre-release versions. Use --prerelease to allow pre-release gems."

lib/rubygems/resolver/strategy.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
# calls during the solver's package selection loop.
66

77
class Gem::Resolver::Strategy
8-
def initialize(source, root_package)
8+
def initialize(source)
99
@source = source
1010
@package_priority_cache = {}
1111

test/rubygems/test_gem_resolver.rb

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -844,6 +844,27 @@ def test_resolve_prerelease_required_by_exact_requirement
844844
assert_resolves_to [a_pre], r
845845
end
846846

847+
def test_resolve_transitive_prerelease_required_by_exact_requirement
848+
# A transitive dep with an exact prerelease version must resolve to that
849+
# version even when stable versions of the same gem are in the set.
850+
# The gate on prereleases lives in versions_for and is per-constraint:
851+
# `= 2.0.pre` carries a prerelease bound, so prereleases are admitted for
852+
# this range even though the global prerelease flag is off.
853+
a = util_spec "a", "1.0" do |s|
854+
s.add_dependency "b", "= 2.0.pre"
855+
end
856+
857+
b_stable = util_spec "b", "1.0"
858+
b_pre = util_spec "b", "2.0.pre"
859+
860+
s = set(a, b_stable, b_pre)
861+
862+
ad = make_dep "a"
863+
r = Gem::Resolver.new([ad], s)
864+
865+
assert_resolves_to [a, b_pre], r
866+
end
867+
847868
def test_error_includes_platform_hint_when_specs_exist_for_other_platforms
848869
a = util_spec "a", "1.0" do |s|
849870
s.add_dependency "b", ">= 1.0"

test/rubygems/test_gem_resolver_strategy.rb

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ def test_most_preferred_version_respects_all_versions_for_ordering
5858
pkg = make_package("a")
5959
source = StubSource.new("a" => [v("2.0"), v("1.0"), v("3.0")])
6060

61-
strategy = Gem::Resolver::Strategy.new(source, Gem::PubGrub::Package.root)
61+
strategy = Gem::Resolver::Strategy.new(source)
6262
unsatisfied = { pkg => make_range_any }
6363

6464
_package, version = strategy.next_package_and_version(unsatisfied)
@@ -77,7 +77,7 @@ def test_picks_most_constrained_package
7777
"b" => [v("1.0")]
7878
)
7979

80-
strategy = Gem::Resolver::Strategy.new(source, Gem::PubGrub::Package.root)
80+
strategy = Gem::Resolver::Strategy.new(source)
8181

8282
unsatisfied = {
8383
pkg_a => make_range_any,
@@ -104,7 +104,7 @@ def test_picks_package_with_fewer_higher_versions_as_tiebreaker
104104
"b" => [v("3.0"), v("2.0"), v("1.0")]
105105
)
106106

107-
strategy = Gem::Resolver::Strategy.new(source, Gem::PubGrub::Package.root)
107+
strategy = Gem::Resolver::Strategy.new(source)
108108

109109
unsatisfied = {
110110
pkg_a => range,
@@ -120,7 +120,7 @@ def test_cache_prevents_redundant_versions_for_calls
120120
pkg = make_package("a")
121121
source = StubSource.new("a" => [v("2.0"), v("1.0")])
122122

123-
strategy = Gem::Resolver::Strategy.new(source, Gem::PubGrub::Package.root)
123+
strategy = Gem::Resolver::Strategy.new(source)
124124

125125
range = make_range_any
126126
unsatisfied = { pkg => range }
@@ -143,7 +143,7 @@ def test_cache_is_keyed_by_package_and_range
143143
pkg = make_package("a")
144144
source = StubSource.new("a" => [v("3.0"), v("2.0"), v("1.0")])
145145

146-
strategy = Gem::Resolver::Strategy.new(source, Gem::PubGrub::Package.root)
146+
strategy = Gem::Resolver::Strategy.new(source)
147147

148148
range_any = make_range_any
149149
range_gte = make_range_gte(v("2.0"))

0 commit comments

Comments
 (0)