cmdx 2.0.0 → 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 +62 -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 +40 -56
  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 +15 -11
  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 +10 -6
  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 +28 -11
  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 +22 -40
  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 +52 -11
  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 +96 -33
  57. data/mkdocs.yml +2 -0
  58. metadata +1 -1
@@ -11,27 +11,44 @@ module CMDx
11
11
 
12
12
  extend self
13
13
 
14
+ # Hard cap on the index to keep multipliers/integer allocations bounded.
15
+ # `fib(78) < 2**63`, well past any realistic retry attempt. Pair with
16
+ # `:max_delay` for the actual sleep ceiling.
17
+ MAX_INDEX = 78
18
+
19
+ # Cache of computed Fibonacci numbers. Shared across calls so consecutive
20
+ # retries reuse prior work instead of recomputing from zero. Reads are
21
+ # lock-free; growth is performed on a local dup and swapped atomically
22
+ # under the mutex.
23
+ @cache = [0, 1].freeze
24
+ @mutex = Mutex.new
25
+
14
26
  # @param attempt [Integer] zero-based retry attempt
15
27
  # @param delay [Float] base delay in seconds
16
28
  # @param _prev_delay [Float, nil] ignored
17
29
  # @return [Float] computed delay
18
30
  def call(attempt, delay, _prev_delay = nil)
19
- delay * sequence(attempt + 1)
31
+ index = attempt + 1
32
+ index = MAX_INDEX if index > MAX_INDEX
33
+ delay * fib(index)
20
34
  end
21
35
 
22
36
  private
23
37
 
24
- # Iterative Fibonacci. `sequence(1) == 1`, `sequence(2) == 1`,
25
- # `sequence(3) == 2`, ...
26
- #
27
- # @param n [Integer] one-based index into the Fibonacci sequence
28
- # @return [Integer]
29
- # @api private
30
- def sequence(n)
31
- a = 0
32
- b = 1
33
- n.times { a, b = b, a + b }
34
- a
38
+ def fib(n)
39
+ cache = @cache
40
+ return cache[n] if n < cache.size
41
+
42
+ @mutex.synchronize do
43
+ cache = @cache
44
+ if cache.size <= n
45
+ grown = cache.dup
46
+ grown << (grown[-1] + grown[-2]) while grown.size <= n
47
+ @cache = grown.freeze
48
+ end
49
+ end
50
+
51
+ @cache[n]
35
52
  end
36
53
 
37
54
  end
data/lib/cmdx/retriers.rb CHANGED
@@ -41,9 +41,12 @@ module CMDx
41
41
  retrier = callable || block
42
42
 
43
43
  if callable && block
44
- raise ArgumentError, "provide either a callable or a block, not both"
44
+ raise ArgumentError, "retrier: provide either a callable or a block, not both"
45
45
  elsif !retrier.respond_to?(:call)
46
- raise ArgumentError, "retrier must respond to #call"
46
+ raise ArgumentError, <<~MSG.chomp
47
+ retrier must respond to #call (got #{retrier.class}).
48
+ See https://drexed.github.io/cmdx/retries/#custom-strategies-via-the-retriers-registry
49
+ MSG
47
50
  end
48
51
 
49
52
  registry[name.to_sym] = retrier
@@ -53,22 +56,25 @@ module CMDx
53
56
  # @param name [Symbol]
54
57
  # @return [Retriers] self for chaining
55
58
  def deregister(name)
56
- registry.delete(name.to_sym)
59
+ registry.delete(name)
57
60
  self
58
61
  end
59
62
 
60
63
  # @param name [Symbol]
61
64
  # @return [Boolean] whether a retrier is registered under `name`
62
65
  def key?(name)
63
- registry.key?(name.to_sym)
66
+ registry.key?(name)
64
67
  end
65
68
 
66
69
  # @param name [Symbol]
67
70
  # @return [#call] the registered retrier
68
- # @raise [ArgumentError] when `name` isn't registered
71
+ # @raise [UnknownEntryError] when `name` isn't registered
69
72
  def lookup(name)
70
73
  registry[name] || begin
71
- raise ArgumentError, "unknown retrier: #{name.inspect}"
74
+ raise UnknownEntryError, <<~MSG.chomp
75
+ unknown retrier #{name.inspect}; registered: #{registry.keys.inspect}.
76
+ See https://drexed.github.io/cmdx/retries/#built-in-strategies
77
+ MSG
72
78
  end
