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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f2cf3294b8f53c29043999fe6749ffee42f6c9a52d2e4e327f1ac4589ae96c47
4
- data.tar.gz: 8d73284f355b01e171bb75f69c3fab37240462140e834f96e07831402517b89c
3
+ metadata.gz: 8d3bfaab740912c354bf1fb028b0ba0271760974f23490a9d6b8a67a82bd1675
4
+ data.tar.gz: 3c8dca3e0708d701b36dc65cbf7eefa0c14a932b783226c40f9f951886c1edb0
5
5
  SHA512:
6
- metadata.gz: 5c1e9c43bddefec0085027447651b20de2e1ef16c57492c060d411b08fc036435c7358bcc910fcb7b7f14a8a3d166f45d4a856b17aaa2454bdb2f5f609ca4be3
7
- data.tar.gz: 382b8a147035f476f2b03fda8fc1573a4b729bfc336d9776bf04d6c3f5bd95f6617d9efa77b0bbd3576a3feae828537618c06cfa53df1b119d8ac2425ac63682
6
+ metadata.gz: c6d28f1702c1034b55b9ffdad6e902f3d99b741bed3b12131d0fc5f15e7538143ac5a4e15f04e82c1f922d872415256290390b93a926f7a3bc830c046603f25c
7
+ data.tar.gz: 63bd3228b4b1fc75acb295d0d10f734e9f7452447568e650252b43a308c1ed9e1552c16a92f7a308e3c0acc87eebe962730ab758c3139a1b0f7c3f46c3664bfd
data/CHANGELOG.md CHANGED
@@ -6,12 +6,57 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
 
7
7
  ## [2.x.x] - UNRELEASED
8
8
 
