Class: Kettle::Jem::Appraisals::RubySeriesDetector

Inherits:
Object
  • Object
show all
Defined in:
lib/kettle/jem/appraisals/ruby_series_detector.rb,
sig/kettle/jem/appraisals.rbs

Overview

Derives Ruby series buckets from the min_ruby seams across gem versions.

Instead of guessing which Ruby series are needed from the project gemspec, this analyzes the actual required_ruby_version of each selected gem version to find "seams" — points where a gem drops Ruby support.

Ruby series bucket semantics (counterintuitive!)

r3     = catch-all for newest in that major (3.2+), NOT "3.0"
r3.1   = covers 3.0–3.1 (older minors before the catch-all)
r2     = catch-all for 2.7+ (last 2.x)
r2.6   = covers 2.6 only
r2.4   = covers 2.4–2.5
vHEAD  = always included (git HEAD)

The major-only bucket (+rN+) is always the NEWEST catch-all for that major. Named rN.M buckets cover from that minor up to (but not including) the next named bucket.

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(resolver:) ⇒ RubySeriesDetector

Returns a new instance of RubySeriesDetector.

Parameters:



42
43
44
# File 'lib/kettle/jem/appraisals/ruby_series_detector.rb', line 42

def initialize(resolver:)
  @resolver = resolver
end

Instance Attribute Details

#resolverGemVersionResolver (readonly)

Returns the resolver used to query RubyGems.

Returns:



39
40
41
# File 'lib/kettle/jem/appraisals/ruby_series_detector.rb', line 39

def resolver
  @resolver
end

Instance Method Details

#bucket_sort_key(bucket) ⇒ [Integer, Integer]

Sort key for bucket names so r2.4 < r2.6 < r2 < r3.1 < r3

Parameters:

  • bucket (String)

Returns:

  • ([Integer, Integer])


214
215
216
217
218
219
220
221
222
# File 'lib/kettle/jem/appraisals/ruby_series_detector.rb', line 214

def bucket_sort_key(bucket)
  match = bucket.match(/\Ar(\d+)(?:\.(\d+))?\z/)
  return [99, 99] unless match

  major = match[1].to_i
  # Major-only buckets sort after all rN.M buckets of the same major
  minor = match[2] ? match[2].to_i : 999
  [major, minor]
end

#collect_min_rubies(gem_configs) ⇒ Array[Gem::Version]

Collects all distinct min_ruby versions across all gem versions. Clamps any value below MINIMUM_RUBY_FLOOR up to the floor.

Parameters:

  • gem_configs (Array[Hash[String, untyped]])

Returns:

  • (Array[Gem::Version])


125
126
127
128
129
130
131
132
133
134
135
136
137
138
# File 'lib/kettle/jem/appraisals/ruby_series_detector.rb', line 125

def collect_min_rubies(gem_configs)
  rubies = Set.new

  gem_configs.each do |config|
    name = config["name"]
    versions = config["versions"] || []
    next if versions.empty?

    seams = find_seams(name, versions)
    seams.each { |s| rubies << [s[:min_ruby], MINIMUM_RUBY_FLOOR].max }
  end

  rubies.to_a.sort
end

#detect(tier1_gems, tier2_gems, project_min_ruby: nil) ⇒ Array<String>

Detects Ruby series buckets needed for the given gem configs.

Convenience wrapper around #detect_with_ranges that returns only the bucket name list.

Parameters:

  • tier1_gems (Array<Hash>)

    tier1 gem configs with "name" and "versions" keys

  • tier2_gems (Array<Hash>)

    tier2 gem configs with "name" and "versions" keys

  • project_min_ruby (Gem::Version, nil) (defaults to: nil)

    the project's own minimum Ruby (floor)

  • project_min_ruby: (Gem::Version, nil) (defaults to: nil)

Returns:

  • (Array<String>)

    sorted Ruby series bucket names (e.g., ["r2.4", "r2", "r3.1", "r3"])



55
56
57
58
# File 'lib/kettle/jem/appraisals/ruby_series_detector.rb', line 55

def detect(tier1_gems, tier2_gems, project_min_ruby: nil)
  result = detect_with_ranges(tier1_gems, tier2_gems, project_min_ruby: project_min_ruby)
  result[:buckets]
end

#detect_with_ranges(tier1_gems, tier2_gems, project_min_ruby: nil) ⇒ Hash{Symbol => Object}

Detects Ruby series buckets and also returns per-bucket floor/ceiling ranges.

Examples:

detector.detect_with_ranges(tier1, tier2)
#=> { buckets: ["r2", "r3.1", "r3"],
#     bucket_ranges: { "r2" => {floor: v("2.7"), ceiling: v("2.99")}, ... } }

Parameters:

  • tier1_gems (Array<Hash>)

    tier1 gem configs with "name" and "versions" keys

  • tier2_gems (Array<Hash>)

    tier2 gem configs with "name" and "versions" keys

  • project_min_ruby (Gem::Version, nil) (defaults to: nil)

    the project's own minimum Ruby (floor)

  • project_min_ruby: (Gem::Version, nil) (defaults to: nil)

Returns:

  • (Hash{Symbol => Object})

    :buckets (Array) and :bucket_ranges (Hash=> Hash{Symbol => Gem::Version}) with :floor and :ceiling per bucket



71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
# File 'lib/kettle/jem/appraisals/ruby_series_detector.rb', line 71

