cmdx 2.0.1 → 2.1.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.
Files changed (58) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +53 -8
  3. data/lib/cmdx/callbacks.rb +31 -11
  4. data/lib/cmdx/chain.rb +29 -10
  5. data/lib/cmdx/coercions/big_decimal.rb +1 -1
  6. data/lib/cmdx/coercions/boolean.rb +3 -9
  7. data/lib/cmdx/coercions/coerce.rb +4 -1
  8. data/lib/cmdx/coercions/date_time.rb +1 -1
  9. data/lib/cmdx/coercions/integer.rb +11 -2
  10. data/lib/cmdx/coercions/symbol.rb +23 -4
  11. data/lib/cmdx/coercions.rb +25 -10
  12. data/lib/cmdx/configuration.rb +31 -16
  13. data/lib/cmdx/context.rb +36 -52
  14. data/lib/cmdx/deprecation.rb +4 -7
  15. data/lib/cmdx/deprecators/error.rb +4 -1
  16. data/lib/cmdx/deprecators.rb +17 -8
  17. data/lib/cmdx/errors.rb +11 -10
  18. data/lib/cmdx/executors/fiber.rb +16 -4
  19. data/lib/cmdx/executors/thread.rb +18 -4
  20. data/lib/cmdx/executors.rb +22 -7
  21. data/lib/cmdx/fault.rb +15 -3
  22. data/lib/cmdx/i18n_proxy.rb +9 -5
  23. data/lib/cmdx/input.rb +23 -21
  24. data/lib/cmdx/inputs.rb +14 -26
  25. data/lib/cmdx/log_formatters/json.rb +8 -1
  26. data/lib/cmdx/log_formatters/logstash.rb +7 -1
  27. data/lib/cmdx/mergers.rb +22 -7
  28. data/lib/cmdx/middlewares.rb +40 -24
  29. data/lib/cmdx/output.rb +5 -2
  30. data/lib/cmdx/pipeline.rb +18 -3
  31. data/lib/cmdx/railtie.rb +1 -0
  32. data/lib/cmdx/result.rb +22 -6
  33. data/lib/cmdx/retriers/decorrelated_jitter.rb +10 -5
  34. data/lib/cmdx/retriers/exponential.rb +10 -2
  35. data/lib/cmdx/retriers/fibonacci.rb +29 -12
  36. data/lib/cmdx/retriers.rb +17 -8
  37. data/lib/cmdx/retry.rb +20 -13
  38. data/lib/cmdx/runtime.rb +18 -17
  39. data/lib/cmdx/settings.rb +9 -9
  40. data/lib/cmdx/signal.rb +1 -1
  41. data/lib/cmdx/task.rb +90 -45
  42. data/lib/cmdx/telemetry.rb +37 -10
  43. data/lib/cmdx/util.rb +50 -4
  44. data/lib/cmdx/validators/absence.rb +1 -1
  45. data/lib/cmdx/validators/exclusion.rb +15 -15
  46. data/lib/cmdx/validators/format.rb +12 -4
  47. data/lib/cmdx/validators/inclusion.rb +15 -15
  48. data/lib/cmdx/validators/length.rb +5 -49
  49. data/lib/cmdx/validators/numeric.rb +5 -49
  50. data/lib/cmdx/validators/presence.rb +1 -1
  51. data/lib/cmdx/validators/validate.rb +7 -1
  52. data/lib/cmdx/validators.rb +21 -9
  53. data/lib/cmdx/version.rb +1 -1
  54. data/lib/cmdx/workflow.rb +28 -14
  55. data/lib/cmdx.rb +24 -0
  56. data/lib/generators/cmdx/templates/install.rb +80 -39
  57. data/mkdocs.yml +1 -0
  58. metadata +1 -1
data/lib/cmdx/retry.rb CHANGED
@@ -20,6 +20,7 @@ module CMDx
20
20
  # `:decorrelated_jitter`) or custom
21
21
  # @yieldparam attempt [Integer]
22
22
  # @yieldparam delay [Float]
23
+ # @yieldparam prev_delay [Float, nil]
23
24
  def initialize(exceptions, options = EMPTY_HASH, &block)
24
25
  @exceptions = exceptions.flatten
25
26
  @options = options.freeze
@@ -28,15 +29,17 @@ module CMDx
28
29
 
29
30
  # Returns a new Retry layering `new_exceptions` and `new_options` onto the
30
31
  # current one. Used for inheritance so subclasses extend rather than