9
+ ### Added
10
+ - `Result#error` — convenience returning `cause` (rescued `Exception`) when a failure was raised, otherwise the `reason` `String`; `nil` for non-failed results. Lets telemetry adapters branch on type without repeating the `cause || reason` dance
11
+ - Middlewares and lifecycle callbacks may halt the task with `success!` / `skip!` / `fail!` / `throw!` — `Runtime#execute` now wraps the middleware chain in `catch(Signal::TAG)` so a signal thrown before yielding to `next_link` becomes the task's outcome instead of an `UncaughtThrowError`
12
+ - `CMDx::Util.deep_merge` — recursive `Hash` merge with scalar last-write-wins; used by `Context#deep_merge` and `I18nProxy` locale YAML folding
13
+ - `CMDx::Util.deep_dup` — recursive `Hash` / `Array` copy with scalar sharing and `#dup` fallback; used by `Context#deep_dup`
14
+ - Add `Telemetry#lookup` (subscriber list, or `UnknownEntryError` when `event` is unknown)
15
+ - Add `CMDx.config` as an alias for `CMDx.configuration`
16
+ - Add `key?` to the name-keyed registries `Callbacks`, `Coercions`, `Validators`, `Executors`, `Mergers`, `Retriers`, and `Deprecators`
17
+ - `Coercions::Symbol` accepts `:max_length` (default `256`); longer strings fail with the usual coercion message (reduces symbol-table pressure from oversized untrusted strings)
18
+ - `Coercions::Integer` honors `:base` for non-decimal string input (e.g. `"0x10"` with `base: 16`); non-`String` values ignore `:base`
19
+
20
+ ### Changed
21
+ - `Executors::Thread` and `Executors::Fiber` require `concurrency >= 1` (`ArgumentError` on zero/negative, avoiding a silent hang); worker `on_job` errors are collected and the first is re-raised after workers finish
22
+ - `Chain` synchronizes `index`, `each`, `last`, `root`, `size`, `empty?`, `freeze`, and `results` on the existing mutex; `#results` dups while the chain stays mutable; `#root` is updated on `push` / `unshift` when the new result is the root
23
+ - `Context#initialize_copy` copies the backing table and preserves `@strict`, so `dup` / `clone` no longer share `@table`; `Context#deep_dup` preserves `@strict`; `Context#to_h` returns `table.dup` unless the context is frozen
24
+ - `Telemetry#emit` returns immediately when the telemetry registry is empty; `Runtime` and `Pipeline` still gate lifecycle emits with `Telemetry#subscribed?` first (calling `emit` for an unknown `event` while other events exist still goes through `lookup` and raises `UnknownEntryError`)
25
+ - `Task.optional` / `Task.required` and the matching `Inputs::ChildBuilder` helpers merge `**options` then pin `required:` (`optional` forces `false`, `required` forces `true`) so `required:` inside `**options` cannot flip polarity
26
+ - `Retry#wait` bounds jitter: non-`Numeric` / non-finite results fall back to base `delay`; the sleep is clamped to `[0, max_delay]` when set; registry strategies, task Symbol methods, `Proc` / `instance_exec` blocks, and `#call`-ables receive `(attempt, delay, prev_delay)`
27
+ - `Retry#build` returns `self` only when new exceptions, new options, and a replacement block are all empty — option-only subclass updates such as `retry_on(limit: 5)` apply
28
+ - `Retriers::Exponential` caps the doubling exponent at `30` (`2 ** attempt` saturates); `Retriers::Fibonacci` memoizes the sequence (lock-free reads, mutex-guarded growth) and caps `attempt + 1` at `78`; `Retriers::DecorrelatedJitter` draws in `[delay, max(prev_delay * 3, delay)]` so the delay never sits below the configured base
29
+ - `Pipeline#rollback_executed!` marks successful results `rolled_back` before each compensator, logs each `#rollback` failure at `error`, continues through the remaining tasks, then re-raises the **first** compensator exception so strict runs surface rollback faults
30
+ - `Validators::Inclusion` / `Validators::Exclusion` raise `ArgumentError` when `:in` / `:within` is a `Hash` (instead of flattening into key/value pairs)
31
+ - `Validators::Format` sends values without `#match?` through `#to_s` before matching so non-strings do not raise `NoMethodError`
32
+ - `Coercions::DateTime` rescues `RangeError` in addition to `ArgumentError` / `TypeError` / `Date::Error` and returns `Failure`
33
+ - `Coercions::Boolean` coerces `nil` to `false` (unknown strings still fail)
34
+ - `Input` resolution treats `Symbol` sources whose resolved object is `false` as absent; parent reads prefer `#fetch` with a sentinel so explicit `nil` stays distinct from a missing key
35
+ - `LogFormatters::JSON` and `LogFormatters::Logstash` rescue `JSON.dump` failures, substitute `message.inspect`, and record `logerr` so a bad payload cannot crash the logger process
36
+ - `Inputs#deregister` ignores unknown names (same as `Outputs#deregister`; avoids `NoMethodError` on a missing entry)
37
+ - `Railtie` uses a `{*}.yml` locale segment when `app.config.i18n.available_locales` is empty (the old `{}` segment matched no files)
38
+ - `Errors` coerces string keys to symbols on `add`, `[]`, `added?`, `key?` / `for?`, and `delete` to match `Context`
39
+ - `I18nProxy.register` deep-merges locale YAML so later files patch nested keys without replacing whole branches
40
+ - `Middlewares#process` builds the wrap chain with the same iterative reverse-reduce pattern as `Callbacks#around` (no recursive lambda trampoline)
41
+ - `Context#to_s` uses a pre-sized `String` buffer instead of `map` / `join`
42
+ - `CMDx.reset_configuration!` walks `ObjectSpace.each_object(Class)` and clears cached registry ivars on every `Task` subclass, not only `Task`
43
+ - Replace selected stdlib exceptions with `CMDx::Error` subclasses so `rescue CMDx::Error` covers more framework cases: `FrozenTaskError` (was `FrozenError`) when signaling after freeze; `UnknownAccessorError` (was `NoMethodError`) on strict context reader misses; `UnknownEntryError` (was `ArgumentError`) on registry misses and on unknown `Telemetry` events in `lookup` / `unsubscribe`; `UnknownLocaleError` (was `LoadError`) when the default locale file cannot be loaded
44
+ - Broader error copy refresh in `lib/cmdx/`: registry misses list the bad key plus registered keys; validator / option errors contrast passed vs accepted keys; type mismatches name the unexpected class; short messages name the entry point (e.g. `CMDx.configure`); `MiddlewareError` names the middleware that failed to yield; nuanced cases append https://drexed.github.io/cmdx/ anchors; multi-line text prefers squiggly heredocs (`<<~MSG`) so permalinks stay on their own line in logs
45
+ - Document the Symbol dispatch contract for callbacks, coercions, validators, and input sources (`send` / instance hooks reach private helpers); never derive those symbols from untrusted input
46
+ - `Workflow.included` raises `ImplementationError` immediately when the host is not a `Task` subclass (instead of failing later at execution)
47
+ - Clearer `DefinitionError` when sibling nested inputs collide on the same accessor, pointing to `:as` / `:prefix` / `:suffix` or separate `inputs` blocks
48
+ - `Fault.for?` / `Fault.reason?` / `Fault.matches?` YARD notes explain per-call anonymous subclass allocation; hoist matchers at module scope on hot paths
49
+ - `Settings#correlation_id`, `Result#xid`, and `Chain#initialize` YARD: `correlation_id` is a callable evaluated when the root chain is created, not a literal `String` / xid
50
+ - Install generator comments refreshed for strict context and current registration APIs
51
+
52
+ ## [2.0.1] - 2026-05-09
53
+
9
54
  ### Changed
10
55
  - Link cause of a raised Fault exception
11
56
  - Simplify context `method_missing` lookup order (small perf boost)
12
57
  - Attempt translation of errors `full_messages`
13
58
  - Emit `:task_rolled_back` telemetry event on workflow rollback
14
- - `around_execution` callbacks now wrap only `Task#work` (and any `#rollback`); `before_validation` runs *before* the around-block and `after_execution` runs *after* it. Previously both were nested inside the around-block
59
+ - Run `before_validation` before the `around_execution` stack (it used to run inside the around callbacks); the around hook still wraps input resolution, retried `work`, output verification, and post-failure `#rollback` when defined `after_execution` remains after the stack
15
60
 
16
61
  ## [2.0.0] - 2026-05-05
17
62
 
