Skip to content

Commit 82dcf04

Browse files
hsbtclaude
andcommitted
Filter cooldown-excluded versions and surface a hint in errors
Adds a per-version filter to `Resolver#filter_specs` that drops specs whose `created_at` falls within the effective cooldown window. The filter only runs over `@all_specs`, so lockfile-pinned versions in `@base` survive even if a newer release would now be excluded. Specs without `created_at` are kept so historical versions and indexes that do not yet expose timestamps remain usable. A shared `cooldown_now` memoization ensures every comparison within one resolve sees the same timestamp, stabilizing tests near a threshold boundary. When the resolver fails because every matching version is in cooldown, both `raise_not_found!` and `no_versions_incompatibility_for` surface a hint suggesting `--cooldown 0` to bypass; a small `cooldown_hint` helper keeps the wording in sync between the two error paths and is locked down with unit specs. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent b3a8b2e commit 82dcf04

3 files changed

Lines changed: 157 additions & 1 deletion

File tree

bundler/lib/bundler/resolver.rb

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,9 @@ def no_versions_incompatibility_for(package, unsatisfied_term)
203203

204204
platforms_explanation = specs_matching_other_platforms.any? ? " for any resolution platforms (#{package.platforms.join(", ")})" : ""
205205
custom_explanation = "#{constraint} could not be found in #{repository_for(package)}#{platforms_explanation}"
206+
if hint = cooldown_hint(specs_matching_other_platforms)
207+
custom_explanation += " (#{hint})"
208+
end
206209

207210
label = "#{name} (#{constraint_string})"
208211
extended_explanation = other_specs_matching_message(specs_matching_other_platforms, label) if specs_matching_other_platforms.any?
@@ -372,6 +375,10 @@ def raise_not_found!(package)
372375
message << "\n#{other_specs_matching_message(specs, matching_part)}"
373376
end
374377

378+
if hint = cooldown_hint(specs_matching_requirement)
379+
message << "\n\n#{hint}."
380+
end
381+
375382
if specs_matching_requirement.any? && (hint = platform_mismatch_hint)
376383
message << "\n\n#{hint}"
377384
end
@@ -415,7 +422,7 @@ def filter_matching_specs(specs, requirements)
415422
end
416423

417424
def filter_specs(specs, package)
418-
filter_remote_specs(filter_prereleases(specs, package), package)
425+
filter_cooldown(filter_remote_specs(filter_prereleases(specs, package), package))
419426
end
420427

421428
def filter_prereleases(specs, package)
@@ -424,6 +431,35 @@ def filter_prereleases(specs, package)
424431
specs.reject {|s| s.version.prerelease? }
425432
end
426433