31
- # replace.
32
+ # replace. Returns `self` only when *every* override (exceptions, options,
33
+ # and block) is empty so option-only updates such as `retry_on(limit: 5)`
34
+ # still take effect.
32
35
  #
33
36
  # @param new_exceptions [Array<Class>]
34
37
  # @param new_options [Hash{Symbol => Object}]
35
38
  # @param block [#call, nil] replacement jitter callable (falls back to the prior block)
36
- # @yield [attempt, delay] optional replacement jitter block
39
+ # @yield [attempt, delay, prev_delay] optional replacement jitter block
37
40
  # @return [Retry]
38
41
  def build(new_exceptions, new_options, &block)
39
- return self if new_exceptions.empty?
42
+ return self if new_exceptions.empty? && new_options.empty? && block.nil?
40
43
 
41
44
  merged_exceptions = exceptions | new_exceptions.flatten
42
45
  merged_options = @options.merge(new_options)
@@ -66,6 +69,16 @@ module CMDx
66
69
 
67
70
  # Sleeps `attempt`'s jittered/bounded delay. No-op when the base delay is zero.
68
71
  #
72
+ # Custom jitter callables (registry, task Symbol method, `Proc` / block via
73
+ # `instance_exec` on the task, and other `#call`-ables) always receive
74
+ # `(attempt, delay, prev_delay)` so strategies share one shape; ignore
75
+ # `prev_delay` when you do not need decorrelated threading.
76
+ #
77
+ # Non-numeric or non-finite jitter results are sanitized to the base `delay`
78
+ # and the final sleep is always clamped to `[0, max_delay]` when `max_delay`
79
+ # is set, preventing self-DoS from a buggy jitter returning `Float::INFINITY`
80
+ # or a non-Numeric value.
81
+ #
69
82
  # @param attempt [Integer] zero-based retry attempt number
70
83
  # @param task [Task, nil] used as receiver for Symbol/Proc jitter strategies
71
84
  # @param prev_delay [Float, nil] previous computed delay; only consumed by
@@ -85,18 +98,19 @@ module CMDx
85
98
  if registry.key?(jitter)
86
99
  registry.lookup(jitter).call(attempt, delay, prev_delay)
87
100
  else
88
- task.send(jitter, attempt, delay)
101
+ task.send(jitter, attempt, delay, prev_delay)
89
102
  end
90
103
  when Proc
91
- task.instance_exec(attempt, delay, &jitter)
104
+ task.instance_exec(attempt, delay, prev_delay, &jitter)
92
105
  else
93
106
  if jitter.respond_to?(:call)
94
- jitter.call(attempt, delay)
107
+ jitter.call(attempt, delay, prev_delay)
95
108
  else
96
109
  delay
97
110
  end
98
111
  end
99
112
 
113
+ d = delay unless d.is_a?(Numeric) && d.finite?
100
114
  d = d.clamp(0, max_delay) if max_delay
101
115
  Kernel.sleep(d) if d.positive?
102
116
  d
@@ -126,13 +140,6 @@ module CMDx
126
140
 
127
141
  private
128
142
 
129
- # Resolves the retriers registry to consult for built-in jitter strategies.
130
- # Prefers the task class's registry (so per-task `register(:retrier, ...)`
131
- # overrides take effect) and falls back to the global configuration when
132
- # no task is supplied.
133
- #
134
- # @param task [Task, nil]
135
- # @return [Retriers]
136
143
  def retriers_registry(task)
137
144
  if task && task.class.respond_to?(:retriers)
138
145
  task.class.retriers
data/lib/cmdx/runtime.rb CHANGED
@@ -6,13 +6,14 @@ module CMDx
6
6
  # retry), output verification, rollback on failure, result finalization,
7
7
  # and teardown (freeze + chain clear).
8
8
  #
9
- # Signal propagation: Runtime wraps `work` in `catch(Signal::TAG)` so
10
- # `success!` / `skip!` / `fail!` / `throw!` break out cleanly. Raised
11
- # Faults are converted to echoed signals (carrying the upstream failed
12
- # result as `:origin`); other `StandardError`s become failed signals with
13
- # the exception as `:cause`. `execute!` (strict mode) re-raises on failure
14
- # after the result is finalized, raising a {Fault} built from the deepest
15
- # originating result so `fault.task` points at the leaf that failed.
9
+ # Signal propagation: `work` and the surrounding middleware/callback chain
10
+ # both run inside `catch(Signal::TAG)`, so `success!` / `skip!` / `fail!` /
11
+ # `throw!` halt cleanly from anywhere. Raised Faults become echoed signals
12
+ # (with the upstream result as `:origin`); other `StandardError`s become
13
+ # failed signals (with the exception as `:cause`). In strict mode,
14
+ # `execute!` re-raises from `ensure` using a {Fault} built from the
15
+ # deepest originating result, so `fault.task` points at the leaf that
16
+ # failed.
16
17
  #