@@ -28,14 +73,14 @@ Full runtime rewrite: the v1 state-machine plus Zeitwerk architecture is replace
28
73
  - Add `CMDx::Signal` halt token thrown via `catch(Signal::TAG)` (`:cmdx_signal`)
29
74
  - Add `Signal#ok?` / `Signal#ko?` predicates
30
75
  - Add `CMDx::Runtime` orchestrating the full task lifecycle and building the final `Result`
31
- - Add `CMDx::Telemetry` pub/sub for `:task_started`, `:task_deprecated`, `:task_retried`, `:task_rolled_back`, `:task_executed`; emits `Telemetry::Event` data objects with `cid`, `root`, `type`, `task`, `tid`, `name`, `payload`, `timestamp`
76
+ - Add `CMDx::Telemetry` pub/sub for `:task_started`, `:task_deprecated`, `:task_retried`, `:task_rolled_back`, `:task_executed`; emits `Telemetry::Event` data objects with `xid`, `cid`, `root`, `type`, `task`, `tid`, `name`, `payload`, `timestamp`
32
77
  - Add `CMDx::Deprecation` for declarative class-level deprecation (`:log`, `:warn`, `:error`, Symbol, Proc, callable) with `:if` / `:unless` gating
33
78
  - Add `CMDx::Input` / `CMDx::Inputs` (replaces `Attribute` / `AttributeRegistry` / `AttributeValue`) supporting `:source`, `:default`, `:transform`, `:as`, `:prefix` / `:suffix`, and nested children via DSL block
34
79
  - Add `CMDx::Output` / `CMDx::Outputs` for first-class declared outputs verified against `task.context` after `work`. Every declared output is implicitly required — there is no `:required` option. Surface is intentionally minimal: `:default` (literal/Symbol/Proc/`#call(task)`-able fallback applied when the value is `nil`, satisfies the implicit required check), `:if` / `:unless` guards, and `:description`. Coercion, transformation, validation, and nested children are intentionally not supported on outputs — use inputs (or compute in `work`) when you need any of those
35
80
  - Add `CMDx::Util` single conditional-evaluation module (`evaluate`, `if?`, `unless?`, `satisfied?`) consolidating the v1 `Utils::*` modules
36
81
  - Add `CMDx::I18nProxy` translation façade that delegates to `I18n` when available, otherwise loads the bundled YAML and percent-interpolates with memoization
37
82
  - Add `CMDx::LoggerProxy` returning a per-task logger, `dup`-ing the base only when the task overrides `log_level` or `log_formatter`
38
- - Add `around_execution` callback that wraps `before_validation`, `Task#work`, any `#rollback`, and `after_execution` in a single hook. Symbol callbacks receive the continuation as their block (use `yield`); Procs/blocks/callables receive `(task, continuation)` and must invoke `continuation.call`. Multiple hooks nest in declaration order; failure to invoke the continuation raises `CMDx::CallbackError`. Sits inside middlewares but outside the state/status callbacks, so it's the right place for symmetric concerns (transactions, instrumentation, per-task logging context) without bolting them onto `middlewares.register`
83
+ - Add `around_execution` callback nesting `before_validation`, input resolution, retried `Task#work`, output verification, `after_execution`, and post-failure `#rollback` (when defined). Symbol callbacks receive the continuation as their block (use `yield`); Procs/blocks/callables receive `(task, continuation)` and must invoke `continuation.call`. Hooks nest in declaration order; skipping the continuation raises `CMDx::CallbackError`. Lives inside middlewares but outside the `on_*` state/status callbacks suited to transactions, instrumentation, and logging scope without extra `middlewares.register` entries (ordering of `before_validation` / `after_execution` vs this hook was refined in 2.0.1)
39
84
  - Add new exception classes: `DefinitionError`, `DeprecationError`, `ImplementationError`, `MiddlewareError`, `CallbackError`
40
85
  - Add `Task#work` abstract method (raises `ImplementationError` when not defined)
41
86
  - Add `Task#rollback` lifecycle hook, auto-invoked by Runtime on failed results when defined; surfaced via `Result#rolled_back?` and the `:task_rolled_back` event
@@ -81,11 +126,11 @@ Full runtime rewrite: the v1 state-machine plus Zeitwerk architecture is replace
81
126
  - Generated input accessors are now plain instance methods backed by `@_input_<name>` ivars set during input resolution; outputs have no accessors and are read/written directly on `task.context`
82
127
  - `Workflow` declares groups via `task` / `tasks` (still aliased) and supports `:strategy => :parallel`, `:pool_size`, `:continue_on_failure`, `:if` / `:unless` per group; defining `#work` on a workflow raises `ImplementationError`
83
128
  - `Pipeline` gains a `:parallel` strategy with `:pool_size` (replacing the removed `Parallelizer`); parallel workers share the parent fiber's chain, each get a `deep_dup`-ed context, successful child contexts are merged back into the workflow's context, and the first failed result is echoed via `throw!` to halt the pipeline. By default pending tasks are cancelled on the first failure (in-flight tasks still finish and successful contexts still merge); opt into batch semantics with `:continue_on_failure => true` to run every task to completion and aggregate failures into the workflow's `errors` (keys namespaced as `"TaskClass.input"` for input/validation errors and `"TaskClass.<status>"` for bare `fail!` reasons). `:continue_on_failure` works for both `:sequential` and `:parallel` groups.
