chrono_machines 0.10.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 520c4b33c24d8122f82758ed724886bfdd7955b427a98de6432bfd2b85f8355e
4
- data.tar.gz: 7f31ccc205a392a0f26f932cdebdd13efb56204137598d38e818768f6116da53
3
+ metadata.gz: 6657b6db5e08ccde2f9145ccb5f2f5701ee4edd1c54a89998bfbe047510e024d
4
+ data.tar.gz: f34228d0ed9ce646a7d5eb4f180ff4c775f53bd9e799d33ec4cc80b861c27401
5
5
  SHA512:
6
- metadata.gz: 51dbb600f208195e5c42f6cc8eec21069aa361f1c1056df5c960ea331a4335c1f18fb328fb27748d5535d9477fea99ae20f1a5961b1cd53ced563b336bc96542
7
- data.tar.gz: 7a82f044a7fe6e185399021cb09e141a51e0370a468767df79058cf78567fda1ecc96dc0b916de6154d02b828a4b5b4ac803a5a0aa7e78078c147220383c0bfc
6
+ metadata.gz: 9c11744285959ebbaeb17cb77e141ea6c8e763b5dddce05a0de06cdcef7fd8ddedfa54a26f381a8435345490b0ad9ec1bae217463ed62bfcd795b05ac069384c
7
+ data.tar.gz: b4d6d6a1b519abba7b0d03c648cd4339782af0eda8d13e78e34232777105cb1023eef1d5040a08a57f41fec329d2dd587557578a466f79f04af8f519a8c6b868
data/CHANGELOG.md CHANGED
@@ -1,5 +1,39 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.12.0](https://github.com/seuros/chrono_machines/compare/chrono_machines/v0.11.0...chrono_machines/v0.12.0) (2026-11-01)
4
+
5
+
6
+ ### ⚠ BREAKING CHANGES
7
+
8
+ * `chrono_machines::fibonacci` returns f64, and `max_attempts` outside 1..255 is rejected instead of silently computed on one backend only.
9
+
10
+ ### Features
11
+
12
+ * expose full jitter as a one-shot ([ff78a9c](https://github.com/seuros/chrono_machines/commit/ff78a9ca3a9e3a3b3a64a5427290e9f72e55305a))
13
+ * honour an ambient deadline during retries ([97b24d0](https://github.com/seuros/chrono_machines/commit/97b24d0b73751a6a25f1ee703cb251ff43ed167a))
14
+
15
+
16
+ ### Bug Fixes
17
+
18
+ * a failing success callback no longer re-runs the operation ([920ecc9](https://github.com/seuros/chrono_machines/commit/920ecc9002b66a7617ed99537c63ab6b2238c1ba))
19
+ * a lone checkout can bundle ([19276ef](https://github.com/seuros/chrono_machines/commit/19276ef6b78f0a13c18c7d23e91e2830d9cf0b1c))
20
+ * a source install can build, and a failed gem push fails the release ([cd85f2c](https://github.com/seuros/chrono_machines/commit/cd85f2cc5270830a89b35e7217a10e2454813ab3))
21
+ * build the native speedup on CRuby only ([0b30fc6](https://github.com/seuros/chrono_machines/commit/0b30fc61b753908a9bff39e77b8854ab74ef603f))
22
+ * Fibonacci backoff no longer saturates on the native backend ([213413c](https://github.com/seuros/chrono_machines/commit/213413ce86af8107396d8462449e7cebd2430e17))
23
+ * load the native build tasks on CRuby only ([f0826ca](https://github.com/seuros/chrono_machines/commit/f0826ca5e9636facde86fb02fd30d5be28b683e0))
24
+ * ship the core's test files so the packaged crate builds ([89b1de5](https://github.com/seuros/chrono_machines/commit/89b1de5067b222de580d761ce17b439e8b689533))
25
+
26
+ ## [0.11.0](https://github.com/seuros/chrono_machines/compare/chrono_machines/v0.10.0...chrono_machines/v0.11.0) (2026-10-14)
27
+
28
+
29
+ ### ⚠ BREAKING CHANGES
30
+
31
+ * **core:** RetryError::cause() returns &E and into_cause() returns E instead of Option.
32
+
33
+ ### Features
34
+
35
+ * **core:** RetryError always carries its cause ([e5222a3](https://github.com/seuros/chrono_machines/commit/e5222a3c394974ba86605944f02d820345cbae8e))
36
+
3
37
  ## [0.10.0](https://github.com/seuros/chrono_machines/compare/chrono_machines/v0.9.2...chrono_machines/v0.10.0) (2026-10-11)
4
38
 
5
39
 
data/Cargo.lock CHANGED
@@ -69,7 +69,7 @@ dependencies = [
69
69
 
70
70
  [[package]]
71
71
  name = "chrono-machines"
72
- version = "0.8.1"
72
+ version = "0.9.0"
73
73
  dependencies = [
74
74
  "rand",
75
75
  "tokio",
@@ -233,9 +233,9 @@ checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a"
233
233
 
234
234
  [[package]]
235
235
  name = "magnus"
236
- version = "0.9.2"
236
+ version = "0.9.3"
237
237
  source = "registry+https://github.com/rust-lang/crates.io-index"
238
- checksum = "b9068710e00d21be762edca59b9c4c8946dbdb29820d4f76ad559afea07982c9"
238
+ checksum = "40ef424404d166cbb71c8b8d19c9c589c748c4547c1afa1928c5504299f6c713"
239
239
  dependencies = [
240
240
  "magnus-macros",
241
241
  "rb-sys",
data/Cargo.toml CHANGED
@@ -8,7 +8,7 @@ rust-version = "1.99"
8
8
 
9
9
  [workspace.dependencies]
10
10
  chrono-machines = { path = "ext/chrono_machines_native/core" }
11
- rand = { version = "0.10", default-features = false, features = ["std_rng", "sys_rng"] }
11
+ rand = { version = "0.10", default-features = false, features = ["std_rng"] }
12
12
 
13
13
  [workspace.lints.rust]
14
14
  unsafe_code = "deny"
data/README.md CHANGED
@@ -39,7 +39,7 @@ Still here? Excellent. Because in the fabric of spacetime, nobody can hear your
39
39
 
40
40
  ### The Pattern Time Forgot
41
41
 
42
- Built for Ruby 3.2+ with fiber-aware sleep and full jitter implementation, because when you're manipulating time itself, precision matters.
42
+ Built for Ruby 4.0+ with fiber-aware sleep and full jitter implementation, because when you're manipulating time itself, precision matters.
43
43
 
44
44
  ## Features
45
45
 
@@ -223,6 +223,8 @@ Anything else raises `ArgumentError` - a broken hook fails loudly instead of sil
223
223
 
224
224
  ### Scheduling retries without sleeping
225
225
 
226
+ See the [Rails integration guide](docs/RAILS_INTEGRATION.md) for Active Job patterns, policy callbacks, and safe retries.
227
+
226
228
  `Executor#next_delay(attempt:, exception: nil)` returns seconds to wait, or `nil` to stop. `attempt: 1` means the initial call has failed; `max_attempts` includes that initial call. It uses the same backoff, jitter, exception rules, and `delay_from` hints as `call`, without executing work, sleeping, or running lifecycle callbacks. The caller owns the attempt count and scheduling. Without an exception, `delay_from` and exception filtering are skipped.
227
229
 
228
230
  For Active Job's `retry_on`, keep the attempt limit in sync with the policy:
@@ -280,7 +282,7 @@ Jitter is drawn from the `random:` option (Ruby's `Random` by default) on both b
280
282
 
281
283
  | Option | Default | Description |
282
284
  |--------|---------|-------------|
283
- | `max_attempts` | `3` | Maximum number of attempts, including the initial call |
285
+ | `max_attempts` | `3` | Maximum number of attempts, including the initial call; `1..255` |
284
286
  | `base_delay` | `0.1` | Initial delay in seconds |
285
287
  | `multiplier` | `2` | Exponential backoff multiplier |
286
288
  | `max_delay` | `10` | Maximum delay cap in seconds |
@@ -625,7 +627,7 @@ end
625
627
  ## Why ChronoMachines?
626
628
 
627
629
  ### Built for Modern Ruby
628
- - **Ruby 3.2+ Support**: Fiber-aware sleep handling
630
+ - **Ruby 4.0+ Support**: Fiber-aware sleep handling
629
631
  - **Clean Architecture**: Separation of concerns with configurable policies
630
632
  - **Rich Instrumentation**: Comprehensive callback system for monitoring
631
633
  - **Battle-Tested**: Full jitter implementation prevents thundering herds
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "chrono-machines"
3
- version = "0.8.1"
3
+ version = "0.10.0"
4
4
  edition.workspace = true
5
5
  rust-version.workspace = true
6
6
  authors = ["Abdelkader Boudih <terminale@gmail.com>"]
@@ -10,7 +10,7 @@ repository = "https://github.com/seuros/chrono_machines"
10
10
  keywords = ["retry", "backoff", "exponential", "jitter", "resilience"]
11
11
  categories = ["no-std", "algorithms"]
12
12
  readme = "README.md"
13
- exclude = ["tests/", "src/**/tests.rs"]
13
+ exclude = ["tests/"]
14
14
 
15
15
  [lib]
16
16
  name = "chrono_machines"
@@ -26,7 +26,7 @@ alloc = []
26
26
  async = ["alloc"]
27
27
 
28
28
  [dependencies]
29
- rand = { version = "0.10", default-features = false, features = ["std_rng"] }
29
+ rand = { workspace = true }
30
30
 
31
31
  [dev-dependencies]
32
32
  tokio = { version = "1", features = ["rt", "rt-multi-thread", "macros", "time"] }
@@ -25,7 +25,7 @@ fn powi_f64(base: f64, exp: i32) -> f64 {
25
25
  acc
26
26
  }
27
27
 
28
- /// Widen a delay (or Fibonacci factor) to `f64` for the backoff arithmetic.
28
+ /// Widen a delay to `f64` for the backoff arithmetic.
29
29
  ///
30
30
  /// There is no lossless `u64 -> f64` conversion. Rounding only starts above
31
31
  /// 2^53 (2^53 ms is ~285,000 years), so every delay that can actually be slept
@@ -53,23 +53,25 @@ pub(crate) fn exponential_ms(
53
53
  base_exponential.min(lossy_f64(max_delay_ms))
54
54
  }
55
55
 
56
- /// Calculate the nth Fibonacci number (1-indexed): 1, 1, 2, 3, 5, 8, 13, ...
56
+ /// The nth Fibonacci number (1-indexed), as a delay factor: 1, 1, 2, 3, 5, 8, ...
57
+ ///
58
+ /// The sequence passes `2^53` at n = 79 and `u64::MAX` at n = 94, so no integer
59
+ /// accumulator reaches the end of the `u8` attempt range. One that stops at its
60
+ /// own ceiling scales to a delay *under* `max_delay`, which is then silently
61
+ /// wrong rather than capped; the `f64` recurrence keeps growing and leaves the
62
+ /// decision to the cap. It rounds from n = 82 on, within two ulps of the exact
63
+ /// sequence over the whole range, and the gem's pure-Ruby backoff runs the same
64
+ /// recurrence, so the two backends agree bit for bit.
57
65
  #[must_use]
58
- pub fn fibonacci(n: u8) -> u64 {
59
- match n {
60
- 0 => 0,
61
- 1 | 2 => 1,
62
- _ => {
63
- let mut a = 1u64;
64
- let mut b = 1u64;
65
- for _ in 2..n {
66
- let next = a.saturating_add(b);
67
- a = b;
68
- b = next;
69
- }
70
- b
71
- }
72
- }
66
+ pub fn fibonacci(n: u8) -> f64 {
67
+ let mut previous = 0.0_f64;
68
+ let mut current = 1.0_f64;
69
+ for _ in 0..n {
70
+ let next = previous + current;
71
+ previous = current;
72
+ current = next;
73
+ }
74
+ previous
73
75
  }
74
76
 
75
77
  /// Blend a base delay with jitter using the full-jitter algorithm.
@@ -448,9 +450,8 @@ impl BackoffStrategy for FibonacciBackoff {
448
450
  return None;
449
451
  }
450
452
 
451
- let fib = fibonacci(attempt);
452
453
  let base =
453
- (lossy_f64(self.base_delay_ms) * lossy_f64(fib)).min(lossy_f64(self.max_delay_ms));
454
+ (lossy_f64(self.base_delay_ms) * fibonacci(attempt)).min(lossy_f64(self.max_delay_ms));
454
455
 
455
456
  Some(apply_jitter(base, self.jitter_factor, rng))
456
457
  }
@@ -51,13 +51,7 @@ fn finalize_failure<E>(
51
51
  cumulative_delay_ms: u64,
52
52
  error: E,
53
53
  ) -> RetryError<E> {
54
- let retry_error = RetryError::new(
55
- kind,
56
- attempt,
57
- max_attempts,
58
- cumulative_delay_ms,
59
- Some(error),
60
- );
54
+ let retry_error = RetryError::new(kind, attempt, max_attempts, cumulative_delay_ms, error);
61
55
  if let Some(callback) = on_failure {
62
56
  callback(&retry_error);
63
57
  }
@@ -99,7 +93,7 @@ pub struct RetryError<E> {
99
93
  attempts: u8,
100
94
  max_attempts: u8,
101
95
  cumulative_delay_ms: u64,
102
- cause: Option<E>,
96
+ cause: E,
103
97
  }
104
98
 
105
99
  impl<E> RetryError<E> {
@@ -108,7 +102,7 @@ impl<E> RetryError<E> {
108
102
  attempts: u8,
109
103
  max_attempts: u8,
110
104
  cumulative_delay_ms: u64,
111
- cause: Option<E>,
105
+ cause: E,
112
106
  ) -> Self {
113
107
  Self {
114
108
  kind,
@@ -119,13 +113,13 @@ impl<E> RetryError<E> {
119
113
  }
120
114
  }
121
115
 
122
- /// Retrieve the underlying cause when available.
123
- pub const fn cause(&self) -> Option<&E> {
124
- self.cause.as_ref()
116
+ /// The error from the final attempt.
117
+ pub const fn cause(&self) -> &E {
118
+ &self.cause
125
119
  }
126
120
 
127
- /// Consume the error and return the underlying cause when available.
128
- pub fn into_cause(self) -> Option<E> {
121
+ /// Consume the error and return the error from the final attempt.
122
+ pub fn into_cause(self) -> E {
129
123
  self.cause
130
124
  }
131
125
 
@@ -175,13 +169,11 @@ where
175
169
  }
176
170
  }
177
171
 
178
- write!(f, " (cumulative delay {}ms)", self.cumulative_delay_ms)?;
179
-
180
- if let Some(cause) = self.cause.as_ref() {
181
- write!(f, ": {cause}")?;
182
- }
183
-
184
- Ok(())
172
+ write!(
173
+ f,
174
+ " (cumulative delay {}ms): {}",
175
+ self.cumulative_delay_ms, self.cause
176
+ )
185
177
  }
186
178
  }
187
179
 
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "chrono_machines_native"
3
- version = "0.9.1"
3
+ version = "0.10.0"
4
4
  edition.workspace = true
5
5
  rust-version.workspace = true
6
6
  authors = ["Abdelkader Boudih <terminale@gmail.com>"]
@@ -15,7 +15,7 @@ crate-type = ["cdylib"]
15
15
 
16
16
  [dependencies]
17
17
  chrono-machines = { workspace = true, features = ["std"] }
18
- magnus = "0.9"
18
+ magnus = { version = ">= 0.9.3", default-features = false, features = ["io"] }
19
19
  rand = { workspace = true }
20
20
  rb-sys = { version = "0.9.124", default-features = false, features = ["stable-api-compiled-fallback"] }
21
21
 
@@ -73,14 +73,7 @@ fn calculate_delay_fibonacci(
73
73
  ) -> f64 {
74
74
  let jitter_factor = normalize_jitter(jitter_factor);
75
75
 
76
- // Fibonacci numbers pass 2^53 at n = 79; from there the cast rounds to the
77
- // nearest f64, an error far below anything a delay in seconds can express.
78
- #[expect(
79
- clippy::cast_precision_loss,
80
- reason = "u64 -> f64 has no lossless conversion; rounding only starts above 2^53"
81
- )]
82
- let fib = fibonacci(attempt_u8(attempt)) as f64;
83
- let base = (base_delay * fib).min(max_delay);
76
+ let base = (base_delay * fibonacci(attempt_u8(attempt))).min(max_delay);
84
77
 
85
78
  apply_jitter(base, jitter_factor)
86
79
  }
@@ -2,6 +2,8 @@
2
2
 
3
3
  module ChronoMachines
4
4
  class Executor
5
+ ATTEMPT_LIMIT = 255
6
+
5
7
  def initialize(policy_or_options = {})
6
8
  policy_options = if policy_or_options.is_a?(Symbol)
7
9
  ChronoMachines.config.get_policy(policy_or_options)
@@ -11,6 +13,11 @@ module ChronoMachines
11
13
 
12
14
  @backoff_strategy = policy_options[:backoff_strategy] || :exponential
13
15
  @max_attempts = policy_options[:max_attempts]
16
+ unless @max_attempts.is_a?(Integer) && @max_attempts.between?(1, ATTEMPT_LIMIT)
17
+ raise ArgumentError, "max_attempts must be an Integer in 1..#{ATTEMPT_LIMIT} " \
18
+ "(the native backend counts attempts in a u8); got #{@max_attempts.inspect}"
19
+ end
20
+
14
21
  @base_delay = policy_options[:base_delay]
15
22
  @multiplier = policy_options[:multiplier] || 2
16
23
  @max_delay = policy_options[:max_delay]
@@ -34,16 +41,14 @@ module ChronoMachines
34
41
 
35
42
  def call
36
43
  attempts = 0
44
+ DeadlineMachines.check!
37
45
 
38
- begin
46
+ result = begin
39
47
  attempts += 1
40
- result = yield
41
-
42
- # Call success callback if defined
43
- @on_success&.call(result: result, attempts: attempts)
44
-
45
- result
48
+ yield
46
49
  rescue StandardError => e
50
+ raise if caller_gave_up?(e)
51
+
47
52
  delay = next_delay(attempt: attempts, exception: e)
48
53
  unless delay
49
54
  handle_final_failure(e, attempts)
@@ -52,17 +57,33 @@ module ChronoMachines
52
57
  raise e
53
58
  end
54
59
 
55
- # Call retry callback if defined
56
- @on_retry&.call(exception: e, attempt: attempts, next_delay: delay)
57
-
58
- # Execute delay with robust sleep
59
- robust_sleep(delay)
60
+ stop_on_spent_budget(e, attempts)
61
+ budget = DeadlineMachines.clamp_sleep(delay)
62
+ @on_retry&.call(exception: e, attempt: attempts, next_delay: budget)
63
+ robust_sleep(DeadlineMachines.clamp_sleep(budget))
64
+ stop_on_spent_budget(e, attempts)
60
65
  retry
61
66
  end
67
+
68
+ @on_success&.call(result: result, attempts: attempts)
69
+ result
62
70
  end
63
71
 
64
72
  private
65
73
 
74
+ # A deadline error from a narrower inner scope is a timeout worth retrying.
75
+ # One raised because this caller's own budget is gone is not.
76
+ def caller_gave_up?(error)
77
+ error.is_a?(DeadlineMachines::Error) && DeadlineMachines.current&.done?
78
+ end
79
+
80
+ def stop_on_spent_budget(exception, attempts)
81
+ return unless DeadlineMachines.current&.done?
82
+
83
+ handle_final_failure(exception, attempts)
84
+ DeadlineMachines.check!
85
+ end
86
+
66
87
  def retryable_exception?(exception)
67
88
  @retryable_exceptions.any? { |type| exception.is_a?(type) }
68
89
  end
@@ -105,16 +126,14 @@ module ChronoMachines
105
126
  apply_jitter(base_delay)
106
127
  end
107
128
 
108
- # Calculate Fibonacci number (1-indexed)
129
+ # The nth Fibonacci number (1-indexed), as a delay factor, accumulated step
130
+ # for step with `fibonacci` in ext/chrono_machines_native/core/src/backoff.rs,
131
+ # whose documentation covers why neither side counts in integers.
109
132
  def fibonacci(n)
110
- return 0 if n.zero?
111
- return 1 if n <= 2
112
-
113
- a, b = 1, 1
114
- (2...n).each do
115
- a, b = b, a + b
116
- end
117
- b
133
+ previous = 0.0
134
+ current = 1.0
135
+ n.times { previous, current = current, previous + current }
136
+ previous
118
137
  end
119
138
 
120
139
  # Main calculate_delay dispatches to the appropriate strategy
@@ -5,14 +5,9 @@
5
5
  # to use the native Rust implementation for delay calculations.
6
6
 
7
7
  begin
8
- # Try to load the native extension
9
- # Prefer local development version if it exists
10
- local_ext_path = File.expand_path('../../ext/chrono_machines_native/chrono_machines_native', __dir__)
11
- if File.exist?("#{local_ext_path}.bundle") || File.exist?("#{local_ext_path}.so")
12
- require local_ext_path
13
- else
14
- require 'chrono_machines_native/chrono_machines_native'
15
- end
8
+ # `rake compile` installs the extension here. A leftover build under ext/
9
+ # must not shadow it: that is how a stale native backend gets tested.
10
+ require 'chrono_machines_native/chrono_machines_native'
16
11
 
17
12
  module ChronoMachines
18
13
  # Native speedup module that overrides backoff calculations
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ChronoMachines
4
- VERSION = '0.10.0'
4
+ VERSION = '0.12.0'
5
5
  end
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'deadline_machines'
3
4
  require 'zeitwerk'
4
5
 
5
6
  loader = Zeitwerk::Loader.for_gem
@@ -27,6 +28,17 @@ module ChronoMachines
27
28
  def self.retry(policy_name_or_options = {}, &)
28
29
  Executor.new(policy_name_or_options).call(&)
29
30
  end
31
+
32
+ # Full jitter over a single duration: the result lands in
33
+ # `seconds * (1 - factor)` to `seconds`, so a fleet of callers waiting the
34
+ # same nominal time does not wake up together.
35
+ def self.jitter(seconds, factor:, random: Random)
36
+ factor = Float(factor)
37
+ raise ArgumentError, 'factor cannot be NaN' if factor.nan?
38
+
39
+ factor = factor.clamp(0.0, 1.0)
40
+ seconds * (1 - factor + (random.rand * factor))
41
+ end
30
42
  end
31
43
 
32
44
  # Load optional async support after core is loaded
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: chrono_machines
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.10.0
4
+ version: 0.12.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Abdelkader Boudih
@@ -9,6 +9,34 @@ bindir: bin
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: deadline_machines
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '0.1'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '0.1'
26
+ - !ruby/object:Gem::Dependency
27
+ name: rb_sys
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '0.9'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '0.9'
12
40
  - !ruby/object:Gem::Dependency
13
41
  name: zeitwerk
14
42
  requirement: !ruby/object:Gem::Requirement