17
18
  # @note Always used via the class method; never new Runtime manually.
18
19
  # @see Task.execute
@@ -51,13 +52,12 @@ module CMDx
51
52
  emit_telemetry(:task_started)
52
53
  run_deprecation
53
54
  run_lifecycle
54
- finalize_result
55
- raise_signal! if @strict
56
55
  end
57
56
 
58
- @result
57
+ finalize_result
59
58
  ensure
60
59
  run_teardown
60
+ raise_signal!
61
61
  end
62
62
 
63
63
  private
@@ -71,10 +71,11 @@ module CMDx
71
71
  end
72
72
 
73
73
  def run_middlewares(&)
74
- middlewares = @task.class.middlewares
75
- return yield if middlewares.empty?
76
-
77
- middlewares.process(@task, &)
74
+ @signal = catch(Signal::TAG) do
75
+ middlewares = @task.class.middlewares
76
+ middlewares.empty? ? yield : middlewares.process(@task, &)
77
+ @signal # Return non-middleware signal
78
+ end
78
79
  end
79
80
 
80
81
  def run_deprecation
@@ -104,9 +105,9 @@ module CMDx
104
105
  end
105
106
 
106
107
  def raise_signal!
107
- return unless @result.failed?
108
+ return unless @strict && @result&.failed?
108
109
 
109
- cause = @signal.cause
110
+ cause = @result.cause
110
111
  raise cause if cause && !cause.is_a?(Fault)
111
112
 
112
113
  raise Fault, @result.caused_failure, cause:
@@ -176,7 +177,7 @@ module CMDx
176
177
  rescue Error => e
177
178
  raise(e)
178
179
  rescue StandardError => e
179
- Signal.failed("[#{e.class}] #{e.message}", cause: e, metadata: @task.metadata)
180
+ Signal.failed(Util.to_error_s(e), cause: e, metadata: @task.metadata)
180
181
  end
181
182
  end
182
183
 
data/lib/cmdx/settings.rb CHANGED
@@ -50,7 +50,10 @@ module CMDx
50
50
  end
51
51
  end
52
52
 
53
- # @return [Array<Symbol>] keys to exclude from logging
53
+ # @return [Array<Symbol>] keys to exclude from `Runtime` log output.
54
+ # Matched against {Result#to_h} keys. Common values for redaction:
55
+ # `:context` (may contain secrets / PII), `:cause` (raw exception),
56
+ # `:reason` (may embed exception messages from unhandled errors).
54
57
  def log_exclusions
55
58
  @options.fetch(:log_exclusions) do
56
59
  CMDx.configuration.log_exclusions
@@ -64,14 +67,9 @@ module CMDx
64
67
  end
65
68
  end
66
69
 
67
- # Returns a fresh array each call so callers can mutate the result
68
- # without affecting other tasks (or hitting `FrozenError` on the
69
- # shared sentinel).
70
- #
71
- # @return [Array<Symbol, String>] task tags, surfaced on result hashes
70
+ # @return [Array<Symbol, String>] task tags
72
71
  def tags
73
- tags = @options[:tags]
74
- tags ? tags.dup : []
72
+ @options[:tags] || EMPTY_ARRAY
75
73
  end
76
74
 
77
75
  # @return [Boolean] whether this task's {Context} should raise on
@@ -83,7 +81,9 @@ module CMDx
83
81
  end
84
82
  end
85
83
 
86
- # @return [String, nil] correlation id or the global configuration's correlation id
84
+ # @return [#call, nil] callable that produces a correlation id when invoked
85
+ # by Runtime at root-chain construction. Resolution order:
86
+ # task-level setting → {Configuration#correlation_id} → nil.
87
87
  def correlation_id
88
88
  @options.fetch(:correlation_id) do
89
89
  CMDx.configuration.correlation_id
data/lib/cmdx/signal.rb CHANGED
@@ -76,7 +76,7 @@ module CMDx
76
76
  # @return [Signal] new instance mirroring `other`
77
77
  # @raise [ArgumentError] when `other` is neither a Signal nor a Result