84
- - `Task.callbacks`, `Task.middlewares`, `Task.coercions`, `Task.validators`, `Task.executors`, `Task.mergers`, `Task.telemetry`, `Task.inputs`, `Task.outputs` lazy-clone from the superclass (or global `Configuration`) on first access — subclasses extend rather than replace
85
- - `Settings` is now a frozen value object holding only `logger`, `log_formatter`, `log_level`, `log_exclusions`, `backtrace_cleaner`, `tags`, `strict_context`; every getter falls back to `CMDx.configuration`
129
+ - `Task.callbacks`, `Task.middlewares`, `Task.coercions`, `Task.validators`, `Task.executors`, `Task.mergers`, `Task.retriers`, `Task.deprecators`, `Task.telemetry`, `Task.inputs`, `Task.outputs` lazy-clone from the superclass (or global `Configuration`) on first access — subclasses extend rather than replace
130
+ - `Settings` is now a frozen value object holding only `logger`, `log_formatter`, `log_level`, `log_exclusions`, `backtrace_cleaner`, `tags`, `strict_context`, `correlation_id`; every getter falls back to `CMDx.configuration`
86
131
  - `Context.build` accepts anything that responds to `#context` (e.g. another `Task`), unwraps repeatedly, and only re-wraps frozen contexts; symbolizes hash keys via `#to_hash` / `#to_h`
87
132
  - `Retry` becomes a value object; `Task.retry_on` accumulates exceptions and options across the inheritance chain via `Retry#build`; supports built-in jitter strategies (`:exponential`, `:half_random`, `:full_random`, `:bounded_random`) plus Symbol / Proc / callable; retry wraps `work` only (input resolution and output verification run once, outside the retry loop)
88
- - All registries (`Callbacks`, `Middlewares`, `Coercions`, `Validators`, `Telemetry`, `Inputs`, `Outputs`) implement `initialize_copy` for cheap copy-on-write inheritance; `register` / `deregister` validate types up-front and raise `ArgumentError` on misuse
133
+ - All registries (`Callbacks`, `Middlewares`, `Coercions`, `Validators`, `Executors`, `Mergers`, `Retriers`, `Deprecators`, `Telemetry`, `Inputs`, `Outputs`) implement `initialize_copy` for cheap copy-on-write inheritance; `register` / `deregister` validate types up-front and raise `ArgumentError` on misuse
89
134
  - `Coercions#coerce` returns a `Coercions::Failure` sentinel with an i18n message recorded on `task.errors`; when multiple declared coercion rules match none (and none were inline), an aggregated `cmdx.coercions.into_any` message is reported instead of the per-rule messages
90
135
  - `Validators#validate` records a message on `task.errors` for each failed rule (the individual built-in validators return `Validators::Failure`)
91
136
  - Extend `Validators::Numeric` and `Validators::Length` with `:gt` / `:lt` (strict comparison, with `:gt_message` / `:lt_message` overrides and `cmdx.validators.{numeric,length}.{gt,lt}` i18n keys), plus `:gte` / `:lte` / `:eq` / `:not_eq` aliases that normalize to `:min` / `:max` / `:is` / `:is_not`
@@ -94,10 +139,10 @@ Full runtime rewrite: the v1 state-machine plus Zeitwerk architecture is replace
94
139
  - `Result#to_h` / `to_s` / `deconstruct_keys` now include `:origin` (compact `{ task:, tid: }` hash, or `nil` for locally originated failures)
95
140
  - **BREAKING**: `Result#deconstruct` now returns `#to_h.to_a` (array of `[key, value]` pairs) instead of the fixed `[type, task, state, status, reason, metadata, cause, origin]` tuple — update any array-pattern matches to use find-patterns (`in [*, [:status, "failed"], *]`)
96
141
  - `Result#deconstruct_keys` now honors its `keys` argument — `nil` returns the full `#to_h`, a key list slices it; previously it always returned the full hash
97
- - `Middlewares` registry entries are now `[callable, options.freeze]` tuples — callers that read `Task.middlewares.registry` directly must map `.first` to recover the callable
142
+ - `Middlewares` registry entries are now `[callable, options.freeze]` tuples — callers that read `Task.middlewares.registry` directly should take `.first` for the callable (per-entry options live in `.last`)
98
143
  - Slim the locale file: remove `attributes.undefined`, `coercions.unknown`, `faults.invalid`, `faults.unspecified`, `returns.*`; rename `returns.missing` → `outputs.missing`; add `nil_value` to `length` / `numeric` validator messages
99
144
  - Generators emit the new `def work` template; the install template documents the new middleware / callback / telemetry / coercion / validator registration shapes