73
79
  end
74
80
 
@@ -78,7 +84,7 @@ module CMDx
78
84
  #
79
85
  # @param spec [Symbol, #call, nil]
80
86
  # @return [#call, nil]
81
- # @raise [ArgumentError] when `spec` is an unknown symbol or not callable
87
+ # @raise [UnknownEntryError] when `spec` is an unknown symbol or not callable
82
88
  def resolve(spec)
83
89
  case spec
84
90
  when NilClass
@@ -88,7 +94,10 @@ module CMDx
88
94
  else
89
95
  return spec if spec.respond_to?(:call)
90
96
 
91
- raise ArgumentError, "unknown retrier: #{spec.inspect}"
97
+ raise UnknownEntryError, <<~MSG.chomp
98
+ unknown retrier #{spec.inspect}; expected a Symbol from #{registry.keys.inspect} or a callable.
99
+ See https://drexed.github.io/cmdx/retries/#built-in-strategies
100
+ MSG
92
101
  end
93
102
  end
94
103
 
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
@@ -90,12 +91,12 @@ module CMDx
90
91
  def run_lifecycle
91
92
  measure_duration do
92
93
  run_callbacks(:before_execution)
94
+ run_callbacks(:before_validation)
93
95
  run_around(:around_execution) do
94
- run_callbacks(:before_validation)
95
96
  perform_work
96
97
  perform_rollback if @signal.failed?
97
- run_callbacks(:after_execution)
98
98
  end
99
+ run_callbacks(:after_execution)
99
100
  run_callbacks(:"on_#{@signal.state}")
100
101
  run_callbacks(:"on_#{@signal.status}")
101
102
  run_callbacks(:on_ok) if @signal.ok?
@@ -104,12 +105,12 @@ 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
- raise Fault, @result.caused_failure
113
+ raise Fault, @result.caused_failure, cause:
113
114
  end
114
115
 
115
116
  def finalize_result
@@ -151,8 +152,6 @@ module CMDx
151
152
  @duration = Process.clock_gettime(Process::CLOCK_MONOTONIC, :float_millisecond) - start
152
153
  end
153
154
 
154
- # @param event [Symbol] callback event from {Callbacks::EVENTS}
155
- # @return [void]
156
155
  def run_callbacks(event)
157
156
  callbacks = @task.class.callbacks
158
157
  return if callbacks.empty?
@@ -160,9 +159,6 @@ module CMDx
160
159
  callbacks.process(event, @task)
161
160
  end
162
161
 
163
- # @param event [Symbol] callback event from {Callbacks::EVENTS}
164
- # @yield nested lifecycle segment wrapped by `around_execution` callbacks
165
- # @return [Object] the wrapped block's return value
166
162
  def run_around(event, &)
167
163
  callbacks = @task.class.callbacks
168
164
  return yield if callbacks.empty?
@@ -181,7 +177,7 @@ module CMDx
181
177
  rescue Error => e
182
178
  raise(e)
183
179
  rescue StandardError => e
184
- Signal.failed("[#{e.class}] #{e.message}", cause: e, metadata: @task.metadata)
180
+ Signal.failed(Util.to_error_s(e), cause: e, metadata: @task.metadata)
185
181
  end
186
182
  end
187
183
 
@@ -225,25 +221,11 @@ module CMDx
225
221
  throw(Signal::TAG, Signal.failed(@task.errors.to_s, metadata: @task.metadata))
226
222
  end
227
223
 
228
- # @param name [Symbol] telemetry channel from {Telemetry::EVENTS}
229
- # @param payload [Hash{Symbol => Object}] forwarded onto {Telemetry::Event#payload}
230
- # @return [void]
231
224
  def emit_telemetry(name, payload = EMPTY_HASH)
232
225
  telemetry = @task.class.telemetry
233
226
  return unless telemetry.subscribed?(name)
234
227
 
235
- event = Telemetry::Event.new(
236
- xid: Chain.current.xid,
237
- cid: Chain.current.id,
238
- root: @root,
239
- type: @task.class.type,
240
- task: @task.class,
241
- tid: @task.tid,
242
- name:,
243
- payload:,
244
- timestamp: Time.now.utc
245
- )
246
-
228
+ event = Telemetry::Event.build(@task, name, root: @root, payload:)
247
229
  telemetry.emit(name, event)
248
230
  end
249
231
 
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