78
78
  def echoed(other, **options)
79
- raise ArgumentError, "must be a Result or Signal" unless other.is_a?(Result) || other.is_a?(Signal)
79
+ raise ArgumentError, "Signal.echoed expected a Result or Signal (got #{other.class})" unless other.is_a?(Result) || other.is_a?(Signal)
80
80
 
81
81
  options[:origin] = other if other.is_a?(Result) && !options.key?(:origin)
82
82
  new(other.state, other.status, **options, reason: other.reason)
data/lib/cmdx/task.rb CHANGED
@@ -53,6 +53,9 @@ module CMDx
53
53
  # @option options [Array<Symbol>] :log_exclusions (see {Settings#initialize})
54
54
  # @option options [Array<Symbol, String>] :tags (see {Settings#initialize})
55
55
  # @option options [Boolean] :strict_context (see {Settings#initialize})
56
+ # @option options [#call] :correlation_id callable returning a String id
57
+ # resolved once by {Runtime} when the root chain is acquired; surfaces
58
+ # as `result.xid` (see {Settings#correlation_id})
56
59
  # @return [Settings]
57
60
  def settings(options = EMPTY_HASH)
58
61
  @settings ||=
@@ -190,7 +193,12 @@ module CMDx
190
193
  inputs.register(self, ...)
191
194
  when :output
192
195
  outputs.register(...)
193
- else raise ArgumentError, "unknown registry type: #{type.inspect}"
196
+ else
197
+ raise ArgumentError, <<~MSG.chomp
198
+ unknown registry type #{type.inspect};
199
+ expected one of [:middleware, :callback, :coercion, :validator, :executor, :merger, :retrier, :deprecator, :input, :output].
200
+ See https://drexed.github.io/cmdx/configuration/#registrations-register-deregister
201
+ MSG
194
202
  end
195
203
  end
196
204
 
@@ -221,7 +229,12 @@ module CMDx
221
229
  inputs.deregister(self, ...)
222
230
  when :output
223
231
  outputs.deregister(...)
224
- else raise ArgumentError, "unknown registry type: #{type.inspect}"
232
+ else
233
+ raise ArgumentError, <<~MSG.chomp
234
+ unknown registry type #{type.inspect};
235
+ expected one of [:middleware, :callback, :coercion, :validator, :executor, :merger, :retrier, :deprecator, :input, :output].
236
+ See https://drexed.github.io/cmdx/configuration/#registrations-register-deregister
237
+ MSG
225
238
  end
226
239
  end
227
240
 
@@ -280,6 +293,8 @@ module CMDx
280
293
  alias input inputs
281
294
 
282
295
  # Declares optional inputs (shorthand for `inputs ..., required: false`).
296
+ # An explicit `required:` in `options` is ignored — use {.inputs} when
297
+ # you need to set the flag dynamically.
283
298
  #
284
299
  # @param names [Array<Symbol>]
285
300
  # @param options [Hash{Symbol => Object}] see {Input#initialize}
@@ -296,10 +311,12 @@ module CMDx
296
311
  # @option options [Object] :validate (see {Validators#extract})
297
312
  # @yield nested-input DSL block (see {Inputs::ChildBuilder})
298
313
  def optional(*names, **options, &)
299
- register(:input, *names, required: false, **options, &)
314
+ register(:input, *names, **options, required: false, &)
300
315
  end
301
316
 
302
317
  # Declares required inputs (shorthand for `inputs ..., required: true`).
318
+ # An explicit `required:` in `options` is ignored — use {.inputs} when
319
+ # you need to set the flag dynamically.
303
320
  #
304
321
  # @param names [Array<Symbol>]
305
322
  # @param options [Hash{Symbol => Object}] see {Input#initialize}
@@ -316,7 +333,7 @@ module CMDx
316
333
  # @option options [Object] :validate (see {Validators#extract})
317
334
  # @yield nested-input DSL block (see {Inputs::ChildBuilder})
318
335
  def required(*names, **options, &)
319
- register(:input, *names, required: true, **options, &)
336
+ register(:input, *names, **options, required: true, &)
320
337
  end
321
338
 
322
339
  # @return [Hash{Symbol => Hash}] serialized input definitions
@@ -382,23 +399,29 @@ module CMDx
382
399
 
383
400
  private
384
401
 
385
- # @param input [Input] defines `##{input.accessor_name}` when not already taken
386
- # @return [void]
387
- # @raise [DefinitionError] when the accessor name collides
388
402
  def define_input_reader(input)