100
- - Slim `Configuration` to: `middlewares`, `callbacks`, `coercions`, `validators`, `telemetry`, `default_locale`, `strict_context`, `backtrace_cleaner`, `logger`, `log_level`, `log_formatter`
145
+ - Slim `Configuration` to: `middlewares`, `callbacks`, `coercions`, `validators`, `executors`, `mergers`, `retriers`, `deprecators`, `telemetry`, `correlation_id`, `default_locale`, `strict_context`, `backtrace_cleaner`, `log_exclusions`, `logger`, `log_level`, `log_formatter`
101
146
  - `Configuration#log_level` and `Configuration#log_formatter` now default to `nil` — treat them as optional overrides on top of `config.logger` (the default `Logger` still carries `Logger::INFO` + `LogFormatters::Line.new`). `LoggerProxy` only `dup`s the logger when a non-nil override differs from the logger's own level/formatter, so swapping `config.logger` no longer requires also clearing these fields
102
147
 
103
148
  ### Removed
@@ -53,11 +53,17 @@ module CMDx
53
53
  callback = callable || block
54
54
 
55
55
  if callable && block
56
- raise ArgumentError, "provide either a callable or a block, not both"
56
+ raise ArgumentError, "callback: provide either a callable or a block, not both"
57
57
  elsif !callback.is_a?(Symbol) && !callback.respond_to?(:call)
58
- raise ArgumentError, "callback must be a Symbol or respond to #call"
58
+ raise ArgumentError, <<~MSG.chomp
59
+ callback must be a Symbol or respond to #call (got #{callback.class}).
60
+ See https://drexed.github.io/cmdx/callbacks/#how-do-i-register-one
61
+ MSG
59
62
  elsif !EVENTS.include?(event)
60
- raise ArgumentError, "unknown event #{event.inspect}, must be one of #{EVENTS.join(', ')}"
63
+ raise ArgumentError, <<~MSG.chomp
64
+ unknown callback event #{event.inspect}, must be one of #{EVENTS.to_a.inspect}.
65
+ See https://drexed.github.io/cmdx/callbacks/#what-callbacks-exist
66
+ MSG
61
67
  end
62
68
 
63
69
  (registry[event] ||= []) << [callback, options.freeze]
@@ -75,7 +81,12 @@ module CMDx
75
81
  # @return [Callbacks] self for chaining
76
82
  # @raise [ArgumentError] when `event` is unknown
77
83
  def deregister(event, callable = nil)
78
- raise ArgumentError, "unknown event #{event.inspect}, must be one of #{EVENTS.join(', ')}" unless EVENTS.include?(event)
84
+ unless EVENTS.include?(event)
85
+ raise ArgumentError, <<~MSG.chomp
86
+ unknown callback event #{event.inspect}, must be one of #{EVENTS.to_a.inspect}.
87
+ See https://drexed.github.io/cmdx/callbacks/#what-callbacks-exist
88
+ MSG
89
+ end
79
90
 
80
91
  if callable.nil?
81
92
  registry.delete(event)
@@ -87,6 +98,12 @@ module CMDx
87
98
  self
88
99
  end
89
100
 
101
+ # @param name [Symbol]
102
+ # @return [Boolean] whether a callback is registered under `name`
103
+ def key?(event)
104
+ registry.key?(event)
105
+ end
106
+
90
107
  # @return [Boolean]
91
108
  def empty?
92
109
  registry.empty?
@@ -110,6 +127,8 @@ module CMDx
110
127
  # @return [void]
111
128
  # @raise [ArgumentError] when a callback is neither a Symbol nor responds to `#call`
112
129
  def process(event, task)
130
+ return if empty?
131
+
113
132
  callbacks = registry[event]
114
133
  return if callbacks.nil? || callbacks.empty?
115
134
 
@@ -150,18 +169,16 @@ module CMDx
150
169
 
151
170
  invoke(callable, task, cont, &cont)
152
171
 
153
- called || raise(CallbackError, "#{event} callback did not invoke its continuation")
172
+ called || raise(CallbackError, <<~MSG.chomp)
173
+ #{event} callback did not invoke its continuation.
174
+ See https://drexed.github.io/cmdx/callbacks/#around_execution-the-wrap-the-whole-thing-hook
175
+ MSG
154
176
  end
155
177
  end.call
156
178
  end
157
179
 
158
180
  private
159
181
 
160
- # @param callable [Symbol, Proc, #call]
161
- # @param task [Task]
162
- # @param extras [Array<Object>] extra args after `task` for continuation-style callbacks
163
- # @return [Object] the callback's return value
164
- # @raise [ArgumentError] when `callable` is invalid
165
182
  def invoke(callable, task, *extras, &)
166
183
  case callable
167
184
  when Symbol
@@ -171,7 +188,10 @@ module CMDx
171
188
  else
172
189
  return callable.call(task, *extras) if callable.respond_to?(:call)
173
190
 
174
- raise ArgumentError, "callback must be a Symbol, Proc, or respond to #call"
191
+ raise ArgumentError, <<~MSG.chomp
192
+ callback must be a Symbol, Proc, or respond to #call (got #{callable.class}).
193
+ See https://drexed.github.io/cmdx/callbacks/#how-do-i-register-one
194
+ MSG
175
195
  end
176
196
  end
177
197
 
data/lib/cmdx/chain.rb CHANGED
@@ -34,16 +34,27 @@ module CMDx
34
34
 
35
35
  end
36
36
 
37
- attr_reader :xid, :id, :results
37
+ attr_reader :xid, :id
38
38
 