def detect_with_ranges(tier1_gems, tier2_gems, project_min_ruby: nil)
  all_min_rubies = collect_min_rubies(tier1_gems + tier2_gems)
  if all_min_rubies.empty?
    return {buckets: ["r3"], bucket_ranges: {"r3" => {floor: Gem::Version.new("3.2"), ceiling: Gem::Version.new("99.99")}}}
  end

  if project_min_ruby
    floor = Gem::Version.new(project_min_ruby.to_s)
    all_min_rubies.reject! { |v| v < floor }
    all_min_rubies << floor unless all_min_rubies.include?(floor)
  end

  minor_versions = all_min_rubies.map { |v| minor_key(v) }.uniq.sort
  buckets_and_ranges = minor_versions_to_buckets_with_ranges(minor_versions)
  buckets = buckets_and_ranges[:buckets].sort_by { |b| bucket_sort_key(b) }
  {buckets: buckets, bucket_ranges: buckets_and_ranges[:ranges]}
end

#find_seams(gem_name, versions) ⇒ Array<Hash>

Returns the seam points for a single gem where min_ruby increases.

A "seam" is a version boundary where the gem's required_ruby_version increases compared to the previous version. These are the natural Ruby-compatibility cutoff points.

Examples:

detector.find_seams("activerecord", ["6.0", "6.1", "7.0", "7.1"])
#=> [{version: "6.0", min_ruby: v("2.5")}, {version: "7.0", min_ruby: v("2.7")}]

Parameters:

  • gem_name (String)

    the RubyGems gem name

  • versions (Array<String>)

    sorted minor version strings (e.g., ["6.0", "6.1", "7.0"])

Returns:

  • (Array<Hash>)

    seam entries, each {version: String, min_ruby: Gem::Version}



101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
# File 'lib/kettle/jem/appraisals/ruby_series_detector.rb', line 101

def find_seams(gem_name, versions)
  return [] if versions.empty?

  seams = []
  prev_ruby = nil

  versions.each do |ver|
    patch = latest_patch(gem_name, ver)
    min_ruby = resolver.min_ruby_version(gem_name, patch)
    next unless min_ruby

    if prev_ruby.nil? || min_ruby > prev_ruby
      seams << {version: ver, min_ruby: min_ruby}
    end
    prev_ruby = min_ruby
  end

  seams
end

#latest_patch(gem_name, minor_version) ⇒ String

Parameters:

  • gem_name (String)
  • minor_version (String)

Returns:

  • (String)


224
225
226
227
228
229
230
231
# File 'lib/kettle/jem/appraisals/ruby_series_detector.rb', line 224

def latest_patch(gem_name, minor_version)
  all_versions = resolver.versions(gem_name)
  prefix = "#{minor_version}."
  matching = all_versions.select { |v| v[:number].start_with?(prefix) || v[:number] == minor_version }
  return minor_version if matching.empty?

  matching.max_by { |v| Gem::Version.new(v[:number]) }[:number]
end

#minor_key(version) ⇒ String

Extracts the minor version key from a Gem::Version. e.g., Gem::Version.new("3.1.4") → "3.1"

Parameters:

  • version (Gem::Version)

Returns:

  • (String)


142
143
144
145
# File 'lib/kettle/jem/appraisals/ruby_series_detector.rb', line 142

def minor_key(version)
  segs = version.segments
  "#{segs[0]}.#{segs[1] || 0}"
end

#minor_versions_to_buckets_with_ranges(minor_versions) ⇒ Hash[Symbol, untyped]

Converts a sorted list of minor version strings into bucket names and computes the floor/ceiling Ruby version for each bucket.

Algorithm: Group by major. Within each major, the LAST (newest) minor becomes the catch-all "rN" bucket. Every earlier minor gets an explicit "rN.M" bucket.

Example: ["2.4", "2.6", "2.7", "3.0", "3.1", "3.2"] Major 2: 2.4→r2.4 (floor=2.4, ceil=2.5), 2.6→r2.6 (floor=2.6, ceil=2.6), 2.7→r2 (floor=2.7, ceil=2.99) Major 3: 3.0/3.1→r3.1 (floor=3.0, ceil=3.1), 3.2→r3 (floor=3.2, ceil=3.99)

Parameters:

  • minor_versions (Array[String])

Returns:

  • (Hash[Symbol, untyped])


158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
# File 'lib/kettle/jem/appraisals/ruby_series_detector.rb', line 158

def minor_versions_to_buckets_with_ranges(minor_versions)
  by_major = {}
  minor_versions.each do |mv|
    major = mv.split(".").first.to_i
    by_major[major] ||= []
    by_major[major] << mv
  end

  buckets = []
  ranges = {}

  by_major.each do |major, minors|
    sorted = minors.sort_by { |m| Gem::Version.new(m) }

    if sorted.size == 1
      bucket = "r#{major}"
      buckets << bucket
      ranges[bucket] = {
        floor: Gem::Version.new(sorted[0]),
        ceiling: Gem::Version.new("#{major}.99")
      }
    else
      sorted.each_with_index do |mv, idx|
        minor_num = mv.split(".").last.to_i

        if idx == sorted.size - 1
          # Last → catch-all
          bucket = "r#{major}"
          buckets << bucket
          ranges[bucket] = {
            floor: Gem::Version.new(mv),
            ceiling: Gem::Version.new("#{major}.99")
          }
        else
          # Named bucket rN.M where M = next_minor - 1
          next_minor = sorted[idx + 1].split(".").last.to_i
          upper = (next_minor > 0) ? next_minor - 1 : minor_num
          upper = [upper, minor_num].max
          bucket = "r#{major}.#{upper}"
          buckets << bucket unless buckets.include?(bucket)
          # Don't overwrite if bucket already exists (merged ranges)
          unless ranges.key?(bucket)
            ranges[bucket] = {
              floor: Gem::Version.new(mv),
              ceiling: Gem::Version.new("#{major}.#{upper}")
            }
          end
        end
      end
    end
  end

  {buckets: buckets.uniq, ranges: ranges}
end