389
403
  accessor = input.accessor_name
390
404
 
391
405
  if method_defined?(accessor) || private_method_defined?(accessor)
392
- raise DefinitionError,
393
- "cannot define input #{accessor.inspect}: ##{accessor} is already defined on #{self}"
406
+ raise DefinitionError, <<~MSG.chomp
407
+ Cannot define input #{accessor.inspect}: ##{accessor} is already defined on #{self}.
408
+
409
+ Typical cause #1: there's a method or private method with the same name as the input.
410
+
411
+ Typical cause #2: two sibling `inputs` groups declare the same nested name
412
+ (for example `inputs :a, :b do ... required :x ... end`, which defines `:x` twice).
413
+
414
+ Fix: rename with `:as`, `:prefix`, or `:suffix`, or give each parent its own
415
+ `inputs ... do ... end` block so nested accessors do not collide.
416
+
417
+ https://drexed.github.io/cmdx/inputs/definitions
418
+ MSG
394
419
  end
395
420
 
396
421
  define_method(accessor) { instance_variable_get(input.ivar_name) }
397
422
  input.children.each { |child| define_input_reader(child) }
398
423
  end
399
424
 
400
- # @param input [Input] removes `##{input.accessor_name}` if defined on this class
401
- # @return [void]
402
425
  def undefine_input_reader(input)
403
426
  accessor = input.accessor_name
404
427
  undef_method(accessor) if method_defined?(accessor)
@@ -449,65 +472,87 @@ module CMDx
449
472
  # @return [void]
450
473
  # @raise [ImplementationError] when the subclass doesn't override
451
474
  def work
452
- raise ImplementationError, "undefined method #{self.class}#work"
475
+ raise ImplementationError, <<~MSG.chomp
476
+ #{self.class} must implement #work.
477
+ See https://drexed.github.io/cmdx/basics/setup/#structure
478
+ MSG
453
479
  end
454
480
 
455
- private
456
-
457
- # Signals a successful halt.
481
+ # Halts `#work` early with a successful outcome. Any `sigdata` is merged
482
+ # onto {#metadata} before the signal is thrown.
458
483
  #
459
- # @param reason [String, nil]
460
- # @param sigdata [Hash{Symbol => Object}] arbitrary metadata merged into {#metadata} before throwing
461
- # @option sigdata [Object] arbitrary entries merged via `metadata.merge!`
462
- # @return [void] throws `Signal::TAG`; never returns
463
- # @raise [FrozenError] when the task has already been frozen (post-execution)
464
- # @note Must be called from inside `work` (inside Runtime's `catch(:cmdx_signal)`).
484
+ # @param reason [String, nil] human-readable explanation surfaced on the {Result}
485
+ # @param sigdata [Hash{Symbol => Object}] extra metadata merged into {#metadata}
486
+ # @return [void] never returns; throws {Signal::TAG}
487
+ # @raise [FrozenTaskError] when the task has already been executed
465
488
  def success!(reason = nil, **sigdata)
466
- raise FrozenError, "cannot throw signals" if frozen?
489
+ if frozen?
490
+ raise FrozenTaskError, <<~MSG.chomp
491
+ cannot call :success! on #{self.class}; the task has already been executed and frozen.
492
+ See https://drexed.github.io/cmdx/outcomes/result/#lifecycle-predicates
493
+ MSG
494
+ end
467
495
 
468
496
  metadata.merge!(sigdata) unless sigdata.empty?
469
497
  throw(Signal::TAG, Signal.success(reason, metadata:))
470
498
  end
471
499
 
472
- # Signals a skip (interrupted + skipped).
500
+ # Halts `#work` and marks the {Result} as `skipped`. Any `sigdata` is merged
501
+ # onto {#metadata} before the signal is thrown.
473
502
  #
474
- # @param reason [String, nil]
475
- # @param sigdata [Hash{Symbol => Object}] arbitrary metadata merged into {#metadata} before throwing
476
- # @option sigdata [Object] arbitrary entries merged via `metadata.merge!`
477
- # @return [void] throws `Signal::TAG`; never returns
478
- # @raise [FrozenError]
503
+ # @param reason [String, nil] human-readable explanation surfaced on the {Result}
504
+ # @param sigdata [Hash{Symbol => Object}] extra metadata merged into {#metadata}
505
+ # @return [void] never returns; throws {Signal::TAG}
506
+ # @raise [FrozenTaskError] when the task has already been executed
479
507
  def skip!(reason = nil, **sigdata)