39
39
  # @param xid [String, nil] external correlation id (e.g. Rails `request_id`)
40
40
  # shared across every {Result} in this chain. Resolved once by Runtime
41
- # from {Configuration#xid} when the root chain is created.
41
+ # from {Settings#correlation_id} (a callable) when the root chain is
42
+ # created.
42
43
  def initialize(xid = nil)
43
44
  @xid = xid
44
45
  @id = SecureRandom.uuid_v7
45
46
  @mutex = Mutex.new
46
47
  @results = []
48
+ @root = nil
49
+ end
50
+
51
+ # @return [Array<Result>] snapshot of the results stored in this chain.
52
+ # While the chain is mutable a dup is returned so callers cannot mutate
53
+ # internal state and see consistent ordering despite parallel pushes;
54
+ # after {#freeze} the actual frozen array is returned to preserve
55
+ # `Array#frozen?` semantics.
56
+ def results
57
+ @mutex.synchronize { @results.frozen? ? @results : @results.dup }
47
58
  end
48
59
 
49
60
  # Appends `result` to the chain. Thread-safe to support parallel pipelines.
@@ -51,7 +62,10 @@ module CMDx
51
62
  # @param result [Result]
52
63
  # @return [Chain] self for chaining
53
64
  def push(result)
54
- @mutex.synchronize { @results << result }
65
+ @mutex.synchronize do
66
+ @results << result
67
+ @root = result if @root.nil? && result.respond_to?(:root?) && result.root?
68
+ end
55
69
  self
56
70
  end
57
71
  alias << push
@@ -61,24 +75,29 @@ module CMDx
61
75
  # @param result [Result]
62
76
  # @return [Chain] self for chaining
63
77
  def unshift(result)
64
- @mutex.synchronize { @results.unshift(result) }
78
+ @mutex.synchronize do
79
+ @results.unshift(result)
80
+ @root = result if result.respond_to?(:root?) && result.root?
81
+ end
65
82
  self
66
83
  end
67
84
 
68
85
  # @param result [Result]
69
86
  # @return [Integer, nil] zero-based position of `result`, or nil when absent
70
87
  def index(result)
71
- @results.index(result)
88
+ @mutex.synchronize { @results.index(result) }
72
89
  end
73
90
 
74
91
  # @return [Result, nil] the most recently appended result
75
92
  def last
76
- @results.last
93
+ @mutex.synchronize { @results.last }
77
94
  end
78
95
 
79
96
  # @return [Result, nil] the root result, or nil when absent
80
97
  def root
81
- @results.find(&:root?)
98
+ @mutex.synchronize do
99
+ @root || @results.find { |r| r.respond_to?(:root?) && r.root? }
100
+ end
82
101
  end
83
102
 
84
103
  # @return [String, nil] the state of the root result, or nil when absent
@@ -93,18 +112,18 @@ module CMDx
93
112
 
94
113
  # @return [Boolean]
95
114
  def empty?
96
- @results.empty?
115
+ @mutex.synchronize { @results.empty? }
97
116
  end
98
117
 
99
118
  # @return [Integer]
100
119
  def size
101
- @results.size
120
+ @mutex.synchronize { @results.size }
102
121
  end
103
122
 
104
123
  # @yield [Result] each result in insertion order
105
124
  # @return [Enumerator, Chain]
106
125
  def each(&)
107
- @results.each(&)
126
+ results.each(&)
108
127
  end
109
128
 
110
129
  # Freezes the chain and its results. Called by Runtime teardown.
@@ -13,7 +13,7 @@ module CMDx
13
13
  # @option options [Integer] :precision (14)
14
14
  # @return [BigDecimal, Coercions::Failure]
15
15
  def call(value, options = EMPTY_HASH)
16
- return value if value.is_a?(BigDecimal)
16
+ return value if value.is_a?(::BigDecimal)
17
17
 
18
18
  BigDecimal(value, options[:precision] || 14)
19
19
  rescue ArgumentError, TypeError
@@ -3,8 +3,8 @@
3
3
  module CMDx
4
4
  class Coercions
5
5
  # Coerces to Boolean by matching the string form against the {TRUTHY}
6
- # and {FALSEY} sets (case- and whitespace-insensitive). Anything else
7
- # (including `nil`) fails.
6
+ # and {FALSEY} sets (case- and whitespace-insensitive). `nil` becomes
7
+ # `false`; anything else unrecognized fails.
8
8
  module Boolean
9
9
 
10
10
  extend self
@@ -17,18 +17,12 @@ module CMDx
17
17
  # @option options [Object] reserved for future per-coercion configuration (currently ignored)
18
18
  # @return [Boolean, Coercions::Failure]
19
19
  def call(value, options = EMPTY_HASH)
20
- return coercion_failure if value.nil?
20
+ return false if value.nil?
21
21
 
22
22
  str = value.to_s.strip.downcase
23
23
  return true if TRUTHY.include?(str)
24
24
  return false if FALSEY.include?(str)
25
25
 
26
- coercion_failure
27
- end
28
-
29
- private
30
-
31
- def coercion_failure
32
26
  type = I18nProxy.t("cmdx.types.boolean")