434+
def filter_cooldown(specs)
435+
return specs if specs.empty?
436+
excluded = cooldown_excluded_specs(specs)
437+
return specs if excluded.empty?
438+
specs - excluded
439+
end
440+
441+
def cooldown_excluded_specs(specs)
442+
specs.select {|spec| cooldown_excluded?(spec) }
443+
end
444+
445+
def cooldown_hint(specs)
446+
excluded = cooldown_excluded_specs(specs)
447+
return nil if excluded.empty?
448+
"#{excluded.size} version#{"s" if excluded.size > 1} excluded by the cooldown setting; pass `--cooldown 0` to bypass"
449+
end
450+
451+
def cooldown_excluded?(spec)
452+
return false unless spec.respond_to?(:created_at) && spec.created_at
453+
return false unless spec.respond_to?(:remote) && spec.remote
454+
days = spec.remote.effective_cooldown
455+
return false if days.nil? || days <= 0
456+
(cooldown_now - spec.created_at) < (days * 86_400)
457+
end
458+
459+
def cooldown_now
460+
@cooldown_now ||= Time.now
461+
end
462+
427463
def filter_remote_specs(specs, package)
428464
if package.prefer_local?
429465
local_specs = specs.select {|s| s.is_a?(StubSpecification) }
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
# frozen_string_literal: true
2+
3+
RSpec.describe Bundler::Resolver do
4+
let(:resolver) { described_class.allocate }
5+
6+
def remote(cooldown:)
7+
instance_double(Bundler::Source::Rubygems::Remote, effective_cooldown: cooldown)
8+
end
9+
10+
def spec(created_at:, remote:)
11+
Struct.new(:created_at, :remote).new(created_at, remote)
12+
end
13+
14+
describe "#filter_cooldown" do
15+
let(:now) { Time.now }
16+
17+
context "with a 7-day cooldown" do
18+
let(:r) { remote(cooldown: 7) }
19+
20+
it "rejects versions published within the window" do
21+
recent = spec(created_at: now - (2 * 86_400), remote: r)
22+
old = spec(created_at: now - (30 * 86_400), remote: r)
23+
24+
expect(resolver.send(:filter_cooldown, [recent, old])).to eq([old])
25+
end
26+
27+
it "keeps versions published exactly at the threshold" do
28+
boundary = spec(created_at: now - (7 * 86_400), remote: r)
29+
30+
expect(resolver.send(:filter_cooldown, [boundary])).to eq([boundary])
31+
end
32+
33+
it "leaves rolling-delay history intact" do
34+
# 7-day cooldown with frequent releases must still expose an older candidate.
35+
in_cooldown = spec(created_at: now - 86_400, remote: r)
36+
also_in_cooldown = spec(created_at: now - (3 * 86_400), remote: r)
37+
eligible = spec(created_at: now - (10 * 86_400), remote: r)
38+
39+
result = resolver.send(:filter_cooldown, [in_cooldown, also_in_cooldown, eligible])
40+
41+
expect(result).to eq([eligible])
42+
end
43+
end
44+
45+
context "when created_at is missing (blank metadata)" do
46+
it "keeps the spec regardless of cooldown" do
47+
s = spec(created_at: nil, remote: remote(cooldown: 7))
48+
49+
expect(resolver.send(:filter_cooldown, [s])).to eq([s])
50+
end
51+
end
52+
53+
context "when the remote has no cooldown" do
54+
it "keeps every spec" do
55+
s = spec(created_at: now - 3600, remote: remote(cooldown: nil))
56+
57+
expect(resolver.send(:filter_cooldown, [s])).to eq([s])
58+
end
59+
end
60+
61+
context "when cooldown is 0" do
62+
it "keeps every spec (escape hatch)" do
63+
s = spec(created_at: now - 3600, remote: remote(cooldown: 0))
64+
65+
expect(resolver.send(:filter_cooldown, [s])).to eq([s])
66+
end
67+
end
68+
69+
context "when the spec does not respond to created_at" do
70+
it "keeps the spec" do
71+
bare = Struct.new(:version).new("1.0.0")
72+
73+
expect(resolver.send(:filter_cooldown, [bare])).to eq([bare])
74+
end
75+
end
76+
77+
context "when the spec has no remote" do
78+
it "keeps the spec" do
79+
s = spec(created_at: now - 86_400, remote: nil)
80+
81+
expect(resolver.send(:filter_cooldown, [s])).to eq([s])
82+
end
83+
end
84+
85+
it "returns the same array when input is empty" do
86+
expect(resolver.send(:filter_cooldown, [])).to eq([])
87+
end
88+
end
89+
90+
describe "#cooldown_hint" do
91+
let(:now) { Time.now }
92+
let(:r) { remote(cooldown: 7) }
93+
94+
it "returns nil when no spec is excluded" do
95+
expect(resolver.send(:cooldown_hint, [])).to be_nil
96+
end
97+
98+
it "returns nil when every spec is outside the cooldown window" do
99+
eligible = [spec(created_at: now - (30 * 86_400), remote: r)]
100+
101+
expect(resolver.send(:cooldown_hint, eligible)).to be_nil
102+
end
103+
104+
it "mentions the count and the bypass flag for one excluded version" do
105+
excluded = [spec(created_at: now - 86_400, remote: r)]
106+
107+
hint = resolver.send(:cooldown_hint, excluded)
108+
109+
expect(hint).to match(/1 version excluded by the cooldown setting/)
110+
expect(hint).to match(/--cooldown 0/)
111+
end
112+
113+
it "uses plural wording when multiple versions are excluded" do
114+
excluded = Array.new(3) { spec(created_at: now - 86_400, remote: r) }
115+
116+
expect(resolver.send(:cooldown_hint, excluded)).to match(/3 versions excluded/)
117+
end
118+
end
119+
end

spec/support/shards.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,7 @@ module Shards
143143
"spec/bundler/ci_detector_spec.rb",
144144
],
145145
shard_d: [
146+
"spec/bundler/resolver/cooldown_spec.rb",
146147
"spec/commands/outdated_spec.rb",
147148
"spec/commands/update_spec.rb",
148149
"spec/lock/lockfile_spec.rb",

0 commit comments

Comments
 (0)