480
- raise FrozenError, "cannot throw signals" if frozen?
508
+ if frozen?
509
+ raise FrozenTaskError, <<~MSG.chomp
510
+ cannot call :skip! on #{self.class}; the task has already been executed and frozen.
511
+ See https://drexed.github.io/cmdx/outcomes/result/#lifecycle-predicates
512
+ MSG
513
+ end
481
514
 
482
515
  metadata.merge!(sigdata) unless sigdata.empty?
483
516
  throw(Signal::TAG, Signal.skipped(reason, metadata:))
484
517
  end
485
518
 
486
- # Signals a failure. Captures current call frames as the signal
487
- # backtrace for Fault propagation.
519
+ # Halts `#work` and marks the {Result} as `failed`. Captures the current
520
+ # caller frames for the {Fault} backtrace and merges any `sigdata` onto
521
+ # {#metadata} before the signal is thrown.
488
522
  #
489
- # @param reason [String, nil]
490
- # @param sigdata [Hash{Symbol => Object}] arbitrary metadata merged into {#metadata} before throwing
491
- # @option sigdata [Object] arbitrary entries merged via `metadata.merge!`
492
- # @return [void] throws `Signal::TAG`; never returns
493
- # @raise [FrozenError]
523
+ # @param reason [String, nil] human-readable explanation surfaced on the {Result}/{Fault}
524
+ # @param sigdata [Hash{Symbol => Object}] extra metadata merged into {#metadata}
525
+ # @return [void] never returns; throws {Signal::TAG}
526
+ # @raise [FrozenTaskError] when the task has already been executed
494
527
  def fail!(reason = nil, **sigdata)
495
- raise FrozenError, "cannot throw signals" if frozen?
528
+ if frozen?
529
+ raise FrozenTaskError, <<~MSG.chomp
530
+ cannot call :fail! on #{self.class}; the task has already been executed and frozen.
531
+ See https://drexed.github.io/cmdx/outcomes/result/#lifecycle-predicates
532
+ MSG
533
+ end
496
534
 
497
535
  metadata.merge!(sigdata) unless sigdata.empty?
498
536
  throw(Signal::TAG, Signal.failed(reason, metadata:, backtrace: caller_locations(1)))
499
537
  end
500
538
 
501
- # Re-throws a failed peer Result's signal through this task. No-op when
502
- # `other` didn't fail.
539
+ # Echoes another {Result} or {Signal}'s failure outcome from this task. A
540
+ # no-op when `other` is not failed, letting callers conditionally bubble up
541
+ # nested failures without branching. Captures caller frames for the {Fault}
542
+ # backtrace and merges any `sigdata` onto {#metadata} before the signal is
543
+ # thrown.
503
544
  #
504
- # @param other [Result]
505
- # @param sigdata [Hash{Symbol => Object}] arbitrary metadata merged into {#metadata} before echoing
506
- # @option sigdata [Object] arbitrary entries merged via `metadata.merge!`
507
- # @return [void]
508
- # @raise [FrozenError]
545
+ # @param other [Result, Signal] upstream outcome to mirror
546
+ # @param sigdata [Hash{Symbol => Object}] extra metadata merged into {#metadata}
547
+ # @return [void, nil] returns `nil` when `other` is not failed; otherwise throws {Signal::TAG}
548
+ # @raise [FrozenTaskError] when the task has already been executed
509
549
  def throw!(other, **sigdata)
510
- raise FrozenError, "cannot throw signals" if frozen?
550
+ if frozen?
551
+ raise FrozenTaskError, <<~MSG.chomp
552
+ cannot call :throw! on #{self.class}; the task has already been executed and frozen.
553
+ See https://drexed.github.io/cmdx/outcomes/result/#lifecycle-predicates
554
+ MSG
555
+ end
511
556
 
512
557
  return unless other.failed?
513
558
 
@@ -57,11 +57,17 @@ module CMDx
57
57
  subscriber = callable || block
58
58
 
59
59
  if callable && block
60
- raise ArgumentError, "provide either a callable or a block, not both"
60
+ raise ArgumentError, "subscriber: provide either a callable or a block, not both"
61
61
  elsif !subscriber.respond_to?(:call)
62
- raise ArgumentError, "subscriber must respond to #call"
62
+ raise ArgumentError, <<~MSG.chomp
63
+ subscriber must respond to #call (got #{subscriber.class}).
64
+ See https://drexed.github.io/cmdx/configuration/#telemetry
65
+ MSG
63
66
  elsif !EVENTS.include?(event)