33
27
  Failure.new(I18nProxy.t("cmdx.coercions.into_a", type:))
34
28
  end
@@ -23,7 +23,10 @@ module CMDx
23
23
  else
24
24
  return handler.call(value, task) if handler.respond_to?(:call)
25
25
 
26
- raise ArgumentError, "coerce handler must be a Symbol, Proc, or respond to #call"
26
+ raise ArgumentError, <<~MSG.chomp
27
+ coerce handler must be a Symbol, Proc, or respond to #call (got #{handler.class}).
28
+ See https://drexed.github.io/cmdx/inputs/coercions/#inline-coerce-callable
29
+ MSG
27
30
  end
28
31
  end
29
32
 
@@ -27,7 +27,7 @@ module CMDx
27
27
  else
28
28
  coercion_failure
29
29
  end
30
- rescue ArgumentError, TypeError, ::Date::Error
30
+ rescue ArgumentError, RangeError, TypeError, ::Date::Error
31
31
  coercion_failure
32
32
  end
33
33
 
@@ -3,16 +3,25 @@
3
3
  module CMDx
4
4
  class Coercions
5
5
  # Coerces to Integer via `Kernel#Integer` (strict; rejects floats-as-strings).
6
+ # Pass `base:` to parse strings written in non-decimal radix
7
+ # (e.g. `"0x10"` with `base: 16`). The `:base` option is applied only
8
+ # when `value` is a String — for numeric inputs `Kernel#Integer` is
9
+ # called without a base, matching its native contract.
6
10
  module Integer
7
11
 
8
12
  extend self
9
13
 
10
14
  # @param value [Object]
11
15
  # @param options [Hash{Symbol => Object}]
12
- # @option options [Object] reserved for future per-coercion configuration (currently ignored)
16
+ # @option options [Integer] :base radix for string parsing (default 10)
13
17
  # @return [Integer, Coercions::Failure]
14
18
  def call(value, options = EMPTY_HASH)
15
- Integer(value)
19
+ base = options[:base]
20
+ if base && value.is_a?(::String)
21
+ Integer(value, base)
22
+ else
23
+ Integer(value)
24
+ end
16
25
  rescue ArgumentError, FloatDomainError, RangeError, TypeError
17
26
  type = I18nProxy.t("cmdx.types.integer")
18
27
  Failure.new(I18nProxy.t("cmdx.coercions.into_an", type:))
@@ -2,21 +2,40 @@
2
2
 
3
3
  module CMDx
4
4
  class Coercions
5
- # Coerces to Symbol via `#to_s.to_sym`. Fails only when `value` has no
6
- # `#to_s` (i.e. `BasicObject` instances).
5
+ # Coerces to Symbol via `#to_s.to_sym`. Fails when `value` has no
6
+ # `#to_s` (i.e. `BasicObject` instances) or when the resulting string
7
+ # exceeds {MAX_LENGTH} characters.
8
+ #
9
+ # Symbols are never garbage-collected when interned from arbitrary
10
+ # strings, so unbounded coercion of attacker-controlled input would
11
+ # grow the symbol table unbounded (memory DoS). The default cap is
12
+ # generous for legitimate identifiers; pass `max_length:` to tighten
13
+ # it for hot paths or untrusted boundaries.
7
14
  module Symbol
8
15
 
9
16
  extend self
10
17
 
18
+ MAX_LENGTH = 256
19
+
11
20
  # @param value [Object]
12
21
  # @param options [Hash{Symbol => Object}]
13
- # @option options [Object] reserved for future per-coercion configuration (currently ignored)
22
+ # @option options [Integer] :max_length (256) reject strings longer than this
14
23
  # @return [Symbol, Coercions::Failure]
15
24
  def call(value, options = EMPTY_HASH)
16
25
  return value if value.is_a?(::Symbol)
17
26
 
18
- value.to_s.to_sym
27
+ str = value.to_s
28
+ limit = options[:max_length] || MAX_LENGTH
29
+ return coercion_failure if str.length > limit
30
+
31
+ str.to_sym
19
32
  rescue NoMethodError
33
+ coercion_failure
34
+ end
35
+
36
+ private
37
+
38
+ def coercion_failure
20
39
  type = I18nProxy.t("cmdx.types.symbol")
21
40
  Failure.new(I18nProxy.t("cmdx.coercions.into_a", type:))
22
41
  end
@@ -52,9 +52,12 @@ module CMDx
52
52
  coercion = callable || block
53
53
 
54
54
  if callable && block
55
- raise ArgumentError, "provide either a callable or a block, not both"
55
+ raise ArgumentError, "coercion: provide either a callable or a block, not both"
56
56
  elsif !coercion.respond_to?(:call)
57
- raise ArgumentError, "coercion must respond to #call"
57
+ raise ArgumentError, <<~MSG.chomp
58
+ coercion must respond to #call (got #{coercion.class}).
59
+ See https://drexed.github.io/cmdx/inputs/coercions/#declarations
60
+ MSG
58
61
  end
59
62
 
60
63
  registry[name.to_sym] = coercion
@@ -64,16 +67,25 @@ module CMDx
64
67
  # @param name [Symbol]
65
68
  # @return [Coercions] self for chaining
