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 |
# 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 find_bucket_below(next_seam_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).
242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 |
# File 'lib/kettle/jem/appraisals/matrix_builder.rb', line 242 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.
292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 |
# File 'lib/kettle/jem/appraisals/matrix_builder.rb', line 292 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.
265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 |
# File 'lib/kettle/jem/appraisals/matrix_builder.rb', line 265 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.
220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 |
# File 'lib/kettle/jem/appraisals/matrix_builder.rb', line 220 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).
199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 |
# File 'lib/kettle/jem/appraisals/matrix_builder.rb', line 199 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.
328 329 330 331 332 333 334 335 |
# File 'lib/kettle/jem/appraisals/matrix_builder.rb', line 328 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).
134 135 136 |
# File 'lib/kettle/jem/appraisals/matrix_builder.rb', line 134 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.
139 140 141 |
# File 'lib/kettle/jem/appraisals/matrix_builder.rb', line 139 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.
149 150 151 152 153 154 155 156 157 158 159 160 161 |
# File 'lib/kettle/jem/appraisals/matrix_builder.rb', line 149 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.
175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 |
# File 'lib/kettle/jem/appraisals/matrix_builder.rb', line 175 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 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 |