64
- raise ArgumentError, "unknown event #{event.inspect}, must be one of #{EVENTS.join(', ')}"
67
+ raise ArgumentError, <<~MSG.chomp
68
+ unknown telemetry event #{event.inspect}, must be one of #{EVENTS.inspect}.
69
+ See https://drexed.github.io/cmdx/configuration/#telemetry
70
+ MSG
65
71
  end
66
72
 
67
73
  (registry[event] ||= []) << subscriber
@@ -74,14 +80,20 @@ module CMDx
74
80
  # @param event [Symbol] one of {EVENTS}
75
81
  # @param callable [#call] the subscriber to remove
76
82
  # @return [Telemetry] self for chaining
77
- # @raise [ArgumentError] when `event` is unknown
83
+ # @raise [UnknownEntryError] when `event` is unknown
78
84
  def unsubscribe(event, callable)
79
- raise ArgumentError, "unknown event #{event.inspect}, must be one of #{EVENTS.join(', ')}" unless EVENTS.include?(event)
85
+ unless EVENTS.include?(event)
86
+ raise UnknownEntryError, <<~MSG.chomp
87
+ unknown telemetry event #{event.inspect}, must be one of #{EVENTS.inspect}.
88
+ See https://drexed.github.io/cmdx/configuration/#telemetry
89
+ MSG
90
+ end
80
91
 
81
- return self unless subscribed?(event)
92
+ if (subscribers = registry[event])
93
+ subscribers.delete(callable)
94
+ registry.delete(event) if subscribers.empty?
95
+ end
82
96
 
83
- registry[event].delete(callable)
84
- registry.delete(event) if registry[event].empty?
85
97
  self
86
98
  end
87
99
 
@@ -91,6 +103,18 @@ module CMDx
91
103
  registry.key?(event)
92
104
  end
93
105
 
106
+ # @param event [Symbol]
107
+ # @return [#call]
108
+ # @raise [UnknownEntryError] when `event` isn't registered
109
+ def lookup(event)
110
+ registry[event] || begin
111
+ raise UnknownEntryError, <<~MSG.chomp
112
+ unknown telemetry event #{event.inspect}; registered: #{registry.keys.inspect}.
113
+ See https://drexed.github.io/cmdx/configuration/#telemetry
114
+ MSG
115
+ end
116
+ end
117
+
94
118
  # @return [Boolean]
95
119
  def empty?
96
120
  registry.empty?
@@ -113,9 +137,12 @@ module CMDx
113
137
  # @param payload [Event]
114
138
  # @return [void]
115
139
  def emit(event, payload)
116
- return unless (subscribers = registry[event])
140
+ return if empty?
141
+
142
+ subscribers = lookup(event)
143
+ return if subscribers.nil? || subscribers.empty?
117
144
 
118
- subscribers.each { |s| s.call(payload) }
145
+ subscribers.each { |callable| callable.call(payload) }
119
146
  end
120
147
 
121
148
  end
data/lib/cmdx/util.rb CHANGED
@@ -1,9 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module CMDx
4
- # Shared helpers for resolving `:if` / `:unless` conditional options across
5
- # tasks, callbacks, inputs, outputs, validators, and deprecations. Normalizes
6
- # booleans, symbols (method names), procs, and call-ables into a truth value.
4
+ # Shared helpers for `:if` / `:unless` gates, recursive hash merge/dup, and
5
+ # related tree utilities used across tasks, context, and i18n.
7
6
  module Util
8
7
 
9
8
  extend self
@@ -28,7 +27,8 @@ module CMDx
28
27
  else
29
28
  return condition.call(receiver, *args) if condition.respond_to?(:call)
30
29
 
31
- raise ArgumentError, "condition must be a Symbol, Proc, or respond to #call"
30
+ raise ArgumentError,
31
+ "condition must be a Symbol, Proc, or respond to #call (got #{condition.class})"
32
32
  end
33
33
  end
34
34
 
@@ -69,5 +69,51 @@ module CMDx
69
69
  unless?(condition_unless, receiver, *args)
70
70
  end
71
71
 