66
69
  def deregister(name)
67
- registry.delete(name.to_sym)
70
+ registry.delete(name)
68
71
  self
69
72
  end
70
73
 
74
+ # @param name [Symbol]
75
+ # @return [Boolean] whether a coercion is registered under `name`
76
+ def key?(name)
77
+ registry.key?(name)
78
+ end
79
+
71
80
  # @param name [Symbol]
72
81
  # @return [#call] the registered coercion
73
- # @raise [ArgumentError] when `name` isn't registered
82
+ # @raise [UnknownEntryError] when `name` isn't registered
74
83
  def lookup(name)
75
84
  registry[name] || begin
76
- raise ArgumentError, "unknown coercion: #{name}"
85
+ raise UnknownEntryError, <<~MSG.chomp
86
+ unknown coercion #{name.inspect}; registered: #{registry.keys.inspect}.
87
+ See https://drexed.github.io/cmdx/inputs/coercions/#built-in-coercions
88
+ MSG
77
89
  end
78
90
  end
79
91
 
@@ -101,7 +113,10 @@ module CMDx
101
113
  else
102
114
  return [[raw, EMPTY_HASH]] if raw.respond_to?(:call)
103
115
 
104
- raise ArgumentError, "unsupported type format: #{raw.inspect}"
116
+ raise ArgumentError, <<~MSG.chomp
117
+ unsupported :coerce format #{raw.inspect}; expected Symbol, Array, Hash, or a callable.
118
+ See https://drexed.github.io/cmdx/inputs/coercions/#declarations
119
+ MSG
105
120
  end
106
121
  end
107
122
 
@@ -156,9 +171,6 @@ module CMDx
156
171
 
157
172
  private
158
173
 
159
- # @param entry [Object] Array entry from a `:coerce` list
160
- # @return [Array(Object, Hash)] handler + options pair
161
- # @raise [ArgumentError] when `entry` is unsupported
162
174
  def normalize_entry(entry)
163
175
  case entry
164
176
  when ::Symbol, ::Proc
@@ -166,7 +178,10 @@ module CMDx
166
178
  else
167
179
  return [entry, EMPTY_HASH] if entry.respond_to?(:call)
168
180
 
169
- raise ArgumentError, "unsupported coerce entry: #{entry.inspect}"
181
+ raise ArgumentError, <<~MSG.chomp
182
+ unsupported coerce entry #{entry.inspect}; expected Symbol, Proc, or a callable.
183
+ See https://drexed.github.io/cmdx/inputs/coercions/#inline-coerce-callable
184
+ MSG
170
185
  end
171
186
  end
172
187
 
@@ -51,38 +51,53 @@ module CMDx
51
51
 
52
52
  @configuration ||= Configuration.new
53
53
  end
54
+ alias config configuration
54
55
 
55
56
  # Yields the global configuration for mutation.
56
57
  #
57
58
  # @yield [Configuration]
58
59
  # @return [Configuration]
59
60
  # @raise [ArgumentError] when no block is given
60
- def configure
61
- raise ArgumentError, "block required" unless block_given?
61
+ def configure(&)
62
+ raise ArgumentError, "CMDx.configure requires a block" unless block_given?
62
63
 
63
- config = configuration
64
- yield(config)
65
- config
64
+ configuration.tap(&)
66
65
  end
67
66
 
68
67
  # Replaces the global configuration with a fresh instance and invalidates
69
- # the cached registries on `Task` so new lookups rebuild from the new config.
70
- # Intended for test setup/teardown.
68
+ # the cached registries on {Task} and every Task subclass currently in the
69
+ # object space, so new lookups rebuild from the new config. Intended for
70
+ # test setup/teardown.
71
+ #
72
+ # Walks `ObjectSpace.each_object(Class)` once — acceptable for tests but
73
+ # avoid calling it on a hot path.
71
74
  #
72
75
  # @return [void]
73
76
  def reset_configuration!
74
77
  @configuration = Configuration.new
75
78
  return unless defined?(Task)
76
79
 
77
- Task.instance_variable_set(:@middlewares, nil)
78
- Task.instance_variable_set(:@callbacks, nil)
79
- Task.instance_variable_set(:@coercions, nil)
80
- Task.instance_variable_set(:@validators, nil)
81
- Task.instance_variable_set(:@executors, nil)
82
- Task.instance_variable_set(:@mergers, nil)
83
- Task.instance_variable_set(:@retriers, nil)
84
- Task.instance_variable_set(:@deprecators, nil)
85
- Task.instance_variable_set(:@telemetry, nil)
80
+ ivars = %i[
81
+ @middlewares
82
+ @callbacks
83
+ @coercions
84
+ @validators
85
+ @executors
86
+ @mergers
87
+ @retriers
88
+ @deprecators
89
+ @telemetry
90
+ ].freeze
91
+
92
+ ObjectSpace.each_object(Class) do |klass|
93
+ next unless klass <= Task
94
+
95
+ ivars.each do |iv|
96
+ next unless klass.instance_variable_defined?(iv)
97
+
98
+ klass.instance_variable_set(iv, nil)
99
+ end
100
+ end
86
101
  end
87
102
 
88
103
  end