Class: Kettle::Jem::Appraisals::MatrixBuilder
- Inherits:
-
Object
- Object
- Kettle::Jem::Appraisals::MatrixBuilder
- Defined in:
- lib/kettle/jem/appraisals/matrix_builder.rb,
sig/kettle/jem/appraisals.rbs
Overview
Selects gem versions according to the configured mode and assigns each version to its optimal Ruby bucket.
The optimal bucket for a gem version V is the NEWEST Ruby where V is
the best (latest) choice — i.e., the Ruby just below the next version's
min_ruby requirement. This is the inverted perspective: not "what's the
minimum Ruby this gem needs?" but "what's the newest Ruby where you'd
still use this gem version?"
Constant Summary collapse
- VALID_MODES =
Returns valid mode strings accepted by #select_versions.
%w[major minor patch minor-minmax semver].freeze
- LARGE_MAJOR_THRESHOLD =
Returns when a single major version has more than this many minor versions, semver mode prunes to only the latest minor + Ruby-cutoff minors.
9
Instance Attribute Summary collapse
-
#resolver ⇒ GemVersionResolver
readonly
The resolver used to query RubyGems.
Instance Method Summary collapse
-
#assign_version_buckets(gem_name, selected_versions, seams:, buckets:, bucket_ranges:, all_versions: nil) ⇒ Array<Hash>
Assigns each selected version to its optimal Ruby bucket.
-
#compute_version_min_rubies(all_minors, seams) ⇒ Hash[String, Gem::Version]
Builds a hash mapping each minor version string to its min_ruby (Gem::Version).
-
#fill_bucket_gaps(assignments, selected_sorted, version_min_ruby, buckets, bucket_ranges, all_minors) ⇒ Array[Hash[Symbol, untyped]]
Fills gaps where a bucket has no assigned version.
-
#find_bucket_below(ruby_floor, buckets, bucket_ranges) ⇒ String?
Finds the bucket whose range covers the Ruby version just below
ruby_floor. -
#find_next_seam_ruby(ver, ver_min_ruby, all_minors, version_min_ruby) ⇒ Gem::Version?
Finds the next seam's min_ruby AFTER a given version.
-
#find_ruby_cutoff_versions(gem_name, minor_versions, requirements: nil) ⇒ Array[String]
Finds versions where the following version drops support for a Ruby version that the current version supports.
-
#initialize(resolver:) ⇒ MatrixBuilder
constructor
A new instance of MatrixBuilder.
-
#latest_patch(gem_name, minor_version, requirements: nil) ⇒ String
Finds the latest patch release for a given minor version.
-
#select_major(by_major) ⇒ Array[String]
One entry per major version (the latest minor of each).
-
#select_minor(by_major) ⇒ Array[String]
Every minor version across all supported majors.
-
#select_minor_minmax(by_major, current_major) ⇒ Array[String]
First + last minor per major < current; all minors of current major.
-
#select_semver(gem_name, by_major, current_major, requirements: nil) ⇒ Array<String>
Last minor per major < current + minors where required_ruby_version changes (natural Ruby cutoff points) + all minors of current major.
-
#select_versions(gem_name, mode:, requirements: nil) ⇒ Array<String>
Returns selected version strings for a gem according to the mode.
Constructor Details
#initialize(resolver:) ⇒ MatrixBuilder
Returns a new instance of MatrixBuilder.
33 34 35 |
# File 'lib/kettle/jem/appraisals/matrix_builder.rb', line 33 def initialize(resolver:) @resolver = resolver end |
Instance Attribute Details
#resolver ⇒ GemVersionResolver (readonly)
Returns the resolver used to query RubyGems.
30 31 32 |
# File 'lib/kettle/jem/appraisals/matrix_builder.rb', line 30 def resolver @resolver end |
Instance Method Details
#assign_version_buckets(gem_name, selected_versions, seams:, buckets:, bucket_ranges:, all_versions: nil) ⇒ Array<Hash>
Assigns each selected version to its optimal Ruby bucket.
Instead of cross-producting versions × all buckets, each version maps to the ONE bucket where it is the best (newest) choice.
Filler: if a bucket has no selected version assigned (gap from
mode selection skipping versions that naturally cover that bucket),
backfill with the newest version from a prior seam range that can
run on that bucket's Ruby. Filler entries are marked with filler: true.
93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 |
# File 'lib/kettle/jem/appraisals/matrix_builder.rb', line 93 def assign_version_buckets(gem_name, selected_versions, seams:, buckets:, bucket_ranges:, all_versions: nil) return [] if selected_versions.empty? || buckets.empty? # Build a lookup: version → min_ruby from seams # For versions between seams, inherit the previous seam's min_ruby all_versions ||= resolver.minor_versions_by_major(gem_name).flat_map { |e| e[:minors] } version_min_ruby = compute_version_min_rubies(all_versions, seams) # For each selected version, find which bucket it's optimal for. # "Optimal" = the bucket whose ceiling is just below the NEXT SEAM # boundary (from the full seam list, not just selected versions). # This correctly handles major mode where selected versions may skip seams. assignments = [] selected_sorted = selected_versions.sort_by { |v| Gem::Version.new(v) } selected_sorted.each do |ver| ver_min_ruby = version_min_ruby[ver] next unless ver_min_ruby # Find the next seam boundary AFTER this version's min_ruby. # This is the min_ruby where a NEWER version of this gem takes over. # We use the full seam list, not just selected versions. next_seam_ruby = find_next_seam_ruby(ver, ver_min_ruby, all_versions, version_min_ruby) bucket = if next_seam_ruby # When the next seam is at or below the project's Ruby floor, no # bucket sits below it; test this version on the lowest bucket # that can run it instead of dropping it. find_bucket_below(next_seam_ruby, buckets, bucket_ranges) || find_lowest_runnable_bucket(ver_min_ruby, buckets, bucket_ranges) else # This is in the latest seam range — catch-all bucket buckets.last end assignments << {version: ver, bucket: bucket} if bucket end # Handle filler: fill gaps where buckets have no assigned version fill_bucket_gaps(assignments, selected_sorted, version_min_ruby, buckets, bucket_ranges, all_versions) end |
#compute_version_min_rubies(all_minors, seams) ⇒ Hash[String, Gem::Version]
Builds a hash mapping each minor version string to its min_ruby (Gem::Version). Versions between seams inherit the previous seam's min_ruby. Values are clamped to MINIMUM_RUBY_FLOOR (setup-ruby GHA minimum).
257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 |
# File 'lib/kettle/jem/appraisals/matrix_builder.rb', line 257 def compute_version_min_rubies(all_minors, seams) mapping = {} current_ruby = nil # Seams are sorted by version. Walk all minors and apply seam boundaries. seam_idx = 0 all_minors.each do |ver| gem_ver = Gem::Version.new(ver) # Advance to the right seam while seam_idx < seams.size && Gem::Version.new(seams[seam_idx][:version]) <= gem_ver current_ruby = seams[seam_idx][:min_ruby] seam_idx += 1 end if current_ruby mapping[ver] = [current_ruby, Kettle::Jem::Appraisals::MINIMUM_RUBY_FLOOR].max end end mapping end |
#fill_bucket_gaps(assignments, selected_sorted, version_min_ruby, buckets, bucket_ranges, all_minors) ⇒ Array[Hash[Symbol, untyped]]
Fills gaps where a bucket has no assigned version. When mode selection (e.g., major) picks a version that skips a bucket (e.g., AR 7.2 on r3.1 but nothing on r2), we backfill with the newest unselected version from the gem's full version list that's optimal for that bucket.
307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 |
# File 'lib/kettle/jem/appraisals/matrix_builder.rb', line 307 def fill_bucket_gaps(assignments, selected_sorted, version_min_ruby, buckets, bucket_ranges, all_minors) covered = assignments.map { |a| a[:bucket] }.uniq # Build min_ruby for ALL minors (not just selected) for filler lookup uncovered = buckets - covered return assignments if uncovered.empty? uncovered.each do |bucket| range = bucket_ranges[bucket] next unless range # Find the newest minor version (from full list) whose min_ruby # falls within this bucket's range (floor <= min_ruby <= ceiling) filler = all_minors.reverse.find { |ver| ver_ruby = version_min_ruby[ver] next false unless ver_ruby ver_ruby.between?(range[:floor], range[:ceiling]) } # If no version has min_ruby IN the range, find the newest version # whose min_ruby is BELOW the range (it can still run on this Ruby) filler ||= all_minors.reverse.find { |ver| ver_ruby = version_min_ruby[ver] next false unless ver_ruby ver_ruby <= range[:ceiling] } assignments << {version: filler, bucket: bucket, filler: true} if filler end assignments.sort_by { |a| bucket_ranges.dig(a[:bucket], :floor) || Gem::Version.new("0") } end |
#find_bucket_below(ruby_floor, buckets, bucket_ranges) ⇒ String?
Finds the bucket whose range covers the Ruby version just below ruby_floor.
E.g., if ruby_floor is 2.5, returns the bucket for Ruby 2.4.
280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 |
# File 'lib/kettle/jem/appraisals/matrix_builder.rb', line 280 def find_bucket_below(ruby_floor, buckets, bucket_ranges) # We want the bucket whose ceiling is just below ruby_floor best_bucket = nil best_ceiling = nil buckets.each do |b| range = bucket_ranges[b] next unless range ceiling = range[:ceiling] # The bucket's ceiling must be BELOW the next version's min_ruby next if ceiling >= ruby_floor if best_ceiling.nil? || ceiling > best_ceiling best_bucket = b best_ceiling = ceiling end end best_bucket end |
#find_next_seam_ruby(ver, ver_min_ruby, all_minors, version_min_ruby) ⇒ Gem::Version?
Finds the next seam's min_ruby AFTER a given version. Walks the full version list to find where min_ruby next increases after this version. Uses the full list (not selected), so mode doesn't affect seam detection.
235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 |
# File 'lib/kettle/jem/appraisals/matrix_builder.rb', line 235 def find_next_seam_ruby(ver, ver_min_ruby, all_minors, version_min_ruby) gem_ver = Gem::Version.new(ver) found_current = false all_minors.each do |mv| mv_gem = Gem::Version.new(mv) if mv_gem >= gem_ver found_current = true end next unless found_current mv_ruby = version_min_ruby[mv] next unless mv_ruby return mv_ruby if mv_ruby > ver_min_ruby end nil end |
#find_ruby_cutoff_versions(gem_name, minor_versions, requirements: nil) ⇒ Array[String]
Finds versions where the following version drops support for a Ruby version that the current version supports. These are natural cutoff points. Returns the version before the drop (the last to support the Ruby version).
214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 |
# File 'lib/kettle/jem/appraisals/matrix_builder.rb', line 214 def find_ruby_cutoff_versions(gem_name, minor_versions, requirements: nil) return [] if minor_versions.size < 2 cutoffs = [] prev_ruby = nil minor_versions.each do |version| current_ruby = resolver.min_ruby_version(gem_name, latest_patch(gem_name, version, requirements: requirements)) if prev_ruby && current_ruby && current_ruby > prev_ruby cutoffs << minor_versions[minor_versions.index(version) - 1] end prev_ruby = current_ruby end cutoffs end |
#latest_patch(gem_name, minor_version, requirements: nil) ⇒ String
Finds the latest patch release for a given minor version.
343 344 345 346 347 348 349 350 |
# File 'lib/kettle/jem/appraisals/matrix_builder.rb', line 343 def latest_patch(gem_name, minor_version, requirements: nil) all_versions = resolver.versions(gem_name, requirements: requirements) 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 |
#select_major(by_major) ⇒ Array[String]
One entry per major version (the latest minor of each).
146 147 148 |
# File 'lib/kettle/jem/appraisals/matrix_builder.rb', line 146 def select_major(by_major) by_major.map { |entry| entry[:minors].last } end |
#select_minor(by_major) ⇒ Array[String]
Every minor version across all supported majors.
151 152 153 |
# File 'lib/kettle/jem/appraisals/matrix_builder.rb', line 151 def select_minor(by_major) by_major.flat_map { |entry| entry[:minors] } end |
#select_minor_minmax(by_major, current_major) ⇒ Array[String]
First + last minor per major < current; all minors of current major.
161 162 163 164 165 166 167 168 169 170 171 172 173 |
# File 'lib/kettle/jem/appraisals/matrix_builder.rb', line 161 def select_minor_minmax(by_major, current_major) versions = [] by_major.each do |entry| if entry[:major] < current_major minors = entry[:minors] versions << minors.first versions << minors.last if minors.size > 1 else versions.concat(entry[:minors]) end end versions.uniq end |
#select_semver(gem_name, by_major, current_major, requirements: nil) ⇒ Array<String>
Last minor per major < current + minors where required_ruby_version changes (natural Ruby cutoff points) + all minors of current major.
For major versions with more than LARGE_MAJOR_THRESHOLD minors,
only the latest minor and Ruby-cutoff minors are kept. This prevents
gems like aws-sdk-dynamodb (166 minors in one major) from exploding
the matrix.
187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 |
# File 'lib/kettle/jem/appraisals/matrix_builder.rb', line 187 def select_semver(gem_name, by_major, current_major, requirements: nil) versions = [] by_major.each do |entry| if entry[:major] < current_major versions << entry[:minors].last ruby_cutoff_versions = find_ruby_cutoff_versions(gem_name, entry[:minors], requirements: requirements) versions.concat(ruby_cutoff_versions) elsif entry[:minors].size > LARGE_MAJOR_THRESHOLD # Large current major: prune to latest + Ruby cutoffs only ruby_cutoff_versions = find_ruby_cutoff_versions(gem_name, entry[:minors], requirements: requirements) versions.concat(ruby_cutoff_versions) versions << entry[:minors].last else versions.concat(entry[:minors]) end end # Explicit requirements declare the oldest supported version, so test it. versions << by_major.first[:minors].first if requirements && !requirements.empty? versions.uniq.sort_by { |v| Gem::Version.new(v) } end |
#select_versions(gem_name, mode:, requirements: nil) ⇒ Array<String>
Returns selected version strings for a gem according to the mode.
46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 |
# File 'lib/kettle/jem/appraisals/matrix_builder.rb', line 46 def select_versions(gem_name, mode:, requirements: nil) raise ArgumentError, "Invalid mode: #{mode}. Must be one of: #{VALID_MODES.join(", ")}" unless VALID_MODES.include?(mode) if mode == "patch" return select_patch(gem_name, requirements: requirements) end by_major = resolver.minor_versions_by_major(gem_name, requirements: requirements) return [] if by_major.empty? current_major = by_major.last[:major] case mode when "major" select_major(by_major) when "minor" select_minor(by_major) when "patch" select_patch(gem_name, requirements: requirements) when "minor-minmax" select_minor_minmax(by_major, current_major) when "semver" select_semver(gem_name, by_major, current_major, requirements: requirements) end end |