72
+ # Recursively merges two Hash-like trees. When both values at a key are
73
+ # Hashes, they merge recursively; otherwise the right-hand value wins
74
+ # (last-write-wins). When either top-level operand is not a Hash, returns
75
+ # `rhs` unchanged — useful when folding unknown YAML roots.
76
+ #
77
+ # @param lhs [Object] left tree (typically a Hash)
78
+ # @param rhs [Object] right tree (typically a Hash)
79
+ # @return [Object] merged Hash or `rhs` when a Hash-only merge is impossible
80
+ def deep_merge(lhs, rhs)
81
+ return rhs unless lhs.is_a?(Hash) && rhs.is_a?(Hash)
82
+
83
+ lhs.merge(rhs) { |_key, l, r| deep_merge(l, r) }
84
+ end
85
+
86
+ # Returns a deep copy of `value`. Immutable scalars (`Numeric`, `Symbol`,
87
+ # booleans, `nil`) are returned as-is; `Hash` and `Array` are walked
88
+ # recursively; other objects use `#dup`, falling back to the original when
89
+ # `#dup` raises.
90
+ #
91
+ # @param value [Object]
92
+ # @return [Object]
93
+ def deep_dup(value)
94
+ case value
95
+ when Numeric, Symbol, TrueClass, FalseClass, NilClass
96
+ value
97
+ when Hash
98
+ value.each_with_object({}) { |(k, v), acc| acc[k] = deep_dup(v) }
99
+ when Array
100
+ value.map { |e| deep_dup(e) }
101
+ else
102
+ begin
103
+ value.dup
104
+ rescue StandardError
105
+ value
106
+ end
107
+ end
108
+ end
109
+
110
+ # Returns a string representation of `error` in the format `[Class] Message`.
111
+ #
112
+ # @param error [Exception]
113
+ # @return [String]
114
+ def to_error_s(error)
115
+ "[#{error.class}] #{error.message}"
116
+ end
117
+
72
118
  end
73
119
  end
@@ -19,7 +19,7 @@ module CMDx
19
19
  elsif value.respond_to?(:empty?)
20
20
  !value.empty?
21
21
  else
22
- !value.nil?
22
+ !!value
23
23
  end
24
24
 
25
25
  return unless present
@@ -19,22 +19,29 @@ module CMDx
19
19
  # @raise [ArgumentError] when neither `:in` nor `:within` is given
20
20
  def call(value, options = EMPTY_HASH)
21
21
  values = options[:in] || options[:within]
22
- raise ArgumentError, "exclusion validator requires :in or :within option" if values.nil?
22
+ if values.nil?
23
+ raise ArgumentError, <<~MSG.chomp
24
+ exclusion validator requires :in or :within (got #{options.keys.inspect}).
25
+ See https://drexed.github.io/cmdx/inputs/validations/#exclusion
26
+ MSG
27
+ elsif values.is_a?(Hash)
28
+ raise ArgumentError, <<~MSG.chomp
29
+ exclusion validator :in/:within does not accept a Hash; pass an Array,
30
+ Set, Range, or other Enumerable (e.g. `#{values.inspect}.keys`).
31
+ See https://drexed.github.io/cmdx/inputs/validations/#exclusion
32
+ MSG
33
+ end
23
34
 
24
35
  if values.is_a?(Range)
25
36
  within_failure(values.begin, values.end, options) if values.cover?(value)
26
- elsif Array(values).any? { |v| v === value }
27
- of_failure(values, options)
37
+ else
38
+ enum = values.is_a?(Enumerable) ? values : [values]
39
+ of_failure(enum, options) if enum.any? { |v| v === value }
28
40
  end
29
41
  end
30
42
 
31
43
  private
32
44
 
33
- # @param values [Enumerable] collection rendered into the failure message
34
- # @param options [Hash{Symbol => Object}]
35
- # @option options [String] :of_message
36
- # @option options [String] :message
37
- # @return [Validators::Failure]
38
45
  def of_failure(values, options)
39
46
  values = values.map(&:inspect).join(", ")
40
47
  message = options[:of_message] || options[:message]
@@ -43,13 +50,6 @@ module CMDx
43
50
  Failure.new(message || I18nProxy.t("cmdx.validators.exclusion.of", values:))
44
51
  end
45
52
 
46
- # @param min [Object] range/exclusion lower bound
47
- # @param max [Object] range/exclusion upper bound
48
- # @param options [Hash{Symbol => Object}]
49
- # @option options [String] :in_message
50
- # @option options [String] :within_message
51
- # @option options [String] :message
52
- # @return [Validators::Failure]
53
53
  def within_failure(min, max, options)
54
54
  message = options[:in_message] || options[:within_message] || options[:message]
55
55
  message %= { min:, max: } unless message.nil?