From c8a31c1451ab0627fa8f24fd623b6e2383db6027 Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Thu, 15 Oct 2026 20:09:55 -0700 Subject: [PATCH 1/2] ref(crons): Extract Fugit schedule conversion Move the fugit cron and interval to monitor config conversion out of the rufus-scheduler integration into Sentry::Cron::FugitSchedule so other fugit-based schedulers can reuse it. No behavior change. Co-Authored-By: Claude --- Gemfile.rubocop.lock | 64 +++++++-------- sentry-ruby/lib/sentry-ruby.rb | 1 + sentry-ruby/lib/sentry/cron/fugit_schedule.rb | 80 +++++++++++++++++++ sentry-ruby/lib/sentry/rufus_scheduler.rb | 65 +-------------- .../spec/sentry/cron/fugit_schedule_spec.rb | 77 ++++++++++++++++++ .../spec/sentry/rufus_scheduler_spec.rb | 69 ++-------------- 6 files changed, 200 insertions(+), 156 deletions(-) create mode 100644 sentry-ruby/lib/sentry/cron/fugit_schedule.rb create mode 100644 sentry-ruby/spec/sentry/cron/fugit_schedule_spec.rb diff --git a/Gemfile.rubocop.lock b/Gemfile.rubocop.lock index f63e0cb3e..c0ebf52d2 100644 --- a/Gemfile.rubocop.lock +++ b/Gemfile.rubocop.lock @@ -87,39 +87,39 @@ DEPENDENCIES rubocop-rails-omakase CHECKSUMS - activesupport (8.1.1) - ast (2.4.3) - base64 (0.3.0) - bigdecimal (4.1.2) + activesupport (8.1.1) sha256=5e92534e8d0c8b8b5e6b16789c69dbea65c1d7b752269f71a39422e9546cea67 + ast (2.4.3) sha256=954615157c1d6a382bc27d690d973195e79db7f55e9765ac7c481c60bdb4d383 + base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b + bigdecimal (4.1.2) sha256=53d217666027eab4280346fba98e7d5b66baaae1b9c3c1c0ffe89d48188a3fbd bundler (4.0.15) sha256=a4ceb882fe94a0e0ac63cd0813932bbfd631a14e5ac0b7975189b19a4d28d9e7 - concurrent-ruby (1.3.7) - connection_pool (3.0.2) - drb (2.2.3) - i18n (1.14.8) - json (2.20.0) - language_server-protocol (3.17.0.5) - lint_roller (1.1.0) - logger (1.7.0) - minitest (6.0.6) - parallel (1.27.0) - parser (3.3.10.0) - prism (1.9.0) - racc (1.8.1) - rack (3.2.1) - rainbow (3.1.1) - regexp_parser (2.11.3) - rubocop (1.82.0) - rubocop-ast (1.48.0) - rubocop-packaging (0.6.0) - rubocop-performance (1.26.1) - rubocop-rails (2.34.2) - rubocop-rails-omakase (1.1.0) - ruby-progressbar (1.13.0) - securerandom (0.4.1) - tzinfo (2.0.6) - unicode-display_width (3.2.0) - unicode-emoji (4.2.0) - uri (1.1.1) + concurrent-ruby (1.3.7) sha256=4412caec3a5ea2e5fdc52076724c071a81f2c0593d83b2ac8cbb8ca63b3151b0 + connection_pool (3.0.2) sha256=33fff5ba71a12d2aa26cb72b1db8bba2a1a01823559fb01d29eb74c286e62e0a + drb (2.2.3) sha256=0b00d6fdb50995fe4a45dea13663493c841112e4068656854646f418fda13373 + i18n (1.14.8) sha256=285778639134865c5e0f6269e0b818256017e8cde89993fdfcbfb64d088824a5 + json (2.20.0) sha256=9362bc6e55a952b056abf9167cf053358181c904cb70cd6eee0808ea830fc32b + language_server-protocol (3.17.0.5) sha256=fd1e39a51a28bf3eec959379985a72e296e9f9acfce46f6a79d31ca8760803cc + lint_roller (1.1.0) sha256=2c0c845b632a7d172cb849cc90c1bce937a28c5c8ccccb50dfd46a485003cc87 + logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203 + minitest (6.0.6) sha256=153ea36d1d987a62942382b61075745042a2b3123b1cd48f4c3675af9cc7d6f1 + parallel (1.27.0) sha256=4ac151e1806b755fb4e2dc2332cbf0e54f2e24ba821ff2d3dcf86bf6dc4ae130 + parser (3.3.10.0) sha256=ce3587fa5cc55a88c4ba5b2b37621b3329aadf5728f9eafa36bbd121462aabd6 + prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85 + racc (1.8.1) sha256=4a7f6929691dbec8b5209a0b373bc2614882b55fc5d2e447a21aaa691303d62f + rack (3.2.1) sha256=30af3f7e5ec21b0d14d822cf24446048dba5f651b617c7e97405b604f20a9e33 + rainbow (3.1.1) sha256=039491aa3a89f42efa1d6dec2fc4e62ede96eb6acd95e52f1ad581182b79bc6a + regexp_parser (2.11.3) sha256=ca13f381a173b7a93450e53459075c9b76a10433caadcb2f1180f2c741fc55a4 + rubocop (1.82.0) sha256=237b7dc24952d7ec469a9593c7a5283315515e2e7dc24ac91532819c254fc4ec + rubocop-ast (1.48.0) sha256=22df9bbf3f7a6eccde0fad54e68547ae1e2a704bf8719e7c83813a99c05d2e76 + rubocop-packaging (0.6.0) sha256=fb92bd0fb48e6f8cdb1648d2249b0cd51c2497dcc87340132d22f01edbf558a7 + rubocop-performance (1.26.1) sha256=cd19b936ff196df85829d264b522fd4f98b6c89ad271fa52744a8c11b8f71834 + rubocop-rails (2.34.2) sha256=10ff246ee48b25ffeabddc5fee86d159d690bb3c7b9105755a9c7508a11d6e22 + rubocop-rails-omakase (1.1.0) sha256=2af73ac8ee5852de2919abbd2618af9c15c19b512c4cfc1f9a5d3b6ef009109d + ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33 + securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1 + tzinfo (2.0.6) sha256=8daf828cc77bcf7d63b0e3bdb6caa47e2272dcfaf4fbfe46f8c3a9df087a829b + unicode-display_width (3.2.0) sha256=0cdd96b5681a5949cdbc2c55e7b420facae74c4aaf9a9815eee1087cb1853c42 + unicode-emoji (4.2.0) sha256=519e69150f75652e40bf736106cfbc8f0f73aa3fb6a65afe62fefa7f80b0f80f + uri (1.1.1) sha256=379fa58d27ffb1387eaada68c749d1426738bd0f654d812fcc07e7568f5c57c6 BUNDLED WITH 4.0.15 diff --git a/sentry-ruby/lib/sentry-ruby.rb b/sentry-ruby/lib/sentry-ruby.rb index cc2f64071..52e32c40d 100644 --- a/sentry-ruby/lib/sentry-ruby.rb +++ b/sentry-ruby/lib/sentry-ruby.rb @@ -27,6 +27,7 @@ require "sentry/session_flusher" require "sentry/backpressure_monitor" require "sentry/cron/monitor_check_ins" +require "sentry/cron/fugit_schedule" require "sentry/vernier/profiler" require "sentry/metrics" diff --git a/sentry-ruby/lib/sentry/cron/fugit_schedule.rb b/sentry-ruby/lib/sentry/cron/fugit_schedule.rb new file mode 100644 index 000000000..82c2fbe72 --- /dev/null +++ b/sentry-ruby/lib/sentry/cron/fugit_schedule.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +module Sentry + module Cron + # Converts fugit schedules into monitor configs. Returns nil for schedules + # Sentry would read differently. + module FugitSchedule + class << self + def from_cron(cron) + crontab = crontab_for(cron) + timezone = timezone_for(cron) if crontab + MonitorConfig.from_crontab(crontab, timezone: timezone, **options) if timezone + end + + def from_seconds(seconds) + seconds = seconds.to_f + return unless seconds > 0 && (seconds % 60).zero? + + seconds = seconds.to_i + if (seconds % 86_400).zero? + MonitorConfig.from_interval(seconds / 86_400, :day, **options) + elsif (seconds % 3_600).zero? + MonitorConfig.from_interval(seconds / 3_600, :hour, **options) + else + MonitorConfig.from_interval(seconds / 60, :minute, **options) + end + end + + private + + def options + cron_config = Sentry.configuration.cron + { checkin_margin: cron_config.default_checkin_margin, max_runtime: cron_config.default_max_runtime } + end + + # Rebuilds a 5-field crontab from fugit's expanded fields so Sentry + # (Debian cron rules) reads it the same way. Returns nil for fugit-only + # syntax: sub-minute seconds, `L`, `#`, `%` and `&`. + def crontab_for(cron) + return unless defined?(::Fugit::Cron) && cron.is_a?(::Fugit::Cron) + return unless cron.seconds&.size == 1 + return if cron.monthdays&.any?(&:negative?) + return if cron.weekdays&.any? { |day| day.size > 1 } + return if cron.monthdays && cron.weekdays && cron.instance_variable_get(:@day_and) + + [ + crontab_field(cron.minutes, 0, 59, step: true), + crontab_field(cron.hours, 0, 23, step: true), + # Day fields never use `*/n`: Debian cron treats a field starting + # with `*` as unrestricted when combining day-of-month and weekday. + crontab_field(cron.monthdays, 1, 31), + crontab_field(cron.months, 1, 12, step: true), + crontab_field(cron.weekdays&.map(&:first), 0, 6) + ].join(" ") + end + + def crontab_field(values, min, max, step: false) + return "*" unless values + + if step && values.size > 1 && values.first == min + increment = values[1] - values[0] + return "*/#{increment}" if values == min.step(max, increment).to_a + end + + values.chunk_while { |a, b| b == a + 1 }.map { |run| run.size > 1 ? "#{run.first}-#{run.last}" : run.first.to_s }.join(",") + end + + # Sentry only accepts IANA names, so offsets such as "+05:30" get no config. + def timezone_for(cron) + zone = cron.timezone || ::EtOrbi.determine_local_tzone + return Sentry.configuration.cron.default_timezone unless zone + + ::TZInfo::Timezone.get(zone.name).identifier + rescue StandardError + nil + end + end + end + end +end diff --git a/sentry-ruby/lib/sentry/rufus_scheduler.rb b/sentry-ruby/lib/sentry/rufus_scheduler.rb index fccb14da4..eb260cb39 100644 --- a/sentry-ruby/lib/sentry/rufus_scheduler.rb +++ b/sentry-ruby/lib/sentry/rufus_scheduler.rb @@ -98,76 +98,15 @@ def slug_for(job) end def monitor_config_for(job) - cron_config = Sentry.configuration.cron - options = { checkin_margin: cron_config.default_checkin_margin, max_runtime: cron_config.default_max_runtime } - case job when ::Rufus::Scheduler::CronJob - crontab = crontab_for(job.cron_line) - timezone = timezone_for(job.cron_line) - Sentry::Cron::MonitorConfig.from_crontab(crontab, timezone: timezone, **options) if crontab && timezone + Sentry::Cron::FugitSchedule.from_cron(job.cron_line) when ::Rufus::Scheduler::EveryJob - interval_for(job.frequency, options) + Sentry::Cron::FugitSchedule.from_seconds(job.frequency) end # IntervalJob waits `interval` after each run ends, so it has no fixed # schedule to monitor against. end - - def interval_for(seconds, options) - seconds = seconds.to_f - return unless seconds > 0 && (seconds % 60).zero? - - seconds = seconds.to_i - if (seconds % 86_400).zero? - Sentry::Cron::MonitorConfig.from_interval(seconds / 86_400, :day, **options) - elsif (seconds % 3_600).zero? - Sentry::Cron::MonitorConfig.from_interval(seconds / 3_600, :hour, **options) - else - Sentry::Cron::MonitorConfig.from_interval(seconds / 60, :minute, **options) - end - end - - # Rebuilds a 5-field crontab from fugit's expanded fields so Sentry - # (Debian cron rules) reads it the same way. Returns nil for fugit-only - # syntax: sub-minute seconds, `L`, `#`, `%` and `&`. - def crontab_for(cron) - return unless defined?(::Fugit::Cron) && cron.is_a?(::Fugit::Cron) - return unless cron.seconds&.size == 1 - return if cron.monthdays&.any?(&:negative?) - return if cron.weekdays&.any? { |day| day.size > 1 } - return if cron.monthdays && cron.weekdays && cron.instance_variable_get(:@day_and) - - [ - crontab_field(cron.minutes, 0, 59, step: true), - crontab_field(cron.hours, 0, 23, step: true), - # Day fields never use `*/n`: Debian cron treats a field starting - # with `*` as unrestricted when combining day-of-month and weekday. - crontab_field(cron.monthdays, 1, 31), - crontab_field(cron.months, 1, 12, step: true), - crontab_field(cron.weekdays&.map(&:first), 0, 6) - ].join(" ") - end - - def crontab_field(values, min, max, step: false) - return "*" unless values - - if step && values.size > 1 && values.first == min - increment = values[1] - values[0] - return "*/#{increment}" if values == min.step(max, increment).to_a - end - - values.chunk_while { |a, b| b == a + 1 }.map { |run| run.size > 1 ? "#{run.first}-#{run.last}" : run.first.to_s }.join(",") - end - - # Sentry only accepts IANA names, so offsets such as "+05:30" get no config. - def timezone_for(cron) - zone = cron.timezone || ::EtOrbi.determine_local_tzone - return Sentry.configuration.cron.default_timezone unless zone - - ::TZInfo::Timezone.get(zone.name).identifier - rescue StandardError - nil - end end end end diff --git a/sentry-ruby/spec/sentry/cron/fugit_schedule_spec.rb b/sentry-ruby/spec/sentry/cron/fugit_schedule_spec.rb new file mode 100644 index 000000000..f0cb07176 --- /dev/null +++ b/sentry-ruby/spec/sentry/cron/fugit_schedule_spec.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +require "fugit" + +RSpec.describe Sentry::Cron::FugitSchedule do + before { perform_basic_setup } + + def from_cron(cron) + described_class.from_cron(Fugit.parse_cron(cron))&.to_h + end + + describe ".from_cron" do + it "sends the crontab and time zone" do + expect(from_cron("0 9 * * 1-5 Europe/Vienna")).to eq( + schedule: { type: :crontab, value: "0 9 * * 1-5" }, + timezone: "Europe/Vienna" + ) + end + + it "applies the cron config defaults" do + Sentry.configuration.cron.default_checkin_margin = 5 + Sentry.configuration.cron.default_max_runtime = 30 + + expect(from_cron("0 9 * * * UTC")).to include(checkin_margin: 5, max_runtime: 30) + end + + { + "*/15 * * * *" => "*/15 * * * *", + "0 9 * * mon-fri" => "0 9 * * 1-5", + "0 0 */2 * 1" => "0 0 1,3,5,7,9,11,13,15,17,19,21,23,25,27,29,31 * 1", + "30 0 9 * * *" => "0 9 * * *", + "@daily" => "0 0 * * *" + }.each do |cron, crontab| + it "sends #{crontab.inspect} for #{cron.inspect}" do + expect(from_cron("#{cron} UTC")[:schedule]).to eq(type: :crontab, value: crontab) + end + end + + ["0 12 L * *", "0 12 * * mon#2", "0 12 * * mon%2", "0 0 1-7 * mon&", "*/10 * * * * *"].each do |cron| + it "returns nil for #{cron.inspect}" do + expect(from_cron(cron)).to be_nil + end + end + + it "returns nil for an offset time zone" do + expect(from_cron("0 9 * * * +05:30")).to be_nil + end + + it "returns nil when the local time zone is an offset" do + allow(EtOrbi).to receive(:determine_local_tzone).and_return(EtOrbi.get_tzone("+05:30")) + + expect(from_cron("0 9 * * *")).to be_nil + end + + it "uses the local time zone when the cron line has none" do + allow(EtOrbi).to receive(:determine_local_tzone).and_return(TZInfo::Timezone.get("Asia/Tokyo")) + + expect(from_cron("0 9 * * *")[:timezone]).to eq("Asia/Tokyo") + end + + it "returns nil for non-cron schedules" do + expect(described_class.from_cron(Fugit.parse("10m"))).to be_nil + end + end + + describe ".from_seconds" do + { 600 => { value: 10, unit: :minute }, 7_200 => { value: 2, unit: :hour }, 86_400 => { value: 1, unit: :day } }.each do |seconds, interval| + it "sends an interval config for #{seconds}s" do + expect(described_class.from_seconds(seconds).to_h[:schedule]).to eq(type: :interval, **interval) + end + end + + it "returns nil for sub-minute frequencies" do + expect(described_class.from_seconds(90)).to be_nil + end + end +end diff --git a/sentry-ruby/spec/sentry/rufus_scheduler_spec.rb b/sentry-ruby/spec/sentry/rufus_scheduler_spec.rb index 97ba75c69..2a7f655bd 100644 --- a/sentry-ruby/spec/sentry/rufus_scheduler_spec.rb +++ b/sentry-ruby/spec/sentry/rufus_scheduler_spec.rb @@ -59,71 +59,18 @@ def monitor_config(type, schedule, **opts) expect(sentry_events.map(&:status)).to eq([:in_progress, :error]) end - it "applies the cron config defaults" do - Sentry.configuration.cron.default_checkin_margin = 5 - Sentry.configuration.cron.default_max_runtime = 30 - - expect(monitor_config(:cron, "0 9 * * * UTC", name: "job")).to include(checkin_margin: 5, max_runtime: 30) + it "sends no config for cron lines Sentry would read differently" do + expect(monitor_config(:cron, "0 12 L * *", name: "job")).to be_nil + expect(sentry_events.count).to eq(2) end - describe "crontab conversion" do - { - "*/15 * * * *" => "*/15 * * * *", - "0 9 * * mon-fri" => "0 9 * * 1-5", - "0 0 */2 * 1" => "0 0 1,3,5,7,9,11,13,15,17,19,21,23,25,27,29,31 * 1", - "30 0 9 * * *" => "0 9 * * *", - "@daily" => "0 0 * * *" - }.each do |cron, crontab| - it "sends #{crontab.inspect} for #{cron.inspect}" do - expect(monitor_config(:cron, "#{cron} UTC", name: "job")[:schedule]).to eq(type: :crontab, value: crontab) - end - end - - ["0 12 L * *", "0 12 * * mon#2", "0 12 * * mon%2", "0 0 1-7 * mon&", "*/10 * * * * *"].each do |cron| - it "sends no config for #{cron.inspect}" do - expect(monitor_config(:cron, cron, name: "job")).to be_nil - expect(sentry_events.count).to eq(2) - end - end - - it "sends no config for an offset time zone" do - expect(monitor_config(:cron, "0 9 * * * +05:30", name: "job")).to be_nil - expect(sentry_events.count).to eq(2) - end - - it "sends no config when the local time zone is an offset" do - allow(EtOrbi).to receive(:determine_local_tzone).and_return(EtOrbi.get_tzone("+05:30")) - - expect(monitor_config(:cron, "0 9 * * *", name: "job")).to be_nil - end - - it "uses the local time zone when the cron line has none" do - allow(EtOrbi).to receive(:determine_local_tzone).and_return(TZInfo::Timezone.get("Asia/Tokyo")) - - expect(monitor_config(:cron, "0 9 * * *", name: "job")[:timezone]).to eq("Asia/Tokyo") - end + it "sends an interval config for every jobs" do + expect(monitor_config(:every, "2h", name: "job")[:schedule]).to eq(type: :interval, value: 2, unit: :hour) end - describe "every and interval jobs" do - { - "10m" => { value: 10, unit: :minute }, - "2h" => { value: 2, unit: :hour }, - "1d" => { value: 1, unit: :day } - }.each do |every, interval| - it "sends an interval config for every #{every}" do - expect(monitor_config(:every, every, name: "job")[:schedule]).to eq(type: :interval, **interval) - end - end - - it "sends check-ins without config for sub-minute frequencies" do - expect(monitor_config(:every, "90s", name: "job")).to be_nil - expect(sentry_events.count).to eq(2) - end - - it "sends check-ins without config for interval jobs" do - expect(monitor_config(:interval, "5m", name: "job")).to be_nil - expect(sentry_events.count).to eq(2) - end + it "sends check-ins without config for interval jobs" do + expect(monitor_config(:interval, "5m", name: "job")).to be_nil + expect(sentry_events.count).to eq(2) end it "does not monitor one-off jobs" do From 08c1a0d76ad32975c7c0762323f9c8e7997886a1 Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Thu, 15 Oct 2026 20:10:02 -0700 Subject: [PATCH 2/2] chore: Restore Gemfile.rubocop.lock Co-Authored-By: Claude --- Gemfile.rubocop.lock | 64 ++++++++++++++++++++++---------------------- 1 file changed, 32 insertions(+), 32 deletions(-) diff --git a/Gemfile.rubocop.lock b/Gemfile.rubocop.lock index c0ebf52d2..f63e0cb3e 100644 --- a/Gemfile.rubocop.lock +++ b/Gemfile.rubocop.lock @@ -87,39 +87,39 @@ DEPENDENCIES rubocop-rails-omakase CHECKSUMS - activesupport (8.1.1) sha256=5e92534e8d0c8b8b5e6b16789c69dbea65c1d7b752269f71a39422e9546cea67 - ast (2.4.3) sha256=954615157c1d6a382bc27d690d973195e79db7f55e9765ac7c481c60bdb4d383 - base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b - bigdecimal (4.1.2) sha256=53d217666027eab4280346fba98e7d5b66baaae1b9c3c1c0ffe89d48188a3fbd + activesupport (8.1.1) + ast (2.4.3) + base64 (0.3.0) + bigdecimal (4.1.2) bundler (4.0.15) sha256=a4ceb882fe94a0e0ac63cd0813932bbfd631a14e5ac0b7975189b19a4d28d9e7 - concurrent-ruby (1.3.7) sha256=4412caec3a5ea2e5fdc52076724c071a81f2c0593d83b2ac8cbb8ca63b3151b0 - connection_pool (3.0.2) sha256=33fff5ba71a12d2aa26cb72b1db8bba2a1a01823559fb01d29eb74c286e62e0a - drb (2.2.3) sha256=0b00d6fdb50995fe4a45dea13663493c841112e4068656854646f418fda13373 - i18n (1.14.8) sha256=285778639134865c5e0f6269e0b818256017e8cde89993fdfcbfb64d088824a5 - json (2.20.0) sha256=9362bc6e55a952b056abf9167cf053358181c904cb70cd6eee0808ea830fc32b - language_server-protocol (3.17.0.5) sha256=fd1e39a51a28bf3eec959379985a72e296e9f9acfce46f6a79d31ca8760803cc - lint_roller (1.1.0) sha256=2c0c845b632a7d172cb849cc90c1bce937a28c5c8ccccb50dfd46a485003cc87 - logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203 - minitest (6.0.6) sha256=153ea36d1d987a62942382b61075745042a2b3123b1cd48f4c3675af9cc7d6f1 - parallel (1.27.0) sha256=4ac151e1806b755fb4e2dc2332cbf0e54f2e24ba821ff2d3dcf86bf6dc4ae130 - parser (3.3.10.0) sha256=ce3587fa5cc55a88c4ba5b2b37621b3329aadf5728f9eafa36bbd121462aabd6 - prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85 - racc (1.8.1) sha256=4a7f6929691dbec8b5209a0b373bc2614882b55fc5d2e447a21aaa691303d62f - rack (3.2.1) sha256=30af3f7e5ec21b0d14d822cf24446048dba5f651b617c7e97405b604f20a9e33 - rainbow (3.1.1) sha256=039491aa3a89f42efa1d6dec2fc4e62ede96eb6acd95e52f1ad581182b79bc6a - regexp_parser (2.11.3) sha256=ca13f381a173b7a93450e53459075c9b76a10433caadcb2f1180f2c741fc55a4 - rubocop (1.82.0) sha256=237b7dc24952d7ec469a9593c7a5283315515e2e7dc24ac91532819c254fc4ec - rubocop-ast (1.48.0) sha256=22df9bbf3f7a6eccde0fad54e68547ae1e2a704bf8719e7c83813a99c05d2e76 - rubocop-packaging (0.6.0) sha256=fb92bd0fb48e6f8cdb1648d2249b0cd51c2497dcc87340132d22f01edbf558a7 - rubocop-performance (1.26.1) sha256=cd19b936ff196df85829d264b522fd4f98b6c89ad271fa52744a8c11b8f71834 - rubocop-rails (2.34.2) sha256=10ff246ee48b25ffeabddc5fee86d159d690bb3c7b9105755a9c7508a11d6e22 - rubocop-rails-omakase (1.1.0) sha256=2af73ac8ee5852de2919abbd2618af9c15c19b512c4cfc1f9a5d3b6ef009109d - ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33 - securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1 - tzinfo (2.0.6) sha256=8daf828cc77bcf7d63b0e3bdb6caa47e2272dcfaf4fbfe46f8c3a9df087a829b - unicode-display_width (3.2.0) sha256=0cdd96b5681a5949cdbc2c55e7b420facae74c4aaf9a9815eee1087cb1853c42 - unicode-emoji (4.2.0) sha256=519e69150f75652e40bf736106cfbc8f0f73aa3fb6a65afe62fefa7f80b0f80f - uri (1.1.1) sha256=379fa58d27ffb1387eaada68c749d1426738bd0f654d812fcc07e7568f5c57c6 + concurrent-ruby (1.3.7) + connection_pool (3.0.2) + drb (2.2.3) + i18n (1.14.8) + json (2.20.0) + language_server-protocol (3.17.0.5) + lint_roller (1.1.0) + logger (1.7.0) + minitest (6.0.6) + parallel (1.27.0) + parser (3.3.10.0) + prism (1.9.0) + racc (1.8.1) + rack (3.2.1) + rainbow (3.1.1) + regexp_parser (2.11.3) + rubocop (1.82.0) + rubocop-ast (1.48.0) + rubocop-packaging (0.6.0) + rubocop-performance (1.26.1) + rubocop-rails (2.34.2) + rubocop-rails-omakase (1.1.0) + ruby-progressbar (1.13.0) + securerandom (0.4.1) + tzinfo (2.0.6) + unicode-display_width (3.2.0) + unicode-emoji (4.2.0) + uri (1.1.1) BUNDLED WITH 4.0.15