From 633b2107ed6baf2cf26ddbe4ca2d30be455a3aa1 Mon Sep 17 00:00:00 2001 From: Juan Gomez Date: Thu, 9 Apr 2026 22:15:51 -0400 Subject: [PATCH 01/54] Move result transition to a resolver class Move more method to resolver Update result.rb Fix specs --- CHANGELOG.md | 3 + lib/cmdx/executor.rb | 27 +- lib/cmdx/middlewares/timeout.rb | 14 +- lib/cmdx/resolver.rb | 263 ++++++++++++++++ lib/cmdx/result.rb | 247 ++------------- lib/cmdx/task.rb | 13 +- spec/cmdx/executor_spec.rb | 75 ++--- spec/cmdx/faults_spec.rb | 11 +- spec/cmdx/middlewares/timeout_spec.rb | 8 +- spec/cmdx/resolver_spec.rb | 316 ++++++++++++++++++++ spec/cmdx/result_spec.rb | 414 ++++---------------------- spec/cmdx/task_spec.rb | 16 +- 12 files changed, 761 insertions(+), 646 deletions(-) create mode 100644 lib/cmdx/resolver.rb create mode 100644 spec/cmdx/resolver_spec.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c5ee2e1c..f6b473901 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ## [UNRELEASED] +### Added +- Add `CMDx::Resolver` class to manage result transitions + ## [1.21.0] - 2026-04-09 ### Added diff --git a/lib/cmdx/executor.rb b/lib/cmdx/executor.rb index 5a2e2711b..727d7d032 100644 --- a/lib/cmdx/executor.rb +++ b/lib/cmdx/executor.rb @@ -28,7 +28,7 @@ class Executor # @rbs @task: Task attr_reader :task - def_delegators :task, :result + def_delegators :task, :result, :resolver # @param task [CMDx::Task] The task to execute # @@ -78,13 +78,13 @@ def execute rescue UndefinedMethodError => e raise_exception(e) rescue Fault => e - result.throw!(e.result, halt: false, cause: e) + resolver.throw!(e.result, halt: false, cause: e) rescue StandardError => e retry if retry_execution?(e) - result.fail!(Utils::Normalize.exception(e), halt: false, cause: e, source: :exception) + resolver.fail!(Utils::Normalize.exception(e), halt: false, cause: e, source: :exception) task.class.settings.exception_handler&.call(task, e) ensure - result.executed! + resolver.executed! post_execution! end @@ -111,20 +111,20 @@ def execute! rescue UndefinedMethodError => e raise_exception(e) rescue Fault => e - result.throw!(e.result, halt: false, cause: e) + resolver.throw!(e.result, halt: false, cause: e) if halt_execution?(e) raise_exception(e) else - result.executed! + resolver.executed! post_execution! end rescue StandardError => e retry if retry_execution?(e) - result.fail!(Utils::Normalize.exception(e), halt: false, cause: e, source: :exception) + resolver.fail!(Utils::Normalize.exception(e), halt: false, cause: e, source: :exception) raise_exception(e) else - result.executed! + resolver.executed! post_execution! end @@ -221,7 +221,7 @@ def pre_execution! task.class.settings.attributes.define_and_verify(task) return if task.errors.empty? - result.fail!( + resolver.fail!( Locale.t("cmdx.faults.invalid"), source: :validation, errors: { @@ -238,7 +238,7 @@ def pre_execution! def execution! invoke_callbacks(:before_execution) - result.executing! + resolver.executing! catch(:cmdx_halt) { task.work } end @@ -254,7 +254,7 @@ def verify_context_returns! missing.each { |name| task.errors.add(name, Locale.t("cmdx.returns.missing")) } - result.fail!( + resolver.fail!( Locale.t("cmdx.faults.invalid"), source: :context, errors: { @@ -285,12 +285,12 @@ def post_execution! def verify_middleware_yield! return unless result.initialized? - result.fail!( + resolver.fail!( Locale.t("cmdx.faults.invalid"), halt: false, source: :middleware ) - result.executed! + resolver.executed! end # Finalizes execution by freezing the task, logging results, and rolling back work. @@ -339,6 +339,7 @@ def freeze_execution! return unless CMDx.configuration.freeze_results task.freeze + resolver.freeze result.freeze # Freezing the context and chain can only be done once the outer-most diff --git a/lib/cmdx/middlewares/timeout.rb b/lib/cmdx/middlewares/timeout.rb index 8c40dee74..a7dad82b2 100644 --- a/lib/cmdx/middlewares/timeout.rb +++ b/lib/cmdx/middlewares/timeout.rb @@ -63,14 +63,12 @@ def call(task, **options, &) ::Timeout.timeout(limit, TimeoutError, "execution exceeded #{limit} seconds", &) rescue TimeoutError => e - task.result.tap do |r| - r.fail!( - Utils::Normalize.exception(e), - cause: e, - source: :timeout, - limit: - ) - end + task.resolver.fail!( + Utils::Normalize.exception(e), + cause: e, + source: :timeout, + limit: + ) end end diff --git a/lib/cmdx/resolver.rb b/lib/cmdx/resolver.rb new file mode 100644 index 000000000..d201eea3b --- /dev/null +++ b/lib/cmdx/resolver.rb @@ -0,0 +1,263 @@ +# frozen_string_literal: true + +module CMDx + # Manages state transitions for a {CMDx::Result}, handling success annotation, + # skipping, failure, throwing, and halting with fault raising. + # + # The Resolver owns the +strict+ flag and all transition logic. It writes to the + # Result via +instance_variable_set+ so that Result remains a pure data object. + # + # @example Accessed through the task + # task.resolver.fail!("Validation failed", cause: validation_error) + # task.resolver.skip!("Already processed", halt: false) + class Resolver + + extend Forwardable + + # Returns the result being resolved. + # + # @return [CMDx::Result] The result instance + # + # @rbs @result: Result + attr_reader :result + + def_delegators :result, :task, :success?, :skipped?, :failed?, + :initialized?, :executing?, :complete?, :interrupted? + + # @param result [CMDx::Result] The result to manage transitions for + # + # @return [CMDx::Resolver] A new resolver instance + # + # @raise [TypeError] When result is not a CMDx::Result instance + # + # @example + # resolver = CMDx::Resolver.new(result) + # + # @rbs (Result) -> void + def initialize(result) + raise TypeError, "must be a CMDx::Result" unless result.is_a?(Result) + + @result = result + end + + # Transitions the result to the appropriate final state based on status. + # Delegates to {#complete!} when successful, {#interrupt!} otherwise. + # + # @return [void] + # + # @example + # resolver.executed! + # + # @rbs () -> void + def executed! + success? ? complete! : interrupt! + end + + # Transitions the result state from initialized to executing. + # + # @raise [RuntimeError] When attempting to transition from invalid state + # + # @example + # resolver.executing! + # + # @rbs () -> void + def executing! + return if executing? + + raise "can only transition to #{Result::EXECUTING} from #{Result::INITIALIZED}" unless initialized? + + assign(state: Result::EXECUTING) + end + + # Transitions the result state from executing to complete. + # + # @raise [RuntimeError] When attempting to transition from invalid state + # + # @example + # resolver.complete! + # + # @rbs () -> void + def complete! + return if complete? + + raise "can only transition to #{Result::COMPLETE} from #{Result::EXECUTING}" unless executing? + + assign(state: Result::COMPLETE) + end + + # Transitions the result state to interrupted. + # + # @raise [RuntimeError] When attempting to transition from invalid state + # + # @example + # resolver.interrupt! + # + # @rbs () -> void + def interrupt! + return if interrupted? + + raise "cannot transition to #{Result::INTERRUPTED} from #{Result::COMPLETE}" if complete? + + assign(state: Result::INTERRUPTED) + end + + # Sets a reason and optional metadata on a successful result without + # changing its state or status. Useful for annotating why a task succeeded. + # When halt is true, uses throw/catch to exit the work method early. + # + # @param reason [String, nil] Reason or note for the success + # @param halt [Boolean] Whether to halt execution after success + # @param metadata [Hash] Additional metadata about the success + # @option metadata [Object] :* Any key-value pairs for additional metadata + # + # @raise [RuntimeError] When status is not success + # + # @example + # resolver.success!("Created 42 records") + # resolver.success!("Imported", halt: false, rows: 100) + # + # @rbs (?String? reason, halt: bool, **untyped metadata) -> void + def success!(reason = nil, halt: true, **metadata) + raise "can only be used while #{Result::SUCCESS}" unless success? + + assign( + reason:, + metadata: + ) + + throw(:cmdx_halt) if halt + end + + # @param reason [String, nil] Reason for skipping the task + # @param halt [Boolean] Whether to halt execution after skipping + # @param cause [Exception, nil] Exception that caused the skip + # @param strict [Boolean] Whether this skip is strict (default: true). + # @param metadata [Hash] Additional metadata about the skip + # @option metadata [Object] :* Any key-value pairs for additional metadata + # + # @raise [RuntimeError] When attempting to skip from invalid status + # + # @example + # resolver.skip!("Dependencies not met", cause: dependency_error) + # resolver.skip!("Already processed", halt: false) + # resolver.skip!("Optional step", strict: false) + # + # @rbs (?String? reason, halt: bool, cause: Exception?, strict: bool, **untyped metadata) -> void + def skip!(reason = nil, halt: true, cause: nil, strict: true, **metadata) + return if skipped? + + raise "can only transition to #{Result::SKIPPED} from #{Result::SUCCESS}" unless success? + + assign( + state: Result::INTERRUPTED, + status: Result::SKIPPED, + reason: reason || Locale.t("cmdx.reasons.unspecified"), + cause:, + strict:, + metadata: + ) + + halt! if halt + end + + # @param reason [String, nil] Reason for task failure + # @param halt [Boolean] Whether to halt execution after failure + # @param cause [Exception, nil] Exception that caused the failure + # @param strict [Boolean] Whether this failure is strict (default: true). + # @param metadata [Hash] Additional metadata about the failure + # @option metadata [Object] :* Any key-value pairs for additional metadata + # + # @raise [RuntimeError] When attempting to fail from invalid status + # + # @example + # resolver.fail!("Validation failed", cause: validation_error) + # resolver.fail!("Network timeout", halt: false, timeout: 30) + # resolver.fail!("Soft failure", strict: false) + # + # @rbs (?String? reason, halt: bool, cause: Exception?, strict: bool, **untyped metadata) -> void + def fail!(reason = nil, halt: true, cause: nil, strict: true, **metadata) + return if failed? + + raise "can only transition to #{Result::FAILED} from #{Result::SUCCESS}" unless success? + + assign( + state: Result::INTERRUPTED, + status: Result::FAILED, + reason: reason || Locale.t("cmdx.reasons.unspecified"), + cause:, + strict:, + metadata: + ) + + halt! if halt + end + + # @raise [SkipFault] When task was skipped + # @raise [FailFault] When task failed + # + # @example + # resolver.halt! + # + # @rbs () -> void + def halt! + return if success? + + klass = skipped? ? SkipFault : FailFault + fault = klass.new(result) + + frames = caller_locations(3..-1) + + unless frames.empty? + frames = frames.map(&:to_s) + + if (cleaner = task.class.settings.backtrace_cleaner) + cleaner.call(frames) + end + + fault.set_backtrace(frames) + end + + raise(fault) + end + + # @param other [CMDx::Result] Result to throw from current result + # @param halt [Boolean] Whether to halt execution after throwing + # @param cause [Exception, nil] Exception that caused the throw + # @param metadata [Hash] Additional metadata to merge + # @option metadata [Object] :* Any key-value pairs for additional metadata + # + # @raise [TypeError] When other is not a CMDx::Result instance + # + # @example + # other_result = OtherTask.execute + # resolver.throw!(other_result, cause: upstream_error) + # + # @rbs (Result other, halt: bool, cause: Exception?, **untyped metadata) -> void + def throw!(other, halt: true, cause: nil, **metadata) + raise TypeError, "must be a CMDx::Result" unless other.is_a?(Result) + + assign( + state: other.state, + status: other.status, + reason: other.reason, + cause: cause || other.cause, + strict: other.strict?, + metadata: other.metadata.merge(metadata) + ) + + halt! if halt + end + + private + + # Writes attributes to the result via instance_variable_set. + # + # @param attrs [Hash] Attributes to assign to the result + # + # @rbs (**untyped attrs) -> void + def assign(**attrs) + attrs.each { |key, value| @result.instance_variable_set(:"@#{key}", value) } + end + + end +end diff --git a/lib/cmdx/result.rb b/lib/cmdx/result.rb index e13d213d1..d46141660 100644 --- a/lib/cmdx/result.rb +++ b/lib/cmdx/result.rb @@ -99,21 +99,11 @@ class Result # @rbs @cause: (Exception | nil) attr_reader :cause - # Returns the number of retries attempted. - # - # @return [Integer] The number of retries attempted - # - # @example - # result.retries # => 2 - # - # @rbs @retries: Integer - attr_accessor :retries - - # Returns whether this result is strict. + # Returns whether this result transitions are strict. # When false, {CMDx::Executor#halt_execution?} returns false # regardless of the task's breakpoint settings. # - # @return [Boolean] Whether the result is strict + # @return [Boolean] Whether the result transitions are strict # # @example # result.strict? # => true @@ -121,6 +111,16 @@ class Result # @rbs @strict: bool attr_reader :strict + # Returns the number of retries attempted. + # + # @return [Integer] The number of retries attempted + # + # @example + # result.retries # => 2 + # + # @rbs @retries: Integer + attr_accessor :retries + # Returns whether the result has been rolled back. # # @return [Boolean] Whether the result has been rolled back @@ -154,8 +154,8 @@ def initialize(task) @metadata = {} @reason = nil @cause = nil - @retries = 0 @strict = true + @retries = 0 @rolled_back = false end @@ -170,16 +170,6 @@ def initialize(task) define_method(:"#{s}?") { state == s } end - # @return [self] Returns self for method chaining - # - # @example - # result.executed! # Transitions to complete or interrupted - # - # @rbs () -> self - def executed! - success? ? complete! : interrupt! - end - # @return [Boolean] Whether the task has been executed (complete or interrupted) # # @example @@ -190,48 +180,6 @@ def executed? complete? || interrupted? end - # @raise [RuntimeError] When attempting to transition from invalid state - # - # @example - # result.executing! # Transitions from initialized to executing - # - # @rbs () -> void - def executing! - return if executing? - - raise "can only transition to #{EXECUTING} from #{INITIALIZED}" unless initialized? - - @state = EXECUTING - end - - # @raise [RuntimeError] When attempting to transition from invalid state - # - # @example - # result.complete! # Transitions from executing to complete - # - # @rbs () -> void - def complete! - return if complete? - - raise "can only transition to #{COMPLETE} from #{EXECUTING}" unless executing? - - @state = COMPLETE - end - - # @raise [RuntimeError] When attempting to transition from invalid state - # - # @example - # result.interrupt! # Transitions from executing to interrupted - # - # @rbs () -> void - def interrupt! - return if interrupted? - - raise "cannot transition to #{INTERRUPTED} from #{COMPLETE}" if complete? - - @state = INTERRUPTED - end - STATUSES.each do |s| # @return [Boolean] Whether the result has the specified status # @@ -282,147 +230,6 @@ def on(*states_or_statuses, &) self end - # Sets a reason and optional metadata on a successful result without - # changing its state or status. Useful for annotating why a task succeeded. - # When halt is true, uses throw/catch to exit the work method early. - # - # @param reason [String, nil] Reason or note for the success - # @param halt [Boolean] Whether to halt execution after success - # @param metadata [Hash] Additional metadata about the success - # @option metadata [Object] :* Any key-value pairs for additional metadata - # - # @raise [RuntimeError] When status is not success - # - # @example - # result.success!("Created 42 records") - # result.success!("Imported", halt: false, rows: 100) - # - # @rbs (?String? reason, halt: bool, **untyped metadata) -> void - def success!(reason = nil, halt: true, **metadata) - raise "can only be used while #{SUCCESS}" unless success? - - @reason = reason - @metadata = metadata - - throw(:cmdx_halt) if halt - end - - # @param reason [String, nil] Reason for skipping the task - # @param halt [Boolean] Whether to halt execution after skipping - # @param cause [Exception, nil] Exception that caused the skip - # @param strict [Boolean] Whether this skip is strict (default: true). - # When false, {CMDx::Executor#halt_execution?} returns false regardless of task settings. - # @param metadata [Hash] Additional metadata about the skip - # @option metadata [Object] :* Any key-value pairs for additional metadata - # - # @raise [RuntimeError] When attempting to skip from invalid status - # - # @example - # result.skip!("Dependencies not met", cause: dependency_error) - # result.skip!("Already processed", halt: false) - # result.skip!("Optional step", strict: false) - # - # @rbs (?String? reason, halt: bool, cause: Exception?, strict: bool, **untyped metadata) -> void - def skip!(reason = nil, halt: true, cause: nil, strict: true, **metadata) - return if skipped? - - raise "can only transition to #{SKIPPED} from #{SUCCESS}" unless success? - - @state = INTERRUPTED - @status = SKIPPED - @reason = reason || Locale.t("cmdx.reasons.unspecified") - @cause = cause - @strict = strict - @metadata = metadata - - halt! if halt - end - - # @param reason [String, nil] Reason for task failure - # @param halt [Boolean] Whether to halt execution after failure - # @param cause [Exception, nil] Exception that caused the failure - # @param strict [Boolean] Whether this failure is strict (default: true). - # When false, {CMDx::Executor#halt_execution?} returns false regardless of task settings. - # @param metadata [Hash] Additional metadata about the failure - # @option metadata [Object] :* Any key-value pairs for additional metadata - # - # @raise [RuntimeError] When attempting to fail from invalid status - # - # @example - # result.fail!("Validation failed", cause: validation_error) - # result.fail!("Network timeout", halt: false, timeout: 30) - # result.fail!("Soft failure", strict: false) - # - # @rbs (?String? reason, halt: bool, cause: Exception?, strict: bool, **untyped metadata) -> void - def fail!(reason = nil, halt: true, cause: nil, strict: true, **metadata) - return if failed? - - raise "can only transition to #{FAILED} from #{SUCCESS}" unless success? - - @state = INTERRUPTED - @status = FAILED - @reason = reason || Locale.t("cmdx.reasons.unspecified") - @cause = cause - @strict = strict - @metadata = metadata - - halt! if halt - end - - # @raise [SkipFault] When task was skipped - # @raise [FailFault] When task failed - # - # @example - # result.halt! # Raises appropriate fault based on status - # - # @rbs () -> void - def halt! - return if success? - - klass = skipped? ? SkipFault : FailFault - fault = klass.new(self) - - # Strip the first two frames (this method and the delegator) - frames = caller_locations(3..-1) - - unless frames.empty? - frames = frames.map(&:to_s) - - if (cleaner = task.class.settings.backtrace_cleaner) - cleaner.call(frames) - end - - fault.set_backtrace(frames) - end - - raise(fault) - end - - # @param result [CMDx::Result] Result to throw from current result - # @param halt [Boolean] Whether to halt execution after throwing - # @param cause [Exception, nil] Exception that caused the throw - # @param metadata [Hash] Additional metadata to merge - # @option metadata [Object] :* Any key-value pairs for additional metadata - # - # @raise [TypeError] When result is not a CMDx::Result instance - # - # @example - # other_result = OtherTask.execute - # result.throw!(other_result, cause: upstream_error) - # - # @rbs (Result result, halt: bool, cause: Exception?, **untyped metadata) -> void - def throw!(result, halt: true, cause: nil, **metadata) - raise TypeError, "must be a CMDx::Result" unless result.is_a?(Result) - - @state = result.state - @status = result.status - @reason = result.reason - @cause = cause || result.cause - @metadata = result.metadata.merge(metadata) - - halt! if halt - end - # @return [CMDx::Result, nil] The result that caused this failure, or nil # # @example @@ -500,24 +307,24 @@ def thrown_failure? failed? && !caused_failure? end - # @return [Boolean] Whether the result has been retried + # @return [Boolean] Whether the result transitions are strict # # @example - # result.retried? # => true + # result.strict? # => true # # @rbs () -> bool - def retried? - retries.positive? + def strict? + !!@strict end - # @return [Boolean] Whether the result is strict + # @return [Boolean] Whether the result has been retried # # @example - # result.strict? # => true + # result.retried? # => true # # @rbs () -> bool - def strict? - !!@strict + def retried? + retries.positive? end # @return [Boolean] Whether the result has been rolled back @@ -620,12 +427,12 @@ def deconstruct(*) # @rbs (*untyped) -> Hash[Symbol, untyped] def deconstruct_keys(*) { - state: state, - status: status, - reason: reason, - cause: cause, - metadata: metadata, - outcome: outcome, + state:, + status:, + reason:, + cause:, + metadata:, + outcome:, executed: executed?, good: good?, bad: bad? diff --git a/lib/cmdx/task.rb b/lib/cmdx/task.rb index bd6d6e772..0dde605db 100644 --- a/lib/cmdx/task.rb +++ b/lib/cmdx/task.rb @@ -70,7 +70,17 @@ class Task # @rbs @chain: Chain attr_reader :chain - def_delegators :result, :success!, :skip!, :fail!, :throw! + # Returns the resolver for managing result state transitions. + # + # @return [Resolver] The resolver instance + # + # @example + # task.resolver.fail!("Validation failed") + # + # @rbs @resolver: Resolver + attr_reader :resolver + + def_delegators :resolver, :success!, :skip!, :fail!, :throw! def_delegators :chain, :dry_run? # @param context [Hash, Context, nil] The initial context for the task @@ -92,6 +102,7 @@ def initialize(context = nil) @context = Context.build(context) @errors = Errors.new @result = Result.new(self) + @resolver = Resolver.new(@result) @chain = Chain.build(@result, dry_run: @context.delete(:dry_run)) @attributes = {} diff --git a/spec/cmdx/executor_spec.rb b/spec/cmdx/executor_spec.rb index 561fdc228..9bfadfd42 100644 --- a/spec/cmdx/executor_spec.rb +++ b/spec/cmdx/executor_spec.rb @@ -66,8 +66,8 @@ # Setup result state to support proper transitions allow(task.result).to receive_messages(to_h: { test: "data" }, state: "executing", executing?: true, executed?: true, success?: true) - allow(task.result).to receive(:complete!) - allow(task.result).to receive(:executed!) + allow(task.resolver).to receive(:complete!) + allow(task.resolver).to receive(:executed!) end context "when execution is successful" do @@ -75,7 +75,7 @@ expect(middlewares).to receive(:call!).with(task).and_yield expect(worker).to receive(:pre_execution!) expect(worker).to receive(:execution!) - expect(task.result).to receive(:executed!) + expect(task.resolver).to receive(:executed!) expect(worker).to receive(:post_execution!) expect(worker).to receive(:freeze_execution!) @@ -87,7 +87,7 @@ allow(worker).to receive(:pre_execution!) allow(worker).to receive(:execution!) - allow(task.result).to receive(:executed!) + allow(task.resolver).to receive(:executed!) allow(worker).to receive(:post_execution!) expect(logger).to receive(:info) @@ -121,9 +121,9 @@ allow(worker).to receive(:pre_execution!) allow(worker).to receive(:execution!).and_raise(fault) - expect(task.result).to receive(:throw!).with(fault_result, halt: false, cause: fault) + expect(task.resolver).to receive(:throw!).with(fault_result, halt: false, cause: fault) - allow(task.result).to receive(:executed!) + allow(task.resolver).to receive(:executed!) allow(worker).to receive(:post_execution!) worker.execute @@ -134,9 +134,9 @@ allow(worker).to receive(:pre_execution!) allow(worker).to receive(:execution!).and_raise(fault) - allow(task.result).to receive(:throw!) + allow(task.resolver).to receive(:throw!) - expect(task.result).to receive(:executed!) + expect(task.resolver).to receive(:executed!) expect(worker).to receive(:post_execution!) expect(worker).to receive(:freeze_execution!) @@ -153,9 +153,9 @@ allow(worker).to receive(:pre_execution!) allow(worker).to receive(:execution!).and_raise(standard_error) - expect(task.result).to receive(:fail!).with("[StandardError] something went wrong", halt: false, cause: standard_error, source: :exception) + expect(task.resolver).to receive(:fail!).with("[StandardError] something went wrong", halt: false, cause: standard_error, source: :exception) - allow(task.result).to receive(:executed!) + allow(task.resolver).to receive(:executed!) allow(worker).to receive(:post_execution!) worker.execute @@ -166,9 +166,9 @@ allow(worker).to receive(:pre_execution!) allow(worker).to receive(:execution!).and_raise(standard_error) - allow(task.result).to receive(:fail!) + allow(task.resolver).to receive(:fail!) - expect(task.result).to receive(:executed!) + expect(task.resolver).to receive(:executed!) expect(worker).to receive(:post_execution!) expect(worker).to receive(:freeze_execution!) @@ -185,9 +185,9 @@ allow(worker).to receive(:pre_execution!) allow(worker).to receive(:execution!).and_raise(custom_error) - expect(task.result).to receive(:fail!).with("[CMDx::TestError] test error", halt: false, cause: custom_error, source: :exception) + expect(task.resolver).to receive(:fail!).with("[CMDx::TestError] test error", halt: false, cause: custom_error, source: :exception) - allow(task.result).to receive(:executed!) + allow(task.resolver).to receive(:executed!) allow(worker).to receive(:post_execution!) worker.execute @@ -209,8 +209,8 @@ # Setup result state to support proper transitions allow(task.result).to receive_messages(to_h: { test: "data" }, state: "executing", executing?: true, executed?: true, success?: true) - allow(task.result).to receive(:complete!) - allow(task.result).to receive(:executed!) + allow(task.resolver).to receive(:complete!) + allow(task.resolver).to receive(:executed!) end context "when execution is successful" do @@ -218,7 +218,7 @@ expect(middlewares).to receive(:call!).with(task).and_yield expect(worker).to receive(:pre_execution!) expect(worker).to receive(:execution!) - expect(task.result).to receive(:executed!) + expect(task.resolver).to receive(:executed!) expect(worker).to receive(:post_execution!) expect(worker).to receive(:freeze_execution!) @@ -252,8 +252,8 @@ allow(worker).to receive(:pre_execution!) allow(worker).to receive(:execution!).and_raise(fault) - expect(task.result).to receive(:throw!).with(fault_result, halt: false, cause: fault) - expect(task.result).to receive(:executed!) + expect(task.resolver).to receive(:throw!).with(fault_result, halt: false, cause: fault) + expect(task.resolver).to receive(:executed!) expect(worker).to receive(:halt_execution?).with(fault).and_return(false) expect(worker).to receive(:post_execution!) @@ -268,7 +268,7 @@ allow(worker).to receive(:pre_execution!) allow(worker).to receive(:execution!).and_raise(fault) - expect(task.result).to receive(:throw!).with(fault_result, halt: false, cause: fault) + expect(task.resolver).to receive(:throw!).with(fault_result, halt: false, cause: fault) expect(worker).to receive(:halt_execution?).with(fault).and_return(true) expect(worker).to receive(:raise_exception).with(fault).and_raise(fault) @@ -286,7 +286,7 @@ allow(worker).to receive(:pre_execution!) allow(worker).to receive(:execution!).and_raise(standard_error) - expect(task.result).to receive(:fail!).with("[StandardError] something went wrong", halt: false, cause: standard_error, source: :exception) + expect(task.resolver).to receive(:fail!).with("[StandardError] something went wrong", halt: false, cause: standard_error, source: :exception) expect(worker).to receive(:raise_exception).with(standard_error).and_raise(standard_error) expect { worker.execute! }.to raise_error(standard_error) @@ -295,12 +295,13 @@ end describe "#halt_execution?" do - let(:fault_result) { instance_double(CMDx::Result, status: "failed", strict?: true, reason: "test failure") } + let(:fault_resolver) { instance_double(CMDx::Resolver) } + let(:fault_task) { instance_double(CMDx::Task, resolver: fault_resolver) } + let(:fault_result) { instance_double(CMDx::Result, status: "failed", task: fault_task, reason: "test failure", strict?: true) } let(:fault) { CMDx::FailFault.new(fault_result) } - context "when result has strict: false" do - let(:fault_result) { instance_double(CMDx::Result, status: "failed", strict?: false, reason: "test failure") } - let(:fault) { CMDx::FailFault.new(fault_result) } + context "when resolver has strict: false" do + let(:fault_result) { instance_double(CMDx::Result, status: "failed", task: fault_task, reason: "test failure", strict?: false) } before do allow(task.class).to receive(:settings).and_return(mock_settings(breakpoints: %w[failed skipped])) @@ -323,7 +324,9 @@ end context "when exception result status is not in breakpoints" do - let(:success_result) { instance_double(CMDx::Result, status: "success", strict?: true, reason: "test success") } + let(:success_resolver) { instance_double(CMDx::Resolver) } + let(:success_task) { instance_double(CMDx::Task, resolver: success_resolver) } + let(:success_result) { instance_double(CMDx::Result, status: "success", task: success_task, reason: "test success", strict?: true) } let(:success_fault) { CMDx::SkipFault.new(success_result) } it "returns false" do @@ -686,11 +689,11 @@ def call(_task, retries) to_s: "Validation failed", to_h: { name: ["is required"] } ) - allow(task.result).to receive(:fail!) + allow(task.resolver).to receive(:fail!) end it "calls fail! on result with error information" do - expect(task.result).to receive(:fail!).with( + expect(task.resolver).to receive(:fail!).with( "Invalid", source: :validation, errors: { @@ -710,13 +713,13 @@ def call(_task, retries) before do allow(task.class).to receive(:settings).and_return(mock_settings(callbacks: callbacks)) allow(callbacks).to receive(:invoke) - allow(task.result).to receive(:executing!) + allow(task.resolver).to receive(:executing!) allow(task).to receive(:work) end it "invokes before_execution callback, sets executing state, and calls work" do expect(callbacks).to receive(:invoke).with(:before_execution, task) - expect(task.result).to receive(:executing!) + expect(task.resolver).to receive(:executing!) expect(task).to receive(:work) worker.send(:execution!) @@ -788,7 +791,7 @@ def call(_task, retries) end it "adds errors for missing returns and fails the result" do - expect(task.result).to receive(:fail!).with( + expect(task.resolver).to receive(:fail!).with( "Invalid", source: :context, errors: { @@ -808,7 +811,7 @@ def call(_task, retries) end it "adds errors for all missing returns and fails the result" do - expect(task.result).to receive(:fail!).with( + expect(task.resolver).to receive(:fail!).with( "Invalid", source: :context, errors: { @@ -1075,12 +1078,12 @@ def call(_task, retries) end it "marks result as failed and transitions to executed" do - expect(task.result).to receive(:fail!).with( + expect(task.resolver).to receive(:fail!).with( CMDx::Locale.t("cmdx.faults.invalid"), halt: false, source: :middleware ) - expect(task.result).to receive(:executed!) + expect(task.resolver).to receive(:executed!) worker.send(:verify_middleware_yield!) end @@ -1092,8 +1095,8 @@ def call(_task, retries) end it "does nothing" do - expect(task.result).not_to receive(:fail!) - expect(task.result).not_to receive(:executed!) + expect(task.resolver).not_to receive(:fail!) + expect(task.resolver).not_to receive(:executed!) worker.send(:verify_middleware_yield!) end diff --git a/spec/cmdx/faults_spec.rb b/spec/cmdx/faults_spec.rb index 5e0a3fdc2..1572309a5 100644 --- a/spec/cmdx/faults_spec.rb +++ b/spec/cmdx/faults_spec.rb @@ -6,9 +6,8 @@ let(:task_class) { create_successful_task(name: "TestTask") } let(:task) { task_class.new } let(:result) do - task.result.tap do |r| - r.fail!("test failure reason", halt: false) - end + task.resolver.fail!("test failure reason", halt: false) + task.result end describe "#initialize" do @@ -110,10 +109,8 @@ describe ".matches?" do let(:fault_with_metadata) do - result_with_metadata = task.result.tap do |r| - r.fail!("failure", halt: false, metadata: { error_code: 500 }) - end - described_class.new(result_with_metadata) + task.resolver.fail!("failure", halt: false, metadata: { error_code: 500 }) + described_class.new(task.result) end context "when block is provided" do diff --git a/spec/cmdx/middlewares/timeout_spec.rb b/spec/cmdx/middlewares/timeout_spec.rb index 4c91e157c..2f5543479 100644 --- a/spec/cmdx/middlewares/timeout_spec.rb +++ b/spec/cmdx/middlewares/timeout_spec.rb @@ -5,14 +5,14 @@ RSpec.describe CMDx::Middlewares::Timeout, type: :unit do subject(:timeout_middleware) { described_class } - let(:task) { double("CMDx::Task", result: result) } # rubocop:disable RSpec/VerifiedDoubles + let(:task) { double("CMDx::Task", result: result, resolver: resolver) } # rubocop:disable RSpec/VerifiedDoubles let(:result) { instance_double(CMDx::Result) } + let(:resolver) { instance_double(CMDx::Resolver) } let(:block_result) { "block executed" } let(:test_block) { proc { block_result } } before do - allow(result).to receive(:fail!) - allow(result).to receive(:tap).and_return(result) + allow(resolver).to receive(:fail!) end describe ".call" do @@ -191,7 +191,7 @@ it "re-raises non-timeout errors without calling fail!" do expect(Timeout).to receive(:timeout).and_yield.and_raise(standard_error) - expect(result).not_to receive(:fail!) + expect(resolver).not_to receive(:fail!) expect do timeout_middleware.call(task, seconds: 5, &error_block) diff --git a/spec/cmdx/resolver_spec.rb b/spec/cmdx/resolver_spec.rb new file mode 100644 index 000000000..bcaf7bdc1 --- /dev/null +++ b/spec/cmdx/resolver_spec.rb @@ -0,0 +1,316 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe CMDx::Resolver, type: :unit do + let(:task_class) { create_successful_task(name: "TestTask") } + let(:task) { task_class.new } + let(:result) { task.result } + let(:resolver) { task.resolver } + + describe "#initialize" do + context "with valid result" do + it "initializes with correct defaults" do + expect(resolver.result).to eq(result) + expect(result.strict?).to be(true) + end + end + + context "with invalid result" do + it "raises TypeError when result is not a CMDx::Result" do + expect { described_class.new("not a result") }.to raise_error(TypeError, "must be a CMDx::Result") + end + end + end + + describe "#success!" do + context "when successful" do + it "sets the reason" do + catch(:cmdx_halt) { resolver.success!("Created 42 records") } + + expect(result.status).to eq(CMDx::Result::SUCCESS) + expect(result.reason).to eq("Created 42 records") + expect(result.metadata).to eq({}) + end + + it "accepts metadata" do + catch(:cmdx_halt) { resolver.success!("Imported", rows: 100) } + + expect(result.reason).to eq("Imported") + expect(result.metadata).to eq({ rows: 100 }) + end + + it "allows nil reason" do + catch(:cmdx_halt) { resolver.success! } + + expect(result.reason).to be_nil + end + + it "does not change state or status" do + original_state = result.state + original_status = result.status + catch(:cmdx_halt) { resolver.success!("note") } + + expect(result.state).to eq(original_state) + expect(result.status).to eq(original_status) + end + + it "throws :cmdx_halt by default" do + expect { resolver.success!("done") }.to throw_symbol(:cmdx_halt) + end + + it "does not throw when halt is false" do + expect { resolver.success!("done", halt: false) }.not_to throw_symbol(:cmdx_halt) + end + end + + context "when not successful" do + it "raises error when skipped" do + resolver.skip!("test", halt: false) + + expect { resolver.success!("reason") }.to raise_error(/can only be used while success/) + end + + it "raises error when failed" do + resolver.fail!("test", halt: false) + + expect { resolver.success!("reason") }.to raise_error(/can only be used while success/) + end + end + end + + describe "#skip!" do + context "when successful" do + it "transitions to skipped status" do + resolver.skip!("test reason", halt: false) + + expect(result.status).to eq(CMDx::Result::SKIPPED) + expect(result.state).to eq(CMDx::Result::INTERRUPTED) + expect(result.reason).to eq("test reason") + expect(result.cause).to be_nil + expect(result.metadata).to eq({}) + end + + it "accepts metadata" do + resolver.skip!("test reason", halt: false, foo: "bar") + + expect(result.metadata).to eq({ foo: "bar" }) + end + + it "accepts cause" do + cause = StandardError.new("cause") + resolver.skip!("test reason", halt: false, cause: cause) + + expect(result.cause).to eq(cause) + end + + it "uses default reason when none provided" do + allow(CMDx::Locale).to receive(:t).with("cmdx.reasons.unspecified").and_return("Unspecified") + + resolver.skip!(halt: false) + + expect(result.reason).to eq("Unspecified") + end + + it "calls halt! by default" do + expect { resolver.skip!("test reason") }.to raise_error(CMDx::SkipFault) + end + + it "does not call halt! when halt: false" do + expect { resolver.skip!("test reason", halt: false) }.not_to raise_error + end + end + + context "when already skipped" do + it "returns early without changes" do + resolver.skip!("first reason", halt: false) + original_reason = result.reason + resolver.skip!("second reason", halt: false) + + expect(result.reason).to eq(original_reason) + end + end + + context "when not successful" do + it "raises error when trying to skip from failed" do + resolver.fail!("test reason", halt: false) + + expect { resolver.skip!("another reason", halt: false) }.to raise_error(/can only transition to skipped from success/) + end + end + end + + describe "#fail!" do + context "when successful" do + it "transitions to failed status" do + resolver.fail!("test reason", halt: false) + + expect(result.status).to eq(CMDx::Result::FAILED) + expect(result.state).to eq(CMDx::Result::INTERRUPTED) + expect(result.reason).to eq("test reason") + expect(result.cause).to be_nil + expect(result.metadata).to eq({}) + end + + it "accepts metadata" do + resolver.fail!("test reason", halt: false, foo: "bar") + + expect(result.metadata).to eq({ foo: "bar" }) + end + + it "accepts cause" do + cause = StandardError.new("cause") + resolver.fail!("test reason", halt: false, cause: cause) + + expect(result.cause).to eq(cause) + end + + it "uses default reason when none provided" do + allow(CMDx::Locale).to receive(:t).with("cmdx.reasons.unspecified").and_return("Unspecified") + + resolver.fail!(halt: false) + + expect(result.reason).to eq("Unspecified") + end + + it "calls halt! by default" do + expect { resolver.fail!("test reason") }.to raise_error(CMDx::FailFault) + end + + it "does not call halt! when halt: false" do + expect { resolver.fail!("test reason", halt: false) }.not_to raise_error + end + end + + context "when already failed" do + it "returns early without changes" do + resolver.fail!("first reason", halt: false) + original_reason = result.reason + resolver.fail!("second reason", halt: false) + + expect(result.reason).to eq(original_reason) + end + end + + context "when not successful" do + it "raises error when trying to fail from skipped" do + resolver.skip!("test reason", halt: false) + + expect { resolver.fail!("another reason", halt: false) }.to raise_error(/can only transition to failed from success/) + end + end + end + + describe "#halt!" do + context "when successful" do + it "returns early without raising" do + expect { resolver.halt! }.not_to raise_error + end + end + + context "when skipped" do + it "raises SkipFault" do + resolver.skip!("test reason", halt: false) + + expect { resolver.halt! }.to raise_error(CMDx::SkipFault) do |fault| + expect(fault.result).to eq(result) + expect(fault.message).to eq("test reason") + end + end + + it "sets proper backtrace" do + resolver.skip!("test reason", halt: false) + + begin + resolver.halt! + rescue CMDx::SkipFault => e + expect(e.backtrace).to be_an(Array) + expect(e.backtrace).not_to be_empty + end + end + end + + context "when failed" do + it "raises FailFault" do + resolver.fail!("test reason", halt: false) + + expect { resolver.halt! }.to raise_error(CMDx::FailFault) do |fault| + expect(fault.result).to eq(result) + expect(fault.message).to eq("test reason") + end + end + end + end + + describe "#throw!" do + let(:other_task) { create_failing_task.new } + let(:other_result) { other_task.result } + + before do + other_task.resolver.fail!("source failure", halt: false, foo: "bar") + end + + context "with valid result" do + it "copies state and status from other result" do + resolver.throw!(other_result, halt: false) + + expect(result.state).to eq(other_result.state) + expect(result.status).to eq(other_result.status) + expect(result.reason).to eq(other_result.reason) + end + + it "merges metadata" do + resolver.throw!(other_result, halt: false, baz: "qux") + + expect(result.metadata).to eq({ foo: "bar", baz: "qux" }) + end + + it "uses provided cause over other result's cause" do + custom_cause = StandardError.new("custom") + + resolver.throw!(other_result, halt: false, cause: custom_cause) + expect(result.cause).to eq(custom_cause) + end + + it "uses other result's cause when none provided" do + other_cause = StandardError.new("other") + other_result.instance_variable_set(:@cause, other_cause) + resolver.throw!(other_result, halt: false) + + expect(result.cause).to eq(other_cause) + end + + it "calls halt! by default" do + expect { resolver.throw!(other_result) }.to raise_error(CMDx::FailFault) + end + + it "does not call halt! when halt: false" do + expect { resolver.throw!(other_result, halt: false) }.not_to raise_error + end + end + + context "with invalid result" do + it "raises TypeError when not a CMDx::Result" do + expect { resolver.throw!("not a result", halt: false) }.to raise_error(TypeError, "must be a CMDx::Result") + end + end + end + + describe "#strict?" do + it "returns true by default" do + expect(result.strict?).to be(true) + end + + it "returns false when strict is false via fail!" do + resolver.fail!("test reason", halt: false, strict: false) + + expect(result.strict?).to be(false) + end + + it "returns false when strict is false via skip!" do + resolver.skip!("test reason", halt: false, strict: false) + + expect(result.strict?).to be(false) + end + end +end diff --git a/spec/cmdx/result_spec.rb b/spec/cmdx/result_spec.rb index ad801e62b..e06438848 100644 --- a/spec/cmdx/result_spec.rb +++ b/spec/cmdx/result_spec.rb @@ -6,6 +6,7 @@ let(:task_class) { create_successful_task(name: "TestTask") } let(:task) { task_class.new } let(:result) { task.result } + let(:resolver) { task.resolver } describe "#initialize" do context "with valid task" do @@ -38,7 +39,7 @@ end it "returns false when state is not initialized" do - result.executing! + resolver.executing! expect(result.initialized?).to be(false) end @@ -50,7 +51,7 @@ end it "returns true when state is executing" do - result.executing! + resolver.executing! expect(result.executing?).to be(true) end @@ -62,8 +63,8 @@ end it "returns true when state is complete" do - result.executing! - result.complete! + resolver.executing! + resolver.complete! expect(result.complete?).to be(true) end @@ -75,8 +76,8 @@ end it "returns true when state is interrupted" do - result.executing! - result.interrupt! + resolver.executing! + resolver.interrupt! expect(result.interrupted?).to be(true) end @@ -87,15 +88,15 @@ describe "#executing!" do context "when initialized" do it "transitions to executing state" do - result.executing! + resolver.executing! expect(result.state).to eq(CMDx::Result::EXECUTING) end it "returns early if already executing" do - result.executing! + resolver.executing! initial_state = result.state - result.executing! + resolver.executing! expect(result.state).to eq(initial_state) end @@ -103,35 +104,35 @@ context "when not initialized" do it "raises error when trying to transition from complete" do - result.executing! - result.complete! + resolver.executing! + resolver.complete! - expect { result.executing! }.to raise_error(/can only transition to executing from initialized/) + expect { resolver.executing! }.to raise_error(/can only transition to executing from initialized/) end it "raises error when trying to transition from interrupted" do - result.executing! - result.interrupt! + resolver.executing! + resolver.interrupt! - expect { result.executing! }.to raise_error(/can only transition to executing from initialized/) + expect { resolver.executing! }.to raise_error(/can only transition to executing from initialized/) end end end describe "#complete!" do context "when executing" do - before { result.executing! } + before { resolver.executing! } it "transitions to complete state" do - result.complete! + resolver.complete! expect(result.state).to eq(CMDx::Result::COMPLETE) end it "returns early if already complete" do - result.complete! + resolver.complete! initial_state = result.state - result.complete! + resolver.complete! expect(result.state).to eq(initial_state) end @@ -139,7 +140,7 @@ context "when not executing" do it "raises error when trying to transition from initialized" do - expect { result.complete! }.to raise_error(/can only transition to complete from executing/) + expect { resolver.complete! }.to raise_error(/can only transition to complete from executing/) end end end @@ -147,22 +148,22 @@ describe "#interrupt!" do context "when not complete" do it "transitions to interrupted from initialized" do - result.interrupt! + resolver.interrupt! expect(result.state).to eq(CMDx::Result::INTERRUPTED) end it "transitions to interrupted from executing" do - result.executing! - result.interrupt! + resolver.executing! + resolver.interrupt! expect(result.state).to eq(CMDx::Result::INTERRUPTED) end it "returns early if already interrupted" do - result.interrupt! + resolver.interrupt! initial_state = result.state - result.interrupt! + resolver.interrupt! expect(result.state).to eq(initial_state) end @@ -170,10 +171,10 @@ context "when complete" do it "raises error when trying to transition from complete" do - result.executing! - result.complete! + resolver.executing! + resolver.complete! - expect { result.interrupt! }.to raise_error(/cannot transition to interrupted from complete/) + expect { resolver.interrupt! }.to raise_error(/cannot transition to interrupted from complete/) end end end @@ -186,13 +187,13 @@ end it "returns false after skip!" do - result.skip!("test reason", halt: false) + resolver.skip!("test reason", halt: false) expect(result.success?).to be(false) end it "returns false after fail!" do - result.fail!("test reason", halt: false) + resolver.fail!("test reason", halt: false) expect(result.success?).to be(false) end @@ -204,7 +205,7 @@ end it "returns true after skip!" do - result.skip!("test reason", halt: false) + resolver.skip!("test reason", halt: false) expect(result.skipped?).to be(true) end @@ -216,7 +217,7 @@ end it "returns true after fail!" do - result.fail!("test reason", halt: false) + resolver.fail!("test reason", halt: false) expect(result.failed?).to be(true) end @@ -228,13 +229,13 @@ end it "returns true when skipped" do - result.skip!("test reason", halt: false) + resolver.skip!("test reason", halt: false) expect(result.good?).to be(true) end it "returns false when failed" do - result.fail!("test reason", halt: false) + resolver.fail!("test reason", halt: false) expect(result.good?).to be(false) end @@ -246,30 +247,18 @@ end it "returns true when skipped" do - result.skip!("test reason", halt: false) + resolver.skip!("test reason", halt: false) expect(result.bad?).to be(true) end it "returns true when failed" do - result.fail!("test reason", halt: false) + resolver.fail!("test reason", halt: false) expect(result.bad?).to be(true) end end - describe "#strict?" do - it "returns true by default" do - expect(result.strict?).to be(true) - end - - it "returns false when strict is false" do - result.fail!("test reason", halt: false, strict: false) - - expect(result.strict?).to be(false) - end - end - describe "#rolled_back?" do it "returns false by default" do expect(result.rolled_back?).to be(false) @@ -299,8 +288,8 @@ describe "#executed!" do context "when successful" do it "calls complete!" do - result.executing! - result.executed! + resolver.executing! + resolver.executed! expect(result.complete?).to be(true) end @@ -308,15 +297,15 @@ context "when not successful" do it "calls interrupt! when skipped" do - result.skip!("test reason", halt: false) - result.executed! + resolver.skip!("test reason", halt: false) + resolver.executed! expect(result.interrupted?).to be(true) end it "calls interrupt! when failed" do - result.fail!("test reason", halt: false) - result.executed! + resolver.fail!("test reason", halt: false) + resolver.executed! expect(result.interrupted?).to be(true) end @@ -329,14 +318,14 @@ end it "returns true when complete" do - result.executing! - result.complete! + resolver.executing! + resolver.complete! expect(result.executed?).to be(true) end it "returns true when interrupted" do - result.interrupt! + resolver.interrupt! expect(result.executed?).to be(true) end @@ -348,7 +337,7 @@ end it "calls block when executed" do - result.interrupt! + resolver.interrupt! called = false result.on(:executed) { |_r| called = true } @@ -368,279 +357,6 @@ end end - describe "#success!" do - context "when successful" do - it "sets the reason" do - catch(:cmdx_halt) { result.success!("Created 42 records") } - - expect(result.status).to eq(CMDx::Result::SUCCESS) - expect(result.reason).to eq("Created 42 records") - expect(result.metadata).to eq({}) - end - - it "accepts metadata" do - catch(:cmdx_halt) { result.success!("Imported", rows: 100) } - - expect(result.reason).to eq("Imported") - expect(result.metadata).to eq({ rows: 100 }) - end - - it "allows nil reason" do - catch(:cmdx_halt) { result.success! } - - expect(result.reason).to be_nil - end - - it "does not change state or status" do - original_state = result.state - original_status = result.status - catch(:cmdx_halt) { result.success!("note") } - - expect(result.state).to eq(original_state) - expect(result.status).to eq(original_status) - end - - it "throws :cmdx_halt by default" do - expect { result.success!("done") }.to throw_symbol(:cmdx_halt) - end - - it "does not throw when halt is false" do - expect { result.success!("done", halt: false) }.not_to throw_symbol(:cmdx_halt) - end - end - - context "when not successful" do - it "raises error when skipped" do - result.skip!("test", halt: false) - - expect { result.success!("reason") }.to raise_error(/can only be used while success/) - end - - it "raises error when failed" do - result.fail!("test", halt: false) - - expect { result.success!("reason") }.to raise_error(/can only be used while success/) - end - end - end - - describe "#skip!" do - context "when successful" do - it "transitions to skipped status" do - result.skip!("test reason", halt: false) - - expect(result.status).to eq(CMDx::Result::SKIPPED) - expect(result.state).to eq(CMDx::Result::INTERRUPTED) - expect(result.reason).to eq("test reason") - expect(result.cause).to be_nil - expect(result.metadata).to eq({}) - end - - it "accepts metadata" do - result.skip!("test reason", halt: false, foo: "bar") - - expect(result.metadata).to eq({ foo: "bar" }) - end - - it "accepts cause" do - cause = StandardError.new("cause") - result.skip!("test reason", halt: false, cause: cause) - - expect(result.cause).to eq(cause) - end - - it "uses default reason when none provided" do - allow(CMDx::Locale).to receive(:t).with("cmdx.reasons.unspecified").and_return("Unspecified") - - result.skip!(halt: false) - - expect(result.reason).to eq("Unspecified") - end - - it "calls halt! by default" do - expect { result.skip!("test reason") }.to raise_error(CMDx::SkipFault) - end - - it "does not call halt! when halt: false" do - expect { result.skip!("test reason", halt: false) }.not_to raise_error - end - end - - context "when already skipped" do - it "returns early without changes" do - result.skip!("first reason", halt: false) - original_reason = result.reason - result.skip!("second reason", halt: false) - - expect(result.reason).to eq(original_reason) - end - end - - context "when not successful" do - it "raises error when trying to skip from failed" do - result.fail!("test reason", halt: false) - - expect { result.skip!("another reason", halt: false) }.to raise_error(/can only transition to skipped from success/) - end - end - end - - describe "#fail!" do - context "when successful" do - it "transitions to failed status" do - result.fail!("test reason", halt: false) - - expect(result.status).to eq(CMDx::Result::FAILED) - expect(result.state).to eq(CMDx::Result::INTERRUPTED) - expect(result.reason).to eq("test reason") - expect(result.cause).to be_nil - expect(result.metadata).to eq({}) - end - - it "accepts metadata" do - result.fail!("test reason", halt: false, foo: "bar") - - expect(result.metadata).to eq({ foo: "bar" }) - end - - it "accepts cause" do - cause = StandardError.new("cause") - result.fail!("test reason", halt: false, cause: cause) - - expect(result.cause).to eq(cause) - end - - it "uses default reason when none provided" do - allow(CMDx::Locale).to receive(:t).with("cmdx.reasons.unspecified").and_return("Unspecified") - - result.fail!(halt: false) - - expect(result.reason).to eq("Unspecified") - end - - it "calls halt! by default" do - expect { result.fail!("test reason") }.to raise_error(CMDx::FailFault) - end - - it "does not call halt! when halt: false" do - expect { result.fail!("test reason", halt: false) }.not_to raise_error - end - end - - context "when already failed" do - it "returns early without changes" do - result.fail!("first reason", halt: false) - original_reason = result.reason - result.fail!("second reason", halt: false) - - expect(result.reason).to eq(original_reason) - end - end - - context "when not successful" do - it "raises error when trying to fail from skipped" do - result.skip!("test reason", halt: false) - - expect { result.fail!("another reason", halt: false) }.to raise_error(/can only transition to failed from success/) - end - end - end - - describe "#halt!" do - context "when successful" do - it "returns early without raising" do - expect { result.halt! }.not_to raise_error - end - end - - context "when skipped" do - it "raises SkipFault" do - result.skip!("test reason", halt: false) - - expect { result.halt! }.to raise_error(CMDx::SkipFault) do |fault| - expect(fault.result).to eq(result) - expect(fault.message).to eq("test reason") - end - end - - it "sets proper backtrace" do - result.skip!("test reason", halt: false) - - begin - result.halt! - rescue CMDx::SkipFault => e - expect(e.backtrace).to be_an(Array) - expect(e.backtrace).not_to be_empty - end - end - end - - context "when failed" do - it "raises FailFault" do - result.fail!("test reason", halt: false) - - expect { result.halt! }.to raise_error(CMDx::FailFault) do |fault| - expect(fault.result).to eq(result) - expect(fault.message).to eq("test reason") - end - end - end - end - - describe "#throw!" do - let(:other_task) { create_failing_task.new } - let(:other_result) { other_task.result } - - before do - other_result.fail!("source failure", halt: false, foo: "bar") - end - - context "with valid result" do - it "copies state and status from other result" do - result.throw!(other_result, halt: false) - - expect(result.state).to eq(other_result.state) - expect(result.status).to eq(other_result.status) - expect(result.reason).to eq(other_result.reason) - end - - it "merges metadata" do - result.throw!(other_result, halt: false, baz: "qux") - - expect(result.metadata).to eq({ foo: "bar", baz: "qux" }) - end - - it "uses provided cause over other result's cause" do - custom_cause = StandardError.new("custom") - - result.throw!(other_result, halt: false, cause: custom_cause) - expect(result.cause).to eq(custom_cause) - end - - it "uses other result's cause when none provided" do - other_cause = StandardError.new("other") - other_result.instance_variable_set(:@cause, other_cause) - result.throw!(other_result, halt: false) - - expect(result.cause).to eq(other_cause) - end - - it "calls halt! by default" do - expect { result.throw!(other_result) }.to raise_error(CMDx::FailFault) - end - - it "does not call halt! when halt: false" do - expect { result.throw!(other_result, halt: false) }.not_to raise_error - end - end - - context "with invalid result" do - it "raises TypeError when not a CMDx::Result" do - expect { result.throw!("not a result", halt: false) }.to raise_error(TypeError, "must be a CMDx::Result") - end - end - end - describe "failure tracking methods" do let(:chain) { CMDx::Chain.new } let(:first_task) { create_successful_task.new } @@ -657,7 +373,7 @@ r.instance_variable_set(:@chain, chain) end - second_result.fail!("test failure", halt: false) + second_task.resolver.fail!("test failure", halt: false) end describe "#caused_failure" do @@ -697,7 +413,7 @@ chain.results << fourth_result fourth_result.instance_variable_set(:@chain, chain) - fourth_result.fail!("another failure", halt: false) + fourth_task.resolver.fail!("another failure", halt: false) end it "returns the next failed result after current" do @@ -767,7 +483,7 @@ context "when not initialized and not thrown failure" do it "returns status" do - result.executing! + resolver.executing! expect(result.outcome).to eq(result.status) end @@ -795,7 +511,7 @@ context "when successful with reason" do it "includes reason without cause or rolled_back" do - catch(:cmdx_halt) { result.success!("Created 42 records") } + catch(:cmdx_halt) { resolver.success!("Created 42 records") } hash = result.to_h @@ -806,7 +522,7 @@ context "when interrupted" do it "includes reason, cause and rolled_back status" do - result.skip!("test reason", halt: false, cause: StandardError.new("test")) + resolver.skip!("test reason", halt: false, cause: StandardError.new("test")) hash = result.to_h @@ -818,7 +534,7 @@ context "when failed" do it "includes failure information" do - result.fail!("test failure", halt: false) + resolver.fail!("test failure", halt: false) # Create mock objects that avoid calling to_h to prevent infinite recursion threw_failure_mock = instance_double(described_class, to_h: { index: 1, class: "Test", id: "123" }) @@ -913,12 +629,12 @@ when CMDx::Result::INITIALIZED # Already in initialized state when CMDx::Result::EXECUTING - result.executing! + resolver.executing! when CMDx::Result::COMPLETE - result.executing! - result.complete! + resolver.executing! + resolver.complete! when CMDx::Result::INTERRUPTED - result.interrupt! + resolver.interrupt! end end @@ -941,7 +657,7 @@ before do case state when CMDx::Result::INITIALIZED - result.executing! + resolver.executing! when CMDx::Result::EXECUTING # Stay in initialized state when CMDx::Result::COMPLETE @@ -979,9 +695,9 @@ when CMDx::Result::SUCCESS # Already in success status when CMDx::Result::SKIPPED - result.skip!("test", halt: false) + resolver.skip!("test", halt: false) when CMDx::Result::FAILED - result.fail!("test", halt: false) + resolver.fail!("test", halt: false) end end @@ -1004,7 +720,7 @@ before do case status when CMDx::Result::SUCCESS - result.skip!("test", halt: false) + resolver.skip!("test", halt: false) when CMDx::Result::SKIPPED # Stay in success status when CMDx::Result::FAILED @@ -1041,7 +757,7 @@ end it "calls the block for skipped" do - result.skip!("test", halt: false) + resolver.skip!("test", halt: false) called = false result.on(:good) { |_r| called = true } @@ -1051,7 +767,7 @@ context "when not good" do it "does not call the block for failed" do - result.fail!("test", halt: false) + resolver.fail!("test", halt: false) called = false result.on(:good) { |_r| called = true } @@ -1071,7 +787,7 @@ context "when bad" do it "calls the block for skipped" do - result.skip!("test", halt: false) + resolver.skip!("test", halt: false) called = false result.on(:bad) { |_r| called = true } @@ -1079,7 +795,7 @@ end it "calls the block for failed" do - result.fail!("test", halt: false) + resolver.fail!("test", halt: false) called = false result.on(:bad) { |_r| called = true } diff --git a/spec/cmdx/task_spec.rb b/spec/cmdx/task_spec.rb index caded1fad..0b01f311c 100644 --- a/spec/cmdx/task_spec.rb +++ b/spec/cmdx/task_spec.rb @@ -75,26 +75,26 @@ end describe "delegated methods" do - it "delegates success! to result" do - expect(task.result).to receive(:success!).with("reason", metadata: "data") + it "delegates success! to resolver" do + expect(task.resolver).to receive(:success!).with("reason", metadata: "data") task.success!("reason", metadata: "data") end - it "delegates skip! to result" do - expect(task.result).to receive(:skip!).with("reason", metadata: "data") + it "delegates skip! to resolver" do + expect(task.resolver).to receive(:skip!).with("reason", metadata: "data") task.skip!("reason", metadata: "data") end - it "delegates fail! to result" do - expect(task.result).to receive(:fail!).with("reason", metadata: "data") + it "delegates fail! to resolver" do + expect(task.resolver).to receive(:fail!).with("reason", metadata: "data") task.fail!("reason", metadata: "data") end - it "delegates throw! to result" do - expect(task.result).to receive(:throw!).with("reason", metadata: "data") + it "delegates throw! to resolver" do + expect(task.resolver).to receive(:throw!).with("reason", metadata: "data") task.throw!("reason", metadata: "data") end From 93ad727388343f9777e5f5fd01badbe3a2ec77ce Mon Sep 17 00:00:00 2001 From: Juan Gomez Date: Sun, 19 Apr 2026 09:23:02 -0400 Subject: [PATCH 02/54] V2 --- .cursor/agents/code-reviewer.md | 175 ++ .cursor/commands/create-pr.md | 112 + .cursor/commands/fix-git.md | 121 + .cursor/commands/fix-test.md | 131 + .cursor/commands/git-commit.md | 88 + .cursor/commands/pr-feedback.md | 112 + .cursor/commands/refresh-agent.md | 68 + .cursor/commands/refresh-command.md | 80 + .cursor/commands/refresh-hook.md | 69 + .cursor/commands/refresh-skill.md | 91 + .cursor/commands/review-pr.md | 163 ++ .cursor/hooks/conventions-check | 74 + .cursor/hooks/session-start | 57 + .cursor/prompts/rspec.md | 24 - .cursor/prompts/skill.md | 31 - .cursor/prompts/yardoc.md | 15 - .cursor/rules/cursor-instructions.mdc | 6 +- .cursor/skills/command-patterns/SKILL.md | 91 + .../assets/COMMAND.template.md | 43 + .../command-patterns/references/checklist.md | 48 + .../scripts/validate-metadata.py | 32 + .cursor/skills/explain-functionality/SKILL.md | 179 ++ .../assets/explanation-template.md | 60 + .../references/checklist.md | 31 + .cursor/skills/issue-debugging/SKILL.md | 173 ++ .../issue-debugging/references/checklist.md | 69 + .../skills/performance-optimizations/SKILL.md | 147 ++ .../references/checklist.md | 66 + .../scripts/allocation-trace.rb | 59 + .../scripts/ips-benchmark.rb | 76 + .../scripts/memory-profile.rb | 54 + .../scripts/yjit-compare.rb | 72 + .cursor/skills/skill-patterns/SKILL.md | 52 + .../skill-patterns/assets/SKILL.template.md | 22 + .../skill-patterns/references/checklist.md | 37 + .../scripts/validate-metadata.py | 50 + .cursor/skills/technical-docs/SKILL.md | 139 + .../technical-docs/assets/yard-templates.md | 200 ++ .../technical-docs/references/checklist.md | 42 + .cursor/skills/test-patterns/SKILL.md | 160 ++ .../test-patterns/assets/spec-template.md | 172 ++ .../test-patterns/references/checklist.md | 47 + .github/copilot-instructions.md | 63 +- .github/workflows/ci.yml | 6 +- .github/workflows/docs.yml | 7 +- .gitignore | 5 + .rubocop.yml | 15 +- CHANGELOG.md | 91 +- Gemfile.lock | 17 +- README.md | 61 +- benchmark/Gemfile | 14 + benchmark/RESULTS.md | 198 ++ benchmark/compare.rb | 112 + benchmark/harness.rb | 281 ++ benchmark/report.rb | 343 +++ cmdx.gemspec | 5 +- docs/attributes/coercions.md | 155 -- docs/attributes/defaults.md | 77 - docs/attributes/definitions.md | 354 --- docs/attributes/naming.md | 68 - docs/attributes/transformations.md | 81 - docs/attributes/validations.md | 354 --- docs/basics/chain.md | 139 +- docs/basics/context.md | 98 +- docs/basics/execution.md | 140 +- docs/basics/setup.md | 85 +- ...ilding-production-ready-rails-with-cmdx.md | 2 + .../posts/cmdx-as-pragmatic-event-sourcing.md | 2 + ...mdx-patterns-advanced-middleware-stacks.md | 2 + ...dx-patterns-debugging-and-observability.md | 2 + .../cmdx-patterns-defensive-contracts.md | 2 + .../cmdx-patterns-error-handling-playbook.md | 2 + .../blog/posts/cmdx-v2-the-runtime-rewrite.md | 217 ++ docs/blog/posts/getting-started-with-cmdx.md | 2 + docs/blog/posts/mastering-cmdx-attributes.md | 2 + ...astering-cmdx-callbacks-and-middlewares.md | 2 + .../blog/posts/mastering-cmdx-fundamentals.md | 2 + .../posts/mastering-cmdx-interruptions.md | 2 + docs/blog/posts/mastering-cmdx-logging.md | 2 + docs/blog/posts/mastering-cmdx-outcomes.md | 2 + ...mastering-cmdx-retries-deprecation-i18n.md | 2 + docs/blog/posts/mastering-cmdx-workflows.md | 2 + .../posts/real-world-cmdx-background-jobs.md | 224 +- .../posts/real-world-cmdx-external-apis.md | 111 +- .../real-world-cmdx-multi-tenant-saas.md | 120 +- .../posts/real-world-cmdx-user-onboarding.md | 57 +- docs/blog/posts/returns-and-contracts.md | 2 + .../posts/structuring-large-cmdx-codebases.md | 2 + .../posts/testing-cmdx-tasks-like-a-pro.md | 2 + docs/blog/posts/why-i-switched-to-cmdx.md | 2 + docs/callbacks.md | 70 +- docs/comparison.md | 35 +- docs/configuration.md | 431 ++-- docs/deprecation.md | 155 +- docs/exceptions.md | 131 - docs/getting_started.md | 83 +- docs/index.md | 6 +- docs/inputs/coercions.md | 176 ++ docs/inputs/defaults.md | 97 + docs/inputs/definitions.md | 354 +++ docs/inputs/naming.md | 76 + docs/inputs/transformations.md | 82 + docs/inputs/validations.md | 376 +++ docs/internationalization.md | 165 +- docs/interruptions/exceptions.md | 180 +- docs/interruptions/faults.md | 143 +- docs/interruptions/halt.md | 256 -- docs/interruptions/signals.md | 294 +++ docs/logging.md | 212 +- docs/middlewares.md | 264 +- docs/outcomes/result.md | 225 +- docs/outcomes/states.md | 54 +- docs/outcomes/statuses.md | 63 +- docs/outputs.md | 177 ++ docs/overrides/home.html | 2265 ++++++++++++----- docs/retries.md | 153 +- docs/returns.md | 91 - docs/stylesheets/extra.css | 85 + docs/testing.md | 194 +- docs/tips_and_tricks.md | 93 +- docs/v2-migration.md | 949 +++++++ docs/workflows.md | 163 +- lib/cmdx.rb | 130 +- lib/cmdx/.DS_Store | Bin 6148 -> 6148 bytes lib/cmdx/attribute.rb | 440 ---- lib/cmdx/attribute_registry.rb | 185 -- lib/cmdx/attribute_value.rb | 252 -- lib/cmdx/callback_registry.rb | 169 -- lib/cmdx/callbacks.rb | 127 + lib/cmdx/chain.rb | 249 +- lib/cmdx/coercion_registry.rb | 138 - lib/cmdx/coercions.rb | 164 ++ lib/cmdx/coercions/array.rb | 51 +- lib/cmdx/coercions/big_decimal.rb | 41 +- lib/cmdx/coercions/boolean.rb | 69 +- lib/cmdx/coercions/coerce.rb | 32 + lib/cmdx/coercions/complex.rb | 39 +- lib/cmdx/coercions/date.rb | 62 +- lib/cmdx/coercions/date_time.rb | 62 +- lib/cmdx/coercions/float.rb | 36 +- lib/cmdx/coercions/hash.rb | 59 +- lib/cmdx/coercions/integer.rb | 39 +- lib/cmdx/coercions/rational.rb | 45 +- lib/cmdx/coercions/string.rb | 29 +- lib/cmdx/coercions/symbol.rb | 37 +- lib/cmdx/coercions/time.rb | 66 +- lib/cmdx/configuration.rb | 273 +- lib/cmdx/context.rb | 435 ++-- lib/cmdx/deprecation.rb | 52 + lib/cmdx/deprecator.rb | 77 - lib/cmdx/errors.rb | 175 +- lib/cmdx/exception.rb | 46 - lib/cmdx/executor.rb | 379 --- lib/cmdx/fault.rb | 145 +- lib/cmdx/i18n_proxy.rb | 82 + lib/cmdx/identifier.rb | 30 - lib/cmdx/input.rb | 259 ++ lib/cmdx/inputs.rb | 145 ++ lib/cmdx/locale.rb | 78 - lib/cmdx/log_formatters/json.rb | 29 +- lib/cmdx/log_formatters/key_value.rb | 31 +- lib/cmdx/log_formatters/line.rb | 26 +- lib/cmdx/log_formatters/logstash.rb | 29 +- lib/cmdx/log_formatters/raw.rb | 28 +- lib/cmdx/logger_proxy.rb | 30 + lib/cmdx/middleware_registry.rb | 148 -- lib/cmdx/middlewares.rb | 112 + lib/cmdx/middlewares/correlate.rb | 140 - lib/cmdx/middlewares/runtime.rb | 77 - lib/cmdx/middlewares/timeout.rb | 76 - lib/cmdx/output.rb | 151 ++ lib/cmdx/outputs.rb | 60 + lib/cmdx/parallelizer.rb | 100 - lib/cmdx/pipeline.rb | 197 +- lib/cmdx/railtie.rb | 46 +- lib/cmdx/resolver.rb | 263 -- lib/cmdx/result.rb | 602 ++--- lib/cmdx/retry.rb | 230 +- lib/cmdx/runtime.rb | 226 ++ lib/cmdx/settings.rb | 246 +- lib/cmdx/signal.rb | 158 ++ lib/cmdx/task.rb | 648 +++-- lib/cmdx/telemetry.rb | 105 + lib/cmdx/util.rb | 73 + lib/cmdx/utils/call.rb | 53 - lib/cmdx/utils/condition.rb | 71 - lib/cmdx/utils/format.rb | 82 - lib/cmdx/utils/normalize.rb | 52 - lib/cmdx/utils/wrap.rb | 38 - lib/cmdx/validator_registry.rb | 143 -- lib/cmdx/validators.rb | 144 ++ lib/cmdx/validators/absence.rb | 49 +- lib/cmdx/validators/exclusion.rb | 73 +- lib/cmdx/validators/format.rb | 68 +- lib/cmdx/validators/inclusion.rb | 75 +- lib/cmdx/validators/length.rb | 204 +- lib/cmdx/validators/numeric.rb | 198 +- lib/cmdx/validators/presence.rb | 49 +- lib/cmdx/validators/validate.rb | 31 + lib/cmdx/version.rb | 6 +- lib/cmdx/workflow.rb | 138 +- lib/generators/cmdx/install_generator.rb | 20 - lib/generators/cmdx/locale_generator.rb | 39 - lib/generators/cmdx/task_generator.rb | 33 - lib/generators/cmdx/templates/install.rb | 116 +- lib/generators/cmdx/templates/task.rb.tt | 2 +- lib/generators/cmdx/templates/workflow.rb.tt | 3 +- lib/generators/cmdx/workflow_generator.rb | 33 - lib/locales/af.yml | 55 - lib/locales/ar.yml | 55 - lib/locales/az.yml | 55 - lib/locales/be.yml | 55 - lib/locales/bg.yml | 55 - lib/locales/bn.yml | 55 - lib/locales/bs.yml | 55 - lib/locales/ca.yml | 55 - lib/locales/cnr.yml | 55 - lib/locales/cs.yml | 55 - lib/locales/cy.yml | 55 - lib/locales/da.yml | 55 - lib/locales/de.yml | 55 - lib/locales/dz.yml | 55 - lib/locales/el.yml | 55 - lib/locales/en.yml | 15 +- lib/locales/eo.yml | 55 - lib/locales/es.yml | 55 - lib/locales/et.yml | 55 - lib/locales/eu.yml | 55 - lib/locales/fa.yml | 55 - lib/locales/fi.yml | 55 - lib/locales/fr.yml | 55 - lib/locales/fy.yml | 55 - lib/locales/gd.yml | 55 - lib/locales/gl.yml | 55 - lib/locales/he.yml | 55 - lib/locales/hi.yml | 55 - lib/locales/hr.yml | 55 - lib/locales/hu.yml | 55 - lib/locales/hy.yml | 55 - lib/locales/id.yml | 55 - lib/locales/is.yml | 55 - lib/locales/it.yml | 55 - lib/locales/ja.yml | 55 - lib/locales/ka.yml | 55 - lib/locales/kk.yml | 55 - lib/locales/km.yml | 55 - lib/locales/kn.yml | 55 - lib/locales/ko.yml | 55 - lib/locales/lb.yml | 55 - lib/locales/lo.yml | 55 - lib/locales/lt.yml | 55 - lib/locales/lv.yml | 55 - lib/locales/mg.yml | 55 - lib/locales/mk.yml | 55 - lib/locales/ml.yml | 55 - lib/locales/mn.yml | 55 - lib/locales/mr-IN.yml | 55 - lib/locales/ms.yml | 55 - lib/locales/nb.yml | 55 - lib/locales/ne.yml | 55 - lib/locales/nl.yml | 55 - lib/locales/nn.yml | 55 - lib/locales/oc.yml | 55 - lib/locales/or.yml | 55 - lib/locales/pa.yml | 55 - lib/locales/pl.yml | 55 - lib/locales/pt.yml | 55 - lib/locales/rm.yml | 55 - lib/locales/ro.yml | 55 - lib/locales/ru.yml | 55 - lib/locales/sc.yml | 55 - lib/locales/sk.yml | 55 - lib/locales/sl.yml | 55 - lib/locales/sq.yml | 55 - lib/locales/sr.yml | 55 - lib/locales/st.yml | 55 - lib/locales/sv.yml | 55 - lib/locales/sw.yml | 55 - lib/locales/ta.yml | 55 - lib/locales/te.yml | 55 - lib/locales/th.yml | 55 - lib/locales/tl.yml | 55 - lib/locales/tr.yml | 55 - lib/locales/tt.yml | 55 - lib/locales/ug.yml | 55 - lib/locales/uk.yml | 55 - lib/locales/ur.yml | 55 - lib/locales/uz.yml | 55 - lib/locales/vi.yml | 55 - lib/locales/wo.yml | 55 - lib/locales/zh-CN.yml | 55 - lib/locales/zh-HK.yml | 55 - lib/locales/zh-TW.yml | 55 - lib/locales/zh-YUE.yml | 55 - mkdocs.yml | 44 +- skills/SKILL.md | 573 +++-- skills/references/attributes.md | 276 -- skills/references/configuration.md | 305 ++- skills/references/inputs.md | 300 +++ skills/references/interruptions.md | 287 +-- skills/references/outputs.md | 93 + skills/references/result.md | 247 +- skills/references/testing.md | 321 ++- skills/references/workflows.md | 217 +- spec/cmdx/attribute_registry_spec.rb | 335 --- spec/cmdx/attribute_spec.rb | 707 ----- spec/cmdx/attribute_value_spec.rb | 763 ------ spec/cmdx/callback_registry_spec.rb | 396 --- spec/cmdx/callbacks_spec.rb | 424 +++ spec/cmdx/chain_spec.rb | 425 ++-- spec/cmdx/coercion_registry_spec.rb | 290 --- spec/cmdx/coercions/array_spec.rb | 213 +- spec/cmdx/coercions/big_decimal_spec.rb | 165 +- spec/cmdx/coercions/boolean_spec.rb | 250 +- spec/cmdx/coercions/coerce_spec.rb | 47 + spec/cmdx/coercions/complex_spec.rb | 216 +- spec/cmdx/coercions/date_spec.rb | 232 +- spec/cmdx/coercions/date_time_spec.rb | 183 +- spec/cmdx/coercions/float_spec.rb | 222 +- spec/cmdx/coercions/hash_spec.rb | 287 +-- spec/cmdx/coercions/integer_spec.rb | 199 +- spec/cmdx/coercions/rational_spec.rb | 254 +- spec/cmdx/coercions/string_spec.rb | 270 +- spec/cmdx/coercions/symbol_spec.rb | 225 +- spec/cmdx/coercions/time_spec.rb | 227 +- spec/cmdx/coercions_spec.rb | 148 ++ spec/cmdx/configuration_spec.rb | 310 +-- spec/cmdx/context_spec.rb | 523 ++-- spec/cmdx/deprecation_spec.rb | 102 + spec/cmdx/deprecator_spec.rb | 177 -- spec/cmdx/errors_spec.rb | 462 +--- spec/cmdx/executor_spec.rb | 1162 --------- spec/cmdx/fault_spec.rb | 154 ++ spec/cmdx/faults_spec.rb | 189 -- spec/cmdx/i18n_proxy_spec.rb | 134 + spec/cmdx/identifier_spec.rb | 49 - spec/cmdx/input_spec.rb | 281 ++ spec/cmdx/inputs_spec.rb | 158 ++ spec/cmdx/locale_spec.rb | 147 -- spec/cmdx/log_formatters/json_spec.rb | 249 +- spec/cmdx/log_formatters/key_value_spec.rb | 384 +-- spec/cmdx/log_formatters/line_spec.rb | 283 +- spec/cmdx/log_formatters/logstash_spec.rb | 262 +- spec/cmdx/log_formatters/raw_spec.rb | 201 +- spec/cmdx/logger_proxy_spec.rb | 59 + spec/cmdx/middleware_registry_spec.rb | 453 ---- spec/cmdx/middlewares/correlate_spec.rb | 444 ---- spec/cmdx/middlewares/runtime_spec.rb | 180 -- spec/cmdx/middlewares/timeout_spec.rb | 272 -- spec/cmdx/middlewares_spec.rb | 178 ++ spec/cmdx/output_spec.rb | 305 +++ spec/cmdx/outputs_spec.rb | 88 + spec/cmdx/parallelizer_spec.rb | 65 - spec/cmdx/pipeline_spec.rb | 420 +-- spec/cmdx/resolver_spec.rb | 316 --- spec/cmdx/result_spec.rb | 946 ++----- spec/cmdx/retry_spec.rb | 397 +-- spec/cmdx/runtime_spec.rb | 138 + spec/cmdx/settings_spec.rb | 332 +-- spec/cmdx/signal_spec.rb | 182 ++ spec/cmdx/task_spec.rb | 659 ++--- spec/cmdx/telemetry_spec.rb | 135 + spec/cmdx/util_spec.rb | 110 + spec/cmdx/utils/call_spec.rb | 424 --- spec/cmdx/utils/condition_spec.rb | 444 ---- spec/cmdx/utils/format_spec.rb | 289 --- spec/cmdx/utils/normalize_spec.rb | 94 - spec/cmdx/utils/wrap_spec.rb | 60 - spec/cmdx/validator_registry_spec.rb | 433 ---- spec/cmdx/validators/absence_spec.rb | 154 +- spec/cmdx/validators/exclusion_spec.rb | 347 +-- spec/cmdx/validators/format_spec.rb | 276 +- spec/cmdx/validators/inclusion_spec.rb | 355 +-- spec/cmdx/validators/length_spec.rb | 431 +--- spec/cmdx/validators/numeric_spec.rb | 379 +-- spec/cmdx/validators/presence_spec.rb | 163 +- spec/cmdx/validators/validate_spec.rb | 46 + spec/cmdx/validators_spec.rb | 133 + spec/cmdx/workflow_spec.rb | 376 +-- spec/cmdx_spec.rb | 216 -- spec/integration/tasks/attributes_spec.rb | 482 ---- spec/integration/tasks/callbacks_spec.rb | 628 ++--- spec/integration/tasks/chain_spec.rb | 110 + spec/integration/tasks/deprecation_spec.rb | 196 ++ spec/integration/tasks/errors_spec.rb | 168 ++ spec/integration/tasks/execution_spec.rb | 903 ++----- spec/integration/tasks/handlers_spec.rb | 332 --- spec/integration/tasks/inputs_spec.rb | 503 ++++ spec/integration/tasks/middlewares_spec.rb | 176 +- spec/integration/tasks/output_spec.rb | 219 ++ spec/integration/tasks/retry_spec.rb | 140 + spec/integration/tasks/returns_spec.rb | 346 --- spec/integration/tasks/rollback_spec.rb | 101 + spec/integration/tasks/settings_spec.rb | 134 + spec/integration/tasks/telemetry_spec.rb | 168 ++ .../integration/workflows/breakpoints_spec.rb | 190 -- .../workflows/conditionals_spec.rb | 213 +- spec/integration/workflows/execution_spec.rb | 216 +- spec/integration/workflows/nested_spec.rb | 95 + spec/integration/workflows/parallel_spec.rb | 126 + spec/spec_helper.rb | 11 - spec/support/config/i18n.rb | 2 +- spec/support/helpers/ruby_version.rb | 19 - spec/support/helpers/task_builders.rb | 23 +- spec/support/helpers/workflow_builders.rb | 1 + 405 files changed, 23316 insertions(+), 36963 deletions(-) create mode 100644 .cursor/agents/code-reviewer.md create mode 100644 .cursor/commands/create-pr.md create mode 100644 .cursor/commands/fix-git.md create mode 100644 .cursor/commands/fix-test.md create mode 100644 .cursor/commands/git-commit.md create mode 100644 .cursor/commands/pr-feedback.md create mode 100644 .cursor/commands/refresh-agent.md create mode 100644 .cursor/commands/refresh-command.md create mode 100644 .cursor/commands/refresh-hook.md create mode 100644 .cursor/commands/refresh-skill.md create mode 100644 .cursor/commands/review-pr.md create mode 100755 .cursor/hooks/conventions-check create mode 100755 .cursor/hooks/session-start delete mode 100644 .cursor/prompts/rspec.md delete mode 100644 .cursor/prompts/skill.md delete mode 100644 .cursor/prompts/yardoc.md create mode 100644 .cursor/skills/command-patterns/SKILL.md create mode 100644 .cursor/skills/command-patterns/assets/COMMAND.template.md create mode 100644 .cursor/skills/command-patterns/references/checklist.md create mode 100644 .cursor/skills/command-patterns/scripts/validate-metadata.py create mode 100644 .cursor/skills/explain-functionality/SKILL.md create mode 100644 .cursor/skills/explain-functionality/assets/explanation-template.md create mode 100644 .cursor/skills/explain-functionality/references/checklist.md create mode 100644 .cursor/skills/issue-debugging/SKILL.md create mode 100644 .cursor/skills/issue-debugging/references/checklist.md create mode 100644 .cursor/skills/performance-optimizations/SKILL.md create mode 100644 .cursor/skills/performance-optimizations/references/checklist.md create mode 100755 .cursor/skills/performance-optimizations/scripts/allocation-trace.rb create mode 100755 .cursor/skills/performance-optimizations/scripts/ips-benchmark.rb create mode 100755 .cursor/skills/performance-optimizations/scripts/memory-profile.rb create mode 100755 .cursor/skills/performance-optimizations/scripts/yjit-compare.rb create mode 100644 .cursor/skills/skill-patterns/SKILL.md create mode 100644 .cursor/skills/skill-patterns/assets/SKILL.template.md create mode 100644 .cursor/skills/skill-patterns/references/checklist.md create mode 100644 .cursor/skills/skill-patterns/scripts/validate-metadata.py create mode 100644 .cursor/skills/technical-docs/SKILL.md create mode 100644 .cursor/skills/technical-docs/assets/yard-templates.md create mode 100644 .cursor/skills/technical-docs/references/checklist.md create mode 100644 .cursor/skills/test-patterns/SKILL.md create mode 100644 .cursor/skills/test-patterns/assets/spec-template.md create mode 100644 .cursor/skills/test-patterns/references/checklist.md create mode 100644 benchmark/Gemfile create mode 100644 benchmark/RESULTS.md create mode 100755 benchmark/compare.rb create mode 100755 benchmark/harness.rb create mode 100755 benchmark/report.rb delete mode 100644 docs/attributes/coercions.md delete mode 100644 docs/attributes/defaults.md delete mode 100644 docs/attributes/definitions.md delete mode 100644 docs/attributes/naming.md delete mode 100644 docs/attributes/transformations.md delete mode 100644 docs/attributes/validations.md create mode 100644 docs/blog/posts/cmdx-v2-the-runtime-rewrite.md delete mode 100644 docs/exceptions.md create mode 100644 docs/inputs/coercions.md create mode 100644 docs/inputs/defaults.md create mode 100644 docs/inputs/definitions.md create mode 100644 docs/inputs/naming.md create mode 100644 docs/inputs/transformations.md create mode 100644 docs/inputs/validations.md delete mode 100644 docs/interruptions/halt.md create mode 100644 docs/interruptions/signals.md create mode 100644 docs/outputs.md delete mode 100644 docs/returns.md create mode 100644 docs/v2-migration.md delete mode 100644 lib/cmdx/attribute.rb delete mode 100644 lib/cmdx/attribute_registry.rb delete mode 100644 lib/cmdx/attribute_value.rb delete mode 100644 lib/cmdx/callback_registry.rb create mode 100644 lib/cmdx/callbacks.rb delete mode 100644 lib/cmdx/coercion_registry.rb create mode 100644 lib/cmdx/coercions.rb create mode 100644 lib/cmdx/coercions/coerce.rb create mode 100644 lib/cmdx/deprecation.rb delete mode 100644 lib/cmdx/deprecator.rb delete mode 100644 lib/cmdx/exception.rb delete mode 100644 lib/cmdx/executor.rb create mode 100644 lib/cmdx/i18n_proxy.rb delete mode 100644 lib/cmdx/identifier.rb create mode 100644 lib/cmdx/input.rb create mode 100644 lib/cmdx/inputs.rb delete mode 100644 lib/cmdx/locale.rb create mode 100644 lib/cmdx/logger_proxy.rb delete mode 100644 lib/cmdx/middleware_registry.rb create mode 100644 lib/cmdx/middlewares.rb delete mode 100644 lib/cmdx/middlewares/correlate.rb delete mode 100644 lib/cmdx/middlewares/runtime.rb delete mode 100644 lib/cmdx/middlewares/timeout.rb create mode 100644 lib/cmdx/output.rb create mode 100644 lib/cmdx/outputs.rb delete mode 100644 lib/cmdx/parallelizer.rb delete mode 100644 lib/cmdx/resolver.rb create mode 100644 lib/cmdx/runtime.rb create mode 100644 lib/cmdx/signal.rb create mode 100644 lib/cmdx/telemetry.rb create mode 100644 lib/cmdx/util.rb delete mode 100644 lib/cmdx/utils/call.rb delete mode 100644 lib/cmdx/utils/condition.rb delete mode 100644 lib/cmdx/utils/format.rb delete mode 100644 lib/cmdx/utils/normalize.rb delete mode 100644 lib/cmdx/utils/wrap.rb delete mode 100644 lib/cmdx/validator_registry.rb create mode 100644 lib/cmdx/validators.rb create mode 100644 lib/cmdx/validators/validate.rb delete mode 100644 lib/generators/cmdx/locale_generator.rb delete mode 100644 lib/locales/af.yml delete mode 100644 lib/locales/ar.yml delete mode 100644 lib/locales/az.yml delete mode 100644 lib/locales/be.yml delete mode 100644 lib/locales/bg.yml delete mode 100644 lib/locales/bn.yml delete mode 100644 lib/locales/bs.yml delete mode 100644 lib/locales/ca.yml delete mode 100644 lib/locales/cnr.yml delete mode 100644 lib/locales/cs.yml delete mode 100644 lib/locales/cy.yml delete mode 100644 lib/locales/da.yml delete mode 100644 lib/locales/de.yml delete mode 100644 lib/locales/dz.yml delete mode 100644 lib/locales/el.yml delete mode 100644 lib/locales/eo.yml delete mode 100644 lib/locales/es.yml delete mode 100644 lib/locales/et.yml delete mode 100644 lib/locales/eu.yml delete mode 100644 lib/locales/fa.yml delete mode 100644 lib/locales/fi.yml delete mode 100644 lib/locales/fr.yml delete mode 100644 lib/locales/fy.yml delete mode 100644 lib/locales/gd.yml delete mode 100644 lib/locales/gl.yml delete mode 100644 lib/locales/he.yml delete mode 100644 lib/locales/hi.yml delete mode 100644 lib/locales/hr.yml delete mode 100644 lib/locales/hu.yml delete mode 100644 lib/locales/hy.yml delete mode 100644 lib/locales/id.yml delete mode 100644 lib/locales/is.yml delete mode 100644 lib/locales/it.yml delete mode 100644 lib/locales/ja.yml delete mode 100644 lib/locales/ka.yml delete mode 100644 lib/locales/kk.yml delete mode 100644 lib/locales/km.yml delete mode 100644 lib/locales/kn.yml delete mode 100644 lib/locales/ko.yml delete mode 100644 lib/locales/lb.yml delete mode 100644 lib/locales/lo.yml delete mode 100644 lib/locales/lt.yml delete mode 100644 lib/locales/lv.yml delete mode 100644 lib/locales/mg.yml delete mode 100644 lib/locales/mk.yml delete mode 100644 lib/locales/ml.yml delete mode 100644 lib/locales/mn.yml delete mode 100644 lib/locales/mr-IN.yml delete mode 100644 lib/locales/ms.yml delete mode 100644 lib/locales/nb.yml delete mode 100644 lib/locales/ne.yml delete mode 100644 lib/locales/nl.yml delete mode 100644 lib/locales/nn.yml delete mode 100644 lib/locales/oc.yml delete mode 100644 lib/locales/or.yml delete mode 100644 lib/locales/pa.yml delete mode 100644 lib/locales/pl.yml delete mode 100644 lib/locales/pt.yml delete mode 100644 lib/locales/rm.yml delete mode 100644 lib/locales/ro.yml delete mode 100644 lib/locales/ru.yml delete mode 100644 lib/locales/sc.yml delete mode 100644 lib/locales/sk.yml delete mode 100644 lib/locales/sl.yml delete mode 100644 lib/locales/sq.yml delete mode 100644 lib/locales/sr.yml delete mode 100644 lib/locales/st.yml delete mode 100644 lib/locales/sv.yml delete mode 100644 lib/locales/sw.yml delete mode 100644 lib/locales/ta.yml delete mode 100644 lib/locales/te.yml delete mode 100644 lib/locales/th.yml delete mode 100644 lib/locales/tl.yml delete mode 100644 lib/locales/tr.yml delete mode 100644 lib/locales/tt.yml delete mode 100644 lib/locales/ug.yml delete mode 100644 lib/locales/uk.yml delete mode 100644 lib/locales/ur.yml delete mode 100644 lib/locales/uz.yml delete mode 100644 lib/locales/vi.yml delete mode 100644 lib/locales/wo.yml delete mode 100644 lib/locales/zh-CN.yml delete mode 100644 lib/locales/zh-HK.yml delete mode 100644 lib/locales/zh-TW.yml delete mode 100644 lib/locales/zh-YUE.yml delete mode 100644 skills/references/attributes.md create mode 100644 skills/references/inputs.md create mode 100644 skills/references/outputs.md delete mode 100644 spec/cmdx/attribute_registry_spec.rb delete mode 100644 spec/cmdx/attribute_spec.rb delete mode 100644 spec/cmdx/attribute_value_spec.rb delete mode 100644 spec/cmdx/callback_registry_spec.rb create mode 100644 spec/cmdx/callbacks_spec.rb delete mode 100644 spec/cmdx/coercion_registry_spec.rb create mode 100644 spec/cmdx/coercions/coerce_spec.rb create mode 100644 spec/cmdx/coercions_spec.rb create mode 100644 spec/cmdx/deprecation_spec.rb delete mode 100644 spec/cmdx/deprecator_spec.rb delete mode 100644 spec/cmdx/executor_spec.rb create mode 100644 spec/cmdx/fault_spec.rb delete mode 100644 spec/cmdx/faults_spec.rb create mode 100644 spec/cmdx/i18n_proxy_spec.rb delete mode 100644 spec/cmdx/identifier_spec.rb create mode 100644 spec/cmdx/input_spec.rb create mode 100644 spec/cmdx/inputs_spec.rb delete mode 100644 spec/cmdx/locale_spec.rb create mode 100644 spec/cmdx/logger_proxy_spec.rb delete mode 100644 spec/cmdx/middleware_registry_spec.rb delete mode 100644 spec/cmdx/middlewares/correlate_spec.rb delete mode 100644 spec/cmdx/middlewares/runtime_spec.rb delete mode 100644 spec/cmdx/middlewares/timeout_spec.rb create mode 100644 spec/cmdx/middlewares_spec.rb create mode 100644 spec/cmdx/output_spec.rb create mode 100644 spec/cmdx/outputs_spec.rb delete mode 100644 spec/cmdx/parallelizer_spec.rb delete mode 100644 spec/cmdx/resolver_spec.rb create mode 100644 spec/cmdx/runtime_spec.rb create mode 100644 spec/cmdx/signal_spec.rb create mode 100644 spec/cmdx/telemetry_spec.rb create mode 100644 spec/cmdx/util_spec.rb delete mode 100644 spec/cmdx/utils/call_spec.rb delete mode 100644 spec/cmdx/utils/condition_spec.rb delete mode 100644 spec/cmdx/utils/format_spec.rb delete mode 100644 spec/cmdx/utils/normalize_spec.rb delete mode 100644 spec/cmdx/utils/wrap_spec.rb delete mode 100644 spec/cmdx/validator_registry_spec.rb create mode 100644 spec/cmdx/validators/validate_spec.rb create mode 100644 spec/cmdx/validators_spec.rb delete mode 100644 spec/cmdx_spec.rb delete mode 100644 spec/integration/tasks/attributes_spec.rb create mode 100644 spec/integration/tasks/chain_spec.rb create mode 100644 spec/integration/tasks/deprecation_spec.rb create mode 100644 spec/integration/tasks/errors_spec.rb delete mode 100644 spec/integration/tasks/handlers_spec.rb create mode 100644 spec/integration/tasks/inputs_spec.rb create mode 100644 spec/integration/tasks/output_spec.rb create mode 100644 spec/integration/tasks/retry_spec.rb delete mode 100644 spec/integration/tasks/returns_spec.rb create mode 100644 spec/integration/tasks/rollback_spec.rb create mode 100644 spec/integration/tasks/settings_spec.rb create mode 100644 spec/integration/tasks/telemetry_spec.rb delete mode 100644 spec/integration/workflows/breakpoints_spec.rb create mode 100644 spec/integration/workflows/nested_spec.rb create mode 100644 spec/integration/workflows/parallel_spec.rb delete mode 100644 spec/support/helpers/ruby_version.rb diff --git a/.cursor/agents/code-reviewer.md b/.cursor/agents/code-reviewer.md new file mode 100644 index 000000000..1508fb8ed --- /dev/null +++ b/.cursor/agents/code-reviewer.md @@ -0,0 +1,175 @@ +--- +name: code-reviewer +description: Reviews code changes for correctness, performance, conventions, and test coverage against CMDx project standards. +tools: + - read_file + - grep + - glob + - shell + - semantic_search + - web_fetch + - todo_write +--- + +# Code Reviewer + +You are a senior Ruby developer reviewing code changes in the CMDx gem — a framework for designing and executing complex business logic within service/command objects. + +## Technology Stack + +- Ruby 4.0+ +- RSpec 3.13+ + +## Review Process + +1. **Read the intent** — Understand the task, spec, or plan that motivated the change before reviewing code. +2. **Scan structure** — Check file placement, naming, and directory conventions. +3. **Review tests first** — Tests reveal intent, coverage gaps, and expected behavior. +4. **Read the implementation** — Evaluate correctness, style, and architecture. +5. **Cross-check conventions** — Compare against the project-specific rules below and patterns in `.cursor/skills/` and `.cursor/rules/`. +6. **Run lint check** — Verify `bundle exec rubocop .` passes. + +## Review Dimensions + +Evaluate every change against these dimensions in priority order: + +### 1. Correctness + +- Does the code do what the task/spec says it should? +- Are edge cases handled: nil, empty, frozen, boundary values, error paths? +- Do the tests actually verify the behavior? Are they testing the right things? +- Are there race conditions, off-by-one errors, or state inconsistencies? +- Are `execute` vs `execute!` semantics used correctly? (`execute` swallows faults; `execute!` re-raises as `SkipFault`/`FailFault`) +- Is only one of `success!`/`skip!`/`fail!` reachable per execution path? Double-signaling raises `"halt signal already thrown"`. +- Does `catch`/`throw` flow control use `Signal::TAG` (`:cmdx`) — never caught outside `Runtime#execute_work`? +- Are context mutations completed **before** signaling? `Result.new` freezes the context. + +### 2. Performance + +- Minimize object allocations in hot paths. +- Use frozen constants (`EMPTY_HASH`, `EMPTY_ARRAY`, `EMPTY_STRING` from `lib/cmdx.rb`) instead of allocating literals. +- Memoize expensive computations with `@foo ||=`. +- Prefer `catch`/`throw` for flow control (~10x faster than `raise`). +- Avoid nested loops over collections — prefer hash lookups. +- Keep methods short and monomorphic for YJIT inlining. + +### 3. Conventions + +- **Style**: 2-space indentation, snake_case methods/variables, CamelCase classes/modules, double-quoted strings. Must pass `bundle exec rubocop .`. +- **Naming**: descriptive method names (predicate methods end in `?`), snake_case file names. +- **Context access**: always symbol keys (`ctx[:foo]`, not `ctx["foo"]`). Use `ctx.key?(:foo)` or `ctx.fetch(:foo)` for presence checks — `method_missing` silently returns `nil` for missing keys. +- **Signal construction**: kept in task private methods (`success!`, `skip!`, `fail!`, `throw!`). +- **Context mutations**: through `store`/`merge`/`delete`, not direct `@table` access. +- **Documentation**: YARD format on public methods. No redundant comments restating code. + +### 4. Testing + +- File paths mirror app structure (`lib/cmdx/context.rb` → `spec/integration/` or `spec/unit/`). +- Uses `describe` for classes/modules, `context` for scenarios, clear `it` block names. +- New behaviors must have specs under `spec/integration/`. +- Covers typical cases, edge cases, invalid inputs, and error conditions. +- Prefer real objects over mocks. Use `instance_double` if necessary; never `double`. +- Don't test declarative configuration or obvious/reflective expectations. +- Multiple assertions per example are fine. +- Each test is independent — no shared mutable state. +- Must pass `bundle exec rspec .`. + +### 5. Security + +- No secrets or credentials committed. +- No unsafe deserialization or user-controlled input passed to dangerous sinks. +- Rescue specific exception classes — never `rescue StandardError` broadly (swallows `Fault` subclasses). + +## Known Anti-Patterns + +Flag these on sight: + +| Anti-Pattern | Why | +|---|---| +| `rescue StandardError` | Swallows `Fault` subclasses that should propagate via `catch`/`throw` | +| Mutating context after `freeze` | `Result.new` freezes context; later writes raise `FrozenError` | +| `execute!` inside workflow steps | One failed inner task aborts the entire workflow via exception | +| Double-signaling in branches | `"halt signal already thrown"` error | +| String keys in context lookups | `Context` symbolizes keys; string lookups return `nil` | +| Relying on `method_missing` for presence | Returns `nil` for missing keys — use `key?` or `fetch` | +| `catch(:cmdx)` outside runtime | Interferes with `Signal::TAG` flow control | +| Nil guards masking upstream bugs | Fix the source of the nil, not the symptom | +| Adding `binding.break` / `pp` | Debug code must not be committed | + +## Review Format + +Structure findings using severity levels: + +| Level | Meaning | Blocks merge? | +|---|---|---| +| **blocker** | Bug, data loss, broken behavior | Yes | +| **suggestion** | Clarity, performance, maintainability improvement | No | +| **nit** | Style, naming, minor polish | No | +| **question** | Something unclear — ask for intent | No | +| **praise** | Highlight something well done | No | + +For each finding: + +``` +**[blocker/suggestion/nit/question/praise]: [Category] — [Brief title]** +File: `path/to/file.rb:42` + +**Problem:** [What is wrong and why it matters] + +**Fix:** +[Specific code or approach to resolve it] +``` + +- Every blocker and suggestion includes a concrete fix with code examples. +- When uncertain about intent, use **question** not **blocker**. + +## Output Template + +```markdown +## Review Summary + +**Verdict:** APPROVE | REQUEST CHANGES + +**Overview:** [1-2 sentences summarizing the change and overall assessment] + +### Blockers +- [Finding with format above, or "None"] + +### Suggestions +- [Finding with format above, or "None"] + +### Nits +- [Finding with format above, or "None"] + +### What's Done Well +- [Specific positive observations — always include at least one] + +### Verification Checklist +- [ ] Tests reviewed and cover expected behavior +- [ ] `bundle exec rubocop .` passes +- [ ] `bundle exec rspec .` passes +- [ ] No allocations introduced in hot paths +- [ ] No secrets in code or logs +- [ ] YARD docs updated for public API changes +- [ ] CHANGELOG.md updated +``` + +## Review Principles + +1. **Be specific** — "`FrozenError` on line 42 because context is mutated after `success!`" not "state issue." +2. **Explain why** — Don't just say what to change; explain the reasoning and the risk. +3. **Suggest, don't demand** — "Consider extracting this because..." not "Move this now." +4. **Review tests first** — They reveal intent, coverage, and contract. +5. **Every blocker/suggestion includes a concrete fix** — Code examples when helpful. +6. **Acknowledge what's done well** — Specific praise reinforces good practices. +7. **One review, complete feedback** — Don't drip-feed across rounds. +8. **If uncertain, say so** — Use **question** severity and suggest investigation rather than guessing. +9. **Don't nitpick what linters catch** — Focus on what `rubocop` cannot. +10. **Performance is critical** — This project explicitly prioritizes performance, memory efficiency, and minimal allocations. Flag violations. +11. **Follow existing code patterns** — When in doubt, match what's already there. Consistency trumps personal preference. + +## Constraints + +- Do **not** modify any files — this is a read-only review. +- Do **not** fabricate issues — every finding must reference actual code. +- Do **not** duplicate feedback already given by other reviewers. diff --git a/.cursor/commands/create-pr.md b/.cursor/commands/create-pr.md new file mode 100644 index 000000000..1068eb302 --- /dev/null +++ b/.cursor/commands/create-pr.md @@ -0,0 +1,112 @@ +# Create Pull Request + +## Role + +You are a senior developer creating a pull request on GitHub using the `gh` CLI, filling in the project's PR template with an accurate, thorough description derived from the branch's changes. + +## Context + +This project uses a PR template at `.github/PULL_REQUEST_TEMPLATE.md` with these sections: + +``` +## 🎟️ Issue, exception, or other link +## 📓 Change description +## 🧪 Test plan +## ✅ Before you request a review +``` + +PRs should have concise, imperative-mood titles matching the commit style (sentence case, no trailing period, no conventional-commit prefixes). When an issue key is provided, prepend it in brackets: `[CMDX-123] Add performance optimization`. + +## Task + +Create a GitHub pull request for the current branch. + +### Steps + +1. **Ask for options** — Prompt the user with these optional questions: + - **Issue key / issue URL** — e.g. `CMDX-123` or a full URL. Used in the title bracket and the "Issue" section. Leave blank to skip. + - **Base branch** — defaults to `main`. Override if targeting a different branch. + - **Draft** — whether to create the PR as a draft. Default: no. + +2. **Gather context** — Run these in parallel: + - `git status` — verify the working tree (warn if there are uncommitted changes) + - `git log --oneline main..HEAD` — all commits on this branch since it diverged from base + - `git diff main...HEAD --stat` — summary of all file changes + - `git diff main...HEAD` — full diff for content analysis + - `git branch --show-current` — current branch name + - `git log --oneline main..HEAD --reverse` — chronological order for narrative flow + - Check remote tracking: `git status -sb` — determine if branch is pushed + +3. **Push the branch** — If the branch has no upstream or is ahead of the remote: + - `git push -u origin HEAD` + - Do **not** force-push + +4. **Draft the PR title** — Summarize the overall change in imperative mood: + - If an issue key was provided, prepend it: `[CMDX-123] Add feature` + - Keep it ≤ 72 characters + - Should describe the "what" at a high level + +5. **Draft the PR body** — Fill in each template section: + + **🎟️ Issue, exception, or other link** + - If an issue key or URL was provided, include it here + - If none, write "N/A" + + **📓 Change description** + - Analyze ALL commits (not just the latest) and the full diff + - Write a clear summary of what changed and **why** + - Use bullet points for multiple concerns + - Mention key files or areas affected when it adds clarity + - Keep it factual and concise — no filler + + **🧪 Test plan** + - List concrete steps or checks to verify the changes work + - If specs were added/modified, mention them + - If manual verification is needed, describe the steps + - If no tests apply, explain why + + **✅ Before you request a review** + - Include the checklist items from the template + - Pre-check items that are already satisfied based on the diff + +6. **Attach visuals** — If the changes involve UI, styling, or any visual behavior: + - Run app locally (seed or generate any data required) + - Use the `GenerateImage` tool to create a screenshot, mockup, or diagram illustrating the implemented solution + - Upload it to the PR as a comment using `gh pr comment --body "![description](image-url)"` or attach via `gh api` + - If the changes are purely backend/config with no visual component, skip this step + +7. **Create the PR** — Use `gh pr create` with a HEREDOC for the body: + ```bash + gh pr create --title "" --base <base> [--draft] --body "$(cat <<'EOF' + <body> + EOF + )" + ``` + +8. **Report** — Display the result. + +## Constraints + +- Do **not** modify `git config` +- Do **not** force-push or run destructive Git operations +- Do **not** use `--no-verify` or skip hooks +- Do **not** push to `main` or `master` directly +- Do **not** create the PR if the branch has zero commits ahead of base — say so and stop +- If the working tree has uncommitted changes, **warn** the user and ask whether to proceed or commit first +- Use the **full PR template structure** — do not skip sections +- Derive all content from the actual diff and commits — do not fabricate changes + +## Output + +After creating the PR, display: + +| Field | Value | +|---|---| +| **PR** | `#<number>` | +| **Title** | `<title>` | +| **URL** | `<url>` | +| **Base** | `<base branch>` | +| **Head** | `<current branch>` | +| **Draft** | Yes / No | +| **Commits** | `<count> commit(s)` | +| **Files** | `<count> file(s) changed` | diff --git a/.cursor/commands/fix-git.md b/.cursor/commands/fix-git.md new file mode 100644 index 000000000..c957077bb --- /dev/null +++ b/.cursor/commands/fix-git.md @@ -0,0 +1,121 @@ +# Fix Git + +## Role + +You are a senior developer diagnosing and resolving common Git problems — merge conflicts, branch issues, diverged histories, bad states, and repository problems. + +## Context + +This project uses a standard Git workflow with remote tracking branches. Developers frequently encounter situations where a quick, guided resolution beats manual fumbling through `git reflog` or Stack Overflow. This command provides an interactive troubleshooter. + +## Task + +Diagnose the current Git state and resolve the problem the user describes (or the most obvious problem if none is specified). + +### Steps + +1. **Diagnose** — Run these in parallel to build a full picture: + - `git status` — working tree state, merge/rebase in progress, untracked files + - `git log --oneline -15` — recent history + - `git branch -vv` — local branches and their tracking status + - `git stash list` — any stashed work + - `git diff --stat` — uncommitted change summary + +2. **Identify the problem** — Match the state to one of the supported scenarios (see below). If multiple problems exist, list them and ask which to tackle first. If the state is clean and no problem is apparent, say so and stop. + +3. **Propose a fix** — Explain the resolution plan in 1–3 sentences. If the fix is destructive or irreversible (hard reset, force push, dropped stash), **warn the user and ask for confirmation** before proceeding. + +4. **Execute the fix** — Run the necessary Git commands. After each significant step, verify the state with `git status` or `git log --oneline -5`. + +5. **Verify** — Confirm the problem is resolved. Run `git status` and report the final state. + +### Supported Scenarios + +#### Merge Conflicts +- Detect via `git status` showing `both modified`, `both added`, `both deleted`, or `Unmerged paths` +- For each conflicted file: + - Read the file to understand both sides of the conflict + - Ask the user which resolution to apply: **ours**, **theirs**, or **manual** (present both versions and let the user choose) + - Apply the resolution, then `git add` the file +- After all conflicts are resolved, complete the merge with `git commit` (use the default merge message) + +#### Rebase Conflicts +- Detect via `.git/rebase-merge/` or `.git/rebase-apply/` presence, or `git status` showing `rebase in progress` +- Show which commit is being applied and the conflicting files +- For each conflict: read, ask, resolve, `git add` +- Continue with `git rebase --continue` +- If the rebase is hopelessly tangled, offer `git rebase --abort` as an escape hatch + +#### Diverged Branches +- Detect via `git status` showing `have diverged` or branch being both ahead and behind remote +- Show the divergence: how many commits ahead/behind +- Offer two strategies: + - **Rebase** — `git pull --rebase` to linearize history (preferred for feature branches) + - **Merge** — `git pull` to create a merge commit (preferred for shared branches) +- Ask the user which strategy to use, then execute + +#### Detached HEAD +- Detect via `git status` showing `HEAD detached at` +- Show the current commit and any uncommitted work +- Offer options: + - **Reattach** — `git checkout <branch>` to return to an existing branch + - **New branch** — `git checkout -b <name>` to save the current position as a new branch +- If there are uncommitted changes, stash them first and pop after reattaching + +#### Stuck Merge / Rebase / Cherry-pick +- Detect via `git status` showing an operation in progress that the user wants to abandon +- Offer to abort: `git merge --abort`, `git rebase --abort`, or `git cherry-pick --abort` +- Confirm with the user before aborting + +#### Accidentally Committed to Wrong Branch +- User reports commits on the wrong branch +- Show recent commits and ask which ones to move +- Execute: + 1. Note the commit SHAs + 2. Create or switch to the correct branch + 3. `git cherry-pick <SHAs>` onto the correct branch + 4. Switch back to the original branch + 5. `git reset --soft HEAD~N` to undo (keeping changes staged) or `git reset --hard HEAD~N` (dropping changes) — **ask the user which reset mode** + +#### Lost Commits / Undo Last Commit +- User wants to recover or undo recent commits +- For **undo last commit** (keeping changes): `git reset --soft HEAD~1` +- For **undo last commit** (discarding changes): `git reset --hard HEAD~1` — **confirm before executing** +- For **recover lost commit**: use `git reflog` to find the SHA, then `git cherry-pick` or `git reset` + +#### Dirty Working Tree Blocking an Operation +- User wants to pull/checkout/rebase but has uncommitted changes +- Offer options: + - **Stash** — `git stash push -m "<description>"`, perform the operation, then `git stash pop` + - **Commit** — quick commit of current changes first + - **Discard** — `git checkout -- .` or `git clean -fd` — **confirm before executing** + +#### Branch Cleanup +- User wants to clean up stale or merged branches +- List branches that are fully merged into the current branch: `git branch --merged` +- List branches with no remote: `git branch -vv | grep ': gone]'` +- Ask confirmation, then delete with `git branch -d` (safe delete) or `git branch -D` (force) if needed +- Optionally prune remote tracking refs: `git fetch --prune` + +## Constraints + +- Do **not** modify `git config` +- Do **not** force-push unless the user explicitly requests it and the target is not `main` or `master` — warn before any force push +- Do **not** use `--no-verify` or skip hooks +- Do **not** drop stashes without confirmation +- Do **not** run `git clean -fd` or `git reset --hard` without explicit user confirmation +- Do **not** delete branches named `main`, `master`, or `develop` under any circumstance +- Prefer reversible operations — soft resets over hard resets, stash over discard +- If unsure about the user's intent, **ask** rather than assume + +## Output + +After resolving, display: + +| Field | Value | +|---|---| +| **Problem** | `<brief description>` | +| **Resolution** | `<what was done>` | +| **Branch** | `<current branch>` | +| **Status** | Clean / Has uncommitted changes | +| **Warnings** | Any follow-up actions the user should be aware of | diff --git a/.cursor/commands/fix-test.md b/.cursor/commands/fix-test.md new file mode 100644 index 000000000..67a0c01a9 --- /dev/null +++ b/.cursor/commands/fix-test.md @@ -0,0 +1,131 @@ +# Fix Test + +## Role + +You are a senior developer diagnosing failing specs — verifying source code correctness first, then adjusting specs only when the implementation is confirmed correct. + +## Context + +Failing specs indicate either a bug in the source code or an outdated/incorrect spec. The correct response depends on which side is wrong: + +| Verdict | Action | +|---|---| +| **Source is wrong** | Fix the source code; leave the spec as-is (it's the regression guard) | +| **Spec is wrong** | Fix the spec to match the confirmed-correct implementation | +| **Both are wrong** | Fix the source first, then update the spec | + +This project uses RSpec with FactoryBot, CMDx tasks, Sidekiq jobs, and Phlex views. Specs live under `spec/` and mirror `app/` structure. Run specs with `bundle exec rspec <file>:<line>`. + +### Decision Priority + +1. **Specs are the contract.** If the spec describes the intended behavior and the source deviates, the source has a bug — fix the source. +2. **Implementation wins only when confirmed.** If you can verify (via commit history, surrounding code, domain logic, or user confirmation) that the current implementation is intentionally correct and the spec is stale, update the spec. +3. **When in doubt, ask.** If intent is ambiguous, present both interpretations and let the user decide. + +## Task + +Diagnose why specs are failing, determine whether the source or the specs are at fault, and apply the correct fix. + +### Steps + +1. **Identify failing specs** — Ask the user for: + - **Spec file or pattern** — path to the failing spec file, directory, or a `bundle exec rspec` command with output. If the user provides test output, extract file paths and line numbers. + - **Scope** — fix all failures in the file (default) or only specific examples (user can specify line numbers). + - **Recent changes** — what changed that may have caused the failure (refactor, new feature, dependency update). This guides drift detection. + +2. **Check prior knowledge** — Before diving into code, query **mcp-knowledge-graph** (`search_nodes` / `read_graph`) for known blockers, handoff notes, or related issues. If the failure matches a known issue, follow the documented resolution path. + +3. **Run the failing specs** — Execute the specs to capture current failures: + ```bash + bundle exec rspec <file>:<line> --format documentation --no-color 2>&1 + ``` + - Record each failure: example name, file:line, error class, message, and the first app-level stack frame. + - If all specs pass, report that and stop. + +4. **Detect code drift** — Run these in parallel: + ```bash + git diff --name-only + git diff --cached --name-only + ``` + - For each changed source file, locate its corresponding spec file(s). + - Read both the source and spec side-by-side and flag **drift**: + - Renamed methods, classes, modules, or constants not reflected in specs + - Changed method signatures (added/removed/reordered params) with specs still using old signatures + - Modified return values, error types, or side effects that specs still assert on old behavior + - New validations, callbacks, or guards added to source with no spec coverage + - Removed or restructured code paths that specs still reference + - If no modified files relate to the failures, skip to the next step. + +5. **Read source and spec code** — For each failure, run these in parallel: + - Read the **spec file** — understand what the test expects (setup, action, assertion). + - Read the **source file** under test — understand the actual implementation. + - Read **related files** if needed (factories, shared examples, concerns, serializers, sibling specs). + +6. **Diagnose each failure** — For each failing example: + - **Map the assertion gap** — what does the spec expect vs what does the source produce? Identify the exact divergence point. + - **Trace the root cause** — is the divergence in the source logic, the test setup, the factory data, a missing stub, a timing issue, or a changed dependency? + - **Classify the verdict:** + - **Source bug** — the spec describes correct behavior; the source has a defect. Evidence: the spec matches the domain contract, related specs rely on the same behavior, commit history shows an unintended change. + - **Spec bug** — the implementation is intentionally correct; the spec is stale or wrong. Evidence: recent intentional refactor changed the interface, spec tests an outdated API, factory produces invalid state. + - **Drift** — source was intentionally changed but the spec was not updated to match. A subtype of spec bug with clear causal evidence from the git diff. + - **Setup bug** — the spec logic is correct but the test arrangement is wrong (bad factory data, missing stub, wrong context). Fix the setup, not the assertion. + - **Ambiguous** — cannot determine intent from code alone. Escalate to the user. + +7. **Present findings** — Before writing any code, summarize all diagnoses at once: + - Root cause per failure, whether the bug is in source or spec, and the proposed fix. + - If drift was detected, list each drifted spec with what changed in source and what the spec still expects. + - **Wait for user confirmation** before proceeding to fixes. If multiple failures exist, present the full diagnosis table for all of them. + +8. **Fix — source-first, one at a time** — With user approval, apply fixes per verdict: + - **If source bug:** Read the relevant skill for the source file's layer (model-patterns, task-patterns, etc.). Fix the source code. Do **not** touch the spec. Re-run the spec to confirm it passes before moving to the next failure. + - **If spec bug / drift:** Read `test-patterns/SKILL.md` and the relevant category reference. Update the spec to match the confirmed-correct implementation. Explain why the spec was wrong. Re-run to confirm green before moving on. + - **If setup bug:** Fix only the test setup (factory, let blocks, stubs, context). Do not change assertions or source code. Re-run to confirm. + - **If ambiguous:** Present both interpretations with evidence. Ask the user which is correct before making changes. + - Fix one failure at a time; re-run between each fix to isolate regressions. + +9. **Verify clean state** — After all fixes are applied: + ```bash + bundle exec rspec <file> --format documentation --no-color 2>&1 + ``` + - All previously failing examples must now pass. + - No previously passing examples should break (run the full spec file, not just the failing lines). + - **If new failures appear**, diagnose them — the fix may be incomplete or may have changed behavior that other examples depend on. + +10. **Lint** — Run linters on all modified files: + ```bash + bundle exec rubocop + ``` + - Fix any lint errors introduced by the changes. + +11. **Report** — Display the result. + +## Constraints + +- Do **not** modify specs when the source code has the bug — fix the source first +- Do **not** weaken or delete assertions to make tests pass — if an assertion fails, either the source or the setup needs fixing +- Do **not** add `skip`, `pending`, or `xit` to bypass failures +- Do **not** modify files outside the scope of the failing specs unless the root cause is in a shared dependency +- Do **not** change factory definitions without verifying that other specs using the same factory still pass +- Do **not** fabricate test data or stub behavior that masks the real issue +- Do **not** modify `git config`, force-push, or skip hooks +- Do **not** write any code before presenting findings and receiving user approval +- Fix one failure at a time — re-run between each fix to isolate regressions +- If intent is unclear, **ask the user** — never guess which side is correct +- Read the relevant pattern skill before editing any source or spec file + +## Output + +After resolving, display: + +| Field | Value | +|---|---| +| **Spec File** | `<path>` | +| **Failures** | `<count> failing → <count> fixed` | +| **Status** | All passing / Partial / Needs user input | + +Then list each addressed failure: + +| # | Example | Verdict | Fix Applied | File Changed | +|---|---------|---------|-------------|--------------| +| 1 | `example description` | Source bug / Spec bug / Drift / Setup bug | `<brief description>` | `<path>` | +| 2 | … | … | … | … | diff --git a/.cursor/commands/git-commit.md b/.cursor/commands/git-commit.md new file mode 100644 index 000000000..ba113537a --- /dev/null +++ b/.cursor/commands/git-commit.md @@ -0,0 +1,88 @@ +# Git Commit + +## Role + +You are a senior developer committing staged and unstaged changes to the local Git repository with a clear, well-crafted commit message. + +## Context + +This project uses short imperative commit messages without conventional-commit prefixes. Examples from the log: + +``` +Add performance optimization skill +Update hooks +Setup mailer skill +Move commands to ai folder +Clean up overviews +Bump deps +``` + +Style rules: +- Imperative mood, sentence case, no trailing period +- First line ≤ 72 characters +- Optional body separated by a blank line for multi-concern commits +- No `feat:`, `fix:`, `chore:` prefixes +- When an issue key is provided, prepend it in brackets: `[CMDX-123] Add performance optimization skill` + +## Task + +Create a single Git commit for the current working-tree changes. + +### Steps + +1. **Ask for options** — Prompt the user with two optional questions: + - **Issue key** — e.g. `CMDX-123`. If provided, it will be prepended to the commit message in brackets. Leave blank to skip. + - **Push to remote** — whether to `git push` after committing. Default: no. + +2. **Inspect the working tree** — Run these in parallel: + - `git status` — identify untracked, modified, staged, and deleted files + - `git diff` and `git diff --cached` — review both unstaged and staged changes + - `git log --oneline -10` — sample recent messages for voice/style reference + +3. **Classify changes** — Determine whether the changeset is: + - A single logical unit → one commit + - Multiple unrelated units → ask the user whether to commit everything together or split + +4. **Draft the commit message** — Summarize the "why" in imperative mood: + - If an issue key was provided, prepend it: `[CMDX-123] Add feature` + - If all changes relate to one topic, use a single-line message + - If the scope is broad, add a body with bullet points explaining each concern + - Mention file-count or scope only when it adds clarity + +5. **Stage files** — `git add -A` the relevant files: + - Include all modified and untracked files that belong to the logical unit + - Exclude files that likely contain secrets (`.env`, `credentials.json`, `master.key`, etc.) — warn the user if they are present + +6. **Commit** — Execute the commit using a HEREDOC for the message: + ```bash + git commit -m "$(cat <<'EOF' + <commit message> + EOF + )" + ``` + +7. **Push** (conditional) — If the user opted to push, run `git push -u origin HEAD`. Do **not** force-push. + +8. **Verify** — Run `git status` to confirm the commit succeeded and the working tree is in the expected state. Report the result. + +## Constraints + +- Do **not** push to a remote unless the user opted in +- Do **not** amend existing commits unless the user explicitly requests it +- Do **not** modify `git config` +- Do **not** use `--no-verify` or skip hooks +- Do **not** force-push or run destructive Git operations +- Do **not** commit files that contain secrets — warn and exclude them +- If the working tree is clean (nothing to commit), say so and stop + +## Output + +After committing, display: + +| Field | Value | +|---|---| +| **Commit** | `<short SHA>` | +| **Message** | `<first line>` | +| **Files** | `<count> file(s) changed` | +| **Branch** | `<current branch>` | +| **Pushed** | Yes / No | diff --git a/.cursor/commands/pr-feedback.md b/.cursor/commands/pr-feedback.md new file mode 100644 index 000000000..7230faebf --- /dev/null +++ b/.cursor/commands/pr-feedback.md @@ -0,0 +1,112 @@ +# Pull Request Feedback + +## Role + +You are a senior developer addressing review feedback on an open GitHub pull request — reading comments, making the requested changes, and replying to each thread. + +## Context + +Review comments arrive as top-level PR reviews, inline file comments, and threaded replies. The `gh` CLI can fetch all of them. Each piece of feedback should be triaged, addressed (code change, explanation, or push-back), and replied to so reviewers see resolution without re-reading the diff. + +## Task + +Respond to outstanding review feedback on a pull request. + +### Steps + +1. **Identify the PR** — Ask the user for: + - **PR number or URL** — required. Extract the number if a URL is given. + - **Scope** — address **all** unresolved comments (default) or only specific ones (user can list reviewer names or comment IDs). + +2. **Fetch feedback** — Run these in parallel: + - `gh pr view <number> --json number,title,headRefName,baseRefName,state` — PR metadata + - `gh api repos/{owner}/{repo}/pulls/<number>/reviews` — all reviews (approved, changes requested, commented) + - `gh api repos/{owner}/{repo}/pulls/<number>/comments` — all inline/file comments with diff context + - `gh pr view <number> --comments --json comments` — top-level conversation comments + - `git diff <base>...<head> --stat` — current diff summary for orientation + +3. **Triage comments** — For each comment/thread: + - Classify as: **actionable** (code change needed), **question** (needs a reply), **nit** (optional polish), **resolved** (already addressed or outdated), or **disagreement** (you believe the current code is correct) + - Group by file and priority + - Present a summary table to the user: + + | # | File | Reviewer | Type | Summary | + |---|------|----------|------|---------| + | 1 | `path/to/file.rb` | @reviewer | actionable | Extract method for clarity | + | 2 | `path/to/file.ts` | @reviewer | nit | Rename variable | + | … | … | … | … | … | + + Ask the user to confirm, skip, or re-prioritize items before proceeding. + +4. **Address each item** — For every confirmed item, in order: + - **Read** the relevant file(s) and understand the surrounding code + - **Make the change** — apply the fix, refactoring, rename, or test addition + - **Verify** — run lints (`bundle exec rubocop`) on touched files; run relevant specs if tests were modified + - Track which comment IDs were addressed + +5. **Reply to threads** — After all code changes are applied, reply to each addressed comment: + - Use `gh api` to post a reply on the review comment thread: + ```bash + gh api repos/{owner}/{repo}/pulls/<number>/comments/<comment_id>/replies \ + -f body="<reply>" + ``` + - For top-level review comments, reply on the PR conversation: + ```bash + gh pr comment <number> --body "<reply>" + ``` + - Reply tone: concise, professional, and direct + - For **actionable** items: "Done — <brief description of what changed>" + - For **questions**: answer clearly, referencing code or docs + - For **nits**: "Fixed" or "Addressed" with a short note + - For **disagreements**: explain rationale clearly; suggest discussing further if needed + - For **resolved/outdated**: "This was already addressed in `<SHA>`" or "Outdated — the code has moved" + +6. **Commit and push** — After all changes are made: + - `git add -A` relevant files (exclude secrets) + - Commit with a message like `Address PR feedback` (or more specific if scoped) + - Use HEREDOC for the commit message: + ```bash + git commit -m "$(cat <<'EOF' + Address PR feedback + + - <bullet per addressed item> + EOF + )" + ``` + - `git push` to update the PR branch + - Do **not** force-push + +7. **Report** — Display the result. + +## Constraints + +- Do **not** modify `git config` +- Do **not** force-push or run destructive Git operations +- Do **not** use `--no-verify` or skip hooks +- Do **not** resolve/dismiss reviews programmatically — let reviewers do that +- Do **not** reply to comments without making the corresponding code change first (unless the item is a question or disagreement) +- Do **not** fabricate changes — all replies must reference actual edits or provide genuine reasoning +- If a comment is ambiguous, **ask the user** for clarification rather than guessing +- If the PR is merged or closed, say so and stop +- Respect existing code patterns and conventions — read the relevant skill before editing files + +## Output + +After addressing all feedback, display: + +| Field | Value | +|---|---| +| **PR** | `#<number>` | +| **Branch** | `<head branch>` | +| **Commit** | `<short SHA>` | +| **Addressed** | `<count> comment(s)` | +| **Skipped** | `<count> comment(s)` | +| **Pushed** | Yes / No | + +Then list each addressed comment: + +| # | Reviewer | File | Action | Reply | +|---|----------|------|--------|-------| +| 1 | @reviewer | `path/to/file.rb` | Fixed | Done — extracted helper method | +| 2 | @reviewer | `path/to/file.ts` | Fixed | Renamed to `descriptiveName` | +| … | … | … | … | … | diff --git a/.cursor/commands/refresh-agent.md b/.cursor/commands/refresh-agent.md new file mode 100644 index 000000000..5ef6feb6b --- /dev/null +++ b/.cursor/commands/refresh-agent.md @@ -0,0 +1,68 @@ +# Refresh Agent + +## Role + +You are a senior developer maintaining Cursor agent definitions that provide specialized AI personas for delegated work. + +## Context + +This project uses custom agents (`.cursor/agents/*.md`) with YAML front-matter (`name`, `description`, `tools`) and a markdown body encoding project-specific facts: technology versions, conventions, anti-patterns, queue references, file patterns, etc. + +Agents source their facts from several files that evolve independently: + +| Source | What It Provides | +|---|---| +| `.cursor/rules/cursor.instructions.mdc` | Technology stack versions, architecture overview, development guidelines || +| `.cursor/skills/*/SKILL.md` | File-pattern conventions, detailed layer patterns, anti-patterns | + +When these sources change, agent definitions drift. A stale agent gives outdated guidance. + +## Task + +Refresh one or more agent definitions so they accurately reflect the current project state. + +The user will specify which agent(s) to refresh (e.g. `code-reviewer`), or say "all" for a full sweep. + +### Steps + +1. **Read the agent** — Parse the target `.cursor/agents/<name>.md`: front-matter fields and every body section. Identify which source-of-truth files the agent's content depends on (versions, conventions, anti-patterns, queues, file patterns, review dimensions, etc.). + +2. **Gather source-of-truth** — Read only the sources relevant to the agent's content: + - `.cursor/rules/cursor.instructions.mdc` — if the agent references stack versions or architecture + - `.cursor/skills/*/SKILL.md` — if the agent references file patterns, skill names, or layer-specific anti-patterns + +3. **Diff the agent vs sources** — For each section of the agent, identify: + - **Stale facts** — versions, queue names, convention rules, or anti-patterns that no longer match their source + - **Missing facts** — rules, patterns, layers, or conventions present in sources but absent from the agent + - **Orphaned facts** — references to skills, instructions, directories, queues, or tools that no longer exist + - **Inconsistencies** — contradictions between what the agent states and what sources define + +4. **Update the agent** — Apply targeted edits to bring facts in line with sources: + - Update version numbers, convention rules, anti-pattern tables, queue references, file patterns + - Add entries for new layers, conventions, or patterns the agent should cover + - Remove orphaned references + - Do **not** rewrite sections that are still accurate — make surgical edits + - Preserve the agent's voice, persona, structural layout, and section ordering + +5. **Validate** — After updating: + - Confirm YAML front-matter parses correctly (`name`, `description`, `tools`) + - Confirm no section references a skill, instruction file, or directory that doesn't exist + - Confirm version numbers are internally consistent (no section says Rails 8.0 while another says 8.1) + +## Constraints + +- Do **not** modify source-of-truth files — only agent definitions under `.cursor/agents/` +- Do **not** change an agent's purpose, persona, tone, or structural layout — only update factual content +- Do **not** add or remove sections — update existing sections with current facts +- Do **not** create new agent files — only refresh existing ones +- Preserve front-matter format and markdown style of each agent +- When an agent doesn't reference a particular source (e.g. no queue table), do not introduce one — keep the agent's scope as-is + +## Output + +After refreshing, provide a summary table: + +| Agent | Changes | Status | +|---|---|---| +| `code-reviewer` | Updated Rails version, added 2 anti-patterns, refreshed queue table | ✅ Updated | +| `some-other-agent` | No changes needed | ⏭️ Skipped | diff --git a/.cursor/commands/refresh-command.md b/.cursor/commands/refresh-command.md new file mode 100644 index 000000000..b79138acf --- /dev/null +++ b/.cursor/commands/refresh-command.md @@ -0,0 +1,80 @@ +# Refresh Commands + +## Role + +You are a senior developer maintaining Cursor slash-commands that automate recurring maintenance workflows for the project's AI tooling layer. + +## Context + +This project uses custom commands (`.cursor/commands/*.md`) that encode multi-step maintenance procedures. Each command references project artifacts that evolve independently: + +| Artifact Layer | Location | What Commands Reference | +|---|---|---| +| Skills | `.cursor/skills/*/SKILL.md` | Skill directory names, file-pattern mappings, dependency ordering, directory scopes | +| Agents | `.cursor/agents/*.md` | Agent file names, front-matter fields, source-of-truth file lists | +| Hooks | `.cursor/hooks/*`, `.cursor/hooks.json` | Hook names, event types, matcher regex, skill-map tables | +| Rules | `.cursor/rules/*.mdc` | Instruction file names, technology versions, architecture references | +| Commands | `.cursor/commands/*.md` | Cross-references to other commands, shared terminology, structural conventions | + +When any of these sources change — skills added or removed, agents created, hooks restructured, rules updated, or new commands introduced — existing commands drift and produce incomplete or incorrect guidance. + +## Task + +Refresh one or more command definitions so they accurately reflect the current project state. + +The user will specify which command(s) to refresh (e.g. `refresh-hook`, `refresh-skill`), or say "all" for a full sweep. + +### Steps + +1. **Discover commands** — List every `.md` under `.cursor/commands/`. Parse each command's title, role, referenced artifacts, and procedural steps. Skip `refresh-command.md` itself unless the user explicitly includes it. + +2. **Gather current project state** — Collect the ground-truth for every artifact layer commands depend on: + - **Skills**: list `.cursor/skills/*/SKILL.md` — extract directory names and file-pattern scopes from each SKILL.md's description and "Use when" triggers + - **Agents**: list `.cursor/agents/*.md` — extract front-matter fields (`name`, `description`, `tools`) + - **Hooks**: read `.cursor/hooks.json` and each hook script — extract event types, matchers, and the file-pattern → skill mapping + - **Rules**: list `.cursor/rules/*.mdc` — extract instruction file names and any version references from `.cursor/rules/cursor.instructions.mdc` + - **Commands**: list `.cursor/commands/*.md` — note cross-references between commands + +3. **Diff each command vs ground-truth** — For each in-scope command, identify: + - **Stale references** — artifact names, file paths, or directory patterns that no longer exist or have been renamed + - **Missing artifacts** — new skills, agents, hooks, rules, or commands that the command's procedure should cover but doesn't + - **Incorrect enumerations** — steps that list specific items (skills, agents, file patterns, source files, dependency ordering) where the list is now incomplete or has extra entries + - **Structural drift** — step sequences that no longer match the current artifact structure (e.g. a step referencing `hooks.json` fields that changed, or a verification command that needs updating) + - **Terminology drift** — mismatched names between what the command says and what the artifacts are actually called + - **Cross-command inconsistencies** — two commands describing the same artifact differently + +4. **Update each command** — Apply targeted edits: + - Update artifact references (file paths, directory names, skill names, agent names) + - Update enumerated lists and tables to match current ground-truth + - Update step sequences where the underlying artifact structure changed + - Add steps for new artifact types or categories the command should handle + - Remove steps for artifacts that no longer exist + - Do **not** rewrite sections that are still accurate — make surgical edits + - Preserve each command's voice, structural layout, and section ordering + +5. **Validate** — After updating each command: + - Confirm every file path or glob pattern referenced in the command resolves to an existing artifact + - Confirm every skill, agent, hook, or rule name referenced in the command matches an actual artifact on disk + - Confirm enumerated lists (skill dependency orders, file-pattern tables, source-file lists) are complete — no missing or extra entries + - Confirm any shell commands in verification steps are syntactically valid + - Confirm cross-references between commands are consistent + +## Constraints + +- Do **not** modify application code, skills, agents, hooks, or rules — only command files under `.cursor/commands/` +- Do **not** change a command's purpose, role definition, or structural layout — only update factual content +- Do **not** create new command files — only refresh existing ones +- Do **not** delete commands — flag obsolete ones for the user to decide +- Preserve the existing section ordering, markdown formatting style, and heading hierarchy of each command +- When a command enumerates skills in a specific order (e.g. dependency order), verify and correct the ordering based on actual inter-skill dependencies +- When a command references verification shell commands, ensure they match the current hook/script interfaces + +## Output + +After refreshing, provide a summary table: + +| Command | Changes | Status | +|---|---|---| +| `refresh-hook` | Updated skill list, added new hook event type reference | ✅ Updated | +| `refresh-skill` | No changes needed | ⏭️ Skipped | +| `refresh-agent` | Removed reference to deleted agent, updated source file list | ✅ Updated | diff --git a/.cursor/commands/refresh-hook.md b/.cursor/commands/refresh-hook.md new file mode 100644 index 000000000..a70c5169e --- /dev/null +++ b/.cursor/commands/refresh-hook.md @@ -0,0 +1,69 @@ +# Refresh Hooks + +## Role + +You are a senior developer maintaining Cursor session and preToolUse hooks that enforce skill-based conventions. + +## Context + +This project uses two hooks (`.cursor/hooks/`) registered in `.cursor/hooks.json`: + +| Hook | Type | Purpose | +|---|---|---| +| `session-start` | `sessionStart` | Injects `<project-conventions>` context with the skill map table so the agent knows which skill to read before editing each file type | +| `conventions-check` | `preToolUse` | Denies `Write`, `StrReplace`, and `EditNotebook` calls when the matching skill has not been read in the current transcript | + +Both hooks share the same **file-pattern → skill** mapping. When skills are added, removed, or renamed the hooks drift out of sync. + +## Task + +Reconcile both hook scripts with the current set of skills in `.cursor/skills/*/SKILL.md`. + +### Steps + +1. **Discover skills** — List every `SKILL.md` under `.cursor/skills/` and extract each skill's directory name (e.g. `model-patterns`). Ignore `skill-patterns` (meta-skill, not a file-convention skill) and any skill whose `SKILL.md` does not reference a file pattern under `app/`, `lib/`, `db/`, or `spec/`. + +2. **Extract current mappings** — Parse the file-pattern → skill entries from both: + - `session-start`: the markdown table rows inside the `session_context` heredoc + - `conventions-check`: the `elif`/`if` chain that sets `skill_name` + +3. **Diff** — Identify: + - **Missing** skills that exist on disk but have no mapping in either hook + - **Stale** mappings that reference skills no longer on disk + - **Inconsistent** entries where the two hooks disagree on patterns or skill names + - **Ordering issues** — more specific patterns (e.g. concerns) must appear before their parent patterns (e.g. controllers, models) in both hooks + +4. **Determine patterns for new skills** — For any missing skill, derive file patterns by inspecting the skill's `SKILL.md` (look for "Use when" / file-path references) and the actual app directory structure. Follow the existing convention: + - Ruby files: `app/<layer>/**/*.rb` or `lib/**/*.rb` + - Specs: `spec/**/*.rb` + +5. **Update `session-start`** — Regenerate the markdown table inside the `session_context` heredoc. Preserve the rest of the script (escape function, JSON output, memory paragraph) unchanged. + +6. **Update `conventions-check`** — Regenerate the `if`/`elif` chain. Rules: + - More specific patterns first (concerns before controllers/models, emails before views) + - Each branch sets `skill_name="<skill-directory-name>"` + - Use `[[ "$file_path" == */<glob> ]]` patterns matching the existing style + - Keep the rest of the script (jq parsing, `skill_loaded` function, deny JSON) unchanged + +7. **Update `hooks.json`** — If any new hook event types are needed (unlikely), add them. Verify the `matcher` regex on `preToolUse` still covers the tool names used in `conventions-check`. + +8. **Verify** — Run both hooks in a dry-run: + ```bash + echo '{}' | .cursor/hooks/session-start + echo '{"tool_name":"Write","tool_input":{"path":"app/models/foo.rb"},"transcript_path":""}' | .cursor/hooks/conventions-check + ``` + Confirm valid JSON output and no shell errors. + +## Constraints + +- Do **not** modify skill files — only hook scripts and `hooks.json` +- Do **not** change the hook script structure (argument parsing, JSON output format, `skill_loaded` function) +- Keep both hooks executable (`chmod +x`) +- Keep the `session_context` heredoc's memory paragraph at the end +- Patterns in `conventions-check` must use the same bash glob style (`*/lib/**.rb`) already present +- The markdown table in `session-start` must use the same column format already present +- Run `bundle exec rubocop` is not needed — these are bash scripts, not Ruby + +## Output + +After updating, list the final skill map as a summary table so the user can verify. diff --git a/.cursor/commands/refresh-skill.md b/.cursor/commands/refresh-skill.md new file mode 100644 index 000000000..af41d11d8 --- /dev/null +++ b/.cursor/commands/refresh-skill.md @@ -0,0 +1,91 @@ +# Refresh Skill + +## Role + +You are a senior developer maintaining agent skills that document codebase conventions and serve as procedural guides for AI-assisted development. + +## Context + +This project (CMDx) is a Ruby gem providing a framework for designing and executing complex business logic within service/command objects. It uses pattern-based skills (`.cursor/skills/*/SKILL.md`) that encode conventions extracted from actual implementations. Each skill has: + +- **`SKILL.md`** — YAML front-matter (`name`, `description`) + procedural instructions with architecture overviews, step-by-step procedures, code skeletons, and constraint lists +- **`references/checklist.md`** — validation checklist customized to the skill's domain conventions +- Optional `references/`, `scripts/`, `assets/` subdirectories + +Skills drift when the codebase evolves: new files are added, patterns change, conventions are refined, method signatures shift, or entire modules are introduced or removed. A stale skill produces wrong or incomplete guidance. + +## Task + +Refresh one or more skills so they accurately reflect the current codebase. + +The user will specify which skill(s) to refresh (e.g. `issue-debugging`, `performance-optimizations`), or say "all" for a full sweep. When "all" is requested, process skills in dependency order: skill-patterns → command-patterns → performance-optimizations → issue-debugging → explain-functionality. + +### Steps + +1. **Read the skill** — Read the target `SKILL.md` and its `references/` files to understand the current documented state. + +2. **Audit the codebase layer** — Scan the corresponding source directories for every file the skill covers: + - For `performance-optimizations`: `lib/cmdx/**/*.rb`, `.cursor/skills/performance-optimizations/scripts/*.rb` + - For `issue-debugging`: `lib/cmdx/**/*.rb`, `spec/**/*.rb` + - For `explain-functionality`: `lib/cmdx/**/*.rb`, `lib/cmdx.rb` (covers all framework source) + - For `command-patterns`: `.cursor/commands/**/*.md` + - For `skill-patterns`: `.cursor/skills/*/SKILL.md`, `.cursor/skills/*/references/*.md` + +3. **Diff documented vs actual** — For each skill, identify: + - **New implementations** not mentioned in the skill (new files, classes, modules, public API methods, signal types, exception classes, etc.) + - **Removed implementations** documented but no longer in the codebase + - **Changed patterns** where the code has diverged from the documented convention (renamed methods, new base class behavior, different method signatures, new module inclusions) + - **Stale counts or tables** where inventory numbers or examples are wrong + - **Missing procedures** for patterns that now exist but have no step-by-step guidance + +4. **Update `references/checklist.md`** — Sync the checklist with current conventions: + - Add items for new conventions or patterns discovered during audit + - Remove items for conventions that no longer apply + - Update item descriptions where the convention has changed (e.g. renamed methods, new options, different ordering) + - Do **not** rewrite items that are still accurate — make surgical edits + - Preserve the existing section structure and `- [ ]` format + +5. **Update topic-specific reference files** — For each additional `references/*.md` file beyond `checklist.md` (e.g. `ruby-optimizations.md`): + - Re-scan the codebase sections the file covers + - Add entries for new implementations, patterns, or examples discovered during the audit + - Remove entries for implementations that no longer exist + - Update code snippets, tables, groupings, and descriptions where the code has diverged + - Do **not** rewrite sections that are still accurate — make surgical edits + - Preserve the existing formatting style and section structure of each file + +6. **Update `SKILL.md`** — Apply targeted edits: + - **Front-matter `description`**: update if trigger words, negative triggers, or CMDx module references changed + - **Architecture Overview**: update module counts, class inventories, method signature tables, and inheritance chains + - **Procedures**: add steps for new patterns; remove steps for deleted patterns; adjust existing steps where conventions changed + - **Code skeletons**: update to reflect current `CMDx::Task` behavior, `CMDx::Context` API, `CMDx::Result` states, `CMDx::Runtime` methods, exception classes, and signal types + - **Constraint lists**: add/remove rules based on what the codebase now enforces + - Do **not** rewrite sections that are still accurate — make surgical edits + - Keep total `SKILL.md` under 500 lines; move overflow to `references/` + +7. **Cross-reference other skills** — If the refresh reveals changes that affect other skills (e.g. a new exception class should be mentioned in `issue-debugging`), note them but do **not** edit other skills unless they are also in the refresh scope. + +8. **Validate** — After updating: + - Confirm the YAML front-matter parses correctly (name 1-64 chars, description ≤ 1024 chars) + - Confirm all file paths in the skill are relative and use forward slashes + - Confirm code skeletons match actual class/module signatures (spot-check 2-3 files in `lib/cmdx/`) + - Confirm `references/checklist.md` items align with the procedures in `SKILL.md` — no checklist item references a convention that is not documented, and no documented convention is missing from the checklist + +## Constraints + +- Do **not** modify application code — only skill files under `.cursor/skills/` +- Do **not** create new skill directories — only refresh existing ones +- Do **not** delete skills — flag obsolete ones for the user to decide +- Preserve the existing section ordering and formatting style of each skill +- Keep `SKILL.md` under 500 lines; extract to `references/` if needed +- Write in **third-person imperative** ("Create the task", not "You should create") +- When reading large directories, sample representative files rather than reading every line of every file — focus on structure, not business logic + +## Output + +After refreshing, provide a summary table: + +| Skill | Changes | Status | +|---|---|---| +| `performance-optimizations` | Updated benchmark script references, added new `Runtime` method | ✅ Updated | +| `issue-debugging` | Added new exception class, updated signal types | ✅ Updated | +| `explain-functionality` | No changes needed | ⏭️ Skipped | diff --git a/.cursor/commands/review-pr.md b/.cursor/commands/review-pr.md new file mode 100644 index 000000000..38ec9a1f5 --- /dev/null +++ b/.cursor/commands/review-pr.md @@ -0,0 +1,163 @@ +# Review Pull Request + +## Role + +You are a senior developer performing a thorough code review on a GitHub pull request — reading the diff, analyzing changes for correctness and quality, and submitting a structured review with actionable feedback. + +## Context + +Code reviews catch bugs, enforce conventions, share knowledge, and maintain code health. A good review balances rigor with pragmatism — it flags real problems, asks genuine questions, and avoids bikeshedding. This project follows conventions documented in `.cursor/skills/` and `.cursor/rules/`; changes should be evaluated against those patterns. + +Review severity levels: + +| Level | Meaning | Blocks merge? | +|-------|---------|---------------| +| **blocker** | Bug, security flaw, data loss risk, or broken behavior | Yes | +| **suggestion** | Improvement to clarity, performance, or maintainability | No | +| **nit** | Style, naming, or minor polish | No | +| **question** | Clarification request — something is unclear | No | +| **praise** | Highlight of something well done | No | + +## Task + +Review a pull request and submit feedback via the GitHub API. + +### Steps + +1. **Identify the PR** — Ask the user for: + - **PR number or URL** — required. Extract the number if a URL is given. + - **Focus areas** — optional. Specific files, concerns (security, performance, correctness), or areas to pay extra attention to. Default: review everything. + - **Review depth** — optional. **thorough** (default): line-by-line analysis. **quick**: high-level scan for blockers only. + +2. **Gather context** — Run these in parallel: + - `gh pr view <number> --json number,title,body,headRefName,baseRefName,state,author,additions,deletions,changedFiles` — PR metadata + - `gh pr diff <number>` — full diff + - `gh pr diff <number> --stat` — diff summary for orientation (not available on all gh versions; fall back to `gh pr view <number> --json files`) + - `gh api repos/{owner}/{repo}/pulls/<number>/reviews` — existing reviews (avoid duplicating feedback already given) + - `gh api repos/{owner}/{repo}/pulls/<number>/comments` — existing inline comments + - `gh pr view <number> --comments --json comments` — top-level conversation + - `git log --oneline main..<head_branch>` — commit history on the branch (if the branch is locally available) + +3. **Read relevant skills** — Based on the files changed, read the corresponding skill files from `.cursor/skills/` to understand the project's conventions for those file types. This ensures feedback is grounded in actual project standards, not generic opinions. + +4. **Analyze the diff** — For each changed file, evaluate: + + **Correctness** + - Does the logic do what the PR description claims? + - Are there edge cases, off-by-one errors, or nil/undefined handling gaps? + - Do database changes have proper migrations, indexes, and constraints? + - Are new associations, validations, and scopes correct? + + **Security** + - Mass assignment exposure, SQL injection, XSS, CSRF gaps + - Secrets or credentials committed + - Authorization checks missing on new endpoints + - Unsafe deserialization or user-controlled input passed to dangerous sinks + + **Performance** + - N+1 queries, missing indexes, unbounded queries + - Unnecessary allocations, expensive operations in hot paths + - Missing pagination or limits on collections + - Frontend: unnecessary re-renders, missing memoization on heavy components + + **Testing** + - Are new behaviors covered by specs? + - Are edge cases tested? + - Do test names clearly describe what they verify? + - Are factories and shared examples used appropriately? + + **Conventions** + - Does the code follow patterns from the relevant skills? + - File placement, naming, module structure + - Consistent use of project abstractions (CMDx tasks, Alba serializers, Phlex views, etc.) + + **Clarity** + - Is the code readable without excessive comments? + - Are names descriptive and consistent? + - Is complexity justified, or can it be simplified? + +5. **Compile findings** — Organize all comments into a review. For each finding: + - Assign a severity level (blocker, suggestion, nit, question, praise) + - Reference the specific file and line(s) + - Explain **why** it matters, not just what to change + - For blockers and suggestions, provide a concrete fix or alternative + - For questions, explain what is unclear and why it matters + +6. **Present the review to the user** — Before submitting, display the review summary: + + **Overview** + - One-paragraph assessment of the PR overall + - Recommended action: **approve**, **request changes**, or **comment only** + + **Findings** + + | # | Severity | File | Line(s) | Summary | + |---|----------|------|---------|---------| + | 1 | blocker | `path/to/file.rb` | 42-45 | Missing authorization check | + | 2 | suggestion | `path/to/file.ts` | 18 | Extract to custom hook | + | 3 | nit | `path/to/spec.rb` | 7 | Factory name mismatch | + | 4 | praise | `path/to/task.rb` | — | Clean workflow composition | + | … | … | … | … | … | + + Ask the user to confirm, edit, or drop items before submitting. + +7. **Submit the review** — Post the review using the GitHub API: + + - Create a pending review with inline comments: + ```bash + gh api repos/{owner}/{repo}/pulls/<number>/reviews \ + -f event="<APPROVE|REQUEST_CHANGES|COMMENT>" \ + -f body="<overall summary>" \ + -f 'comments[][path]=<file>' \ + -f 'comments[][position]=<diff position>' \ + -f 'comments[][body]=<comment>' + ``` + - If the `gh api` batch approach is unreliable, fall back to individual comments: + ```bash + gh api repos/{owner}/{repo}/pulls/<number>/comments \ + -f body="<comment>" \ + -f commit_id="<head SHA>" \ + -f path="<file>" \ + -f position=<diff position> + ``` + - For general feedback not tied to a specific line, include it in the review body + +8. **Report** — Display the result. + +## Constraints + +- Do **not** modify any files in the repository — this is a read-only review +- Do **not** modify `git config` +- Do **not** approve PRs with unresolved blockers — use **request changes** or **comment only** +- Do **not** submit the review without user confirmation +- Do **not** duplicate feedback already given by other reviewers — reference their comments instead +- Do **not** bikeshed — skip pure style preferences that have no functional or readability impact +- Do **not** fabricate issues — every finding must reference actual code from the diff +- Prefer **concrete suggestions** over vague advice ("extract this into a method" beats "consider refactoring") +- When uncertain about intent, classify as **question** rather than **blocker** +- If the PR is merged or closed, say so and stop +- If the diff is empty or the PR has no commits, say so and stop + +## Output + +After submitting the review, display: + +| Field | Value | +|---|---| +| **PR** | `#<number>` | +| **Title** | `<title>` | +| **Author** | `@<author>` | +| **Verdict** | Approved / Changes Requested / Commented | +| **Blockers** | `<count>` | +| **Suggestions** | `<count>` | +| **Nits** | `<count>` | +| **Questions** | `<count>` | +| **Praise** | `<count>` | + +Then list each submitted comment: + +| # | Severity | File | Line(s) | Comment | +|---|----------|------|---------|---------| +| 1 | blocker | `path/to/file.rb` | 42-45 | Missing authorization — added inline | +| 2 | suggestion | `path/to/file.ts` | 18 | Extract to hook — added inline | +| … | … | … | … | … | diff --git a/.cursor/hooks/conventions-check b/.cursor/hooks/conventions-check new file mode 100755 index 000000000..85cbd62b9 --- /dev/null +++ b/.cursor/hooks/conventions-check @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +set -euo pipefail + +if ! command -v jq &>/dev/null; then + echo '{"decision":"allow"}' + exit 0 +fi + +input=$(cat) + +tool_name=$(echo "$input" | jq -r '.tool_name // empty') +transcript_path=$(echo "$input" | jq -r '.transcript_path // empty') + +file_path="" +case "$tool_name" in + Write|StrReplace) + file_path=$(echo "$input" | jq -r '.tool_input.path // empty') + ;; + EditNotebook) + file_path=$(echo "$input" | jq -r '.tool_input.target_notebook // empty') + ;; + *) + echo '{"decision":"allow"}' + exit 0 + ;; +esac + +if [[ -z "$file_path" ]]; then + echo '{"decision":"allow"}' + exit 0 +fi + +skill_name="" + +if [[ "$file_path" == */.cursor/skills/*/SKILL.md ]]; then + skill_name="skill-patterns" +elif [[ "$file_path" == */.cursor/commands/*.md ]]; then + skill_name="command-patterns" +elif [[ "$file_path" == */spec/*.rb ]]; then + skill_name="test-patterns" +elif [[ "$file_path" == */lib/*.rb ]] || [[ "$file_path" == *CHANGELOG.md ]]; then + skill_name="technical-docs" +fi + +if [[ -z "$skill_name" ]]; then + echo '{"decision":"allow"}' + exit 0 +fi + +skill_path=".cursor/skills/${skill_name}/SKILL.md" + +skill_loaded() { + if [[ -n "$transcript_path" && -f "$transcript_path" ]]; then + if grep -q "${skill_name}/SKILL.md" "$transcript_path" 2>/dev/null; then + return 0 + fi + fi + return 1 +} + +if skill_loaded; then + echo '{"decision":"allow"}' + exit 0 +fi + +reason="BLOCKED: You must read the ${skill_name} skill before editing this file.\\n\\nSTOP. Do not immediately retry your edit.\\n1. Read the skill: Read file .cursor/skills/${skill_name}/SKILL.md\\n2. Follow the conventions from the skill\\n3. Then retry your edit" + +cat <<EOF +{ + "decision": "deny", + "reason": "${reason}" +} +EOF +exit 2 diff --git a/.cursor/hooks/session-start b/.cursor/hooks/session-start new file mode 100755 index 000000000..93f1cfcd7 --- /dev/null +++ b/.cursor/hooks/session-start @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)" +PROJECT_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" + +escape_for_json() { + local s="$1" + s="${s//\\/\\\\}" + s="${s//\"/\\\"}" + s="${s//$'\n'/\\n}" + s="${s//$'\r'/\\r}" + s="${s//$'\t'/\\t}" + printf '%s' "$s" +} + +session_context="<project-conventions> +This project (CMDx) is a Ruby gem for designing and executing complex business logic +within service/command objects. It is NOT a Rails app. + +This project enforces skill-based conventions via hooks. +Before editing files, you MUST read the relevant skill from .cursor/skills/. + +A preToolUse hook will DENY your edits if the skill has not been read first. + +Skill map (more specific patterns take priority): +| File Pattern | Skill to Read | +|---|---| +| .cursor/skills/*/SKILL.md | .cursor/skills/skill-patterns/SKILL.md | +| .cursor/commands/*.md | .cursor/skills/command-patterns/SKILL.md | +| spec/**/*.rb | .cursor/skills/test-patterns/SKILL.md | +| lib/**/*.rb, CHANGELOG.md | .cursor/skills/technical-docs/SKILL.md | + +Additional skills available (read on demand, not hook-gated): +| Skill | When to Use | +|---|---| +| .cursor/skills/technical-docs/SKILL.md | Writing YARD docs or CHANGELOG entries | +| .cursor/skills/performance-optimizations/SKILL.md | Benchmarking or optimizing hot paths | +| .cursor/skills/issue-debugging/SKILL.md | Diagnosing bugs or unexpected behavior | +| .cursor/skills/explain-functionality/SKILL.md | Explaining code flow or intent | + +When you need to edit one of the gated file types: +1. Read the corresponding SKILL.md first +2. Follow the conventions described in the skill +3. Then make your edit +</project-conventions>" + +escaped=$(escape_for_json "$session_context") + +cat <<EOF +{ + "additional_context": "${escaped}", + "continue": true +} +EOF + +exit 0 diff --git a/.cursor/prompts/rspec.md b/.cursor/prompts/rspec.md deleted file mode 100644 index 2b6010148..000000000 --- a/.cursor/prompts/rspec.md +++ /dev/null @@ -1,24 +0,0 @@ -You are a senior Ruby developer with expert knowledge of RSpec. - -Add tests for the active tab using the following guidelines: - -- Expectations should be concise, non-repetitive, and realistic (how it would be used in the real world) -- Follow best practices and implementation -- Update any pre-existing specs to match stated rules -- New tests should be consistent with current `spec/cmdx` specs -- Use custom matchers available within `lib/cmdx/rspec` -- Use task helpers available within `spec/support/helpers` -- Use stubs to return predefined values for specific methods. Isolate the unit being tested, but avoid over-mocking; test real behavior when possible (mocks should be used only when necessary) -- Ensure each test is independent; avoid shared state between tests -- Use let and let! to define test data, ensuring minimal and necessary setup -- Context block descriptions should start with the following words: `when`, `with`, `without` -- Organize tests logically using describe for classes/modules and context for different scenarios -- Use subject to define the object under test when appropriate to avoid repetition -- Ensure test file paths mirror the structure of the files being tested, but within the spec directory (e.g., lib/cmdx/task.rb → spec/cmdx/task_spec.rb) -- Use clear and descriptive names for describe, context, and it blocks -- Prefer the expect syntax for assertions to improve readability -- Keep test code concise; avoid unnecessary complexity or duplication -- Tests must cover both typical cases and edge cases, including Invalid and error conditions -- Consider all possible scenarios for each method or behavior and ensure they are tested -- Do NOT include integration or real world examples -- Verify all specs are passing diff --git a/.cursor/prompts/skill.md b/.cursor/prompts/skill.md deleted file mode 100644 index 7825b3952..000000000 --- a/.cursor/prompts/skill.md +++ /dev/null @@ -1,31 +0,0 @@ -You are a senior Ruby developer and workflow architect who designs concise, actionable SKILL.md files for AI-assisted development. - -Create a Cursor Agent Skill for the CMDx gem that enables AI agents to build, debug, and optimize CMDx tasks and workflows. - -## Context gathering - -Read the following to build a deep understanding of the framework: - -- Read `lib/cmdx` source files for API surface and internals -- Read `spec/cmdx` specs for real usage patterns and edge cases -- Read `docs/` for feature documentation and examples -- Read https://agentskills.io/specification for the latest skill specification -- Read https://drexed.github.io/cmdx/llms-full.txt for the published LLM reference - -## Output - -Generate a single `skills/SKILL.md` file with valid YAML frontmatter (`name`, `description`). Create as `skills/references/*.md` files to expand its capabilities. - -## Authoring guidelines - -- Write in third person (the description is injected into system prompts) -- Be concise — only include what an AI agent wouldn't already know -- Stay under 500 lines in the main SKILL.md -- Use progressive disclosure: link to `docs/` files for deep dives rather than inlining everything -- Prefer concrete code snippets over prose explanations -- Include a minimal and a full-featured task example -- Include a workflow example showing task composition -- Cover the complete lifecycle: define → execute → react → observe -- Use consistent terminology matching `lib/cmdx` naming (e.g., "task" not "command", "context" not "params") -- Include a "Common pitfalls" section with fixes -- No time-sensitive information or version-specific branching diff --git a/.cursor/prompts/yardoc.md b/.cursor/prompts/yardoc.md deleted file mode 100644 index 094ef18c6..000000000 --- a/.cursor/prompts/yardoc.md +++ /dev/null @@ -1,15 +0,0 @@ -You are a senior Ruby developer with expert knowledge of YARDoc. - -Add yardoc to the active tab using the following guidelines: - -- Follow best practices and implementation -- New documentation should be consistent with current `lib/cmdx` documentation -- Examples should be concise, non-repetitive, and realistic -- Avoid unnecessary complexity or duplication -- Update any pre-existing documentation to match stated rules -- Do NOT include `CMDx` module level docs -- Module level docs description should NOT include `@example` -- Method level docs should include `@example`, `param`, `@options`, `@return`, and any `@raise` -- Hash `@params` should expand with possible `@option` -- Module and method level docs should NOT include `@since` -- Add RBS inline comments after YARDoc block diff --git a/.cursor/rules/cursor-instructions.mdc b/.cursor/rules/cursor-instructions.mdc index ddeb98d0e..c17a02823 100644 --- a/.cursor/rules/cursor-instructions.mdc +++ b/.cursor/rules/cursor-instructions.mdc @@ -14,8 +14,8 @@ CMDx provides a framework for designing and executing complex business logic wit Reference the CMDx documentation in https://github.com/drexed/cmdx/blob/main/LLM.md ## Technology Stack -- Ruby 3.4+ -- RSpec 3.1+ +- Ruby 4.0+ +- RSpec 3.13+ ## Development Guidelines - Performance is critical - benchmark any changes that could affect speed @@ -65,4 +65,4 @@ Reference the CMDx documentation in https://github.com/drexed/cmdx/blob/main/LLM - Avoid redundant comments that merely restate the code - Keep comments up-to-date with code changes - Keep documentation consistent -- Update CHANGELOG.md with any changes +- Update CHANGELOG.md with any changes (keep with similar voice and style) diff --git a/.cursor/skills/command-patterns/SKILL.md b/.cursor/skills/command-patterns/SKILL.md new file mode 100644 index 000000000..3f68f16c0 --- /dev/null +++ b/.cursor/skills/command-patterns/SKILL.md @@ -0,0 +1,91 @@ +--- +name: command-patterns +description: Authors and structures Cursor slash-commands that automate recurring developer and AI-maintenance workflows. Use when creating new command files under .cursor/commands/, adding procedural steps, defining constraints, or optimizing command output tables. Do not use for skill authoring, agent definitions, hook scripts, or application code. +--- + +# Command Authoring Procedure + +Follow these steps to generate a command that adheres to the project's structural conventions and produces deterministic, high-quality agent behavior. + +## Step 1: Validate Metadata + +1. Choose a **kebab-case** name (1-64 characters, lowercase, numbers, and single hyphens only). The name becomes the filename: `.cursor/commands/<folder>/<name>.md`. +2. Determine the **folder**: `sd/` for software developers, `ai/` for AI tooling. Create new folders only when neither fits. +3. Execute the validation script: + `python3 scripts/validate-metadata.py --name "<name>" --folder "<folder>"` +4. If the script returns an error, self-correct and re-run until successful. + +## Step 2: Draft the Command + +Use the template in `assets/COMMAND.template.md` as the starting point. Fill in each section following the conventions below. + +### Section: Role + +- One sentence, third-person: "You are a senior developer [doing X]." +- Describe the persona, not the task. + +### Section: Context + +- Background the agent needs before acting. +- Use tables for structured references (severity levels, artifact layers, style rules). +- Include project-specific conventions (commit style, PR template path, tool names). +- Keep under 30 lines; move large reference material to skill files or link to project docs. + +### Section: Task + +- One sentence stating the goal. +- Followed by a `### Steps` subsection with numbered phases. + +### Section: Steps + +Each step follows this pattern: + +1. **Bold phase name** — One-sentence description of the phase. + - Bullet list of specific actions. + - Shell commands in fenced code blocks. + - Parallel operations explicitly noted: "Run these in parallel:" + - Conditional logic with bold keywords: "If [condition], [action]. Otherwise, [fallback]." + +Apply these conventions: + +| Convention | Rule | +|------------|------| +| Gathering info | Always the first step; run independent commands in parallel | +| User prompts | Use "Ask the user for:" or "Prompt the user with:" — list each option with defaults | +| Shell commands | Use fenced `bash` blocks; prefer `gh` CLI for GitHub operations | +| HEREDOC commits | Always use `cat <<'EOF'` pattern for multi-line messages | +| Verification | Always include a final verification step (`git status`, `gh pr view`, etc.) | +| Decision points | Present options to the user; never assume destructive choices | + +### Section: Constraints + +- Bullet list of "Do **not**" rules. +- Always include applicable safety rules from this canonical set: + +| Scope | Standard constraints | +|-------|---------------------| +| Git | Do not modify `git config`; do not force-push; do not skip hooks (`--no-verify`); do not run destructive operations without confirmation | +| GitHub | Do not push to `main`/`master` directly; do not dismiss reviews programmatically | +| Files | Do not modify files outside the command's scope; do not commit secrets | +| General | Do not fabricate data; ask rather than assume when intent is unclear | + +- Add command-specific constraints after the standard ones. + +### Section: Output + +- Always a summary table with `Field | Value` columns. +- Fields should be concise identifiers (e.g., **PR**, **Branch**, **Commit**, **Status**). +- Values use inline code for SHAs, numbers, and branch names. +- Optional: a detail table below the summary for itemized results (comments addressed, files changed, findings). + +## Step 3: Review Against Checklist + +1. Review the `SKILL.md` for "hallucination gaps" (points where the agent is forced to guess). +2. Verify all file paths are **relative** and use forward slashes (`/`). +3. Cross-reference the final output against `references/checklist.md`. + +## Error Handling + +- **Validation failure:** If `scripts/validate-metadata.py` fails, fix the flagged field and re-run. +- **Scope ambiguity:** If the command straddles consumers, prefer the primary consumer. +- **Overlapping commands:** Before creating a new command, check existing commands that already cover the workflow. Extend an existing command rather than creating a duplicate. diff --git a/.cursor/skills/command-patterns/assets/COMMAND.template.md b/.cursor/skills/command-patterns/assets/COMMAND.template.md new file mode 100644 index 000000000..bdcf17670 --- /dev/null +++ b/.cursor/skills/command-patterns/assets/COMMAND.template.md @@ -0,0 +1,43 @@ +# [Command Title] + +## Role + +You are a senior developer [performing specific workflow — e.g., "committing staged changes to the local Git repository with a clear commit message"]. + +## Context + +[Background the agent needs. Include tables for structured references, project conventions, and terminology. Keep under 30 lines.] + +## Task + +[One-sentence goal statement.] + +### Steps + +1. **[Phase Name]** — [Brief description of the phase.] + - [Specific action or sub-step] + - [Shell commands in fenced blocks] + - [Parallel operations noted: "Run these in parallel:"] + +2. **[Phase Name]** — [Brief description.] + - [Conditional logic: "If [condition], [action]. Otherwise, [fallback]."] + +3. **Verify** — Confirm the operation succeeded. + - [Verification command, e.g., `git status`, `gh pr view`] + - Report the result. + +## Constraints + +- Do **not** [standard safety rule] +- Do **not** [standard safety rule] +- Do **not** [command-specific constraint] + +## Output + +After completing, display: + +| Field | Value | +|---|---| +| **[Field]** | `<value>` | +| **[Field]** | `<value>` | +| **Status** | [Success indicator] | diff --git a/.cursor/skills/command-patterns/references/checklist.md b/.cursor/skills/command-patterns/references/checklist.md new file mode 100644 index 000000000..3ba2ecacf --- /dev/null +++ b/.cursor/skills/command-patterns/references/checklist.md @@ -0,0 +1,48 @@ +# Command Validation Checklist + +Use this checklist to perform a final audit of a generated command before deployment. Every item must be marked as "Pass" to ensure the command is well-structured, safe, and deterministic. + +## 1. Metadata & Placement + +- [ ] **Naming:** The filename is kebab-case, 1-64 characters, lowercase letters, numbers, and single hyphens only. +- [ ] **Folder:** Placed in the correct folder — `sd/` for software developers, `ai/` for AI tooling. +- [ ] **No Duplicates:** No existing command already covers the same workflow (check `.cursor/commands/`). + +## 2. Section Hierarchy + +- [ ] **Role:** Present, one sentence, third-person ("You are a senior developer [doing X]."). +- [ ] **Context:** Present, under 30 lines, includes tables for structured references where applicable. +- [ ] **Task:** Present, one-sentence goal statement followed by a `### Steps` subsection. +- [ ] **Constraints:** Present, bullet list of "Do **not**" rules. +- [ ] **Output:** Present, summary table with `Field | Value` columns. +- [ ] **No Extra Sections:** Only the five standard sections appear (Role, Context, Task, Constraints, Output). + +## 3. Steps Quality + +- [ ] **Numbered Phases:** Steps are numbered with **bold phase names** and a dash separator. +- [ ] **Parallel Gather:** First step collects independent information with "Run these in parallel:" when multiple commands are needed. +- [ ] **User Prompts:** User input requests use "Ask the user for:" with listed options and defaults. +- [ ] **Shell Commands:** All shell commands appear in fenced `bash` blocks. +- [ ] **HEREDOC Pattern:** Multi-line Git/CLI messages use `cat <<'EOF'` syntax. +- [ ] **Conditional Logic:** Decision points use bold keywords — "If [condition], [action]. Otherwise, [fallback]." +- [ ] **Verification:** Final step confirms success with a verification command (`git status`, `gh pr view`, etc.). + +## 4. Constraints & Safety + +- [ ] **Git Safety:** Includes applicable rules — no `git config` changes, no force-push, no `--no-verify`, no destructive ops without confirmation. +- [ ] **GitHub Safety:** Includes applicable rules — no direct push to `main`/`master`, no programmatic review dismissal. +- [ ] **File Safety:** Includes applicable rules — no modifications outside scope, no secret commits. +- [ ] **Data Integrity:** Includes "do not fabricate" and "ask rather than assume" rules. +- [ ] **Command-Specific:** Additional constraints specific to the command's domain are present. + +## 5. Output Format + +- [ ] **Summary Table:** Uses `Field | Value` columns with concise field names. +- [ ] **Inline Code:** Values use backticks for SHAs, numbers, branch names, and URLs. +- [ ] **Detail Table:** Optional itemized table present when the command produces per-item results (comments, findings, files). + +## 6. Tone & Style + +- [ ] **Third-Person Imperative:** No first/second person pronouns in Role or Context sections. +- [ ] **Concise:** Total command length under 200 lines. +- [ ] **No Filler:** Every sentence adds actionable information — no preamble or summaries restating the title. diff --git a/.cursor/skills/command-patterns/scripts/validate-metadata.py b/.cursor/skills/command-patterns/scripts/validate-metadata.py new file mode 100644 index 000000000..f31b73852 --- /dev/null +++ b/.cursor/skills/command-patterns/scripts/validate-metadata.py @@ -0,0 +1,32 @@ +import re +import sys +import argparse + +def validate_metadata(name, folder): + errors = [] + + if not (1 <= len(name) <= 64): + errors.append(f"NAME ERROR: '{name}' is {len(name)} characters. Must be between 1-64.") + + if not re.match(r"^[a-z0-9]+(-[a-z0-9]+)*$", name): + errors.append( + f"NAME ERROR: '{name}' contains invalid characters. " + "Use only lowercase letters, numbers, and single hyphens. " + "No consecutive hyphens, and cannot start/end with a hyphen." + ) + + target_path = f".cursor/commands/{folder}/{name}.md" + + if errors: + print("\n".join(errors), file=sys.stderr) + sys.exit(1) + else: + print(f"SUCCESS: Metadata valid. Target path: {target_path}") + sys.exit(0) + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Validate command metadata") + parser.add_argument("--name", required=True, help="Command name (kebab-case)") + parser.add_argument("--folder", required=True, help="Target folder (ai, pm, sd, etc.)") + args = parser.parse_args() + validate_metadata(args.name, args.folder) diff --git a/.cursor/skills/explain-functionality/SKILL.md b/.cursor/skills/explain-functionality/SKILL.md new file mode 100644 index 000000000..d094fad2d --- /dev/null +++ b/.cursor/skills/explain-functionality/SKILL.md @@ -0,0 +1,179 @@ +--- +name: explain-functionality +description: Explains selected code in depth — data flow, dependencies, side effects, and intent. Use when the user asks to explain, trace, walk through, or understand a function, method, class, module, or code block. Emphasizes how and why over what. Don't use for debugging, refactoring, performance tuning, or generating new code. +--- + +# Explain Functionality + +Deep-explanation procedure for any code selection. Emphasizes *why* and *how* over restating *what*. + +> **Scope check:** Confirm the exact code region and depth of explanation the user expects before producing output. + +## Procedures + +**Step 0: Determine Scope** + +1. Identify the code the user selected or referenced. +2. If no explicit selection, ask the user to specify the file, class, method, or line range. +3. Read the target code and its surrounding context (imports, class definition, module namespace). +4. Determine **audience depth** — quick overview, moderate walkthrough, or forensic deep-dive. Default to moderate unless the user specifies otherwise. + +**Step 1: Map the Structure** + +Before explaining line-by-line, build a mental model of the code's shape: + +1. **Entry points** — public methods, exported functions, event handlers. What triggers this code? +2. **Internal flow** — call graph within the selection. Which private methods call which? What order do they execute? +3. **Exit points** — return values, emitted signals, enqueued jobs, written records. What leaves this code? +4. **Branching** — conditionals, guard clauses, `catch`/`throw`, error paths. Under what conditions does flow diverge? + +Present this as a concise structural overview before diving into details. + +**Step 2: Map Data Flow** + +Answer: *What goes in, what comes out, and how is data transformed along the way?* + +1. Identify all **inputs**: method parameters, instance variables read, context keys accessed, globals, constants, environment variables. +2. Identify all **outputs**: return values, instance variables written, context mutations, yielded values, thrown signals. +3. Trace the transformation pipeline from inputs to outputs. Note type changes (string→symbol, hash→struct, raw→validated). +4. Note branching paths — conditionals, early returns, guard clauses, `catch`/`throw` — and describe which conditions lead to which output. +5. **Implicit data** — memoized values, thread-locals, `Current` attributes. Call out data that flows through hidden channels. +6. For CMDx tasks, pay special attention to: + - Context reads (`ctx[:key]`, `ctx.key`, `method_missing` delegates). + - Context writes (`store`, `merge`, `delete`). + - Signal emission (`success!`, `skip!`, `fail!`, `throw!`) and the data each carries. + +Use compact notation when multiple data paths exist: + +``` +input_param → validate → transform → persist → return result + ↘ fail! on invalid +``` + +**Step 3: Identify Dependencies** + +Answer: *What does this code rely on to function correctly?* + +Catalog everything this code depends on and everything that depends on it: + +| Direction | What to Check | +|---|---| +| **Upstream** (code depends on) | Called methods, included modules, inherited behavior, injected services, config values, ENV vars | +| **Downstream** (depends on code) | Callers of this method/class, jobs enqueued, callbacks triggered, signals caught | +| **Lateral** (shared state) | Class variables, memoized singletons, shared caches | +| **Framework conventions** | `work` called by `Runtime`, `catch`/`throw` with `Signal::TAG`, `Context#method_missing` delegation — implicit behavior not visible in the code under analysis | + +For each dependency, note: +- **Coupling strength** — hard-coded call vs injected vs duck-typed. +- **Failure mode** — what happens if the dependency is nil, raises, times out, or returns unexpected data. +- **Staleness risk** — can the dependency's value change between read and use (TOCTOU)? + +**Step 4: Surface Side Effects** + +Answer: *What does this code change beyond its return value?* + +Enumerate every state change observable outside the method's return value: + +| Category | Examples | +|---|---| +| **State mutations** | Instance variables written, class-level state, context mutated via `store`/`merge`/`delete` | +| **Signal/flow control** | `throw` unwinding the call stack, exceptions raised, `Fault` propagation | +| **I/O** | File writes, network calls, logging | +| **Job dispatch** | Enqueued background work | +| **Cache mutation** | Memoization on shared objects (`@foo ||=`); note invalidation strategy or lack of one | +| **Callbacks** | `after_save`, lifecycle hooks — invisible control flow | + +For each side effect, state: +- **When** it fires (always, conditionally, on success only, on failure only). +- **Reversibility** — can it be undone if a later step fails? (transactional writes yes, sent emails no). +- **Idempotent?** — safe to repeat, or duplicating causes problems. + +**Step 5: Explain Intent (the Why)** + +Code shows *what* happens; the explanation must convey *why*. + +1. **Design intent** — why does this code exist? What problem or requirement does it satisfy? +2. **Approach rationale** — why this implementation over alternatives? Note trade-offs (performance vs readability, safety vs flexibility). +3. **Non-obvious choices** — guard clauses that prevent subtle bugs, ordering that matters, seemingly redundant checks. +4. **Historical context** — if git blame or specs reveal why a decision was made, include it. "This eager load was added to fix an N+1" beats "this eager loads the association." +5. **Domain semantics** — translate code constructs into business language when applicable. +6. **Technical debt signals** — `TODO`, `FIXME`, `HACK`, `XXX` markers and known limitations. Surface them so the reader knows what's deferred vs complete. + +**Step 6: Assess Edge Cases and Assumptions** + +1. List implicit assumptions the code makes (e.g., input is never nil, context key always present, array is non-empty). +2. Identify edge cases that could violate those assumptions. +3. Note any defensive coding already present (guard clauses, type checks, default values). +4. Flag missing guards that could cause unexpected behavior. +5. Note race conditions, ordering assumptions, or TOCTOU risks if applicable. + +**Step 7: Compose the Explanation** + +Structure the output using the template in `assets/explanation-template.md`. Adapt section depth to the complexity of the code: + +| Code Complexity | Depth | Typical Output | +|---|---|---| +| Single method, < 10 lines | Light | 3–5 sentences covering intent, data flow, and any gotcha | +| Method with branching or callbacks | Moderate | Structured walkthrough with flow diagram and side effects | +| Class or module | Full | All steps; dependency map; cross-reference callers/callees | +| Multi-file flow (task → runtime → signal) | Forensic | End-to-end trace across files; sequence of side effects | + +## Output Guidelines + +- Lead with a **one-sentence summary** in domain terms before any detail. +- Use concrete values in examples, not abstract placeholders. +- When describing data flow, show a before/after of the data shape when the transformation is non-trivial. +- Reference line numbers and file paths so the reader can navigate back to source. +- Use code references (`` `method_name` ``, `` `ClassName` ``) to anchor prose to code. +- Prefer inline code flow diagrams over paragraph-heavy descriptions for complex paths. +- **Mermaid diagrams** — use when they clarify structure better than prose: + - Sequence diagrams for multi-object interactions (task → runtime → signal → result). + - Flowcharts for branching logic and guard clauses. + - State diagrams for lifecycle transitions. + - Keep compact (≤15 nodes); omit if they'd be noise. +- Omit sections that add no value — a pure function needs no side-effects section. +- Close complex explanations with a **quick reference table**: + + | Item | Value | + |---|---| + | Entry point | method/action that triggers this code | + | Key classes | classes and modules involved | + | Side effects | non-obvious state changes | + +- Keep the tone direct and technical — no filler, no hedging. + +## CMDx-Specific Vocabulary + +Use these terms consistently when explaining CMDx code: + +| Term | Meaning | +|---|---| +| **Task** | A unit of work defined by a class with a `work` method | +| **Context** | The shared data object (`ctx`) passed through task execution | +| **Signal** | The halt mechanism (`success!`, `skip!`, `fail!`) that short-circuits via `catch`/`throw` | +| **Result** | The frozen outcome object containing state, status, reason, metadata, and context | +| **Fault** | An exception subclass used for `execute!` error propagation | +| **Runtime** | The execution wrapper that manages `catch`/`throw` flow and exception rescue | + +## Anti-Patterns + +| Anti-Pattern | Why It Hurts | Fix | +|---|---|---| +| Restating code in English | "This calls `find`" adds zero value | Explain **why** it calls `find` and what happens if it returns nil | +| Ignoring implicit behavior | Callbacks, concerns, `method_missing` delegation are invisible but critical | Always check for `included` blocks, lifecycle hooks, and framework conventions | +| Explaining in isolation | Code exists in a call chain; severing context loses meaning | Trace at least one level up (caller) and one level down (callee) | +| Assuming reader knows the domain | Technical explanation without business context is half an explanation | Translate code constructs to domain language | +| Over-explaining simple code | Wastes reader attention on trivial lines | Scale depth to complexity | +| Skipping error/failure paths | Happy path is often obvious; error handling reveals design intent | Enumerate failure modes and their consequences | + +## Error Handling + +- If the selected code is too large (>200 lines), ask the user to narrow scope or break the explanation into sections by class/method. +- If the selected code references files that don't exist, note the broken dependency and explain what the code *expects* to be there. +- If the code is incomplete (partial selection), state the assumptions made about the missing context and flag them clearly. +- If YARD docs contradict actual behavior, flag the discrepancy and trust the code. +- If intent is truly ambiguous after checking history, specs, and naming, present the two most likely interpretations and let the user decide. + +## Final Validation + +Cross-reference the completed explanation against `references/checklist.md`. diff --git a/.cursor/skills/explain-functionality/assets/explanation-template.md b/.cursor/skills/explain-functionality/assets/explanation-template.md new file mode 100644 index 000000000..4c6c0c9d0 --- /dev/null +++ b/.cursor/skills/explain-functionality/assets/explanation-template.md @@ -0,0 +1,60 @@ +# Explanation: `[method/class name]` + +**File:** `[file path]` | **Lines:** [start]–[end] + +> [One-sentence summary: what this code does and why, in domain terms.] + +## 1. Structure + +- **Entry:** [what triggers this code] +- **Flow:** [internal call sequence] +- **Exit:** [return values, signals, mutations] +- **Branches:** [conditions that diverge flow] + +## 2. Data Flow + +**Inputs:** +| Name | Source | Type / Shape | +|------|--------|-------------| +| ... | ... | ... | + +**Transformation:** +``` +input → step_1 → step_2 → output + ↘ fail! on [condition] +``` + +**Outputs:** +| Name | Destination | Type / Shape | +|------|-------------|-------------| +| ... | ... | ... | + +## 3. Dependencies + +| Dependency | Direction | Coupling | Failure Mode | +|------------|-----------|----------|-------------| +| ... | upstream/downstream/lateral | hard/soft | ... | + +## 4. Side Effects + +| Effect | Category | When | Reversible? | Idempotent? | +|--------|----------|------|-------------|-------------| +| ... | ... | ... | ... | ... | + +## 5. Intent & Rationale + +[Why this code exists. Why this approach over alternatives. Non-obvious choices. Domain semantics.] + +## 6. Edge Cases & Assumptions + +| Assumption | Guarded? | Risk if Violated | +|------------|----------|------------------| +| ... | ... | ... | + +## 7. Quick Reference + +| Item | Value | +|---|---| +| Entry point | ... | +| Key classes | ... | +| Side effects | ... | diff --git a/.cursor/skills/explain-functionality/references/checklist.md b/.cursor/skills/explain-functionality/references/checklist.md new file mode 100644 index 000000000..af0928a12 --- /dev/null +++ b/.cursor/skills/explain-functionality/references/checklist.md @@ -0,0 +1,31 @@ +# Explanation Quality Checklist + +Verify every item before delivering the explanation to the user. + +## 1. Completeness + +- [ ] **Intent stated first.** The explanation opens with why the code exists, not what it does mechanically. +- [ ] **All inputs identified.** Parameters, instance state, context keys, globals, env vars — nothing omitted. +- [ ] **All outputs identified.** Return values, mutations, signals, side effects — nothing omitted. +- [ ] **Branching paths covered.** Every conditional / early return / guard clause accounted for. +- [ ] **Dependencies classified.** Explicit, implicit, runtime, and ordering dependencies listed. +- [ ] **Side effects surfaced.** State mutations, I/O, flow control, observer notifications all flagged. + +## 2. Accuracy + +- [ ] **Code over docs.** If YARD docs and behavior disagree, the explanation follows the code. +- [ ] **No invented behavior.** Every claim can be traced to a specific line in source. +- [ ] **Edge cases grounded.** Listed assumptions are derived from the code, not hypothetical. + +## 3. Clarity + +- [ ] **Concrete examples.** Data shapes use real or realistic values, not abstract placeholders. +- [ ] **Line references included.** Key statements reference file path and line number. +- [ ] **Consistent terminology.** Uses the CMDx vocabulary table from SKILL.md without mixing synonyms. +- [ ] **Appropriate depth.** Simple code gets a brief explanation; complex flows get the full template. + +## 4. Navigability + +- [ ] **Template structure followed.** Sections match `assets/explanation-template.md` ordering. +- [ ] **Flow diagram present (if multi-file).** Call chains across files include a visual trace. +- [ ] **No filler.** Every sentence conveys information the reader didn't already have. diff --git a/.cursor/skills/issue-debugging/SKILL.md b/.cursor/skills/issue-debugging/SKILL.md new file mode 100644 index 000000000..2c17d2d28 --- /dev/null +++ b/.cursor/skills/issue-debugging/SKILL.md @@ -0,0 +1,173 @@ +--- +name: issue-debugging +description: Systematically diagnose and resolve bugs, errors, and unexpected behavior in CMDx tasks, workflows, context, and runtime execution. Use when the user mentions a bug, error, unexpected result, failing test, exception, stack trace, wrong state, wrong status, nil value, or debugging. Don't use for feature additions, performance tuning, or test-only changes. +--- + +# Issue Debugging + +> **Scope check:** Clarify the symptom, reproduction steps, and expected vs actual behavior with the user before diving into code. + +## Prerequisites + +Ensure the test suite and linter are runnable: + +```bash +bundle exec rspec . +bundle exec rubocop . +``` + +## Procedures + +**Step 0: Gather Context** + +Before touching code, collect everything available: + +1. **Symptoms** — exact error message, stack trace, wrong state/status, unexpected nil. Note **frequency**: always, intermittent, or flaky. +2. **Expected vs actual** — what should happen, what does happen. +3. **Reproduction path** — exact input hash, task class, `execute` vs `execute!`, context shape. +4. **Environment** — Ruby version, YJIT on/off, gem versions, OS. +5. **Recency** — when did it start? Correlate with recent commits, dependency updates, or Ruby upgrades. + +**Step 1: Reproduce the Issue** + +Never debug what you can't reproduce. Establish a reliable reproduction first. + +1. Write the smallest possible reproduction case. +2. Confirm the reproduction fails consistently. If the issue is intermittent, note the conditions (input shape, context state, Ruby version, YJIT on/off). +3. If a failing spec already exists, skip the script and use the spec directly. +4. **Differential debugging** — if the same code works in one context but not another, compare the two. The delta is the clue. +5. **If not reproducible** — the bug is likely environment-specific, data-dependent, or timing-dependent: + - Check for context state that differs between runs. + - Check for frozen object mutations that only trigger under certain execution orders. + +**Step 2: Classify the Bug** + +Determine which category the issue falls into — this guides where to look: + +| Category | Symptoms | Start Looking At | +|---|---|---| +| **Signal/Flow** | Wrong `state` or `status` on result; `success!`/`skip!`/`fail!` not halting | `lib/cmdx/task.rb` signal methods, `lib/cmdx/runtime.rb` `catch`/`throw` | +| **Context** | `nil` values, missing keys, wrong types, frozen context mutation | `lib/cmdx/context.rb` `method_missing`, `build`, `merge`, `freeze` | +| **Fault propagation** | Exception not caught, wrong fault type rescued, `execute!` vs `execute` mismatch | `lib/cmdx/exceptions.rb`, `lib/cmdx/runtime.rb` rescue chain | +| **Result** | Incorrect `reason`, `metadata`, `cause`; pattern match failures | `lib/cmdx/result.rb`, `lib/cmdx/signal.rb` | +| **Workflow** | Tasks execute in wrong order, breakpoints ignored, conditionals misfire | Workflow runner, task ordering logic | +| **Integration** | Works in isolation, fails when composed; context bleeds between tasks | Context sharing via `Context.build` passthrough, frozen state | + +**Step 3: Isolate the Failure** + +Narrow the search space systematically. **Mark each finding as verified or inferred** — don't let assumptions compound. + +1. **Read the stack trace top-to-bottom.** The exception origin is usually in app code, not framework internals. Identify the first app-level frame. +2. **Map the execution path.** Trace the chain: task entry → context build → `work` → signal/exception → runtime catch → result. Identify where data goes wrong. +3. **Binary search the code.** Comment out half the logic, see if the bug persists, repeat. +4. **Binary search the history.** If the bug is a regression, use `git bisect` to pinpoint the introducing commit. If bisect lands on a large commit, narrow further by inspecting the diff file-by-file. + +**Step 4: Diagnose Root Cause** + +Identify **why** the bug exists, not just **where**. Use evidence, not guesswork. + +1. **Form ranked hypotheses** — list candidate causes ordered by likelihood. Design **small experiments** (one variable at a time) to falsify each. +2. **Collect evidence** — trace output, result inspection, `pp` in console. **Verify** actual values at each step; don't assume what a variable holds. + +Check these common root causes in priority order: + +1. **Mismatched `execute` vs `execute!`** — `execute` swallows faults and returns a result; `execute!` re-raises as `SkipFault`/`FailFault`. Nested tasks using the wrong variant will silently swallow or unexpectedly raise. +2. **Signal already thrown** — calling `success!`/`skip!`/`fail!` after one was already thrown raises `"halt signal already thrown"`. Check for conditional branches that double-signal. +3. **Context frozen after `Result#freeze`** — `Result.new` calls `freeze`, which cascades to `Context#freeze` → `@table.freeze`. Tasks that mutate context after a result is generated will hit `FrozenError`. +4. **String key vs symbol key** — `Context` calls `transform_keys(&:to_sym)` on initialization. Passing string keys to `fetch` or `key?` without `.to_sym` returns `nil` or `false`. +5. **`method_missing` masking errors** — `Context#method_missing` returns `@table[method_name]` for any unknown method, which yields `nil`. A typo in a context accessor silently returns `nil` instead of raising. +6. **Fault `for?` / `matches?` not matching** — `Fault.for?` creates anonymous subclasses that use `===`; `rescue` clauses require `===` to match. Ensure the rescue variable is the fault instance, not the class. +7. **`throw!` propagating stale state** — `throw!` copies `state`, `status`, `reason`, and merges metadata from another result. If the source result is from a swallowed execution, its state may not reflect what you expect. + +**Step 5: Implement the Fix** + +1. **Fix the root cause, not the symptom.** Adding a nil guard is sometimes correct, but often masks the real bug upstream. +2. Write a **failing spec first** that captures the exact bug. Place it in the most relevant spec file under `spec/integration/`. +3. Apply the **minimum change** that resolves the issue. Resist refactoring adjacent code in the same fix. +4. **Check analogous code** — inspect sibling code paths (other task classes, similar runtime paths) for how they handle the same situation. Match established patterns unless the bug proves the pattern wrong. +5. **Search for related occurrences** — if the bug is a pattern (e.g., missing nil check on context access), search for the same pattern elsewhere: `rg "pattern" lib/` +6. Follow CMDx patterns: + - Use `catch`/`throw` for flow control, never exceptions for non-error paths. + - Keep signal construction in task private methods (`success!`, `skip!`, `fail!`, `throw!`). + - Context mutations go through `store`/`merge`/`delete`, not direct `@table` access. +7. **Keep the fix isolated** — separate the bugfix commit from any refactoring. + +**Step 6: Validate the Fix** + +1. Run the full test suite: `bundle exec rspec .` +2. Run the linter: `bundle exec rubocop .` +3. Verify no collateral regressions by inspecting related specs (e.g., if you fixed a context bug, run all context-touching specs). +4. **Edge cases** — test nil, empty, frozen, and boundary scenarios related to the fix. + +**Step 7: Guard Against Recurrence** + +1. The failing spec from Step 5 is the regression guard. Ensure it covers the exact input that triggered the bug. +2. If the bug was caused by a subtle interaction (e.g., frozen context + nested tasks), add a comment explaining the constraint. +3. If the bug category appears in the anti-patterns table below, verify the broader codebase isn't affected. +4. **One-line root cause summary** — state what caused the bug in a single sentence for the commit message. + +**Step 8: Document the Fix** + +1. Add a `## Bug Fixes` entry to `CHANGELOG.md` describing what was broken and the root cause. +2. Update YARD docs if the fix changed method contracts or added constraints. +3. Correct any existing documentation that contradicts the fix. + +## Debugging Principles + +**Follow the data, not your assumptions.** Read actual values at each step. Use `pp`, `binding.break`, or other debugging tools. Don't assume what a variable holds. + +**Reproduce before you fix.** A fix without reproduction is a guess. The reproduction spec proves the bug exists and proves the fix works. + +**Simplify to isolate.** Remove variables until the bug disappears, then add back the last one — that's the cause. + +**One change at a time.** When testing hypotheses, change one thing, verify, then move on. Multiple simultaneous changes make it impossible to know what worked. + +**Check the boundaries.** Most bugs live at the interface between two components — context passing, signal propagation, `execute` vs `execute!`, frozen vs mutable state. + +**Don't cargo-cult fixes.** If you don't understand why a fix works, you don't have a fix — you have a coincidence. Understand the root cause. + +## Common Anti-Patterns + +| Anti-Pattern | Why It Causes Bugs | Fix | +|---|---|---| +| Fixing without reproducing | You're guessing; the bug may persist or recur | Write a failing spec first | +| Fixing the symptom, not the cause | Bug recurs in a different form | Trace to root cause | +| Adding nil guards everywhere | Masks upstream bugs, creates silent data loss | Fix the source of the nil | +| Changing multiple things at once | Can't tell which change fixed it | One hypothesis at a time | +| Leaving debug code in | `binding.break`, `pp` in committed code | Remove before committing | +| Rescuing `StandardError` broadly | Swallows `Fault` subclasses that should propagate | Rescue specific exception classes; let `Fault` flow through `catch`/`throw` | +| Mutating context after `freeze` | `Result.new` freezes the context; later writes raise `FrozenError` | Complete all context mutations before signaling | +| Using `execute!` in workflow steps | One failed inner task aborts the entire workflow via exception | Use `execute` + inspect result, or `throw!` to propagate signals | +| Double-signaling in branching logic | `"halt signal already thrown"` error | Ensure only one of `success!`/`skip!`/`fail!` is reachable per execution path | +| Comparing result with `==` | `Result` is frozen and doesn't define `==` | Use `have_attributes` matcher or check individual fields | +| String keys in context lookups | `Context` symbolizes keys on init; string lookups return `nil` | Always use symbol keys: `ctx[:foo]`, not `ctx["foo"]` | +| Relying on `method_missing` return for presence | Returns `nil` for missing keys, not `KeyError` | Use `ctx.key?(:foo)` or `ctx.fetch(:foo)` for presence checks | +| Catching `:cmdx` tag outside runtime | Interferes with `Signal::TAG` flow | Never use `catch(:cmdx)` outside of `Runtime#execute_work` | + +## Decision Tree + +When the bug doesn't fit neatly into the classification table, follow this tree: + +1. **Is there an exception/stack trace?** + - Yes → Read the backtrace bottom-up. Find the first CMDx frame. That's your entry point. + - No → Go to 2. +2. **Is the result in an unexpected state/status?** + - Yes → Trace signal construction. Check which of `success!`/`skip!`/`fail!`/`throw!` was called, or if `catch` fell through to `Signal::Success`. + - No → Go to 3. +3. **Is context data wrong or missing?** + - Yes → Trace context mutations. Check `Context.build` passthrough, `method_missing` typos, freeze timing. + - No → Go to 4. +4. **Does the bug only appear in composition (workflows/nested tasks)?** + - Yes → Check `execute` vs `execute!` usage, `throw!` propagation, context sharing between tasks. + - No → Collect more information; the reproduction may be incomplete. + +## Error Handling + +- If a fix introduces new test failures, the fix is incomplete — it changed behavior that other code depends on. Understand the dependency before adjusting. +- If the bug is not reproducible outside of a specific Ruby version, check for VM-specific behavior (frozen string interning, `method_missing` dispatch changes, YJIT deoptimizations). +- If `git bisect` points to a large commit, narrow further by inspecting the diff file-by-file and testing each changed file's effect in isolation. +- If the bug is in a third-party gem, verify with the library's changelog and issue tracker before patching locally. Prefer upgrading over monkey-patching. + +## Final Validation + +Cross-reference the completed fix against `references/checklist.md`. diff --git a/.cursor/skills/issue-debugging/references/checklist.md b/.cursor/skills/issue-debugging/references/checklist.md new file mode 100644 index 000000000..9da7fdd74 --- /dev/null +++ b/.cursor/skills/issue-debugging/references/checklist.md @@ -0,0 +1,69 @@ +# Issue Debugging Checklist + +Final audit before merging a CMDx bug fix. Every item must pass. + +## 0. Context & Reproduction + +- [ ] **Bug Described:** The exact error message, wrong state/status, or unexpected behavior is documented. +- [ ] **Expected vs Actual:** Both are stated clearly. +- [ ] **Frequency Noted:** Always, intermittent, or flaky — and under what conditions. +- [ ] **Minimal Reproduction:** A standalone script or spec isolates the bug with the smallest possible input. +- [ ] **Consistently Reproducible:** The reproduction fails every time (or conditions for intermittent failure are noted). + +## 1. Classification + +- [ ] **Category Identified:** Bug is classified as signal/flow, context, fault propagation, result, workflow, or integration. +- [ ] **Root Cause Located:** The specific file, method, and line where the defect originates is identified. +- [ ] **Root Cause Verified:** The proposed cause actually explains the observed behavior (not just correlated). + +## 2. Isolation + +- [ ] **Fault Boundary Narrowed:** Binary search or tracing confirms exactly where behavior diverges from expectation. +- [ ] **Findings Labeled:** Each finding is marked as **verified** (observed) or **inferred** (hypothesized). +- [ ] **Related Code Inspected:** Sibling code paths (other tasks, similar runtime paths) checked for the same defect. +- [ ] **No Red Herrings:** Symptoms that looked related but aren't have been eliminated. + +## 3. Fix Implementation + +- [ ] **Root Cause Fixed:** The fix addresses the root cause, not the symptom. +- [ ] **Failing Spec First:** A spec that captures the exact bug was written before the fix. +- [ ] **Minimal Change:** The fix is the smallest possible change that addresses the root cause. +- [ ] **Analogous Code Checked:** Sibling code paths inspected for how they handle the same situation. +- [ ] **Related Occurrences Searched:** If the bug is a pattern, searched for the same pattern elsewhere. +- [ ] **CMDx Patterns Followed:** + - `catch`/`throw` for signal flow (not exceptions). + - Signal construction in task private methods only. + - Context mutations through `store`/`merge`/`delete`. +- [ ] **No Side Effects:** The fix doesn't change behavior for currently-passing scenarios. +- [ ] **Isolated Commit:** Bugfix is not mixed with refactoring. + +## 4. Validation + +- [ ] **Reproduction Passes:** The previously-failing reproduction script/spec now passes. +- [ ] **Test Suite Passes:** `bundle exec rspec .` passes with zero failures. +- [ ] **Linter Passes:** `bundle exec rubocop .` passes with zero offenses. +- [ ] **No Collateral Regressions:** Related specs (same category/file) inspected and passing. +- [ ] **Edge Cases Tested:** Nil, empty, frozen, and boundary scenarios related to the fix are verified. + +## 5. Regression Guard + +- [ ] **Spec Covers Exact Input:** The new spec uses the same input shape that triggered the bug. +- [ ] **Edge Cases Covered:** If the bug was at a boundary (nil, empty, frozen), the spec tests that boundary. +- [ ] **Constraint Documented:** If the fix introduces or reveals a non-obvious constraint, a code comment explains it. + +## 6. Documentation + +- [ ] **One-Line Summary:** Root cause stated in a single sentence for the commit message. +- [ ] **CHANGELOG Updated:** `## Bug Fixes` entry describes what was broken and the root cause. +- [ ] **YARD Updated:** If the fix changed method contracts, params, return types, or added raises, YARD docs are updated. +- [ ] **No Stale Docs:** Existing documentation that contradicts the fix is corrected. + +## 7. Anti-Pattern Avoidance + +- [ ] **No Broad Rescue:** Fix doesn't introduce `rescue StandardError` that swallows faults. +- [ ] **No Post-Freeze Mutation:** Fix doesn't mutate context after result generation. +- [ ] **No Double Signal:** Fix doesn't introduce paths where multiple halt signals can fire. +- [ ] **No String Keys:** Context lookups use symbol keys. +- [ ] **No External `catch(:cmdx)`:** Fix doesn't intercept `Signal::TAG` outside runtime. +- [ ] **No Nil Guards Masking Upstream Bugs:** Nil checks are justified, not papering over a missing value. +- [ ] **No Debug Code Left:** `binding.break`, `pp`, `puts` debugging removed before commit. diff --git a/.cursor/skills/performance-optimizations/SKILL.md b/.cursor/skills/performance-optimizations/SKILL.md new file mode 100644 index 000000000..622d10067 --- /dev/null +++ b/.cursor/skills/performance-optimizations/SKILL.md @@ -0,0 +1,147 @@ +--- +name: performance-optimizations +description: Profile, benchmark, and optimize CMDx task execution, context handling, and runtime hot paths. Use when the user mentions performance, benchmarking, profiling, memory allocation, optimization, slow execution, or YJIT. Don't use for general refactoring, feature additions, or test-only changes. +--- + +# Performance Optimization + +## Prerequisites + +Ensure these tools are available before profiling: + +```bash +gem install benchmark-ips memory_profiler ruby-prof +``` + +## Procedures + +**Step 0: Baseline from Siblings** + +Inspect **similar code paths** in the repo (e.g. another task class, context builder, or runtime hook) and compare allocation/query patterns. This keeps recommendations aligned with how the rest of CMDx solves the same class of problem and avoids introducing an inconsistent optimization style. + +**Step 1: Establish a Baseline** + +1. Run `ruby scripts/ips-benchmark.rb` for iterations/second across all scenarios, context construction, and access patterns. +2. Run `ruby scripts/memory-profile.rb [scenario]` for per-file/per-line allocation and retained memory report. +3. Run `ruby scripts/allocation-trace.rb` for per-class allocation counts filtered to CMDx source. +4. Run `ruby scripts/yjit-compare.rb` to measure YJIT speedup delta. +5. Save all outputs to a scratch file for before/after comparison. + +All script paths are relative to `.cursor/skills/performance-optimizations/`. + +**Step 2: Identify the Bottleneck** + +Classify the bottleneck: + - **CPU-bound** (deep call stacks, slow methods) → focus on algorithmic changes, memoization, or YJIT-friendly patterns. + - **Allocation-bound** (high GC pressure, many short-lived objects) → focus on object reuse, frozen constants, and avoiding intermediate collections. + +Prioritize bottlenecks by **impact × frequency** — a method adding 0.1ms overhead called 1000× per execution outranks a 5ms method called once. + +**Step 3: Classify and Apply the Optimization** + +Determine which category the fix falls into — prefer low-risk categories first: + +| Category | Risk | Examples | +|---|---|---| +| **Allocation reduction** | Low | Frozen strings, `map!` vs `map`, reuse buffers, `EMPTY_HASH`/`EMPTY_ARRAY` | +| **Algorithmic** | Medium | Hash lookup vs linear scan, early returns, `catch`/`throw` vs exceptions | +| **Concurrency** | High | Thread-safe memoization, parallel task execution | +| **Architectural** | High | Structural change to context/runtime data flow | + +Then follow these rules in priority order: + +1. **Reuse frozen constants** — use `EMPTY_HASH`, `EMPTY_ARRAY`, `EMPTY_STRING` instead of allocating literals in hot paths. These are defined in `lib/cmdx.rb`. +2. **Memoize expensive computations** — use `@foo ||=` for values computed more than once per instance lifetime. +3. **Use `catch`/`throw` for flow control** — this is already the pattern in `Runtime`; never replace it with exceptions for non-error control flow since `throw` is ~10x faster than `raise`. +4. **Estimate impact** before committing — e.g. "reduces allocations ~60%", "eliminates O(n²) lookup". Avoid fake precision; order-of-magnitude or directional estimates are fine. +5. Keep the optimization **isolated** — do not mix performance changes with feature changes in the same commit. + +**Step 4: Validate the Change** + +1. Re-run `ruby scripts/ips-benchmark.rb` and compare iterations/second against the baseline. +2. Re-run `ruby scripts/memory-profile.rb` and confirm allocated memory/objects decreased (or stayed flat). +3. Re-run `ruby scripts/allocation-trace.rb` and confirm allocation counts decreased (or stayed flat). +4. Run `bundle exec rspec .` to ensure no regressions. +5. Run `bundle exec rubocop .` to ensure style compliance (rubocop-performance cops are active). +6. Verify no regressions in other metrics (e.g. fixing memory shouldn't degrade latency). + +**Step 5: Guard Against Regression** + +1. Add a spec or benchmark that exercises the optimized path if the bottleneck was severe. +2. Consider **performance budgets** for hot paths — e.g. max allocation count or IPS floor that CI can enforce. +3. If improvement is <5%, reconsider whether the change is worth the added complexity. + +**Step 6: Document the Change** + +1. Add a `## Performance` entry to `CHANGELOG.md` noting the before/after numbers. +2. If a new frozen constant or memoized value was introduced, add a brief YARD comment explaining the trade-off. + +## General Principles + +**Measure, don't guess.** Profiling data drives every decision. Intuition about performance is wrong more often than right. + +**Optimize the hot path.** Code that runs once during boot doesn't matter. Code in a per-task or per-context-access loop does. + +**Reduce allocations before reducing instructions.** In Ruby, GC pressure from object churn often dominates CPU time. Fewer allocations → fewer GC pauses → lower p99 latency. + +**Fail fast.** Guard clauses and early returns prevent wasted work. Check the cheapest condition first. + +**Prefer lazy over eager (for large datasets).** Use lazy enumerators and streaming when processing unbounded collections. + +**Avoid work entirely.** The fastest code is code that doesn't run. Conditional execution and `skip!` guards eliminate unnecessary computation. + +**Cache computed results.** If the same computation runs repeatedly with the same inputs, memoize it. Prefer instance-level `@foo ||=` → class-level constant → external cache. + +## Anti-Patterns + +| Anti-Pattern | Why It Hurts | Fix | +|---|---|---| +| Optimizing without profiling | Wastes time on non-bottlenecks | Profile first, always | +| Micro-optimizing cold paths | No measurable user impact | Focus on hot paths only | +| String concatenation in loops | Creates intermediate string objects | Use `String#<<`, `Array#join`, or `StringIO` | +| Nested loops over collections | O(n²) or worse | Hash lookup, precompute, or restructure | +| Unbounded in-memory collections | RSS spikes, OOM risk, GC stalls | Lazy enumerators, streaming | +| Over-memoizing | Holds references, prevents GC | Memoize only expensive computations | + +## Key Patterns + +### Frozen Constant Reuse + +```ruby +# lib/cmdx.rb already defines these — use them as defaults +EMPTY_ARRAY = [].freeze +EMPTY_HASH = {}.freeze +EMPTY_STRING = "" +``` + +## YJIT Considerations + +- Keep methods short and monomorphic — YJIT inlines small methods aggressively. +- Avoid `method_missing` fan-out on the critical path; YJIT cannot optimize dynamic dispatch. +- `freeze` on value objects helps YJIT prove immutability and skip write barriers. +- Run benchmarks with and without YJIT to measure the delta; report both numbers. + +## Scripts Reference + +| Script | Gem Dependency | Purpose | +|--------|---------------|---------| +| `scripts/ips-benchmark.rb` | `benchmark-ips` | IPS across execution scenarios, context construction, and access patterns with `compare!` | +| `scripts/memory-profile.rb` | `memory_profiler` | Per-file/per-line allocated and retained memory; accepts scenario arg | +| `scripts/allocation-trace.rb` | stdlib (`objspace`) | Per-class allocation counts via ObjectSpace tracing filtered to CMDx | +| `scripts/yjit-compare.rb` | `benchmark-ips` | Side-by-side YJIT on/off runs with speedup ratios (Ruby 3.3+) | + +The project also ships `bin/benchmark` (basic IPS) and `bin/profile` (ruby-prof call graph). + +## Final Validation + +Cross-reference the completed optimization against `references/checklist.md`. + +## Error Handling + +- If any script fails with `LoadError`, install the missing gem: `gem install benchmark-ips memory_profiler ruby-prof`. +- If `scripts/allocation-trace.rb` reports `0` allocations, the test helpers may be out of sync with the current codebase. Verify `spec/support/helpers/task_builders.rb` defines the referenced builder methods. +- If `scripts/yjit-compare.rb` aborts with "YJIT not available", ensure CRuby 3.1+ built with `--enable-yjit`. Runtime enable requires Ruby 3.3+. +- If benchmark numbers are noisy (>10% variance between runs), increase warmup time or close background processes. +- If profiling shows no clear bottleneck, check whether the system is I/O-bound — external service latency, file reads, or network round trips. +- If an optimization introduces test failures, it changed behavior — revert and re-profile. +- If memory improves but latency worsens (or vice versa), evaluate whether the trade-off is acceptable for the workload. diff --git a/.cursor/skills/performance-optimizations/references/checklist.md b/.cursor/skills/performance-optimizations/references/checklist.md new file mode 100644 index 000000000..a04c319a6 --- /dev/null +++ b/.cursor/skills/performance-optimizations/references/checklist.md @@ -0,0 +1,66 @@ +# Performance Optimization Checklist + +Final audit before merging a CMDx performance optimization. Every item must pass. + +## 0. Context Clarification + +- [ ] **Hot Path Identified:** The task/runtime path, expected call volume, and performance targets are understood. +- [ ] **Pain Categorized:** The bottleneck class is named — CPU-bound or allocation-bound. +- [ ] **Constraints Stated:** Correctness, deploy risk, and maintainability trade-offs are acknowledged. +- [ ] **Sibling Baseline:** Similar code paths in the repo (other task classes, context builders, runtime hooks) have been inspected to keep the optimization consistent with existing patterns. + +## 1. Baseline Measurement + +- [ ] **Symptom Identified:** The specific symptom is documented (slow execution, high memory, excess allocations). +- [ ] **Reproducible:** The issue is reproducible under consistent conditions (same task, same context shape). +- [ ] **Scripts Run:** Baseline captured with the profiling scripts: + - `scripts/ips-benchmark.rb` — iterations/second + - `scripts/memory-profile.rb` — allocated/retained memory + - `scripts/allocation-trace.rb` — per-class allocation counts + - `scripts/yjit-compare.rb` — YJIT on/off delta +- [ ] **Numbers Saved:** Baseline outputs are saved for before/after comparison. + +## 2. Profiling + +- [ ] **Correct Profiler:** The appropriate tool is used (`benchmark-ips` for throughput, `memory_profiler` for allocations, `stackprof`/`ruby-prof` for call stacks, `ObjectSpace` for allocation tracing). +- [ ] **Bottleneck Identified:** The specific bottleneck is identified by profiling data, not intuition. +- [ ] **Impact × Frequency:** Bottleneck is prioritized by impact multiplied by call frequency. + +## 3. Classification + +- [ ] **Category Determined:** Optimization is classified as allocation reduction, algorithmic, concurrency, or architectural. +- [ ] **Risk Assessment:** Low-risk categories (allocation reduction) are preferred; high-risk changes (architectural) are justified by profiling data. + +## 4. Implementation + +- [ ] **CMDx Patterns Followed:** Frozen constants (`EMPTY_HASH`, `EMPTY_ARRAY`, `EMPTY_STRING`), `@foo ||=` memoization, and `catch`/`throw` flow control are used where applicable. +- [ ] **Minimal Change:** The smallest possible change addresses the profiled bottleneck. +- [ ] **Impact Estimated:** Expected improvement is stated directionally (e.g. "reduces allocations ~60%", "eliminates O(n²) lookup") — no fake precision. +- [ ] **Isolated Commit:** Performance change is not mixed with feature changes. +- [ ] **No Premature Abstraction:** The concrete case is optimized first. +- [ ] **YJIT-Friendly:** Methods on the hot path remain short and monomorphic; no `method_missing` fan-out on the critical path. + +## 5. Validation + +- [ ] **Same Scripts Re-run:** The exact same profiling scripts from Step 1 are re-run. +- [ ] **Compared to Baseline:** Results are compared against saved baseline numbers. +- [ ] **No Regressions:** No regressions in other metrics (memory ↔ latency trade-off evaluated). +- [ ] **Meaningful Improvement:** Improvement is ≥5% or the complexity trade-off is justified. +- [ ] **Tests Pass:** `bundle exec rspec .` passes. +- [ ] **Style Pass:** `bundle exec rubocop .` passes (rubocop-performance cops are active). + +## 6. Regression Guard + +- [ ] **Spec or Benchmark:** A spec or benchmark exercises the optimized path (for severe bottlenecks). +- [ ] **Performance Budget:** For hot paths, a budget is considered (max allocation count or IPS floor that CI can enforce). +- [ ] **Trade-offs Documented:** Memory vs CPU or complexity vs gain trade-offs are stated when present. +- [ ] **Non-Obvious Comment:** Optimization rationale is documented in a code comment only if the "why" is non-obvious. +- [ ] **CHANGELOG Updated:** `## Performance` entry added with before/after numbers. + +## 7. Anti-Pattern Avoidance + +- [ ] **No Blind Optimization:** Change is backed by profiling data. +- [ ] **Hot Path Only:** Optimization targets hot paths, not cold/boot paths. +- [ ] **No String Concat in Loops:** Uses `String#<<`, `Array#join`, or `StringIO` instead. +- [ ] **No Over-Memoization:** Memoization is limited to expensive computations; not holding references that prevent GC. +- [ ] **No Unbounded Collections:** Large datasets use lazy enumerators or streaming. diff --git a/.cursor/skills/performance-optimizations/scripts/allocation-trace.rb b/.cursor/skills/performance-optimizations/scripts/allocation-trace.rb new file mode 100755 index 000000000..11ba6d2f0 --- /dev/null +++ b/.cursor/skills/performance-optimizations/scripts/allocation-trace.rb @@ -0,0 +1,59 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# USAGE: ruby .cursor/skills/performance-optimizations/scripts/allocation-trace.rb +# +# Traces object allocations during a single CMDx task execution cycle. +# Reports per-class allocation counts sorted by frequency. + +require_relative "../../../../lib/cmdx" +require_relative "../../../../spec/support/helpers/task_builders" + +include CMDx::Testing::TaskBuilders # rubocop:disable Style/MixinUsage + +SCENARIOS = { + "success" => -> { create_successful_task }, + "skipped" => -> { create_skipping_task }, + "failed" => -> { create_failing_task }, + "errored" => -> { create_erroring_task } +}.freeze + +def trace_allocations(label, task_class) + task_class.execute + + GC.start + GC.disable + + alloc_counts = Hash.new(0) + + ObjectSpace.trace_object_allocations_start + + result = task_class.execute + + ObjectSpace.trace_object_allocations_stop + + ObjectSpace.each_object do |obj| + file = ObjectSpace.allocation_sourcefile(obj) + next unless file&.include?("cmdx") + + klass = obj.class.name || obj.class.to_s + alloc_counts[klass] += 1 + end + + GC.enable + + puts "--- #{label} (status: #{result.status}) ---" + alloc_counts.sort_by { |_, count| -count }.each do |klass, count| + puts " #{klass.ljust(30)} #{count}" + end + puts +end + +puts "CMDx Allocation Trace" +puts "Ruby: #{RUBY_VERSION} | YJIT: #{defined?(RubyVM::YJIT) ? 'available' : 'unavailable'}" +puts + +SCENARIOS.each do |label, builder| + task_class = builder.call + trace_allocations(label, task_class) +end diff --git a/.cursor/skills/performance-optimizations/scripts/ips-benchmark.rb b/.cursor/skills/performance-optimizations/scripts/ips-benchmark.rb new file mode 100755 index 000000000..3d730a8e6 --- /dev/null +++ b/.cursor/skills/performance-optimizations/scripts/ips-benchmark.rb @@ -0,0 +1,76 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# USAGE: ruby .cursor/skills/performance-optimizations/scripts/ips-benchmark.rb +# +# Detailed iterations/second benchmark covering task execution scenarios, +# context access patterns, and nested task dispatch. +# Runs with comparison mode so regressions are immediately visible. + +begin + require "benchmark/ips" +rescue LoadError + abort "Install with: gem install benchmark-ips" +end + +require_relative "../../../../lib/cmdx" +require_relative "../../../../spec/support/helpers/task_builders" + +include CMDx::Testing::TaskBuilders # rubocop:disable Style/MixinUsage + +puts "CMDx IPS Benchmark" +puts "Ruby: #{RUBY_VERSION} | YJIT: #{defined?(RubyVM::YJIT) && RubyVM::YJIT.enabled? ? 'enabled' : 'disabled'}" +puts + +successful_task = create_successful_task +skipping_task = create_skipping_task +failing_task = create_failing_task +erroring_task = create_erroring_task +nested_task = create_nested_task(strategy: :swallow, status: :success) + +puts "=== Task Execution ===" +Benchmark.ips do |x| + x.config(warmup: 2, time: 5) + + x.report("success") { successful_task.execute } + x.report("skip!") { skipping_task.execute } + x.report("fail!") { failing_task.execute } + x.report("error (rescue)") { erroring_task.execute } + x.report("nested (3-deep)") { nested_task.execute } + + x.compare! +end + +puts +puts "=== Context Construction ===" +small_hash = { a: 1, b: 2, c: 3 } +large_hash = (1..50).each_with_object({}) { |i, h| h[:"key_#{i}"] = i } +string_hash = { "a" => 1, "b" => 2, "c" => 3 } + +Benchmark.ips do |x| + x.config(warmup: 2, time: 5) + + x.report("Context.new (3 sym keys)") { CMDx::Context.new(small_hash) } + x.report("Context.new (3 str keys)") { CMDx::Context.new(string_hash) } + x.report("Context.new (50 sym keys)") { CMDx::Context.new(large_hash) } + x.report("Context.build (passthrough)") { CMDx::Context.build(CMDx::Context.new(small_hash)) } + + x.compare! +end + +puts +puts "=== Context Access ===" +ctx = CMDx::Context.new(a: 1, b: 2, c: 3) + +Benchmark.ips do |x| + x.config(warmup: 2, time: 5) + + x.report("ctx[:a] (bracket)") { ctx[:a] } + x.report("ctx.fetch(:a)") { ctx.fetch(:a) } + x.report("ctx.a (method_missing)") { ctx.a } + x.report("ctx.a = 1 (mm setter)") { ctx.a = 1 } + x.report("ctx.store(:a, 1)") { ctx.store(:a, 1) } + x.report("ctx.key?(:a)") { ctx.key?(:a) } + + x.compare! +end diff --git a/.cursor/skills/performance-optimizations/scripts/memory-profile.rb b/.cursor/skills/performance-optimizations/scripts/memory-profile.rb new file mode 100755 index 000000000..7926ca660 --- /dev/null +++ b/.cursor/skills/performance-optimizations/scripts/memory-profile.rb @@ -0,0 +1,54 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# USAGE: ruby .cursor/skills/performance-optimizations/scripts/memory-profile.rb [scenario] +# +# Per-file and per-line memory allocation report using the memory_profiler gem. +# Scenarios: success (default), skipped, failed, errored, nested +# +# Output includes: +# - Total allocated/retained memory and objects +# - Top allocation sites by file and line +# - Per-gem breakdown + +begin + require "memory_profiler" +rescue LoadError + abort "Install with: gem install memory_profiler" +end + +require_relative "../../../../lib/cmdx" +require_relative "../../../../spec/support/helpers/task_builders" + +include CMDx::Testing::TaskBuilders # rubocop:disable Style/MixinUsage + +SCENARIOS = { + "success" => -> { create_successful_task }, + "skipped" => -> { create_skipping_task }, + "failed" => -> { create_failing_task }, + "errored" => -> { create_erroring_task }, + "nested" => -> { create_nested_task(strategy: :swallow, status: :success) } +}.freeze + +scenario = ARGV.fetch(0, "success") +builder = SCENARIOS.fetch(scenario) { abort "Unknown scenario: #{scenario}. Choose: #{SCENARIOS.keys.join(', ')}" } + +task_class = builder.call + +# Warm up to avoid measuring autoload/class creation +task_class.execute + +puts "CMDx Memory Profile — #{scenario}" +puts "Ruby: #{RUBY_VERSION} | YJIT: #{defined?(RubyVM::YJIT) && RubyVM::YJIT.enabled? ? 'enabled' : 'disabled'}" +puts + +report = MemoryProfiler.report(allow_files: "cmdx") do + task_class.execute +end + +report.pretty_print( + detailed_report: true, + allocated_strings: 10, + retained_strings: 5, + scale_bytes: true +) diff --git a/.cursor/skills/performance-optimizations/scripts/yjit-compare.rb b/.cursor/skills/performance-optimizations/scripts/yjit-compare.rb new file mode 100755 index 000000000..534c431bc --- /dev/null +++ b/.cursor/skills/performance-optimizations/scripts/yjit-compare.rb @@ -0,0 +1,72 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# USAGE: ruby .cursor/skills/performance-optimizations/scripts/yjit-compare.rb +# +# Runs the same benchmark suite twice — once without YJIT and once with YJIT +# enabled — then prints a side-by-side comparison with speedup ratios. +# Requires Ruby 3.3+ for runtime YJIT enable/disable. + +begin + require "benchmark/ips" +rescue LoadError + abort "Install with: gem install benchmark-ips" +end + +require_relative "../../../../lib/cmdx" +require_relative "../../../../spec/support/helpers/task_builders" + +include CMDx::Testing::TaskBuilders # rubocop:disable Style/MixinUsage + +puts "CMDx YJIT Comparison" +puts "Ruby: #{RUBY_VERSION}" + +abort "YJIT not available on this Ruby build. Use CRuby 3.1+ built with --enable-yjit." unless defined?(RubyVM::YJIT) + +successful_task = create_successful_task +skipping_task = create_skipping_task +failing_task = create_failing_task +ctx_hash = { a: 1, b: 2, c: 3 } + +BENCHMARKS = { + "Task.execute (success)" => -> { successful_task.execute }, + "Task.execute (skip!)" => -> { skipping_task.execute }, + "Task.execute (fail!)" => -> { failing_task.execute }, + "Context.new (3 keys)" => -> { CMDx::Context.new(ctx_hash) }, + "ctx[:a] (bracket)" => -> { CMDx::Context.new(ctx_hash)[:a] } +}.freeze + +def run_suite(label) + puts + puts "--- #{label} ---" + results = {} + + BENCHMARKS.each do |name, work| + report = Benchmark::IPS::Job.new + report.config(warmup: 1, time: 3, quiet: true) + report.report(name, &work) + report.run + + entry = report.entries.first + results[name] = entry.ips + puts " #{name.ljust(30)} #{format('%.1f', entry.ips)} i/s" + end + + results +end + +no_yjit = run_suite("Without YJIT") + +RubyVM::YJIT.enable +yjit = run_suite("With YJIT") + +puts +puts "=== Speedup (YJIT / no-YJIT) ===" +BENCHMARKS.each_key do |name| + base = no_yjit[name] + fast = yjit[name] + ratio = fast / base + + bar = "|" + ("#" * [(ratio * 20).to_i, 60].min) + puts " #{name.ljust(30)} #{format('%.2fx', ratio)} #{bar}" +end diff --git a/.cursor/skills/skill-patterns/SKILL.md b/.cursor/skills/skill-patterns/SKILL.md new file mode 100644 index 000000000..1de3e20b7 --- /dev/null +++ b/.cursor/skills/skill-patterns/SKILL.md @@ -0,0 +1,52 @@ +--- +name: skill-creator +description: Authors and structures professional-grade agent skills following the agentskills.io spec. Use when creating new skill directories, drafting procedural instructions, or optimizing metadata for discoverability. Don't use for general documentation, non-agentic library code, or README files. +--- + +# Skill Authoring Procedure + +Follow these steps to generate a skill that adheres to the agentskills.io specification and progressive disclosure principles. + +## Step 1: Initialize and Validate Metadata + +1. Define a unique `name`: 1-64 characters, lowercase, numbers, and single hyphens only. +2. Draft a `description`: Max 1,024 characters, written in the third person, including negative triggers. +3. **Execute Validation Script:** Run the validation script to ensure compliance before proceeding: + `python3 scripts/validate-metadata.py --name "[name]" --description "[description]"` +4. If the script returns an error, self-correct the metadata based on the `stderr` output and re-run until successful. + +## Step 2: Structure the Directory + +1. Create the root directory using the validated `name`. +2. Initialize the following subdirectories: + - `scripts/`: For tiny CLI tools and deterministic logic. + - `references/`: For flat (one-level deep) context like schemas or API docs. + - `assets/`: For output templates, JSON schemas, or static files. +3. Ensure no human-centric files (README.md, INSTALLATION.md) are created. + +## Step 3: Draft Core Logic (SKILL.md) + +1. Use the template in `assets/skill-template.md` as the starting point. +2. Write all instructions in the **third-person imperative** (e.g., "Extract the text," "Run the build"). +3. **Enforce Progressive Disclosure:** + - Keep the main logic under 500 lines. + - If a procedure requires a large schema or complex rule set, move it to `references/`. + - Command the agent to read the specific file only when needed: *"Read references/api-spec.md to identify the correct endpoint."* + +## Step 4: Identify and Bundle Scripts + +1. Identify "fragile" tasks (regex, complex parsing, or repetitive boilerplate). +2. Outline a single-purpose script for the `scripts/` directory. +3. Ensure the script uses standard output (stdout/stderr) to communicate success or failure to the agent. + +## Step 5: Final Logic Validation + +1. Review the `SKILL.md` for "hallucination gaps" (points where the agent is forced to guess). +2. Verify all file paths are **relative** and use forward slashes (`/`). +3. Cross-reference the final output against `references/checklist.md`. + +## Error Handling + +- **Metadata Failure:** If `scripts/validate-metadata.py` fails, identify the specific error (e.g., "STYLE ERROR") and rewrite the field to remove first/second person pronouns. +- **Context Bloat:** If the draft exceeds 500 lines, extract the largest procedural block and move it to a file in `references/`. +- **Overlapping commands:** Before creating a new command, check existing commands that already cover the workflow. Extend an existing command rather than creating a duplicate. diff --git a/.cursor/skills/skill-patterns/assets/SKILL.template.md b/.cursor/skills/skill-patterns/assets/SKILL.template.md new file mode 100644 index 000000000..f32c7b28c --- /dev/null +++ b/.cursor/skills/skill-patterns/assets/SKILL.template.md @@ -0,0 +1,22 @@ +--- +name: [skill-name] +description: [Action-oriented capability description in the third person. Max 1024 characters. Include positive triggers. Do not use for [explicit negative triggers].] +--- + +# [Skill Title] + +## Procedures + +**Step 1: [Action Phase]** +1. [Third-person imperative instruction, e.g., "Extract the query parameters..."] +2. [Instruction referencing an asset, e.g., "Read `assets/template.json` to structure the final output."] + +**Step 2: [Action Phase]** +1. [Decision tree/conditional logic, e.g., "If source maps are required, run `scripts/build.sh`. Otherwise, skip to Step 3."] +2. [Instruction requiring JiT loading, e.g., "Read `references/auth-flow.md` to map the specific error codes."] +3. Execute `python scripts/[script-name].py` to [perform deterministic action]. + +## Error Handling + +* If `scripts/[script-name].py` fails due to [specific edge case], execute [recovery step]. +* If [condition B occurs], read `references/[troubleshooting-file].md`. diff --git a/.cursor/skills/skill-patterns/references/checklist.md b/.cursor/skills/skill-patterns/references/checklist.md new file mode 100644 index 000000000..84e0169ee --- /dev/null +++ b/.cursor/skills/skill-patterns/references/checklist.md @@ -0,0 +1,37 @@ +# Agent Skill Validation Checklist + +Use this checklist to perform a final audit of the generated skill before deployment. Every item must be marked as "Pass" to ensure the skill is discoverable, lean, and deterministic. + +## 1. Metadata & Discovery + +- [ ] **Naming:** The `name` field is 1-64 characters, lowercase, and contains only numbers or single hyphens. +- [ ] **Directory Match:** The `name` field exactly matches the parent directory name. +- [ ] **Description Length:** The description is under 1,024 characters. +- [ ] **Trigger Optimization:** The description includes both use cases ("Use when...") and negative triggers ("Don't use for..."). +- [ ] **Third-Person Tone:** The description avoids "I", "me", "my", "you", or "your". + +## 2. File Structure & Paths + +- [ ] **Flat Hierarchy:** All files in `scripts/`, `references/`, and `assets/` are exactly one level deep (no nested subfolders). +- [ ] **Standard Folders:** Only use `scripts/`, `references/`, and `assets/`. +- [ ] **No Human Docs:** The directory contains NO `README.md`, `CHANGELOG.md`, or `INSTALLATION_GUIDE.md`. +- [ ] **Forward Slashes:** All file paths in `SKILL.md` use forward slashes (`/`) regardless of the operating system. + +## 3. Logic & Instructions (SKILL.md) + +- [ ] **Lean Context:** The `SKILL.md` file is under 500 lines. +- [ ] **Imperative Mood:** Instructions use direct commands (e.g., "Extract," "Run," "Validate"). +- [ ] **Deterministic Steps:** The workflow is a numbered, chronological sequence with clear decision trees. +- [ ] **Progressive Disclosure:** Large schemas, templates, or rule sets are stored in `references/` or `assets/` and read only when needed. +- [ ] **Specific Terminology:** Uses domain-native terms consistently (e.g., "component" instead of "file"). + +## 4. Scripts & Determinism + +- [ ] **CLI Design:** Scripts in `scripts/` are designed as tiny CLIs that take arguments. +- [ ] **Feedback Loop:** Scripts provide descriptive `stdout` for success and `stderr` for failure to allow agent self-correction. +- [ ] **No Library Code:** Scripts are single-purpose; complex logic is offloaded to the repository's standard CLI or external tools. + +## 5. Error Handling + +- [ ] **Edge Cases:** The `SKILL.md` includes an "Error Handling" section addressing common failure states or missing configurations. +- [ ] **Validation:** The `SKILL.md` includes a step to run validation scripts where applicable. diff --git a/.cursor/skills/skill-patterns/scripts/validate-metadata.py b/.cursor/skills/skill-patterns/scripts/validate-metadata.py new file mode 100644 index 000000000..68a3f5724 --- /dev/null +++ b/.cursor/skills/skill-patterns/scripts/validate-metadata.py @@ -0,0 +1,50 @@ +import re +import sys +import argparse + +def validate_metadata(name, description): + errors = [] + + # 1. Validate Name Length + if not (1 <= len(name) <= 64): + errors.append(f"NAME ERROR: '{name}' is {len(name)} characters. Must be between 1-64.") + + # 2. Validate Name Characters (lowercase, numbers, single hyphens) + # Regex: Starts/ends with alphanumeric, allows single hyphens in between + if not re.match(r"^[a-z0-9]+(-[a-z0-9]+)*$", name): + errors.append( + f"NAME ERROR: '{name}' contains invalid characters. " + "Use only lowercase letters, numbers, and single hyphens. " + "No consecutive hyphens, and cannot start/end with a hyphen." + ) + + # 3. Validate Description Length + if len(description) > 1024: + errors.append( + f"DESCRIPTION ERROR: Description is {len(description)} characters. " + "Must be 1,024 characters or fewer." + ) + + # 4. Check for Third-Person Perspective (Basic Heuristic) + first_person_words = {"i", "me", "my", "we", "our", "you", "your"} + desc_words = set(re.findall(r'\b\w+\b', description.lower())) + found_forbidden = first_person_words.intersection(desc_words) + if found_forbidden: + errors.append( + f"STYLE WARNING: Description contains first/second person terms: {found_forbidden}. " + "Use third-person imperative (e.g., 'Creates...', 'Updates...')." + ) + + if errors: + print("\n".join(errors), file=sys.stderr) + sys.exit(1) + else: + print("SUCCESS: Metadata is valid and optimized for discovery.") + sys.exit(0) + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Validate skill metadata") + parser.add_argument("--name", required=True, help="Skill name") + parser.add_argument("--description", required=True, help="Skill description") + args = parser.parse_args() + validate_metadata(args.name, args.description) diff --git a/.cursor/skills/technical-docs/SKILL.md b/.cursor/skills/technical-docs/SKILL.md new file mode 100644 index 000000000..c46f4235d --- /dev/null +++ b/.cursor/skills/technical-docs/SKILL.md @@ -0,0 +1,139 @@ +--- +name: technical-docs +description: Write, update, and maintain YARD documentation and CHANGELOG entries for CMDx classes, modules, and methods. Use when the user asks to document, add YARD docs, update docs, write docstrings, add @param/@return tags, update CHANGELOG, or fix documentation inconsistencies. Don't use for README generation, non-agentic library docs, or code-only changes. +--- + +# Technical Documentation + +> **Scope check:** Confirm which classes, modules, or methods need documentation and whether CHANGELOG updates are required before writing. + +## Prerequisites + +Ensure YARD is available and the codebase is lintable: + +```bash +bundle exec yard stats --list-undoc +bundle exec rubocop . +``` + +## Procedures + +**Step 1: Identify Documentation Targets** + +1. Determine what needs documentation: new code, changed signatures, missing YARD tags, or CHANGELOG entries. +2. Run `bundle exec yard stats --list-undoc` to find undocumented methods and classes. +3. Read the source file(s) to understand the public API, parameters, return types, and side effects. +4. Check git diff for recently changed method signatures — these need doc updates. + +**Step 2: Read Existing Documentation** + +Before writing, scan adjacent documented code in the same file or module: + +1. Match the tone, depth, and tag order of sibling methods. +2. Note any `@see`, `@note`, or `@example` patterns already in use. +3. Ensure new docs read as siblings — not outliers. + +**Step 3: Write YARD Documentation** + +Use the templates in `assets/yard-templates.md` as starting points. Adapt to the code's complexity. + +Core rules: + +1. **Document every public method and class.** Private methods only when behavior is non-obvious (signal methods, `method_missing` delegates). +2. **No `CMDx` module-level docs.** Do not add YARD documentation to the top-level `module CMDx` declaration. +3. **Module/class-level docs: description only.** No `@example` or `@since` on module or class-level docstrings. +4. **Lead with intent.** The first line answers "what does this do and why?" — not "this method does X." +5. **Use `@param`, `@option`, `@return`, `@raise`, `@yield`, `@yieldparam`, `@example`** — in that order. +6. **Expand hash params with `@option`.** When a `@param` is a `Hash`, enumerate its keys with `@option` tags. +7. **No `@since` anywhere.** Neither module-level nor method-level docs use `@since`. +8. **Type annotations use YARD syntax:** `String`, `Symbol`, `Hash{Symbol => Object}`, `Array<String>`, `nil`, `void`, `Boolean`. +9. **`@return [void]`** for methods whose return value is not part of the API contract (side-effect-only methods, signal methods). +10. **Frozen return values** — note when a return value is frozen (e.g., `Result`, `Context` after signal). +11. **`@note`** for constraints the caller must know: thread safety, freeze semantics, `catch`/`throw` flow interruption. +12. **`@see`** for cross-references to related classes or methods. +13. **`@example`** on methods for non-obvious usage. Keep examples minimal — 3–5 lines max. +14. **No filler.** Don't restate the method name. `# Stores a value` on `#store` adds nothing — explain the symbolization or overwrite semantics instead. + +**Step 4: Document CMDx-Specific Patterns** + +These patterns require special documentation attention: + +| Pattern | Documentation Focus | +|---|---| +| Signal methods (`success!`, `skip!`, `fail!`, `throw!`) | Document `throw` semantics — method never returns. Note `@raise` for double-signal. | +| `Context#method_missing` | Document dynamic accessor behavior, `=` suffix for store, `?` suffix for presence. | +| `Context.build` passthrough | Document when a new context is created vs when the existing one is reused. | +| `Result` freeze cascade | Document that `Result.new` freezes both the result and its context. | +| `Runtime.execute` | Document `catch`/`throw` flow, exception rescue chain, and `raise_signal` behavior. | +| `on` callbacks | Document chaining pattern and predicate dispatch. | +| Pattern matching (`deconstruct`, `deconstruct_keys`) | Document the array/hash shapes returned for `case`/`in` usage. | + +**Step 5: Update CHANGELOG** + +When documenting alongside code changes, update `CHANGELOG.md`: + +1. Add entries under the appropriate `## [Unreleased]` section. +2. Use these categories in order: `### Added`, `### Changed`, `### Deprecated`, `### Removed`, `### Fixed`. +3. Each entry is a single bullet starting with the affected class/method in backticks. +4. Format: `` - `ClassName#method` — description of what changed `` +5. Group related changes under the same category. + +**Step 6: Verify** + +1. Run `bundle exec yard stats --list-undoc` — undocumented count should decrease or stay at zero. +2. Run `bundle exec rubocop .` — no new offenses. +3. Cross-reference the completed documentation against `references/checklist.md`. + +## YARD Tag Reference + +| Tag | Usage | When | +|---|---|---| +| `@param name [Type] description` | Method parameters | Always for public methods | +| `@option name [Type] :key description` | Hash param keys | When `@param` is a Hash | +| `@return [Type] description` | Return value | Always | +| `@raise [ExceptionClass] description` | Exceptions raised | When method can raise | +| `@yield [Type] description` | Block parameter | When method accepts a block | +| `@yieldparam name [Type] description` | Block parameters | When block receives arguments | +| `@example Title` | Usage example | Non-obvious APIs | +| `@note` | Constraints, caveats | Freeze, thread safety, flow interruption | +| `@see ClassName#method` | Cross-reference | Related methods or classes | +| `@api private` | Visibility marker | Internal methods exposed by Ruby's visibility | +| `@deprecated Use X instead` | Deprecation notice | Deprecated methods | +| `@overload` | Multiple signatures | Methods with polymorphic arguments | +| `@abstract` | Abstract methods | Template methods like `#work`, `#rollback` | +| `@todo` | Incomplete implementation | Placeholder methods | + +## CMDx Vocabulary + +Use these terms consistently in documentation prose: + +| Term | Meaning | Don't Say | +|---|---|---| +| **Task** | Unit of work with a `work` method | command, service, action | +| **Context** | Shared data object (`ctx`) passed through execution | params, attributes, state | +| **Signal** | Halt mechanism via `catch`/`throw` | event, message, notification | +| **Result** | Frozen outcome containing state, status, reason, metadata, context | response, output | +| **Fault** | Exception subclass for `execute!` error propagation | error (too generic) | +| **Runtime** | Execution wrapper managing `catch`/`throw` and rescue | executor, runner | +| **state** | Execution lifecycle: `executing`, `complete`, `interrupted` | phase, stage | +| **status** | Outcome: `success`, `skipped`, `failed` | result (ambiguous with Result) | + +## Anti-Patterns + +| Anti-Pattern | Why It Hurts | Fix | +|---|---|---| +| Restating the method name | `# Stores a key` on `#store` wastes tokens | Explain semantics: symbolization, overwrite behavior | +| Missing `@return` on public methods | YARD reports undocumented; callers can't infer types | Always add `@return` — use `void` for side-effect-only | +| Documenting private helpers extensively | Inflates docs, creates maintenance burden | Use `@api private` one-liner or skip | +| Stale parameter names | Renamed params with old `@param` tags mislead | Update `@param` tags when signatures change | +| Examples with fake data | `foo`, `bar`, `baz` examples don't help | Use realistic CMDx examples: task classes, context hashes | +| Documenting what, not why | "Returns the state" — obvious from the method name | "Returns the execution lifecycle state, frozen after result creation" | +| Missing signal semantics | Callers don't know `success!` never returns | Add `@note Throws `:cmdx` — control never returns to caller` | +| CHANGELOG without class reference | "Fixed a bug" is useless | `` `Context#merge` — fixed symbol key conversion on nested hashes `` | + +## Error Handling + +- If `yard stats` is not available, fall back to manual inspection of public methods without YARD comments. +- If a method's return type is genuinely polymorphic, use `@overload` to document each signature separately. +- If documentation contradicts code behavior, update the documentation to match the code — never the reverse. +- If a `@todo` tag exists on a method, preserve it and add documentation for the currently implemented behavior. diff --git a/.cursor/skills/technical-docs/assets/yard-templates.md b/.cursor/skills/technical-docs/assets/yard-templates.md new file mode 100644 index 000000000..9ada2a0cb --- /dev/null +++ b/.cursor/skills/technical-docs/assets/yard-templates.md @@ -0,0 +1,200 @@ +# YARD Documentation Templates + +## Class / Module + +No `@example` or `@since` at class/module level. Do not document the top-level `module CMDx`. + +```ruby +# Brief one-sentence purpose. +# +# Longer description if the class has non-obvious behavior, lifecycle, +# or freeze semantics worth calling out. +# +# @see RelatedClass +class MyClass +``` + +## Public Method (simple) + +```ruby +# Symbolizes and stores +value+ under +key+, overwriting any existing entry. +# +# @param key [Symbol, String] the context key (converted to Symbol) +# @param value [Object] the value to store +# @return [Object] the stored value +def store(key, value) +``` + +## Public Method (with block) + +```ruby +# Executes the task and yields the result to the block if given. +# +# @param context [Hash{Symbol => Object}] initial context values +# @option context [Integer] :user_id the user to process +# @yield [result] invoked after execution completes +# @yieldparam result [Result] the frozen execution result +# @return [Result] the execution result +# +# @example +# MyTask.execute(user_id: 1) do |result| +# result.on(:success) { |r| log(r.context) } +# end +# +# @see .execute! +def self.execute(context = {}, &) +``` + +## Public Method (hash param with @option) + +```ruby +# Initializes the context from a hash, symbolizing all keys. +# +# @param context [Hash{Symbol => Object}] key-value pairs for the context +# @option context [Array<Symbol>] :executed list of completed task names +# @option context [String] :reason human-readable explanation +# @return [Context] the initialized context +def initialize(context = EMPTY_HASH) +``` + +## Bang Method (raises) + +```ruby +# Executes the task. Raises on skip or failure instead of returning +# an interrupted result. +# +# @param context [Hash{Symbol => Object}] initial context values +# @return [Result] the execution result (always complete/success) +# @raise [SkipFault] when the task signals skip +# @raise [FailFault] when the task signals failure or an exception occurs +# +# @see .execute +def self.execute!(context = {}, &) +``` + +## Signal Method (never returns) + +```ruby +# Signals a successful halt with optional reason and metadata. +# +# Throws +:cmdx+ — control never returns to the caller. +# Calling after a signal was already thrown raises RuntimeError. +# +# @param reason [String, nil] human-readable explanation +# @param metadata [Hash{Symbol => Object}] arbitrary metadata attached to the result +# @return [void] method never returns +# @raise [RuntimeError] if a signal was already thrown in this execution +# +# @note Uses +throw(Signal::TAG)+ — must be called inside a +catch(:cmdx)+ block +# managed by Runtime. +def success!(reason = nil, **metadata) +``` + +## Abstract / Template Method + +```ruby +# Performs the task's core logic. Subclasses must override this method. +# +# Access context via +ctx+ or +context+. Mutate with +ctx.store+, +# +ctx.merge+, +ctx.delete+. Signal outcomes with +success!+, +skip!+, +# or +fail!+. +# +# @abstract Override in subclasses to define task behavior. +# @return [void] +# @raise [ImplementationError] if not overridden +def work +``` + +## Predicate Method + +```ruby +# Whether the execution completed without interruption. +# +# @return [Boolean] +def complete? +``` + +## Pattern Matching + +```ruby +# Deconstructs the result into a positional array for pattern matching. +# +# @return [Array(String, String, String, Hash, Exception)] +# +[state, status, reason, metadata, cause]+ +# +# @example +# case result +# in ["complete", "success", *] +# handle_success +# in [*, "failed", reason, *] +# handle_failure(reason) +# end +def deconstruct(*) +``` + +## Factory / Builder + +```ruby +# Builds a {Context} from the given input. Reuses an unfrozen Context +# as-is. Unwraps objects responding to +#context+. Wraps hashes into +# a new Context with symbolized keys. +# +# @param context [Context, #context, Hash, #to_h] the input to normalize +# @return [Context] a mutable context instance +# @raise [ArgumentError] if +context+ responds to neither +to_h+ nor +to_hash+ +def self.build(context = EMPTY_HASH) +``` + +## Dynamic Accessor (method_missing) + +```ruby +# Provides dynamic read/write/predicate access to context keys. +# +# - +ctx.name+ — reads +@table[:name]+, returns +nil+ if missing. +# - +ctx.name = val+ — stores +val+ under +:name+. +# - +ctx.name?+ — returns +true+ if +@table[:name?]+ is truthy. +# +# @note Returns +nil+ for missing keys — use +#key?+ or +#fetch+ for +# presence checks to avoid silent nils. +# +# @api private +def method_missing(method_name, *args, **_kwargs, &) +``` + +## Callback / Chaining + +```ruby +# Dispatches to the block when any of +keys+ match a truthy predicate +# on this result. Returns +self+ for chaining. +# +# @param keys [Array<Symbol>] predicate names (without +?+ suffix) +# @yield [result] invoked if any predicate returns true +# @yieldparam result [Result] this result instance +# @return [Result] self for chaining +# @raise [ArgumentError] if no block given +# +# @example +# result +# .on(:success) { |r| notify(r.context) } +# .on(:failed) { |r| alert(r.reason) } +def on(*keys, &) +``` + +## CHANGELOG Entry + +```markdown +## [Unreleased] + +### Added + +- `Context#retrieve` — fetch-or-store accessor with lazy default via block. +- `Result#on` — predicate-based callback dispatch with chaining support. + +### Changed + +- `Context#merge` — now returns `self` instead of the merged hash for chaining. + +### Fixed + +- `Context#method_missing` — predicate suffix (`?`) now checks `@table[:key?]` correctly. +``` diff --git a/.cursor/skills/technical-docs/references/checklist.md b/.cursor/skills/technical-docs/references/checklist.md new file mode 100644 index 000000000..8c5a37941 --- /dev/null +++ b/.cursor/skills/technical-docs/references/checklist.md @@ -0,0 +1,42 @@ +# Technical Documentation Checklist + +Verify every item before finalizing documentation changes. + +## 1. YARD Completeness + +- [ ] **Every public class has a class-level doc.** One-sentence purpose + key behavior notes. +- [ ] **Every public method has `@param` and `@return`.** No undocumented parameters. +- [ ] **`@raise` tags present.** Every `raise` in the method body has a corresponding `@raise` tag. +- [ ] **`@yield` / `@yieldparam` present.** Every method accepting a block documents its block signature. +- [ ] **`@example` on non-obvious APIs.** Signal methods, pattern matching, `on` callbacks, `method_missing` accessors. +- [ ] **`@note` on constraint-bearing methods.** Freeze semantics, `throw` flow interruption, thread safety. +- [ ] **`@see` cross-references.** Related methods link to each other (e.g., `execute` ↔ `execute!`). + +## 2. Accuracy + +- [ ] **Docs match code.** Parameter names, types, and return types reflect current signatures. +- [ ] **No stale tags.** Renamed or removed parameters don't have orphaned `@param` entries. +- [ ] **Signal semantics documented.** Methods using `throw(Signal::TAG)` note that control never returns. +- [ ] **Freeze semantics documented.** `Result.new` freeze cascade is noted on `Result`, `Context#freeze`. +- [ ] **`method_missing` documented.** Dynamic accessor behavior on `Context` is explained. + +## 3. Style + +- [ ] **Intent first.** First doc line explains why, not what. +- [ ] **No filler.** No restating the method name or parameter name as the description. +- [ ] **Consistent vocabulary.** Uses the CMDx vocabulary table — no synonyms like "command" for "task." +- [ ] **Tag order.** `@param` → `@return` → `@raise` → `@yield` → `@yieldparam` → `@example` → `@note` → `@see`. +- [ ] **Realistic examples.** Examples use CMDx types (task classes, context hashes, result objects), not `foo`/`bar`. + +## 4. CHANGELOG + +- [ ] **Entry exists for each user-facing change.** New APIs get `### Added`, signature changes get `### Changed`. +- [ ] **Class/method in backticks.** Each entry starts with the affected constant. +- [ ] **No orphaned entries.** Every CHANGELOG bullet corresponds to an actual code change. +- [ ] **Categories ordered.** Added → Changed → Deprecated → Removed → Fixed. + +## 5. Tooling + +- [ ] **`yard stats --list-undoc` clean.** No new undocumented items introduced. +- [ ] **`rubocop .` clean.** No new offenses from documentation changes. +- [ ] **No debug artifacts.** No `pp`, `puts`, `binding.break` left in documented code. diff --git a/.cursor/skills/test-patterns/SKILL.md b/.cursor/skills/test-patterns/SKILL.md new file mode 100644 index 000000000..d4d00999d --- /dev/null +++ b/.cursor/skills/test-patterns/SKILL.md @@ -0,0 +1,160 @@ +--- +name: test-patterns +description: Write, structure, and maintain RSpec specs for CMDx tasks, workflows, context, and configuration. Use when the user asks to add, update, fix, or refactor tests, write specs for new features, scaffold test files, or follow project testing conventions. Don't use for debugging production bugs, performance benchmarking, or non-test code changes. +--- + +# Test Patterns + +> **Scope check:** Confirm what is being tested (task, workflow, context, configuration, or module) and whether the spec is unit or integration before writing code. + +> **Bug-first rule:** If you discover a bug in the source code while writing or debugging a spec, ask the user whether they want to fix the bug before proceeding. Never silently work around a bug in the test (e.g. adjusting expectations to match broken behavior, skipping a scenario, or adding setup that compensates for the defect). + +## Prerequisites + +Ensure the test suite and linter pass before making changes: + +```bash +bundle exec rspec . +bundle exec rubocop . +``` + +## Procedures + +**Step 1: Classify the Spec** + +Determine the spec type based on what is under test: + +| Type | Directory | `RSpec.describe` target | Metadata | +|---|---|---|---| +| **Unit** | `spec/cmdx/` | Constant (`CMDx::Context`) | none | +| **Module** | `spec/` | Constant (`CMDx`) | none | +| **Integration** | `spec/integration/<area>/` | String (`"Task execution"`) | `type: :feature` | + +Place the file in the matching directory. Mirror the `lib/` path for unit specs (e.g., `lib/cmdx/context.rb` → `spec/cmdx/context_spec.rb`). Group integration specs by area: `tasks/`, `workflows/`. + +After classifying, scan 1–2 existing specs in the same directory. Mirror their `describe`/`context` structure, matchers, and setup style so the new spec reads like a sibling. + +**Step 2: Scaffold the Spec File** + +Read `assets/spec-template.md` and use the appropriate template (unit or integration) as the starting point. + +Every spec file must: +1. Start with `# frozen_string_literal: true` +2. Require `"spec_helper"` (never `rails_helper`) +3. Use `RSpec.describe` at the top level — no `module` wrappers +4. Use `expect` syntax exclusively — never `should` + +**Step 3: Structure the Spec** + +Follow these nesting conventions: + +1. **Top-level `describe`** — the class/module or feature string. +2. **Method-level `describe`** — use `".method_name"` for class methods, `"#method_name"` for instance methods. Skip for integration specs. +3. **`context` blocks** — group by conditions using `"when ..."` or `"with ..."` prefixes. Nest deeply when exercising multiple dimensions (execution mode × task outcome). +4. **`it` blocks** — one behavior per example, but multiple assertions are encouraged via global `aggregate_failures`. + +Naming rules: +- `describe` takes a string or constant — never both. +- `context` always starts with `"when"`, `"with"`, or `"without"`. +- `it` descriptions state the expected outcome, not the setup. A failing spec name should explain *what broke* without reading the source. +- Use `described_class` to reference the class under test inside examples — never hardcode the constant. + +**Step 4: Set Up Test Data** + +Use the project's builder helpers — never FactoryBot, Fabrication, or bare `double`. + +| Builder | Returns | Use for | +|---|---|---| +| `create_task_class(base:, name:, &block)` | Anonymous task class | Custom `work` logic | +| `create_successful_task` | Task that appends `:success` | Happy-path execution | +| `create_skipping_task(reason:, **metadata)` | Task that calls `skip!` | Skip flow | +| `create_failing_task(reason:, **metadata)` | Task that calls `fail!` | Failure flow | +| `create_erroring_task(reason:)` | Task that raises `CMDx::TestError` | Exception handling | +| `create_nested_task(strategy:, status:)` | Outer→Middle→Inner chain | Nested execution with `:swallow`, `:throw`, or `:raise` | +| `create_workflow_class(base:, name:, &block)` | Workflow class with `CMDx::Workflow` | Workflow composition | +| `create_successful_workflow` | Workflow with mixed tasks | Workflow happy path | +| `create_skipping_workflow` | Workflow with a skipping inner task | Workflow skip flow | +| `create_failing_workflow` | Workflow with a failing inner task | Workflow failure flow | +| `create_erroring_workflow` | Workflow with an erroring inner task | Workflow error flow | + +Guidelines: +- Prefer `let(:task)` / `let(:workflow)` for the object under test. +- Use `subject(:result)` for the execution result when the entire example group tests one call. +- Pass blocks to builders for inline DSL (`settings`, `task`, `tasks`, custom methods). +- Prefer real objects over mocks. Use stubs only to return predefined values when isolating a unit; avoid over-mocking. Use `instance_double` only when necessary; never `double`. + +**Step 5: Write Assertions** + +Follow these matcher conventions: + +| Scenario | Pattern | +|---|---| +| Result state/status | `have_attributes(state: CMDx::Signal::COMPLETE, status: CMDx::Signal::SUCCESS, ...)` | +| Context values | `expect(result.context).to have_attributes(executed: %i[...])` or `be_empty` | +| Exception raising | `expect { result }.to raise_error(CMDx::FailFault, "message")` | +| Block yielding | `expect { \|b\| described_class.configure(&b) }.to yield_with_args(CMDx::Configuration)` | +| Collection contents | `contain_exactly(...)` for unordered, `eq([...])` for ordered | +| Identity | `be(object)` for same object, `eq(value)` for equality | +| Type | `be_a(CMDx::Configuration)` | +| Boolean predicate | `be(true)` / `be(false)` — not `be_truthy`/`be_falsy` | +| Nested hash matching | `hash_including(key: value)` | +| String prefix | `start_with("OuterTask")` | + +Coverage: +- Cover both typical cases and edge cases — invalid inputs, error conditions, nil, empty, frozen, and boundary values. +- Integration expectations should be realistic — test the API how it would actually be used, not contrived scenarios. +- When touching an existing spec file, update pre-existing examples to match current conventions. + +What NOT to test: +- Declarative configuration (e.g., `settings` DSL output that just stores values). +- Obvious reflective expectations (e.g., "it returns what was passed in" for trivial getters). +- Constants, enum lists, or attribute declarations — test *behavior* callers rely on, not structure. + +**Step 6: Handle Setup and Teardown** + +- Use `let` / `let!` for test data. Prefer lazy `let` unless eagerness is required. Avoid instance variables. +- Use `before` / `after` for state mutation (e.g., `CMDx.reset_configuration!`). +- Use `subject(:name)` when the return value of the call is the focus of the group. +- Never rely on example ordering — specs run in `--order random`. +- No cross-example shared mutable state — avoid class variables, global mutation, or leaked state that causes order-dependent failures. Each example must be independent. + +**Step 7: Verify** + +1. Run the full suite: `bundle exec rspec .` +2. Run the linter: `bundle exec rubocop .` +3. Confirm new specs are picked up (check file naming ends in `_spec.rb`). +4. Confirm no commented-out code or debug output (`pp`, `puts`, `binding.break`). + +Cross-reference the completed spec against `references/checklist.md`. + +## Spec Placement Decision Tree + +1. **Does it test a single class/module's public API in isolation?** + - Yes → `spec/cmdx/<class_name>_spec.rb` (unit) + - No → Go to 2. +2. **Does it test execution flow across tasks or workflows?** + - Yes → `spec/integration/<area>/` with `type: :feature` + - No → Go to 3. +3. **Does it test the top-level `CMDx` module?** + - Yes → `spec/cmdx_spec.rb` + - No → Ask the user for clarification. + +## Existing Patterns Quick Reference + +| Pattern | Where Used | Mechanism | +|---|---|---| +| Result attribute assertion | Task execution specs | `have_attributes(state:, status:, reason:, metadata:, cause:)` | +| Context emptiness check | Skip/fail specs | `expect(result.context).to be_empty` | +| Nested propagation strategies | Task execution specs | `create_nested_task(strategy: :throw, status: :failure)` | +| Workflow conditional | Workflow conditionals spec | `task task1, if: :method?` / `if: proc { ... }` / `unless:` | +| Workflow breakpoints | Workflow breakpoints spec | `settings(workflow_breakpoints: %w[skipped failed])` | +| Group-level breakpoints | Workflow breakpoints spec | `tasks t1, t2, breakpoints: []` | +| Bang vs non-bang execution | Workflow/task execution specs | `execute` returns result, `execute!` raises `CMDx::FailFault` | +| Configuration reset | `cmdx_spec.rb` | `after { described_class.reset_configuration! }` | +| Runtime context mutation | Conditionals spec | Setup task writes to context, later task reads via `if: proc` | + +## Error Handling + +- If `bundle exec rspec .` shows failures unrelated to the new spec, investigate before proceeding — the suite should be green before and after. +- If RuboCop flags `RSpec/` cop violations, fix them inline. Common ones: `RSpec/NestedGroups` (max depth), `RSpec/MultipleExpectations` (not an issue — `aggregate_failures` is global), `RSpec/ExampleLength`. +- If a builder doesn't exist for the needed test scenario, extend the builder module in `spec/support/helpers/` following the existing naming pattern rather than creating a one-off helper. diff --git a/.cursor/skills/test-patterns/assets/spec-template.md b/.cursor/skills/test-patterns/assets/spec-template.md new file mode 100644 index 000000000..6ef4dcf22 --- /dev/null +++ b/.cursor/skills/test-patterns/assets/spec-template.md @@ -0,0 +1,172 @@ +# Spec Templates + +## Unit Spec (class/module under test) + +```ruby +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe CMDx::ClassName do + subject(:instance) { described_class.new(args) } + + let(:args) { {} } + + describe ".class_method" do + context "when condition" do + it "returns expected" do + expect(described_class.class_method).to eq(expected) + end + end + end + + describe "#instance_method" do + context "when condition" do + it "returns expected" do + expect(instance.instance_method).to eq(expected) + end + end + + context "with invalid input" do + it "raises error" do + expect { instance.instance_method(bad) }.to raise_error(ArgumentError) + end + end + end +end +``` + +## Integration Spec (task execution) + +```ruby +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe "Feature name", type: :feature do + context "when soft execution" do + subject(:result) { task.execute } + + context "when successful" do + let(:task) { create_successful_task } + + it "returns success" do + expect(result).to have_attributes( + state: CMDx::Signal::COMPLETE, + status: CMDx::Signal::SUCCESS, + reason: nil, + metadata: {}, + cause: nil + ) + expect(result.context).to have_attributes( + executed: %i[success] + ) + end + end + + context "when failing" do + let(:task) { create_failing_task(reason: "something broke") } + + it "returns failure" do + expect(result).to have_attributes( + state: CMDx::Signal::INTERRUPTED, + status: CMDx::Signal::FAILED, + reason: "something broke" + ) + end + end + end + + context "when bang execution" do + subject(:result) { task.execute! } + + context "when failing" do + let(:task) { create_failing_task(reason: "something broke") } + + it "raises fault" do + expect { result }.to raise_error(CMDx::FailFault, "something broke") + end + end + end +end +``` + +## Integration Spec (workflow execution) + +```ruby +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe "Workflow feature name", type: :feature do + context "when non-blocking" do + subject(:result) { workflow.execute } + + context "when successful" do + let(:workflow) { create_successful_workflow } + + it "returns success" do + expect(result).to be_successful + expect(result).to have_matching_context(executed: %i[success inner middle outer success]) + end + end + end + + context "when blocking" do + subject(:result) { workflow.execute! } + + context "when failing" do + let(:workflow) { create_failing_workflow } + + it "raises fault" do + expect { result }.to raise_error(CMDx::FailFault) + end + end + end +end +``` + +## Integration Spec (workflow with inline DSL) + +```ruby +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe "Workflow feature name", type: :feature do + context "when using custom configuration" do + it "applies settings to execution" do + task1 = create_successful_task(name: "Task1") + task2 = create_successful_task(name: "Task2") + + workflow = create_workflow_class do + settings(workflow_breakpoints: []) + + task task1 + task task2 + end + + result = workflow.new.execute + + expect(result).to be_successful + expect(result.chain.results.size).to eq(3) + end + end + + context "when using conditionals" do + it "evaluates condition at runtime" do + task1 = create_successful_task(name: "Task1") + + workflow = create_workflow_class do + task task1, if: proc { context.enabled == true } + end + + enabled_result = workflow.execute(enabled: true) + disabled_result = workflow.execute(enabled: false) + + expect(enabled_result.chain.results.size).to eq(2) + expect(disabled_result.chain.results.size).to eq(1) + end + end +end +``` diff --git a/.cursor/skills/test-patterns/references/checklist.md b/.cursor/skills/test-patterns/references/checklist.md new file mode 100644 index 000000000..756e3c464 --- /dev/null +++ b/.cursor/skills/test-patterns/references/checklist.md @@ -0,0 +1,47 @@ +# Test Patterns Checklist + +Final audit before committing a new or modified spec. Every item must pass. + +## 1. File Structure + +- [ ] **Frozen String Literal:** File starts with `# frozen_string_literal: true`. +- [ ] **Require:** File requires `"spec_helper"` — not `rails_helper` or anything else. +- [ ] **Placement:** File is in the correct directory (`spec/cmdx/` for unit, `spec/integration/<area>/` for integration). +- [ ] **Naming:** File ends in `_spec.rb`. Unit specs mirror the `lib/` path. +- [ ] **No Module Wrappers:** Top level is `RSpec.describe`, not wrapped in a module. + +## 2. Describe and Context + +- [ ] **Top-Level Describe:** Uses a constant for unit specs, a string with `type: :feature` for integration specs. +- [ ] **Method Describes:** Uses `".method"` for class methods, `"#method"` for instance methods. +- [ ] **Context Prefixes:** Every `context` description starts with `"when"`, `"with"`, or `"without"`. +- [ ] **No Redundant Nesting:** Context blocks add meaningful behavioral dimensions, not just indentation. + +## 3. Test Data + +- [ ] **Builders Used:** Test tasks and workflows are created via `CMDx::Testing::TaskBuilders` / `WorkflowBuilders`. +- [ ] **No Raw Doubles:** No `double(...)` calls. `instance_double` only when strictly necessary. +- [ ] **No FactoryBot:** No external factory gems. +- [ ] **Lazy Let:** `let` is preferred over `let!` unless eager evaluation is required. +- [ ] **Named Subject:** `subject(:result)` or `subject(:context)` used when the group tests a single call. + +## 4. Assertions + +- [ ] **Expect Syntax:** Only `expect(...).to` — no `should`. +- [ ] **Aggregate Failures:** Multiple `expect` calls per example are fine (global `aggregate_failures`). +- [ ] **Correct Matchers:** `be(obj)` for identity, `eq(val)` for equality, `have_attributes` for result state, `raise_error` for exceptions. +- [ ] **No Obvious Tests:** No specs that merely assert a value equals itself or test trivial getters. +- [ ] **No Declarative Config Tests:** Settings DSL output is not tested as a standalone assertion. + +## 5. Setup and Teardown + +- [ ] **No Order Dependency:** Specs pass in `--order random` without relying on execution sequence. +- [ ] **State Cleaned:** Any global state mutation (e.g., `CMDx.configure`) is reset in `after` blocks. +- [ ] **No Side Effects:** Specs don't write to disk, make network calls, or mutate shared constants. + +## 6. Code Quality + +- [ ] **RSpec Passes:** `bundle exec rspec .` exits with zero failures. +- [ ] **RuboCop Passes:** `bundle exec rubocop .` exits with zero offenses. +- [ ] **No Debug Code:** No `pp`, `puts`, `binding.break`, or commented-out code left in specs. +- [ ] **No Commented Specs:** No `xit`, `xdescribe`, `xcontext`, or `pending` without justification. diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 916c545af..36f70c390 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1,62 +1 @@ -# Ruby Coding Standards - -Follow the official Ruby gem guides for best practices. -Reference the guides outlined in https://guides.rubygems.org - -## Project Context -CMDx provides a framework for designing and executing complex business logic within service/command objects. -Reference the CMDx documentation in https://github.com/drexed/cmdx/blob/main/LLM.md - -## Technology Stack -- Ruby 3.4+ -- RSpec 3.1+ - -## Development Guidelines -- Performance is critical - benchmark any changes that could affect speed -- Follow existing code patterns and conventions -- Maintain backward compatibility for public API - -## Code Style and Structure -- Write concise, idiomatic Ruby code with accurate examples -- Follow Ruby conventions and best practices -- Use object-oriented and functional programming patterns as appropriate -- Prefer iteration and modularization over code duplication -- Use descriptive variable and method names (e.g., user_signed_in?, calculate_total) -- Write comprehensive code documentation using the Yardoc format -- Minimize object allocations in hot paths - -## Naming Conventions -- Use snake_case for file names, method names, and variables -- Use CamelCase for class and module names - -## Syntax and Formatting -- Follow the Ruby Style Guide (https://rubystyle.guide/) -- Follow Ruby style conventions (2-space indentation, snake_case methods) -- Use Ruby's expressive syntax (e.g., unless, ||=, &.) -- Prefer double quotes for strings -- Respect my Rubocop options -- Run `bundle exec rubocop .` before finalizing any code changes - -## Performance Optimization -- Use memoization for expensive operations - -## Testing -- Follow the RSpec Style Guide (https://rspec.rubystyle.guide/) -- Write comprehensive tests using RSpec -- It's ok to put multiple assertions in the same example -- Include both BDD and TDD based tests -- Create test objects to share across tests -- Do NOT make tests for obvious or reflective expectations -- Prefer real objects over mocks. Use `instance_double` if necessary; never `double` -- Don't test declarative configuration -- Use appropriate matchers -- Update tests and update Yardocs after you write code -- Run `bundle rspec .` before finalizing any code changes - -## Documentation -- Utilize the YARDoc format when documenting Ruby code -- Follow these best practices: - - Avoid redundant comments that merely restate the code - - Keep comments up-to-date with code changes - - Keep documentation consistent -- Update CHANGELOG.md with any changes +Load and reference `.cursor/rules/cursor-instructions.mdc` diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0cc7a331f..f24c40a7d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,21 +1,21 @@ name: CI on: pull_request: - branches: [main] + branches: [ main ] jobs: build: runs-on: ubuntu-latest strategy: fail-fast: false matrix: - ruby-version: ["3.2", "3.3", "3.4", "4.0"] + ruby-version: [ "3.3", "3.4", "4.0" ] env: FORCE_COLOR: 1 CLICOLOR_FORCE: 1 BUNDLE_GEMFILE: Gemfile steps: - name: Checkout code - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Set up Ruby ${{ matrix.ruby-version }} uses: ruby/setup-ruby@v1 with: diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 74e16be40..fad7c5546 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -1,7 +1,7 @@ name: Docs on: push: - branches: [main] + branches: [ main ] permissions: contents: write jobs: @@ -12,7 +12,7 @@ jobs: CLICOLOR_FORCE: 1 steps: - name: Checkout code - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: fetch-depth: 0 - name: Configure Git Credentials @@ -36,7 +36,8 @@ jobs: ruby-version: 3.4 bundler-cache: true - name: Install Python dependencies - run: pip install "mkdocs>=1.6,<2" mkdocs-material mkdocs-llmstxt mkdocs-rss-plugin pymdown-extensions + run: pip install "mkdocs>=1.6,<2" mkdocs-material mkdocs-llmstxt + mkdocs-rss-plugin pymdown-extensions - name: Generate API documentation run: bundle exec rake yard - name: Deploy documentation diff --git a/.gitignore b/.gitignore index 76c943b8e..ddeecb544 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,11 @@ /spec/reports/ /tmp/ +# benchmark artifacts +/benchmark/vendor/ +/benchmark/.bundle/ +/benchmark/Gemfile.lock + # rspec failure tracking .rspec_status diff --git a/.rubocop.yml b/.rubocop.yml index 311b4038d..bb56ba6cf 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -6,9 +6,13 @@ AllCops: NewCops: enable DisplayCopNames: true DisplayStyleGuide: true - TargetRubyVersion: 3.1 + TargetRubyVersion: 3.3 Gemspec/DevelopmentDependencies: EnforcedStyle: gemspec + Exclude: + - 'benchmark/**/*' +Layout/ArgumentAlignment: + EnforcedStyle: with_fixed_indentation Layout/EmptyLinesAroundAttributeAccessor: Enabled: true Layout/EmptyLinesAroundClassBody: @@ -46,6 +50,8 @@ Metrics/PerceivedComplexity: Enabled: false Naming/MethodParameterName: Enabled: false +Naming/PredicateMethod: + Mode: aggressive RSpec/DescribeClass: Exclude: - 'spec/integrations/**/*' @@ -71,8 +77,7 @@ RSpec/StubbedMock: RSpec/VerifiedDoubleReference: Enabled: false Style/ArgumentsForwarding: - Exclude: - - 'lib/cmdx/utils/call.rb' + UseAnonymousForwarding: false Style/CaseEquality: Enabled: false Style/DocumentDynamicEvalDefinition: @@ -83,6 +88,10 @@ Style/DoubleNegation: Enabled: false Style/EmptyClassDefinition: Enabled: false +Style/FormatStringToken: + EnforcedStyle: template + Exclude: + - 'benchmark/**/*' Style/FrozenStringLiteralComment: Enabled: true EnforcedStyle: always_true diff --git a/CHANGELOG.md b/CHANGELOG.md index f6b473901..1330b1745 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,10 +4,97 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [UNRELEASED] +## [2.0.0] - UNRELEASED + +Full runtime rewrite: the v1 state-machine plus Zeitwerk architecture is replaced by an explicit signal-based runtime, immutable results, fiber-local chains, and a slimmer registry surface. See [docs/v2-migration.md](docs/v2-migration.md) for the full upgrade guide. ### Added -- Add `CMDx::Resolver` class to manage result transitions +- Add `CMDx::Signal` halt token thrown via `catch(Signal::TAG)` (`:cmdx_signal`), with `Signal::Success` / `Signal::Skipped` / `Signal::Failed` frozen singleton constants, `Signal.success` / `.skipped` / `.failed` class methods that return them when called with no args, and `Signal.echoed(other)` for propagating nested fault outcomes (auto-sets `:origin` to the upstream `Result`) +- Add `Signal#ok?` / `Signal#ko?` predicates +- Add `CMDx::Runtime` orchestrating the full task lifecycle and building the final `Result` +- Add `CMDx::Telemetry` pub/sub for `:task_started`, `:task_deprecated`, `:task_retried`, `:task_rolled_back`, `:task_executed`; emits `Telemetry::Event` data objects with `chain_id`, `chain_root`, `task_type`, `task_class`, `task_id`, `name`, `payload`, `timestamp` +- Add `CMDx::Deprecation` for declarative class-level deprecation (`:log`, `:warn`, `:error`, Symbol, Proc, callable) with `:if` / `:unless` gating +- Add `CMDx::Input` / `CMDx::Inputs` (replaces `Attribute` / `AttributeRegistry` / `AttributeValue`) supporting `:source`, `:default`, `:transform`, `:as`, `:prefix` / `:suffix`, and nested children via DSL block +- Add `CMDx::Output` / `CMDx::Outputs` for first-class declared outputs verified against `task.context` after `work` (required-presence, default application, coercion, transformation, validation, write-back of final value); `:default` and `:transform` mirror input semantics — defaults fire for nil/absent values and can satisfy `:required`, transforms run between coerce and validate +- Add `CMDx::Util` single conditional-evaluation module (`evaluate`, `if?`, `unless?`, `satisfied?`) consolidating the v1 `Utils::*` modules +- Add `CMDx::I18nProxy` translation façade that delegates to `I18n` when available, otherwise loads the bundled YAML and percent-interpolates with memoization +- Add `CMDx::LoggerProxy` returning a per-task logger, `dup`-ing the base only when the task overrides `log_level` or `log_formatter` +- Add new exception classes: `DefinitionError`, `DeprecationError`, `ImplementationError`, `MiddlewareError` +- Add `Task#work` abstract method (raises `ImplementationError` when not defined) +- 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 +- Add `Task#success!` for signaling a successful halt, joining `skip!` / `fail!` / `throw!` +- Add `Task.execute` / `Task.execute!` as the execution entry points (aliased as `call` / `call!` for backward compatibility) +- Add `Result#on(:success, :failed, ...)` chainable predicate-dispatch helper +- Add `Result#deconstruct` / `Result#deconstruct_keys` for pattern matching; `deconstruct` returns `[type, task, state, status, reason, metadata, cause, origin]` +- Add `Result#strict?`, `Result#deprecated?`, `Result#duration`, `Result#chain_index`, `Result#chain_root?`, `Result#backtrace`, `Result#errors`, `Result#tags`, `Result#origin`, and `Result#ctx` alias +- Add `Signal#origin` / `Result#origin` — upstream `Result` a signal/result was echoed from (`nil` for locally originated failures); set by `Task#throw!`, `Pipeline` when propagating workflow failures, and `Runtime` when rescuing a `Fault` inside `work` +- Add `Chain#unshift`, `Chain#root`, `Chain#state`, `Chain#status`, `Chain#last`, `Chain#freeze`; Runtime `unshift`s the root result (so `chain.root` and `chain[0]` point to the outermost task) and freezes the chain on root teardown +- Add `Fault.for?(*tasks)` and `Fault.matches?(&block)` anonymous matcher subclasses suitable for `rescue` +- Add `include Enumerable` to `Errors`, `Chain`, and `Context`, exposing `map`, `select`, `find`, `include?`, `to_a`, `any?`, `all?`, `group_by`, `partition`, etc. +- Add `Set`-backed deduping per key on `Errors`, plus `keys`, `each_key`, `each_value`, `count`, `delete`, `clear`, `full_messages`, `to_hash(full)` +- Add `Context#keys`, `values`, `empty?`, `size`, `delete`, `clear`, `eql?` / `==`, `hash`, `deep_dup`, `respond_to_missing?`, and `Context#merge` that accepts any context-like object +- Add `Coercions::Coerce` and `Validators::Validate` inline-callable handlers for `:coerce` / `:validate` hash entries; generic callables receive `(value, task)`, Symbol and Proc handlers still resolve against the task +- Add `Configuration#backtrace_cleaner` and `Configuration#telemetry` +- Add `CMDx.reset_configuration!` which clears global registry ivars on `Task` for clean test setup/teardown; subclasses that already cloned their registries are unaffected +- Add `:if` / `:unless` gates to `Callbacks#register` (Symbol, Proc, or any `#call`-able); per-event DSL helpers (`before_execution`, `on_success`, etc.) forward the options through + +### Changed +- **BREAKING**: Rename `#call` → `#work` on task subclasses; `Task.execute` / `Task.execute!` are the new entry points (`call` / `call!` kept as aliases) +- **BREAKING**: `Result` is now frozen and read-only; all state lives in the embedded `Signal`, built once during `Runtime#finalize_result` +- **BREAKING**: Move `STATES` / `STATUSES` constants and the `initialized` / `executing` / `executed!` transitions from `Result` to `Signal::STATES` / `Signal::STATUSES` (only `complete` / `interrupted` and `success` / `skipped` / `failed` remain) +- **BREAKING**: Halt mechanism uses `catch(Signal::TAG)` + `throw` instead of mutating result state; `success!` / `skip!` / `fail!` / `throw!` are now private `Task` instance methods (no longer delegated through `Result`) and raise `FrozenError` when called after teardown +- **BREAKING**: `Chain` is now fiber-local (was thread-local), keyed on `Fiber[:cmdx_chain]`, with internal `Mutex` on `push` / `unshift`; root Runtime clears the chain on teardown +- **BREAKING**: `Result#chain` now returns the owning `Chain` object directly instead of its results array (use `result.chain.to_a` / `result.chain.results`, or iterate via `Chain`'s new Enumerable methods) +- **BREAKING**: Drive `Result#caused_failure` / `threw_failure` / `caused_failure?` / `thrown_failure?` off `Signal#origin` instead of `signal.cause`; `caused_failure` walks `origin` recursively to the originating leaf, `threw_failure` returns `origin || self`, `caused_failure?` is true when the result originated the failure chain, `thrown_failure?` is true when the result re-threw an upstream failure +- 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` +- `Workflow` declares groups via `task` / `tasks` (still aliased) and supports `:strategy => :parallel`, `:pool_size`, `:if` / `:unless` per group; defining `#work` on a workflow raises `ImplementationError` +- `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 +- `Task.callbacks`, `Task.middlewares`, `Task.coercions`, `Task.validators`, `Task.telemetry`, `Task.inputs`, `Task.outputs` lazy-clone from the superclass (or global `Configuration`) on first access — subclasses extend rather than replace +- `Settings` is now a frozen value object holding only `logger`, `log_formatter`, `log_level`, `backtrace_cleaner`, `tags`; every getter falls back to `CMDx.configuration` +- `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` +- `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) +- 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 +- `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 +- `Validators#validate` records a message on `task.errors` for each failed rule (the individual built-in validators return `Validators::Failure`) +- 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` +- `Fault#initialize` takes a single `Result`; `task`, `context`, and `chain` delegate to it; `Runtime` raises `Fault.new(@result.caused_failure)` so `fault.task` always points at the originating leaf (including in workflows and nested `execute!` chains) +- `Runtime` finalizes the `Result` before `raise_signal!` so the `Fault` it raises always carries a fully-built `Result` +- `Result#to_h` / `to_s` / `deconstruct_keys` now include `:origin` (compact `{ task:, id: }` hash, or `nil` for locally originated failures) +- 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 +- Generators emit the new `def work` template; the install template documents the new middleware / callback / telemetry / coercion / validator registration shapes +- Slim `Configuration` to: `middlewares`, `callbacks`, `coercions`, `validators`, `telemetry`, `default_locale`, `backtrace_cleaner`, `logger`, `log_level`, `log_formatter` + +### Removed +- **BREAKING**: Remove `Result::STATES = [INITIALIZED, EXECUTING, COMPLETE, INTERRUPTED]`, the `executed!` / `executing!` transitions, and the `executed?` / `initialized?` / `executing?` predicates +- **BREAKING**: Remove `Task#id`, `Task#result`, `Task#chain` direct accessors — read these off the `Result` returned by `execute` +- **BREAKING**: Remove `Result#threw_failure?` predicate (`result.thrown_failure?` remains, with semantics flipped — true when the result re-threw an upstream failure) +- **BREAKING**: Remove `Result#chain_id` — read it off the chain: `result.chain.id` +- **BREAKING**: `Result#to_h` no longer produces nested `caused_failure` / `threw_failure` hashes; failure references render as `{ task:, id: }` and `to_s` formats them as `<TaskClass uuid>` +- Remove `CMDx::Executor` (replaced by `CMDx::Runtime`) +- Remove `CMDx::Attribute`, `CMDx::AttributeRegistry`, `CMDx::AttributeValue` (replaced by `Input` / `Inputs` and `Output` / `Outputs`) +- Remove `CMDx::Resolver` (value resolution is owned by `Input#resolve`) +- Remove `CMDx::Identifier` (Runtime / Chain use `SecureRandom.uuid_v7` directly) +- Remove `CMDx::Locale` (superseded by `I18nProxy`) +- Remove `CMDx::Deprecator` (superseded by `Deprecation` declared per task class) +- Remove `CMDx::Parallelizer` (parallelism now lives in `Pipeline#run_parallel`) +- Remove `CMDx::CallbackRegistry`, `CMDx::MiddlewareRegistry`, `CMDx::CoercionRegistry`, `CMDx::ValidatorRegistry` (replaced by the simpler `Callbacks`, `Middlewares`, `Coercions`, `Validators` plain classes) +- Remove `CMDx::Utils::Call`, `CMDx::Utils::Condition`, `CMDx::Utils::Format`, `CMDx::Utils::Normalize`, `CMDx::Utils::Wrap` (collapsed into `CMDx::Util`) +- Remove built-in `CMDx::Middlewares::Correlate`, `CMDx::Middlewares::Runtime`, `CMDx::Middlewares::Timeout` — register equivalents on `config.middlewares` if needed +- Remove `CMDx::Exception` file — `CMDx::Error` / `Exception` and friends are now defined in `lib/cmdx.rb` +- Remove Zeitwerk autoloading (replaced by explicit `require_relative` ordering in `lib/cmdx.rb`); drop `forwardable`, `pathname`, `timeout`, and `zeitwerk` requires +- Remove `CMDx.gem_path` top-level helper +- Remove `Configuration#task_breakpoints`, `Configuration#workflow_breakpoints`, `Configuration#freeze_results`, `Configuration#exception_handler`, and the `SKIP_CMDX_FREEZING` env var — failure halting is now intrinsic to Runtime via `Signal` and `execute!` strict mode +- Remove `Chain#dry_run?` and the `dry_run:` context flag + +### Migration notes + +See [docs/v2-migration.md](docs/v2-migration.md) for the full upgrade guide. At minimum: + +- Rename `def call` → `def work`; `MyTask.call(ctx)` still works (aliased) but prefer `MyTask.execute(ctx)` +- Replace `register :attribute, ...` with `required :name, ...` / `optional :name, ...` / `output :name, ...` +- Replace `result.chain_id` with `result.chain.id` +- Replace `task.id` / `task.result` / `task.chain` with reads off the `Result` returned by `execute` +- Subscribe to lifecycle observability via `config.telemetry.subscribe(:task_executed) { |event| ... }` instead of the removed `Runtime` / `Correlate` middlewares ## [1.21.0] - 2026-04-09 diff --git a/Gemfile.lock b/Gemfile.lock index 497a87f37..449ca4993 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,26 +1,22 @@ PATH remote: . specs: - cmdx (1.21.0) + cmdx (2.0.0) bigdecimal logger - zeitwerk GEM remote: https://rubygems.org/ specs: ast (2.4.3) - bigdecimal (4.1.1) - cmdx-rspec (1.3.0) - cmdx (>= 1.5.0) - rspec + bigdecimal (4.1.2) concurrent-ruby (1.3.6) date (3.5.1) diff-lcs (1.6.2) - erb (6.0.2) + erb (6.0.3) i18n (1.14.8) concurrent-ruby (~> 1.0) - json (2.19.3) + json (2.19.4) language_server-protocol (3.17.0.5) lint_roller (1.1.0) logger (1.7.0) @@ -34,7 +30,7 @@ GEM stringio racc (1.8.1) rainbow (3.1.1) - rake (13.3.1) + rake (13.4.2) rdoc (7.2.0) erb psych (>= 4.0.0) @@ -83,7 +79,7 @@ GEM unicode-display_width (3.2.0) unicode-emoji (~> 4.1) unicode-emoji (4.2.0) - yard (0.9.39) + yard (0.9.43) yard-lint (1.5.1) yard (~> 0.9) zeitwerk (~> 2.6) @@ -96,7 +92,6 @@ PLATFORMS DEPENDENCIES bundler cmdx! - cmdx-rspec i18n rake rdoc diff --git a/README.md b/README.md index 72bca2226..6ca942789 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ [Request Feature](https://github.com/drexed/cmdx/issues) · [AI Skills](https://github.com/drexed/cmdx/blob/main/skills) · [llms.txt](https://drexed.github.io/cmdx/llms.txt) · - [llms-full.txt](https://drexed.github.io/cmdx/llms-full.txt) · + [llms-full.txt](https://drexed.github.io/cmdx/llms-full.txt) <img alt="Version" src="https://img.shields.io/gem/v/cmdx"> <img alt="Build" src="https://github.com/drexed/cmdx/actions/workflows/ci.yml/badge.svg"> @@ -26,14 +26,26 @@ Say goodbye to messy service objects. CMDx helps you design business logic with clarity and consistency—build faster, debug easier, and ship with confidence. > [!NOTE] -> [Documentation](https://drexed.github.io/cmdx/getting_started/) reflects the latest code on `main`. For version-specific documentation, please refer to the `docs/` directory within that version's tag. +> [Documentation](https://drexed.github.io/cmdx/getting_started/) reflects the latest code on `main`. For version-specific documentation, refer to the `docs/` directory within that version's tag. + +## What you get + +- **Standardized task contract** — typed inputs, declared outputs, explicit halts +- **Type system** — 13 coercers, 7 validators, all pluggable +- **Built-in flow control** — `skip!` / `fail!` / `throw!` with structured metadata +- **Retries and faults** — declarative `retry_on` with configurable jitter +- **Middleware and callbacks** — wrap the lifecycle without touching `work` +- **Observability** — structured logs and telemetry, no extra instrumentation +- **Composable workflows** — chain tasks into larger processes + +See the [feature comparison](https://drexed.github.io/cmdx/comparison/) for how CMDx stacks up against other service-object gems. ## Requirements -- Ruby: MRI 3.1+ or JRuby 9.4+ -- Dependencies: None +- Ruby: MRI 3.3+ or a compatible JRuby/TruffleRuby release +- Runtime dependencies: `bigdecimal` and `logger` (stdlib only — no ActiveSupport required) -Rails support is built-in, but it's framework-agnostic at its core. +Rails support is built-in, but CMDx is framework-agnostic at its core. ## Installation @@ -45,24 +57,23 @@ bundle add cmdx ## Quick Example -Build powerful business logic in four simple steps: +CMDx organizes business logic around the **CERO** pattern (pronounced "zero"): **Compose**, **Execute**, **React**, **Observe**. ### 1. Compose -```ruby -# Full-featured task example -# See docs for minimum viable task examples +Declare inputs, outputs, retries, and callbacks, then implement `work`. +```ruby class AnalyzeMetrics < CMDx::Task - register :middleware, CMDx::Middlewares::Correlate, id: -> { Current.request_id } + retry_on Net::ReadTimeout, limit: 3, jitter: :exponential on_success :track_analysis_completion! - required :dataset_id, type: :integer, numeric: { min: 1 } + required :dataset_id, coerce: :integer, numeric: { min: 1 } optional :analysis_type, default: "standard" - returns :result, :analyzed_at + output :result, :analyzed_at def work if dataset.nil? @@ -91,6 +102,8 @@ end ### 2. Execute +Every invocation returns a `Result`. Inputs are coerced and validated, exceptions are captured, outputs are verified, and the outcome is logged — automatically. + ```ruby result = AnalyzeMetrics.execute( dataset_id: 123, @@ -98,39 +111,39 @@ result = AnalyzeMetrics.execute( ) ``` +Use `execute!` instead when you want failures to raise a `Fault`. + ### 3. React +Branch on the result's status and read values, reasons, or metadata from it. + ```ruby if result.success? puts "Metrics analyzed at #{result.context.analyzed_at}" elsif result.skipped? - puts "Skipping analyzation due to: #{result.reason}" + puts "Skipped: #{result.reason}" elsif result.failed? - puts "Analyzation failed due to: #{result.reason} with code #{result.metadata[:code]}" + puts "Failed: #{result.reason} (code #{result.metadata[:code]})" end ``` ### 4. Observe +Every execution emits a structured log line with the chain id, task identity, state, status, reason, metadata, and duration — enough to correlate nested tasks and reconstruct what happened. + ```log -I, [2022-07-17T18:42:37.000000 #3784] INFO -- CMDx: -index=1 chain_id="018c2b95-23j4-2kj3-32kj-3n4jk3n4jknf" type="Task" class="SendAnalyzedEmail" state="complete" status="success" metadata={runtime: 347} +I, [2026-04-19T18:42:37.000000Z #3784] INFO -- cmdx: chain_id="018c2b95-b764-7fff-a1d2-..." chain_index=1 chain_root=false type="Task" task=SendAnalyzedEmail id="018c2b95-c091-..." state="complete" status="success" reason=nil metadata={} duration=34.7 tags=[] -I, [2022-07-17T18:43:15.000000 #3784] INFO -- CMDx: -index=0 chain_id="018c2b95-b764-7615-a924-cc5b910ed1e5" type="Task" class="AnalyzeMetrics" state="complete" status="success" metadata={runtime: 187} +I, [2026-04-19T18:43:15.000000Z #3784] INFO -- cmdx: chain_id="018c2b95-b764-7fff-a1d2-..." chain_index=0 chain_root=true type="Task" task=AnalyzeMetrics id="018c2b95-b764-..." state="complete" status="success" reason=nil metadata={} duration=187.4 tags=[] ``` -Ready to dive in? Check out the [Getting Started](https://drexed.github.io/cmdx/getting_started/) guide to learn more. +Ready to dive in? Check out the [Getting Started](https://drexed.github.io/cmdx/getting_started/) guide. ## Ecosystem +- [cmdx-i18n](https://github.com/drexed/cmdx-i18n) - 85+ translations - [cmdx-rspec](https://github.com/drexed/cmdx-rspec) - RSpec test matchers -For backwards compatibility of certain functionality: - -- [cmdx-i18n](https://github.com/drexed/cmdx-i18n) - 85+ translations, `v1.5.0` - `v1.6.2` -- [cmdx-parallel](https://github.com/drexed/cmdx-parallel) - Parallel workflow tasks, `v1.6.1` - `v1.6.2` - ## Contributing Bug reports and pull requests are welcome at <https://github.com/drexed/cmdx>. We're committed to fostering a welcoming, collaborative community. Please follow our [code of conduct](CODE_OF_CONDUCT.md). diff --git a/benchmark/Gemfile b/benchmark/Gemfile new file mode 100644 index 000000000..7a7c10bf2 --- /dev/null +++ b/benchmark/Gemfile @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +source "https://rubygems.org" + +# CMDx runtime deps (needed for v1 on Ruby 4.0+) +gem "bigdecimal" +gem "logger" +gem "zeitwerk" + +# Benchmark tooling +gem "benchmark" +gem "benchmark-ips", "~> 2.14" +gem "get_process_mem", "~> 1.0" +gem "memory_profiler", "~> 1.1" diff --git a/benchmark/RESULTS.md b/benchmark/RESULTS.md new file mode 100644 index 000000000..886b4c4f5 --- /dev/null +++ b/benchmark/RESULTS.md @@ -0,0 +1,198 @@ +# CMDx Benchmark Results: v1 (1.21.0) vs v2 (2.0.0) + +## System Information + +| Property | Value | +|---|---| +| CPU | Apple M1 Pro | +| Memory | 32 GB | +| Architecture | arm64 | +| OS | macOS 26.4.1 (Build 25E253) | +| Ruby | 4.0.0 (2025-12-25 revision 553f1675f3) +PRISM [arm64-darwin25] | +| YJIT | disabled | +| Date | 2026-04-18 | + +## Versions Under Test + +| Label | CMDx Version | Git SHA | +|---|---|---| +| v1 (baseline) | 1.21.0 | e20d7aa4 | +| v2 | 2.0.0 | 3116ee6c | + +--- + +## IPS (iterations/second) -- higher is better + +### Task Execution + +| Benchmark | v1 (i/s) | v2 (i/s) | Delta | +|---|---:|---:|---:| +| success | 79,709.3 | 90,925.0 | **+14.1%** | +| skip! | 36,789.3 | 96,569.6 | **+162.5%** | +| fail! | 36,720.3 | 82,757.1 | **+125.4%** | +| error (rescue) | 69,128.0 | 86,563.1 | **+25.2%** | +| nested (3-deep) | 32,003.9 | 38,190.0 | **+19.3%** | + +### Workflow Execution + +| Benchmark | v1 (i/s) | v2 (i/s) | Delta | +|---|---:|---:|---:| +| workflow success (3 tasks) | 15,750.1 | 18,830.2 | **+19.6%** | +| workflow failure (halting) | 6,069.9 | 17,326.1 | **+185.4%** | + +### Context Construction + +| Benchmark | v1 (i/s) | v2 (i/s) | Delta | +|---|---:|---:|---:| +| Context.new (3 sym keys) | 2,611,008.9 | 2,718,211.2 | **+4.1%** | +| Context.new (3 str keys) | 2,594,568.1 | 2,767,112.2 | **+6.7%** | +| Context.new (50 sym keys) | 240,652.3 | 252,674.2 | **+5.0%** | +| Context.build (passthrough) | 2,328,444.4 | 2,447,361.1 | **+5.1%** | + +### Context Access + +| Benchmark | v1 (i/s) | v2 (i/s) | Delta | +|---|---:|---:|---:| +| ctx[:a] (bracket) | 16,077,743.3 | 16,864,817.4 | **+4.9%** | +| ctx.fetch(:a) | 9,191,399.2 | 9,645,896.0 | **+4.9%** | +| ctx.a (method_missing) | 6,163,262.7 | 4,887,819.0 | -20.7% | +| ctx.a = 1 (mm setter) | 3,530,007.1 | 3,707,047.0 | **+5.0%** | +| ctx.key?(:a) | 10,747,062.8 | 15,296,420.5 | **+42.3%** | + +--- + +## Memory Profiling (per single execution) -- lower is better + +### Allocated Memory (bytes) + +| Scenario | v1 | v2 | Delta | +|---|---:|---:|---:| +| success | 3,520 | 2,800 | **-20.5%** | +| skip! | 12,596 | 2,480 | **-80.3%** | +| fail! | 12,596 | 4,600 | **-63.5%** | +| error (rescue) | 3,520 | 2,480 | **-29.5%** | +| nested (3-deep) | 8,880 | 7,120 | **-19.8%** | +| workflow success | 17,808 | 14,200 | **-20.3%** | +| workflow failure | 99,756 | 30,720 | **-69.2%** | + +### Allocated Objects (count) + +| Scenario | v1 | v2 | Delta | +|---|---:|---:|---:| +| success | 31 | 30 | **-3.2%** | +| skip! | 86 | 25 | **-70.9%** | +| fail! | 86 | 49 | **-43.0%** | +| error (rescue) | 30 | 27 | **-10.0%** | +| nested (3-deep) | 73 | 72 | **-1.4%** | +| workflow success | 144 | 139 | **-3.5%** | +| workflow failure | 667 | 331 | **-50.4%** | + +### Retained Memory + +Zero retention across all scenarios for both versions. + +--- + +## Object Allocations (top classes per scenario) + +### success + +| Class | v1 | v2 | Delta | +|---|---:|---:|---:| +| Hash | 17 | 12 | -29.4% | +| Array | 6 | 7 | +16.7% | +| String | 1 | 3 | +200.0% | +| CMDx::Executor | 1 | 0 | -100.0% | +| CMDx::Runtime | 0 | 1 | new | +| Logger | 0 | 1 | new | +| Thread::Mutex | 1 | 1 | -- | +| CMDx::Errors | 1 | 1 | -- | +| CMDx::Context | 1 | 1 | -- | +| CMDx::Chain | 1 | 1 | -- | +| CMDx::Result | 1 | 1 | -- | + +### nested (3-deep) + +| Class | v1 | v2 | Delta | +|---|---:|---:|---:| +| Hash | 45 | 32 | -28.9% | +| Array | 12 | 15 | +25.0% | +| String | 1 | 7 | +600.0% | +| CMDx::Executor | 3 | 0 | -100.0% | +| CMDx::Runtime | 0 | 3 | new | +| Logger | 0 | 3 | new | +| CMDx::Errors | 3 | 3 | -- | +| CMDx::Result | 3 | 3 | -- | +| Thread::Mutex | 1 | 1 | -- | +| CMDx::Chain | 1 | 1 | -- | +| CMDx::Context | 1 | 1 | -- | + +### workflow success + +| Class | v1 | v2 | Delta | +|---|---:|---:|---:| +| Hash | 90 | 63 | -30.0% | +| Array | 25 | 29 | +16.0% | +| String | 1 | 13 | +1200.0% | +| CMDx::Executor | 6 | 0 | -100.0% | +| CMDx::Runtime | 0 | 6 | new | +| Logger | 0 | 6 | new | +| CMDx::Errors | 6 | 6 | -- | +| CMDx::Result | 6 | 6 | -- | +| CMDx::Pipeline | 1 | 1 | -- | +| Thread::Mutex | 1 | 1 | -- | +| CMDx::Chain | 1 | 1 | -- | + +--- + +## RSS (Resident Set Size) -- 1,000 iterations each + +| Metric | v1 (MB) | v2 (MB) | Delta | +|---|---:|---:|---:| +| Before | 60.75 | 60.72 | -0.0% | +| After tasks | 60.78 | 60.75 | -0.0% | +| After workflows | 60.78 | 60.75 | -0.0% | +| Task growth | 0.03 | 0.03 | -- | +| Workflow growth | 0.00 | 0.00 | -- | + +--- + +## GC Stats -- 1,000 iterations each + +### After Task Execution + +| Metric | v1 | v2 | Delta | +|---|---:|---:|---:| +| total_allocated_objects | 56,002 | 50,002 | **-10.7%** | +| heap_live_slots | 17,263 | 23,903 | +38.5% | +| major_gc_count | 0 | 0 | -- | +| minor_gc_count | 1 | 1 | -- | + +### After Workflow Execution + +| Metric | v1 | v2 | Delta | +|---|---:|---:|---:| +| total_allocated_objects | 248,002 | 209,002 | **-15.7%** | +| heap_live_slots | -703 | 228 | -- | +| major_gc_count | 0 | 0 | -- | +| minor_gc_count | 7 | 5 | **-28.6%** | + +--- + +## Summary + +| Area | Verdict | +|---|---| +| Task IPS (success) | v2 is **1.14x faster** (+14.1%) | +| Task IPS (skip!) | v2 is **2.62x faster** (+162.5%) | +| Task IPS (fail!) | v2 is **2.25x faster** (+125.4%) | +| Task IPS (error rescue) | v2 is **1.25x faster** (+25.2%) | +| Workflow IPS (success) | v2 is **1.20x faster** (+19.6%) | +| Workflow IPS (failure) | v2 is **2.85x faster** (+185.4%) | +| Memory (success) | v2 uses **20.5% less** memory | +| Memory (workflow failure) | v2 uses **69.2% less** memory | +| GC allocations (tasks) | v2 allocates **10.7% fewer** objects | +| GC allocations (workflows) | v2 allocates **15.7% fewer** objects | +| RSS footprint | parity (~0%) | +| Context method_missing | v2 is **20.7% slower** (known trade-off for `?` predicate support) | +| Context key? | v2 is **42.3% faster** | diff --git a/benchmark/compare.rb b/benchmark/compare.rb new file mode 100755 index 000000000..caa53a1ef --- /dev/null +++ b/benchmark/compare.rb @@ -0,0 +1,112 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# Orchestrator — sets up git worktrees for v1 and v2, runs the harness in +# isolated subprocesses, then invokes the report. +# +# USAGE: +# ruby benchmark/compare.rb +# RUBY_YJIT_ENABLE=1 ruby benchmark/compare.rb # include YJIT comparison + +require "fileutils" + +REPO_ROOT = File.expand_path("..", __dir__) +BENCH_DIR = __dir__ +TMP_DIR = File.join(REPO_ROOT, "tmp", "benchmark") +HARNESS = File.join(BENCH_DIR, "harness.rb") +REPORT = File.join(BENCH_DIR, "report.rb") +GEMFILE = File.join(BENCH_DIR, "Gemfile") + +VERSIONS = { + "v1" => "e20d7aa4", + "v2" => "TODO" +}.freeze + +FileUtils.mkdir_p(TMP_DIR) + +# --------------------------------------------------------------------------- +# Worktree management +# --------------------------------------------------------------------------- +def setup_worktree(label, sha) + wt_path = File.join(TMP_DIR, label) + + if Dir.exist?(wt_path) + puts "Worktree #{label} already exists at #{wt_path}, resetting..." + system("git", "-C", wt_path, "checkout", "--force", sha, exception: true) + else + puts "Creating worktree #{label} at #{sha}..." + system("git", "-C", REPO_ROOT, "worktree", "add", "--detach", wt_path, sha, exception: true) + end + + wt_path +end + +def cleanup_worktrees + VERSIONS.each_key do |label| + wt_path = File.join(TMP_DIR, label) + next unless Dir.exist?(wt_path) + + puts "Removing worktree #{label}..." + system("git", "-C", REPO_ROOT, "worktree", "remove", "--force", wt_path) + end +end + +# --------------------------------------------------------------------------- +# Run harness in subprocess +# --------------------------------------------------------------------------- +def run_harness(label, wt_path) + output_file = File.join(TMP_DIR, "#{label}.json") + + env = { + "BUNDLE_GEMFILE" => GEMFILE, + "BUNDLER_ORIG_BUNDLE_GEMFILE" => nil + } + + cmd = [ + RbConfig.ruby, "-rbundler/setup", + HARNESS, + "--root", wt_path, + "--version", label, + "--output", output_file + ] + + puts "\n#{'=' * 60}" + puts "Running benchmark for #{label} (#{VERSIONS[label]})" + puts("=" * 60) + + success = system(env, *cmd) + unless success + warn "ERROR: Harness failed for #{label}" + exit 1 + end + + output_file +end + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- +at_exit do + puts "\nCleaning up worktrees..." + cleanup_worktrees +end + +worktrees = {} +VERSIONS.each do |label, sha| + worktrees[label] = setup_worktree(label, sha) +end + +output_files = {} +VERSIONS.each_key do |label| + output_files[label] = run_harness(label, worktrees[label]) +end + +puts "\n#{'=' * 60}" +puts "Generating comparison report..." +puts "#{'=' * 60}\n\n" + +system( + RbConfig.ruby, REPORT, + "--v1", output_files["v1"], + "--v2", output_files["v2"] +) diff --git a/benchmark/harness.rb b/benchmark/harness.rb new file mode 100755 index 000000000..ee4ca19da --- /dev/null +++ b/benchmark/harness.rb @@ -0,0 +1,281 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# Benchmark harness — runs all suites for a single CMDx version. +# +# USAGE (called by compare.rb, not directly): +# ruby benchmark/harness.rb --root /path/to/worktree --version v1 --output tmp/v1.json +# +# Requires benchmark-ips, memory_profiler, get_process_mem to be installed. + +require "optparse" +require "json" + +options = {} +OptionParser.new do |opts| + opts.on("--root PATH", "Path to CMDx worktree root") { |v| options[:root] = v } + opts.on("--version LABEL", "Version label (v1 or v2)") { |v| options[:version] = v } + opts.on("--output PATH", "JSON output file path") { |v| options[:output] = v } +end.parse! + +root = options.fetch(:root) +version = options.fetch(:version) +output = options.fetch(:output) + +$LOAD_PATH.unshift("#{root}/lib") +require "cmdx" +require "#{root}/spec/support/helpers/task_builders" +require "#{root}/spec/support/helpers/workflow_builders" + +include CMDx::Testing::TaskBuilders # rubocop:disable Style/MixinUsage +include CMDx::Testing::WorkflowBuilders # rubocop:disable Style/MixinUsage + +CMDx.reset_configuration! +CMDx.configuration.logger = Logger.new(nil) + +require "benchmark/ips" +require "memory_profiler" +require "get_process_mem" + +results = { + version: version, + cmdx_version: CMDx::VERSION, + ruby: RUBY_VERSION, + yjit: defined?(RubyVM::YJIT) && RubyVM::YJIT.respond_to?(:enabled?) && RubyVM::YJIT.enabled?, + timestamp: Time.now.utc.iso8601, + suites: {} +} + +# --------------------------------------------------------------------------- +# Shared fixtures +# --------------------------------------------------------------------------- +successful_task = create_successful_task +skipping_task = create_skipping_task +failing_task = create_failing_task +erroring_task = create_erroring_task +nested_task = create_nested_task(strategy: :swallow, status: :success) + +successful_workflow = create_successful_workflow +failing_workflow = create_failing_workflow + +small_hash = { a: 1, b: 2, c: 3 } +large_hash = (1..50).to_h { |i| [:"key_#{i}", i] } +string_hash = { "a" => 1, "b" => 2, "c" => 3 } + +# Warm up everything once +[successful_task, skipping_task, failing_task, erroring_task, nested_task, + successful_workflow, failing_workflow].each { |t| t.execute rescue nil } # rubocop:disable Style/RescueModifier + +# --------------------------------------------------------------------------- +# Helper: capture benchmark-ips results as a hash +# --------------------------------------------------------------------------- +def capture_ips(warmup: 1, time: 3) + collected = {} + job = nil + Benchmark.ips do |x| + x.config(warmup: warmup, time: time, quiet: true) + yield(x) + job = x + end + job.full_report.entries.each do |e| + collected[e.label] = { ips: e.ips.round(1), error_pct: e.error_percentage.round(2) } + end + collected +end + +# --------------------------------------------------------------------------- +# 1. IPS — Task Execution +# --------------------------------------------------------------------------- +warn "[#{version}] Running IPS: task execution..." +results[:suites][:ips_tasks] = capture_ips do |x| + x.report("success") { successful_task.execute } + x.report("skip!") { skipping_task.execute } + x.report("fail!") { failing_task.execute } + x.report("error (rescue)") { erroring_task.execute } + x.report("nested (3-deep)") { nested_task.execute } +end + +# --------------------------------------------------------------------------- +# 2. IPS — Workflow Execution +# --------------------------------------------------------------------------- +warn "[#{version}] Running IPS: workflow execution..." +results[:suites][:ips_workflows] = capture_ips do |x| + x.report("workflow success (3 tasks)") { successful_workflow.execute } + x.report("workflow failure (halting)") { failing_workflow.execute } +end + +# --------------------------------------------------------------------------- +# 3. IPS — Context +# --------------------------------------------------------------------------- +warn "[#{version}] Running IPS: context..." +results[:suites][:ips_context] = capture_ips do |x| + x.report("Context.new (3 sym keys)") { CMDx::Context.new(small_hash) } + x.report("Context.new (3 str keys)") { CMDx::Context.new(string_hash) } + x.report("Context.new (50 sym keys)") { CMDx::Context.new(large_hash) } + x.report("Context.build (passthrough)") { CMDx::Context.build(CMDx::Context.new(small_hash)) } +end + +ctx = CMDx::Context.new(a: 1, b: 2, c: 3) +results[:suites][:ips_context_access] = capture_ips do |x| + x.report("ctx[:a] (bracket)") { ctx[:a] } + x.report("ctx.fetch(:a)") { ctx.fetch(:a) } + x.report("ctx.a (method_missing)") { ctx.a } + x.report("ctx.a = 1 (mm setter)") { ctx.a = 1 } + x.report("ctx.key?(:a)") { ctx.key?(:a) } +end + +# --------------------------------------------------------------------------- +# 4. Memory Profiling (memory_profiler) +# --------------------------------------------------------------------------- +warn "[#{version}] Running memory profiling..." + +def memory_profile(label, task_class) + task_class.execute rescue nil # rubocop:disable Style/RescueModifier + report = MemoryProfiler.report(allow_files: "cmdx") { task_class.execute rescue nil } # rubocop:disable Style/RescueModifier + { + label: label, + total_allocated_memsize: report.total_allocated_memsize, + total_allocated_objects: report.total_allocated, + total_retained_memsize: report.total_retained_memsize, + total_retained_objects: report.total_retained + } +end + +results[:suites][:memory] = [ + memory_profile("success", successful_task), + memory_profile("skip!", skipping_task), + memory_profile("fail!", failing_task), + memory_profile("error (rescue)", erroring_task), + memory_profile("nested (3-deep)", nested_task), + memory_profile("workflow success", successful_workflow), + memory_profile("workflow failure", failing_workflow) +] + +# --------------------------------------------------------------------------- +# 5. Object Allocations (ObjectSpace) +# --------------------------------------------------------------------------- +warn "[#{version}] Running allocation trace..." + +def allocation_trace(label, task_class) + task_class.execute rescue nil # rubocop:disable Style/RescueModifier + + GC.start + GC.disable + + alloc_counts = Hash.new(0) + ObjectSpace.trace_object_allocations_start + task_class.execute rescue nil # rubocop:disable Style/RescueModifier + ObjectSpace.trace_object_allocations_stop + + ObjectSpace.each_object do |obj| + file = ObjectSpace.allocation_sourcefile(obj) + next unless file&.include?("cmdx") + + klass = obj.class.name || obj.class.to_s + alloc_counts[klass] += 1 + end + + GC.enable + ObjectSpace.trace_object_allocations_clear + + { label: label, allocations: alloc_counts.sort_by { |_, c| -c }.first(15).to_h } +end + +results[:suites][:allocations] = [ + allocation_trace("success", successful_task), + allocation_trace("nested (3-deep)", nested_task), + allocation_trace("workflow success", successful_workflow) +] + +# --------------------------------------------------------------------------- +# 6. RSS (get_process_mem) +# --------------------------------------------------------------------------- +warn "[#{version}] Running RSS measurement..." + +iterations = 1000 +mem = GetProcessMem.new + +GC.start +rss_before = mem.mb + +iterations.times { successful_task.execute } +GC.start +rss_after_tasks = mem.mb + +iterations.times { successful_workflow.execute } +GC.start +rss_after_workflows = mem.mb + +results[:suites][:rss] = { + iterations: iterations, + before_mb: rss_before.round(2), + after_tasks_mb: rss_after_tasks.round(2), + after_workflows_mb: rss_after_workflows.round(2), + task_growth_mb: (rss_after_tasks - rss_before).round(2), + workflow_growth_mb: (rss_after_workflows - rss_after_tasks).round(2) +} + +# --------------------------------------------------------------------------- +# 7. GC Stats +# --------------------------------------------------------------------------- +warn "[#{version}] Running GC stats..." + +GC.start +gc_before = GC.stat + +iterations.times { successful_task.execute } +gc_after_tasks = GC.stat + +iterations.times { successful_workflow.execute } +gc_after_all = GC.stat + +gc_keys = %i[total_allocated_objects heap_live_slots major_gc_count minor_gc_count] + +results[:suites][:gc_stats] = { + iterations: iterations, + after_tasks: gc_keys.to_h { |k| [k, gc_after_tasks[k] - gc_before[k]] }, + after_workflows: gc_keys.to_h { |k| [k, gc_after_all[k] - gc_after_tasks[k]] } +} + +# --------------------------------------------------------------------------- +# 8. YJIT Comparison (if available) +# --------------------------------------------------------------------------- +if defined?(RubyVM::YJIT) + warn "[#{version}] Running YJIT comparison..." + + yjit_results = {} + + # Without YJIT (already the default state if not enabled) + unless RubyVM::YJIT.enabled? + yjit_results[:no_yjit] = capture_ips(warmup: 1, time: 2) do |x| + x.report("success") { successful_task.execute } + x.report("workflow success (3 tasks)") { successful_workflow.execute } + end + end + + RubyVM::YJIT.enable + yjit_results[:with_yjit] = capture_ips(warmup: 1, time: 2) do |x| + x.report("success") { successful_task.execute } + x.report("workflow success (3 tasks)") { successful_workflow.execute } + end + + if yjit_results[:no_yjit] + speedups = {} + yjit_results[:no_yjit].each do |label, base| + fast = yjit_results[:with_yjit][label] + speedups[label] = (fast[:ips] / base[:ips]).round(2) if fast && base[:ips].positive? + end + yjit_results[:speedup] = speedups + end + + results[:suites][:yjit] = yjit_results +else + warn "[#{version}] YJIT not available, skipping..." + results[:suites][:yjit] = { error: "YJIT not available" } +end + +# --------------------------------------------------------------------------- +# Write output +# --------------------------------------------------------------------------- +File.write(output, JSON.pretty_generate(results)) +warn "[#{version}] Results written to #{output}" diff --git a/benchmark/report.rb b/benchmark/report.rb new file mode 100755 index 000000000..aa2f92dd4 --- /dev/null +++ b/benchmark/report.rb @@ -0,0 +1,343 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# Report generator — reads JSON output from two harness runs and prints +# a side-by-side comparison with deltas and percentage changes. +# +# USAGE (called by compare.rb, or standalone): +# ruby benchmark/report.rb --v1 tmp/benchmark/v1.json --v2 tmp/benchmark/v2.json + +require "optparse" +require "json" + +options = {} +OptionParser.new do |opts| + opts.on("--v1 PATH", "Path to v1 JSON results") { |v| options[:v1] = v } + opts.on("--v2 PATH", "Path to v2 JSON results") { |v| options[:v2] = v } +end.parse! + +v1_data = JSON.parse(File.read(options.fetch(:v1)), symbolize_names: true) +v2_data = JSON.parse(File.read(options.fetch(:v2)), symbolize_names: true) + +# --------------------------------------------------------------------------- +# ANSI colors +# --------------------------------------------------------------------------- +GREEN = "\e[32m" +RED = "\e[31m" +YELLOW = "\e[33m" +CYAN = "\e[36m" +BOLD = "\e[1m" +DIM = "\e[2m" +RESET = "\e[0m" + +def green(s) = "#{GREEN}#{s}#{RESET}" +def red(s) = "#{RED}#{s}#{RESET}" +def yellow(s) = "#{YELLOW}#{s}#{RESET}" +def cyan(s) = "#{CYAN}#{s}#{RESET}" +def bold(s) = "#{BOLD}#{s}#{RESET}" +def dim(s) = "#{DIM}#{s}#{RESET}" + +# Positive delta = v2 is faster/better; negative = v2 is worse +def colorize_delta(pct, higher_is_better: true) + better = higher_is_better ? pct.positive? : pct.negative? + str = format("%+.1f%%", pct) + if better + green(str) + else + (pct.zero? ? yellow(str) : red(str)) + end +end + +def format_number(n) + return n.to_s if n.is_a?(String) + + n.to_s.reverse.gsub(/(\d{3})(?=\d)/, '\\1,').reverse +end + +def section_header(title) + puts "\n#{bold(cyan('━' * 70))}" + puts bold(cyan(" #{title}")) + puts bold(cyan("━" * 70)) +end + +def table_header(*cols, widths:) + fmt = widths.map { |w| "%-#{w}s" }.join(" ") + puts bold(format(fmt, *cols)) + puts dim("─" * (widths.sum + (2 * (widths.size - 1)))) +end + +# --------------------------------------------------------------------------- +# Header +# --------------------------------------------------------------------------- +puts bold("\n#{cyan('╔' + ('═' * 68) + '╗')}") +puts bold(cyan("║ CMDx Benchmark Comparison: v1 vs v2#{' ' * 31}║")) +puts bold(cyan("╚#{'═' * 68}╝")) +puts +puts " v1: CMDx #{v1_data[:cmdx_version]} (#{dim(v1_data[:version])})" +puts " v2: CMDx #{v2_data[:cmdx_version]} (#{dim(v2_data[:version])})" +puts " Ruby: #{v1_data[:ruby]} | YJIT: #{v1_data[:yjit] ? green('enabled') : yellow('disabled')}" +puts " Timestamp: #{v2_data[:timestamp]}" + +# --------------------------------------------------------------------------- +# IPS comparison tables +# --------------------------------------------------------------------------- +def print_ips_table(title, v1_suite, v2_suite) + return unless v1_suite && v2_suite + + section_header("IPS: #{title}") + widths = [28, 14, 14, 10] + table_header("Benchmark", "v1 (i/s)", "v2 (i/s)", "Delta", widths: widths) + + v1_suite.each do |label, v1_entry| + label_s = label.to_s + v2_entry = v2_suite[label] + next unless v2_entry + + v1_ips = v1_entry[:ips] + v2_ips = v2_entry[:ips] + delta = v1_ips.positive? ? ((v2_ips - v1_ips).to_f / v1_ips * 100) : 0 + + printf "%-28s %14s %14s %s\n", + label_s, + format_number(v1_ips), + format_number(v2_ips), + colorize_delta(delta, higher_is_better: true) + end +end + +v1s = v1_data[:suites] +v2s = v2_data[:suites] + +print_ips_table("Task Execution", v1s[:ips_tasks], v2s[:ips_tasks]) +print_ips_table("Workflow Execution", v1s[:ips_workflows], v2s[:ips_workflows]) +print_ips_table("Context Construction", v1s[:ips_context], v2s[:ips_context]) +print_ips_table("Context Access", v1s[:ips_context_access], v2s[:ips_context_access]) + +# --------------------------------------------------------------------------- +# Memory profiling +# --------------------------------------------------------------------------- +if v1s[:memory] && v2s[:memory] + section_header("Memory Profiling (per single execution)") + + widths = [22, 14, 14, 10] + table_header("Scenario", "v1 alloc", "v2 alloc", "Delta", widths: widths) + + v1_mem = v1s[:memory].to_h { |m| [m[:label], m] } + v2_mem = v2s[:memory].to_h { |m| [m[:label], m] } + + v1_mem.each do |label, v1_entry| + v2_entry = v2_mem[label] + next unless v2_entry + + v1_bytes = v1_entry[:total_allocated_memsize] + v2_bytes = v2_entry[:total_allocated_memsize] + delta = v1_bytes.positive? ? ((v2_bytes - v1_bytes).to_f / v1_bytes * 100) : 0 + + printf "%-22s %14s %14s %s\n", + label, + "#{format_number(v1_bytes)} B", + "#{format_number(v2_bytes)} B", + colorize_delta(delta, higher_is_better: false) + end + + puts + table_header("Scenario", "v1 objects", "v2 objects", "Delta", widths: widths) + + v1_mem.each do |label, v1_entry| + v2_entry = v2_mem[label] + next unless v2_entry + + v1_obj = v1_entry[:total_allocated_objects] + v2_obj = v2_entry[:total_allocated_objects] + delta = v1_obj.positive? ? ((v2_obj - v1_obj).to_f / v1_obj * 100) : 0 + + printf "%-22s %14s %14s %s\n", + label, + format_number(v1_obj), + format_number(v2_obj), + colorize_delta(delta, higher_is_better: false) + end + + puts + table_header("Scenario", "v1 retained", "v2 retained", "Delta", widths: widths) + + v1_mem.each do |label, v1_entry| + v2_entry = v2_mem[label] + next unless v2_entry + + v1_ret = v1_entry[:total_retained_memsize] + v2_ret = v2_entry[:total_retained_memsize] + delta = v1_ret.positive? ? ((v2_ret - v1_ret).to_f / v1_ret * 100) : 0 + + printf "%-22s %14s %14s %s\n", + label, + "#{format_number(v1_ret)} B", + "#{format_number(v2_ret)} B", + colorize_delta(delta, higher_is_better: false) + end +end + +# --------------------------------------------------------------------------- +# Object Allocations +# --------------------------------------------------------------------------- +if v1s[:allocations] && v2s[:allocations] + section_header("Object Allocations (top classes per scenario)") + + v1_allocs = v1s[:allocations].to_h { |a| [a[:label], a[:allocations]] } + v2_allocs = v2s[:allocations].to_h { |a| [a[:label], a[:allocations]] } + + v1_allocs.each do |label, v1_classes| + v2_classes = v2_allocs[label] || {} + all_classes = (v1_classes.keys + v2_classes.keys).uniq.first(12) + + puts "\n #{bold(label)}:" + widths = [30, 10, 10, 10] + printf " %-30s %10s %10s %s\n", bold("Class"), bold("v1"), bold("v2"), bold("Delta") + puts " #{dim('─' * 66)}" + + all_classes.each do |klass| + v1_count = (v1_classes[klass.to_s] || v1_classes[klass.to_sym] || 0).to_i + v2_count = (v2_classes[klass.to_s] || v2_classes[klass.to_sym] || 0).to_i + delta = v1_count.positive? ? ((v2_count - v1_count).to_f / v1_count * 100) : 0 + + printf " %-30s %10s %10s %s\n", + klass.to_s, + format_number(v1_count), + format_number(v2_count), + colorize_delta(delta, higher_is_better: false) + end + end +end + +# --------------------------------------------------------------------------- +# RSS +# --------------------------------------------------------------------------- +if v1s[:rss] && v2s[:rss] + section_header("RSS (Resident Set Size) — #{v1s[:rss][:iterations]} iterations each") + + widths = [28, 12, 12, 10] + table_header("Metric", "v1 (MB)", "v2 (MB)", "Delta", widths: widths) + + [ + ["Before", :before_mb], + ["After tasks", :after_tasks_mb], + ["After workflows", :after_workflows_mb], + ["Task growth", :task_growth_mb], + ["Workflow growth", :workflow_growth_mb] + ].each do |label, key| + v1_val = v1s[:rss][key] + v2_val = v2s[:rss][key] + delta = v1_val.to_f.positive? ? ((v2_val - v1_val).to_f / v1_val * 100) : 0 + + printf "%-28s %12.2f %12.2f %s\n", + label, v1_val.to_f, v2_val.to_f, + colorize_delta(delta, higher_is_better: false) + end +end + +# --------------------------------------------------------------------------- +# GC Stats +# --------------------------------------------------------------------------- +if v1s[:gc_stats] && v2s[:gc_stats] + section_header("GC Stats — #{v1s[:gc_stats][:iterations]} iterations each") + + widths = [28, 14, 14, 10] + + %i[after_tasks after_workflows].each do |phase| + label = phase == :after_tasks ? "After Task Execution" : "After Workflow Execution" + puts "\n #{bold(label)}:" + table_header("Metric", "v1", "v2", "Delta", widths: widths) + + v1_gc = v1s[:gc_stats][phase] || {} + v2_gc = v2s[:gc_stats][phase] || {} + + (v1_gc.keys | v2_gc.keys).each do |metric| + v1_val = (v1_gc[metric] || v1_gc[metric.to_s] || 0).to_i + v2_val = (v2_gc[metric] || v2_gc[metric.to_s] || 0).to_i + delta = v1_val.positive? ? ((v2_val - v1_val).to_f / v1_val * 100) : 0 + + printf "%-28s %14s %14s %s\n", + metric.to_s, + format_number(v1_val), + format_number(v2_val), + colorize_delta(delta, higher_is_better: false) + end + end +end + +# --------------------------------------------------------------------------- +# YJIT +# --------------------------------------------------------------------------- +if v1s[:yjit] && v2s[:yjit] && !v1s[:yjit][:error] && !v2s[:yjit][:error] + section_header("YJIT Speedup (with YJIT / without YJIT)") + + v1_yjit = v1s[:yjit] + v2_yjit = v2s[:yjit] + + if v1_yjit[:speedup] && v2_yjit[:speedup] + widths = [28, 12, 12] + table_header("Benchmark", "v1 speedup", "v2 speedup", widths: widths) + + v1_yjit[:speedup].each do |label, v1_ratio| + v2_ratio = v2_yjit[:speedup][label] || v2_yjit[:speedup][label.to_s] + next unless v2_ratio + + v1_str = format("%.2fx", v1_ratio) + v2_str = format("%.2fx", v2_ratio) + + printf "%-28s %12s %12s\n", label.to_s, v1_str, v2_str + end + elsif v1_yjit[:with_yjit] && v2_yjit[:with_yjit] + puts "\n YJIT was enabled at process start; showing YJIT-on IPS only:" + widths = [28, 14, 14, 10] + table_header("Benchmark", "v1 (i/s)", "v2 (i/s)", "Delta", widths: widths) + + v1_yjit[:with_yjit].each do |label, v1_entry| + v2_entry = v2_yjit[:with_yjit][label] || v2_yjit[:with_yjit][label.to_s] + next unless v2_entry + + v1_ips = v1_entry[:ips] || v1_entry["ips"] + v2_ips = v2_entry[:ips] || v2_entry["ips"] + delta = v1_ips.to_f.positive? ? ((v2_ips - v1_ips).to_f / v1_ips * 100) : 0 + + printf "%-28s %14s %14s %s\n", + label.to_s, + format_number(v1_ips.to_f.round(1)), + format_number(v2_ips.to_f.round(1)), + colorize_delta(delta, higher_is_better: true) + end + end +end + +# --------------------------------------------------------------------------- +# Summary +# --------------------------------------------------------------------------- +section_header("Summary") + +if v1s[:ips_tasks] && v2s[:ips_tasks] + v1_success = v1s[:ips_tasks][:success] || v1s[:ips_tasks]["success"] + v2_success = v2s[:ips_tasks][:success] || v2s[:ips_tasks]["success"] + + if v1_success && v2_success + ratio = v2_success[:ips].to_f / v1_success[:ips] + change = ((ratio - 1) * 100).round(1) + verdict = ratio >= 1.0 ? green("FASTER") : red("SLOWER") + + puts " Task execution (success): v2 is #{format('%.2fx', ratio)} #{verdict} than v1 (#{colorize_delta(change, higher_is_better: true)})" + end +end + +if v1s[:memory] && v2s[:memory] + v1_mem_success = v1s[:memory].find { |m| m[:label] == "success" } + v2_mem_success = v2s[:memory].find { |m| m[:label] == "success" } + + if v1_mem_success && v2_mem_success + mem_ratio = v2_mem_success[:total_allocated_memsize].to_f / v1_mem_success[:total_allocated_memsize] + mem_change = ((mem_ratio - 1) * 100).round(1) + mem_verdict = mem_ratio <= 1.0 ? green("LESS") : red("MORE") + + puts " Memory (success): v2 uses #{format('%.2fx', mem_ratio)} #{mem_verdict} memory than v1 (#{colorize_delta(mem_change, higher_is_better: false)})" + end +end + +puts diff --git a/cmdx.gemspec b/cmdx.gemspec index 37efaac03..73ec07476 100644 --- a/cmdx.gemspec +++ b/cmdx.gemspec @@ -12,7 +12,7 @@ Gem::Specification.new do |spec| spec.description = spec.summary spec.homepage = "https://github.com/drexed/cmdx" spec.license = "LGPL-3.0" - spec.required_ruby_version = ">= 3.1.0" + spec.required_ruby_version = ">= 3.3.0" spec.metadata["homepage_uri"] = spec.homepage spec.metadata["source_code_uri"] = "https://github.com/drexed/cmdx" @@ -30,6 +30,7 @@ Gem::Specification.new do |spec| *%w[ .cursor/ .github/ + benchmark/ bin/ docs/ examples/ @@ -56,10 +57,8 @@ Gem::Specification.new do |spec| spec.add_dependency "bigdecimal" spec.add_dependency "logger" - spec.add_dependency "zeitwerk" spec.add_development_dependency "bundler" - spec.add_development_dependency "cmdx-rspec" spec.add_development_dependency "i18n" spec.add_development_dependency "rake" spec.add_development_dependency "rdoc" diff --git a/docs/attributes/coercions.md b/docs/attributes/coercions.md deleted file mode 100644 index 0d33ed984..000000000 --- a/docs/attributes/coercions.md +++ /dev/null @@ -1,155 +0,0 @@ -# Attributes - Coercions - -Automatically convert inputs to expected types. Coercions handle everything from simple string-to-integer conversions to JSON parsing. - -See [Global Configuration](../configuration.md#coercions) for custom coercion setup. - -## Usage - -Define attribute types to enable automatic coercion: - -```ruby -class ParseMetrics < CMDx::Task - # Coerce into a symbol - attribute :measurement_type, type: :symbol - - # Coerce into a rational fallback to big decimal - attribute :value, type: [:rational, :big_decimal] - - # Coerce with options - attribute :recorded_at, type: :date, strptime: "%m-%d-%Y" - - def work - measurement_type #=> :temperature - recorded_at #=> <Date 2024-01-23> - value #=> 98.6 (Float) - end -end - -ParseMetrics.execute( - measurement_type: "temperature", - recorded_at: "01-23-2020", - value: "98.6" -) -``` - -!!! tip - - Specify multiple coercion types for attributes that could be a variety of value formats. CMDx attempts each type in order until one succeeds. - -## Built-in Coercions - -| Type | Options | Description | Examples | -|------|---------|-------------|----------| -| `:array` | | Array conversion with JSON support | `"val"` → `["val"]`<br>`"[1,2,3]"` → `[1, 2, 3]` | -| `:big_decimal` | `:precision` | High-precision decimal | `"123.456"` → `BigDecimal("123.456")` | -| `:boolean` | | Boolean with text patterns | `"yes"` → `true`, `"no"` → `false` | -| `:complex` | | Complex numbers | `"1+2i"` → `Complex(1, 2)` | -| `:date` | `:strptime` | Date objects | `"2024-01-23"` → `Date.new(2024, 1, 23)` | -| `:datetime` | `:strptime` | DateTime objects | `"2024-01-23 10:30"` → `DateTime.new(2024, 1, 23, 10, 30)` | -| `:float` | | Floating-point numbers | `"123.45"` → `123.45` | -| `:hash` | | Hash conversion with JSON support | `'{"a":1}'` → `{"a" => 1}` | -| `:integer` | | Integer with hex/octal support | `"0xFF"` → `255`, `"077"` → `63` | -| `:rational` | | Rational numbers | `"1/2"` → `Rational(1, 2)` | -| `:string` | | String conversion | `123` → `"123"` | -| `:symbol` | | Symbol conversion | `"abc"` → `:abc` | -| `:time` | `:strptime` | Time objects | `"10:30:00"` → `Time.new(2024, 1, 23, 10, 30)` | - -## Declarations - -!!! warning "Important" - - Custom coercions must raise `CMDx::CoercionError` with a descriptive message. - -### Proc or Lambda - -Use anonymous functions for simple coercion logic: - -```ruby -class TransformCoordinates < CMDx::Task - # Proc - register :coercion, :geolocation, proc do |value, options = {}| - begin - Geolocation(value) - rescue StandardError - raise CMDx::CoercionError, "could not convert into a geolocation" - end - end - - # Lambda - register :coercion, :geolocation, ->(value, options = {}) { - begin - Geolocation(value) - rescue StandardError - raise CMDx::CoercionError, "could not convert into a geolocation" - end - } -end -``` - -### Class or Module - -Register custom coercion logic for specialized type handling: - -```ruby -class GeolocationCoercion - def self.call(value, options = {}) - Geolocation(value) - rescue StandardError - raise CMDx::CoercionError, "could not convert into a geolocation" - end -end - -class TransformCoordinates < CMDx::Task - register :coercion, :geolocation, GeolocationCoercion - - attribute :latitude, type: :geolocation -end -``` - -## Removals - -Remove unwanted coercions: - -!!! warning - - Each `deregister` call removes one coercion. Use multiple calls for batch removals. - -```ruby -class TransformCoordinates < CMDx::Task - deregister :coercion, :geolocation -end -``` - -## Error Handling - -Coercion failures provide detailed error information including attribute paths, attempted types, and specific failure reasons: - -```ruby -class AnalyzePerformance < CMDx::Task - attribute :iterations, type: :integer - attribute :score, type: [:float, :big_decimal] - - def work - # Your logic here... - end -end - -result = AnalyzePerformance.execute( - iterations: "not-a-number", - score: "invalid-float" -) - -result.state #=> "interrupted" -result.status #=> "failed" -result.reason #=> "Invalid" -result.metadata #=> { - # errors: { - # full_message: "iterations could not coerce into an integer. score could not coerce into one of: float, big_decimal.", - # messages: { - # iterations: ["could not coerce into an integer"], - # score: ["could not coerce into one of: float, big_decimal"] - # } - # } - # } -``` diff --git a/docs/attributes/defaults.md b/docs/attributes/defaults.md deleted file mode 100644 index 1d8875de3..000000000 --- a/docs/attributes/defaults.md +++ /dev/null @@ -1,77 +0,0 @@ -# Attributes - Defaults - -Provide fallback values for optional attributes. Defaults kick in when values aren't provided or are `nil`. - -## Declarations - -Defaults work seamlessly with coercions, validations, and nested attributes: - -### Static Values - -```ruby -class OptimizeDatabase < CMDx::Task - attribute :strategy, default: :incremental - attribute :level, default: "basic" - attribute :notify_admin, default: true - attribute :timeout_minutes, default: 30 - attribute :indexes, default: [] - attribute :options, default: {} - - def work - strategy #=> :incremental - level #=> "basic" - notify_admin #=> true - timeout_minutes #=> 30 - indexes #=> [] - options #=> {} - end -end -``` - -### Symbol References - -Reference instance methods by symbol for dynamic default values: - -```ruby -class ProcessAnalytics < CMDx::Task - attribute :granularity, default: :default_granularity - - def work - # Your logic here... - end - - private - - def default_granularity - Current.user.premium? ? "hourly" : "daily" - end -end -``` - -### Proc or Lambda - -Use anonymous functions for dynamic default values: - -```ruby -class CacheContent < CMDx::Task - # Proc - attribute :expire_hours, default: proc { Current.tenant.cache_duration || 24 } - - # Lambda - attribute :compression, default: -> { Current.tenant.premium? ? "gzip" : "none" } -end -``` - -## Coercions and Validations - -Defaults follow the same coercion and validation rules as provided values: - -```ruby -class ScheduleBackup < CMDx::Task - # Coercions - attribute :retention_days, default: "7", type: :integer - - # Validations - optional :frequency, default: "daily", inclusion: { in: %w[hourly daily weekly monthly] } -end -``` diff --git a/docs/attributes/definitions.md b/docs/attributes/definitions.md deleted file mode 100644 index 6951bc5c0..000000000 --- a/docs/attributes/definitions.md +++ /dev/null @@ -1,354 +0,0 @@ -# Attributes - Definitions - -Attributes define your task's interface with automatic validation, type coercion, and accessor generation. They're the contract between callers and your business logic. - -## Declarations - -!!! warning "Important" - - Attributes are order-dependent. If one attribute references another as a source or condition, the referenced attribute must be defined first. - -```ruby -# Correct: credentials defined before connection_string -required :credentials, source: :database_config -attribute :connection_string, source: :credentials - -# Wrong: connection_string references credentials before it exists -attribute :connection_string, source: :credentials -required :credentials, source: :database_config -``` - -!!! warning "Important" - - Attribute names that conflict with existing Ruby or CMDx methods will raise an error. Use `:as`, `:prefix`, or `:suffix` to resolve naming conflicts. See [Naming](naming.md). - -!!! tip - - Prefer using the `required` and `optional` alias for `attributes` for brevity and to clearly signal intent. - -### Optional - -Optional attributes return `nil` when not provided. - -```ruby -class ScheduleEvent < CMDx::Task - attribute :title - attributes :duration, :location - - # Alias for attributes (preferred) - optional :description - optional :visibility, :attendees - - def work - title #=> "Team Standup" - duration #=> 30 - location #=> nil - description #=> nil - visibility #=> nil - attendees #=> ["alice@company.com", "bob@company.com"] - end -end - -# Attributes passed as keyword arguments -ScheduleEvent.execute( - title: "Team Standup", - duration: 30, - attendees: ["alice@company.com", "bob@company.com"] -) -``` - -### Required - -Required attributes must be provided in call arguments or task execution will fail. - -```ruby -class PublishArticle < CMDx::Task - attribute :title, required: true - attributes :content, :author_id, required: true - - # Alias for attributes => required: true (preferred) - required :category - required :status, :tags - - # Conditionally required - required :publisher, if: :magazine? - attribute :approver, required: true, unless: proc { status == :published } - - def work - title #=> "Getting Started with Ruby" - content #=> "This is a comprehensive guide..." - author_id #=> 42 - category #=> "programming" - status #=> :published - tags #=> ["ruby", "beginner"] - publisher #=> "Eastbay" - approver #=> #<Editor ...> - end - - private - - def magazine? - context.title.ends_with?("[M]") - end -end -``` - -!!! note - - When a required attribute's condition evaluates to `false`, the attribute behaves as optional. All other attribute features such as coercions, validations, defaults, and transformations still apply normally. - -## Removals - -Remove inherited or previously defined attributes and their accessor methods: - -```ruby -class ApplicationTask < CMDx::Task - required :tenant_id - optional :debug_mode -end - -class PublicTask < ApplicationTask - remove_attribute :tenant_id - remove_attributes :debug_mode - - def work - # tenant_id and debug_mode are no longer defined - end -end -``` - -!!! warning "Important" - - Removing an attribute also removes any nested children defined under it. - -## Introspection - -Inspect the full attribute schema for tooling, documentation generation, or debugging: - -```ruby -class CreateUser < CMDx::Task - required :email, type: :string, format: /\A.+@.+\z/ - optional :role, default: "member", inclusion: { in: %w[member admin] } -end - -CreateUser.attributes_schema -#=> { -# email: { required: true, types: [:string], format: /\A.+@.+\z/, ... }, -# role: { required: false, default: "member", inclusion: { in: ["member", "admin"] }, ... } -# } -``` - -## Sources - -Attributes read from any accessible object—not just context. Use sources to pull data from models, services, or any callable: - -### Context - -```ruby -class BackupDatabase < CMDx::Task - # Default source is :context - required :database_name - optional :compression_level - - # Explicitly specify context source - attribute :backup_path, source: :context - - def work - database_name #=> context.database_name - backup_path #=> context.backup_path - compression_level #=> context.compression_level - end -end -``` - -### Symbol References - -Reference instance methods by symbol for dynamic source values: - -```ruby -class BackupDatabase < CMDx::Task - attributes :host, :credentials, source: :database_config - - # Access from declared attributes - attribute :connection_string, source: :credentials - - def work - # Your logic here... - end - - private - - def database_config - @database_config ||= DatabaseConfig.find(context.database_name) - end -end -``` - -### Proc or Lambda - -Use anonymous functions for dynamic source values: - -```ruby -class BackupDatabase < CMDx::Task - # Proc - attribute :timestamp, source: proc { Time.current } - - # Lambda - attribute :server, source: -> { Current.server } -end -``` - -### Class or Module - -For complex source logic, use classes or modules: - -```ruby -class DatabaseResolver - def self.call(task) - Database.find(task.context.database_name) - end -end - -class BackupDatabase < CMDx::Task - # Class or Module - attribute :schema, source: DatabaseResolver - - # Instance - attribute :metadata, source: DatabaseResolver.new -end -``` - -## Description - -Add metadata to attributes for documentation or introspection purposes. - -```ruby -class CreateUser < CMDx::Task - required :email, description: "The user's primary email address" - - # Alias :desc - optional :phone, desc: "Primary contact number" - - # Bulk definition - description applies to all - attributes :first_name, :last_name, desc: "Part of user's legal name" -end -``` - -## Nesting - -Build complex structures with nested attributes. Children inherit their parent as source and support all attribute options: - -!!! note - - Nested attributes support all features: naming, coercions, validations, defaults, and more. - -```ruby -class ConfigureServer < CMDx::Task - # Required parent with required children - required :network_config do - required :hostname, :port, :protocol, :subnet - optional :load_balancer - attribute :firewall_rules - end - - # Optional parent with conditional children - optional :ssl_config do - required :certificate_path, :private_key # Only required if ssl_config provided - optional :enable_http2, prefix: true - end - - # Multi-level nesting - attribute :monitoring do - required :provider - - optional :alerting do - required :threshold_percentage - optional :notification_channel - end - end - - def work - network_config #=> { hostname: "api.company.com" ... } - hostname #=> "api.company.com" - load_balancer #=> nil - end -end - -ConfigureServer.execute( - server_id: "srv-001", - network_config: { - hostname: "api.company.com", - port: 443, - protocol: "https", - subnet: "10.0.1.0/24", - firewall_rules: "allow_web_traffic" - }, - monitoring: { - provider: "datadog", - alerting: { - threshold_percentage: 85.0, - notification_channel: "slack" - } - } -) -``` - -!!! warning "Important" - - Child requirements only apply when the parent is provided—perfect for optional structures. - -## Error Handling - -Validation failures provide detailed, structured error messages: - -!!! note - - Nested attributes are only validated when their parent is present and valid. - -```ruby -class ConfigureServer < CMDx::Task - required :server_id, :environment - required :network_config do - required :hostname, :port - end - - def work - # Your logic here... - end -end - -# Missing required top-level attributes -result = ConfigureServer.execute(server_id: "srv-001") - -result.state #=> "interrupted" -result.status #=> "failed" -result.reason #=> "Invalid" -result.metadata #=> { - # errors: { - # full_message: "environment is required. network_config is required.", - # messages: { - # environment: ["is required"], - # network_config: ["is required"] - # } - # } - # } - -# Missing required nested attributes -result = ConfigureServer.execute( - server_id: "srv-001", - environment: "production", - network_config: { hostname: "api.company.com" } # Missing port -) - -result.state #=> "interrupted" -result.status #=> "failed" -result.reason #=> "Invalid" -result.metadata #=> { - # errors: { - # full_message: "port is required.", - # messages: { - # port: ["is required"] - # } - # } - # } -``` diff --git a/docs/attributes/naming.md b/docs/attributes/naming.md deleted file mode 100644 index f36249508..000000000 --- a/docs/attributes/naming.md +++ /dev/null @@ -1,68 +0,0 @@ -# Attributes - Naming - -Customize accessor method names to avoid conflicts and improve clarity. Affixing changes only the generated methods—not the original attribute names. - -!!! note - - Use naming when attributes conflict with existing methods or need better clarity in your code. - -## Prefix - -Adds a prefix to the generated accessor method name. - -```ruby -class GenerateReport < CMDx::Task - # Dynamic from attribute source - attribute :template, prefix: true - - # Static - attribute :format, prefix: "report_" - - def work - context_template #=> "monthly_sales" - report_format #=> "pdf" - end -end - -# Attributes passed as original attribute names -GenerateReport.execute(template: "monthly_sales", format: "pdf") -``` - -## Suffix - -Adds a suffix to the generated accessor method name. - -```ruby -class DeployApplication < CMDx::Task - # Dynamic from attribute source - attribute :branch, suffix: true - - # Static - attribute :version, suffix: "_tag" - - def work - branch_context #=> "main" - version_tag #=> "v1.2.3" - end -end - -# Attributes passed as original attribute names -DeployApplication.execute(branch: "main", version: "v1.2.3") -``` - -## As - -Completely renames the generated accessor method. - -```ruby -class ScheduleMaintenance < CMDx::Task - attribute :scheduled_at, as: :when - - def work - when #=> <DateTime> - end -end - -# Attributes passed as original attribute names -ScheduleMaintenance.execute(scheduled_at: DateTime.new(2024, 12, 15, 2, 0, 0)) -``` diff --git a/docs/attributes/transformations.md b/docs/attributes/transformations.md deleted file mode 100644 index 193a86f5c..000000000 --- a/docs/attributes/transformations.md +++ /dev/null @@ -1,81 +0,0 @@ -# Attributes - Transformations - -Modify attribute values after coercion but before validation. Perfect for normalization, formatting, and data cleanup. - -## Processing Pipeline - -Each attribute flows through a fixed pipeline: - -```mermaid -flowchart LR - Source --> Coerce --> Transform --> Validate -``` - -| Stage | Description | -|-------|-------------| -| **Source** | Resolve value from context, method, proc, or callable | -| **Coerce** | Convert to declared `type` (e.g., string → integer) | -| **Transform** | Apply `transform` function to coerced value | -| **Validate** | Run validators (presence, format, etc.) on final value | - -This means transformations receive already-coerced values, and validators see the final transformed output. - -## Declarations - -### Symbol References - -Reference instance methods by symbol for dynamic value transformations: - -```ruby -class ProcessAnalytics < CMDx::Task - attribute :options, transform: :compact_blank -end -``` - -### Proc or Lambda - -Use anonymous functions for dynamic value transformations: - -```ruby -class CacheContent < CMDx::Task - # Proc - attribute :expire_hours, transform: proc { |v| v * 2 } - - # Lambda - attribute :compression, transform: ->(v) { v.to_s.upcase.strip[0..2] } -end -``` - -### Class or Module - -Use any object that responds to `call` for reusable transformation logic: - -```ruby -class EmailNormalizer - def call(value) - value.to_s.downcase.strip - end -end - -class ProcessContacts < CMDx::Task - # Class or Module - attribute :email, transform: EmailNormalizer - - # Instance - attribute :email, transform: EmailNormalizer.new -end -``` - -## Validations - -Validations run on transformed values, ensuring data consistency: - -```ruby -class ScheduleBackup < CMDx::Task - # Coercions - attribute :retention_days, type: :integer, transform: proc { |v| v.clamp(1, 5) } - - # Validations - optional :frequency, transform: :downcase, inclusion: { in: %w[hourly daily weekly monthly] } -end -``` diff --git a/docs/attributes/validations.md b/docs/attributes/validations.md deleted file mode 100644 index a41a31fd0..000000000 --- a/docs/attributes/validations.md +++ /dev/null @@ -1,354 +0,0 @@ -# Attributes - Validations - -Ensure inputs meet requirements before execution. Validations run after coercions, giving you declarative data integrity checks. - -See [Global Configuration](../configuration.md#validators) for custom validator setup. - -## Usage - -Define validation rules on attributes to enforce data requirements: - -```ruby -class ProcessSubscription < CMDx::Task - # Required field with presence validation - attribute :user_id, presence: true - - # String with length constraints - optional :preferences, length: { minimum: 10, maximum: 500 } - - # Numeric range validation - required :tier_level, inclusion: { in: 1..5 } - - # Format validation for email - attribute :contact_email, format: /\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i - - def work - user_id #=> "98765" - preferences #=> "Send weekly digest emails" - tier_level #=> 3 - contact_email #=> "user@company.com" - end -end - -ProcessSubscription.execute( - user_id: "98765", - preferences: "Send weekly digest emails", - tier_level: 3, - contact_email: "user@company.com" -) -``` - -!!! tip - - Validations run after coercions, so you can validate the final coerced values rather than raw input. - -## Built-in Validators - -### Common Options - -```ruby -class ProcessProduct < CMDx::Task - # Allow nil - attribute :tier_level, inclusion: { - in: 1..5, - allow_nil: true - } - - # Conditionals - optional :contact_email, format: { - with: /\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i, - if: ->(value) { value.includes?("@") } - } - required :status, exclusion: { - in: %w[recalled archived], - unless: :product_sunsetted? - } - - # Custom message - attribute :title, length: { - within: 5..100, - message: "must be in optimal size" - } - - def work - # Your logic here... - end - - private - - def product_defunct?(value) - context.company.out_of_business? || value == "deprecated" - end -end -``` - -This list of options is available to all validators: - -| Option | Description | -|--------|-------------| -| `:allow_nil` | Skip validation when value is `nil` | -| `:if` | Symbol, proc, lambda, or callable determining when to validate | -| `:unless` | Symbol, proc, lambda, or callable determining when to skip validation | -| `:message` | Custom error message for validation failures | - -### Absence - -```ruby -class CreateUser < CMDx::Task - attribute :honey_pot, absence: true - - attribute :honey_pot, absence: { message: "must be empty" } - - def work - # Your logic here... - end -end -``` - -| Options | Description | -|---------|-------------| -| `true` | Ensures value is nil, empty string, or whitespace | - -### Exclusion - -```ruby -class ProcessProduct < CMDx::Task - attribute :status, exclusion: { in: %w[recalled archived] } - - def work - # Your logic here... - end -end -``` - -| Options | Description | -|---------|-------------| -| `:in` | The collection of forbidden values or range | -| `:within` | Alias for :in option | -| `:of_message` | Custom message for discrete value exclusions | -| `:in_message` | Custom message for range-based exclusions | -| `:within_message` | Alias for :in_message option | - -### Format - -```ruby -class ProcessProduct < CMDx::Task - attribute :sku, format: /\A[A-Z]{3}-[0-9]{4}\z/ - - attribute :sku, format: { with: /\A[A-Z]{3}-[0-9]{4}\z/ } - - def work - # Your logic here... - end -end -``` - -| Options | Description | -|---------|-------------| -| `regexp` | Alias for :with option | -| `:with` | Regex pattern that the value must match | -| `:without` | Regex pattern that the value must not match | - -### Inclusion - -```ruby -class ProcessProduct < CMDx::Task - attribute :availability, inclusion: { in: %w[available limited] } - - def work - # Your logic here... - end -end -``` - -| Options | Description | -|---------|-------------| -| `:in` | The collection of allowed values or range | -| `:within` | Alias for :in option | -| `:of_message` | Custom message for discrete value inclusions | -| `:in_message` | Custom message for range-based inclusions | -| `:within_message` | Alias for :in_message option | - -### Length - -```ruby -class CreateBlogPost < CMDx::Task - attribute :title, length: { within: 5..100 } - - def work - # Your logic here... - end -end -``` - -| Options | Description | -|---------|-------------| -| `:within` | Range that the length must fall within (inclusive) | -| `:not_within` | Range that the length must not fall within | -| `:in` | Alias for :within | -| `:not_in` | Range that the length must not fall within | -| `:min` | Minimum allowed length | -| `:max` | Maximum allowed length | -| `:is` | Exact required length | -| `:is_not` | Length that is not allowed | -| `:within_message` | Custom message for within/range validations | -| `:in_message` | Custom message for :in validation | -| `:not_within_message` | Custom message for not_within validation | -| `:not_in_message` | Custom message for not_in validation | -| `:min_message` | Custom message for minimum length validation | -| `:max_message` | Custom message for maximum length validation | -| `:is_message` | Custom message for exact length validation | -| `:is_not_message` | Custom message for is_not validation | - -### Numeric - -```ruby -class CreateBlogPost < CMDx::Task - attribute :word_count, numeric: { min: 100 } - - def work - # Your logic here... - end -end -``` - -| Options | Description | -|---------|-------------| -| `:within` | Range that the value must fall within (inclusive) | -| `:not_within` | Range that the value must not fall within | -| `:in` | Alias for :within option | -| `:not_in` | Alias for :not_within option | -| `:min` | Minimum allowed value (inclusive, >=) | -| `:max` | Maximum allowed value (inclusive, <=) | -| `:is` | Exact value that must match | -| `:is_not` | Value that must not match | -| `:within_message` | Custom message for range validations | -| `:not_within_message` | Custom message for exclusion validations | -| `:min_message` | Custom message for minimum validation | -| `:max_message` | Custom message for maximum validation | -| `:is_message` | Custom message for exact match validation | -| `:is_not_message` | Custom message for exclusion validation | - -### Presence - -```ruby -class CreateBlogPost < CMDx::Task - attribute :content, presence: true - - attribute :content, presence: { message: "cannot be blank" } - - def work - # Your logic here... - end -end -``` - -| Options | Description | -|---------|-------------| -| `true` | Ensures value is not nil, empty string, or whitespace | - -## Declarations - -!!! warning "Important" - - Custom validators must raise `CMDx::ValidationError` with a descriptive message. - -### Proc or Lambda - -Use anonymous functions for simple validation logic: - -```ruby -class SetupApplication < CMDx::Task - # Proc - register :validator, :api_key, proc do |value, options = {}| - unless value.match?(/\A[a-zA-Z0-9]{32}\z/) - raise CMDx::ValidationError, "invalid API key format" - end - end - - # Lambda - register :validator, :api_key, ->(value, options = {}) { - unless value.match?(/\A[a-zA-Z0-9]{32}\z/) - raise CMDx::ValidationError, "invalid API key format" - end - } -end -``` - -### Class or Module - -Register custom validation logic for specialized requirements: - -```ruby -class ApiKeyValidator - def self.call(value, options = {}) - unless value.match?(/\A[a-zA-Z0-9]{32}\z/) - raise CMDx::ValidationError, "invalid API key format" - end - end -end - -class SetupApplication < CMDx::Task - register :validator, :api_key, ApiKeyValidator - - attribute :access_key, api_key: true -end -``` - -## Removals - -Remove unwanted validators: - -!!! warning - - Each `deregister` call removes one validator. Use multiple calls for batch removals. - -```ruby -class SetupApplication < CMDx::Task - deregister :validator, :api_key -end -``` - -## Error Handling - -Validation failures provide detailed, structured error messages: - -```ruby -class CreateProject < CMDx::Task - attribute :project_name, - presence: true, - length: { minimum: 3, maximum: 50 } - optional :budget, - numeric: { greater_than: 1000, less_than: 1000000 } - required :priority, - inclusion: { in: [:low, :medium, :high] } - attribute :contact_email, - format: /\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i - - def work - # Your logic here... - end -end - -result = CreateProject.execute( - project_name: "AB", # Too short - budget: 500, # Too low - priority: :urgent, # Not in allowed list - contact_email: "invalid-email" # Invalid format -) - -result.state #=> "interrupted" -result.status #=> "failed" -result.reason #=> "Invalid" -result.metadata #=> { - # errors: { - # full_message: "project_name is too short (minimum is 3 characters). budget must be greater than 1000. priority is not included in the list. contact_email is invalid.", - # messages: { - # project_name: ["is too short (minimum is 3 characters)"], - # budget: ["must be greater than 1000"], - # priority: ["is not included in the list"], - # contact_email: ["is invalid"] - # } - # } - # } -``` diff --git a/docs/basics/chain.md b/docs/basics/chain.md index b48ec034e..0257841bb 100644 --- a/docs/basics/chain.md +++ b/docs/basics/chain.md @@ -1,118 +1,99 @@ # Basics - Chain -Chains automatically track related task executions within a thread. Think of them as execution traces that help you understand what happened and in what order. +A `Chain` is the ordered trace of every `Result` produced by a top-level task and the subtasks it triggered. It's assembled automatically and gives you one id to correlate an entire execution. -## Management - -Each execution context maintains its own isolated chain. CMDx uses `Fiber.storage` when available (Ruby 3.2+), falling back to `Thread.current` on older Rubies. - -!!! tip - - Fiber-based storage means CMDx works correctly with async frameworks like Falcon and the `async` gem out of the box. - -```ruby -# Thread A -Thread.new do - result = ImportDataset.execute(file_path: "/data/batch1.csv") - result.chain.id #=> "018c2b95-b764-7615-a924-cc5b910ed1e5" -end - -# Thread B (completely separate chain) -Thread.new do - result = ImportDataset.execute(file_path: "/data/batch2.csv") - result.chain.id #=> "z3a42b95-c821-7892-b156-dd7c921fe2a3" -end +## Structure -# Access current thread's chain -CMDx::Chain.current #=> Returns current chain or nil -CMDx::Chain.clear #=> Clears current thread's chain -``` +A `Chain` is an ordered, mutex-guarded collection of `Result`s. Subtasks `push` onto the chain as they finalize; the root `unshift`s itself last, so the root ends up at index 0 and children follow in completion order. -## Links +From a result, reach the chain via: -Tasks automatically create or join the current thread's chain: +| Method | Returns | +|--------|---------| +| `result.chain` | The owning `CMDx::Chain` (Enumerable; `id`, `size`, `first`, `last`, etc.) | +| `result.chain.id` | The chain's UUID v7 (`String`) | +| `result.chain_index` | This result's zero-based position in the chain (`Integer`, `nil` if absent) | +| `result.chain_root?` | `true` when this result is the chain's root | +| `CMDx::Chain.current` | The live `Chain` object (only inside execution) | -!!! warning "Important" +!!! note - Chain management is automatic—no manual lifecycle handling needed. + `result.chain_id` is **not** a method on `Result`—it only appears as a key in `result.to_h`. Use `result.chain.id` to access the UUID. ```ruby -class ImportDataset < CMDx::Task - def work - # First task creates new chain - result1 = ValidateHeaders.execute(file_path: context.file_path) - result1.chain.id #=> "018c2b95-b764-7615-a924-cc5b910ed1e5" - result1.chain.results.size #=> 1 +result = ImportDataset.execute(dataset_id: 456) - # Second task joins existing chain - result2 = SendNotification.execute(to: "admin@company.com") - result2.chain.id == result1.chain.id #=> true - result2.chain.results.size #=> 2 +result.chain.id #=> "018c2b95-b764-7615-a924-cc5b910ed1e5" +result.chain_index #=> 0 +result.chain_root? #=> true +result.chain.size #=> 4 +result.chain.first #=> root result (ImportDataset) +result.chain.last #=> last subtask result - # Both results reference the same chain - result1.chain.results == result2.chain.results #=> true - end +result.chain.each_with_index do |r, idx| + puts "#{idx}: #{r.task} - #{r.status}" end ``` -## Inheritance +The `Chain` instance exposes `id`, `results`, `push` (aliased `<<`), `unshift`, `index`, `size`, `empty?`, `each`, `last`, plus root delegators: + +| Method | Returns | +|--------|---------| +| `chain.root` | The result flagged with `root: true`, or `nil` | +| `chain.state` | `chain.root&.state` — `"complete"` / `"interrupted"` / `nil` | +| `chain.status` | `chain.root&.status` — `"success"` / `"skipped"` / `"failed"` / `nil` | -Subtasks automatically inherit the current thread's chain, building a unified execution trail: +## Subtasks + +Subtasks automatically join the current chain, building a unified execution trail: ```ruby class ImportDataset < CMDx::Task def work context.dataset = Dataset.find(context.dataset_id) - # Subtasks automatically inherit current chain - ValidateSchema.execute - TransformData.execute!(context) + result1 = ValidateSchema.execute + result1.chain.size #=> 1 (the parent hasn't finalized yet) + + result2 = TransformData.execute!(context) + result2.chain.id == result1.chain.id #=> true + result2.chain.size #=> 2 + SaveToDatabase.execute(dataset_id: context.dataset_id) end end +# After ImportDataset finalizes, its result is unshifted to position 0: result = ImportDataset.execute(dataset_id: 456) -chain = result.chain -# All tasks share the same chain -chain.results.size #=> 4 (main task + 3 subtasks) -chain.results.map { |r| r.task.class } +result.chain.size #=> 4 (parent + 3 subtasks) +result.chain.first.task #=> ImportDataset (the root) +result.chain.map(&:task) #=> [ImportDataset, ValidateSchema, TransformData, SaveToDatabase] ``` -## Structure +!!! note -Chains expose comprehensive execution information: + Chain lifecycle is automatic: Runtime installs a fresh chain when the top-level task starts and clears it on teardown. -!!! warning "Important" +## Fiber Storage - Chain state reflects the first (outermost) task result. Subtasks maintain their own states. +The active chain lives on `Fiber[]` (fiber-local), so each `Thread`'s root fiber and every explicit `Fiber.new` sees its own chain. `Workflow` parallel groups intentionally propagate the parent chain into their worker threads so their results roll up under the same trace; the chain's internal mutex makes concurrent pushes safe. ```ruby -result = ImportDataset.execute(dataset_id: 456) -chain = result.chain - -# Chain identification (UUID v7 when available, UUID v4 otherwise) -chain.id #=> "018c2b95-b764-7615-a924-cc5b910ed1e5" -chain.results #=> Array of all results in execution order -chain.dry_run? #=> true/false - -# State delegation (from first/outer-most result) -chain.state #=> "complete" -chain.status #=> "success" -chain.outcome #=> "success" - -# Convenience methods (delegated from results array) -chain.size #=> 4 (number of results) -chain.first #=> First result (outermost task) -chain.last #=> Last result (most recent subtask) - -# Access individual results -chain.results.each_with_index do |result, index| - puts "#{index}: #{result.task.class} - #{result.status}" +# Thread A — its root fiber gets a fresh chain +Thread.new do + result = ImportDataset.execute(file_path: "/data/batch1.csv") + result.chain.id #=> "018c2b95-b764-7615-a924-cc5b910ed1e5" end -``` -## Freezing +# Thread B — completely separate chain +Thread.new do + result = ImportDataset.execute(file_path: "/data/batch2.csv") + result.chain.id #=> "018c2c11-c821-7892-b156-dd7c921fe2a3" +end -After the outermost task completes, CMDx freezes the chain, context, and all results to enforce immutability. Subtask results are frozen as part of this top-level freeze—not individually during their own execution. +# Inspect or clear the current fiber's chain (rarely needed) +CMDx::Chain.current #=> Returns current chain or nil +CMDx::Chain.clear #=> Clears current fiber's chain +``` diff --git a/docs/basics/context.md b/docs/basics/context.md index 96ac21bfc..8c2b30103 100644 --- a/docs/basics/context.md +++ b/docs/basics/context.md @@ -1,55 +1,33 @@ # Basics - Context -Context is your data container for inputs, intermediate values, and outputs. It makes sharing data between tasks effortless. - -## Building Context - -`Context.build` intelligently handles different input types: - -```ruby -# From a hash -Context.build(email: "user@example.com") - -# From an existing Context (reuses if not frozen) -Context.build(existing_context) - -# From a Result or Task (extracts its context) -Context.build(some_result) # equivalent to some_result.context - -# From nil (creates empty context) -Context.build(nil) -``` - -!!! warning "Important" - - `Context.build` raises `ArgumentError` if the argument doesn't respond to `to_h` or `to_hash`. +`Context` is the shared data bag passed through a task's execution. It holds inputs, intermediate values, and anything the task writes back for downstream consumers. ## Assigning Data -Context automatically captures all task inputs, normalizing keys to symbols: +The hash (or Context / Task / Result) handed to `execute` is normalized into a `Context`. String keys are symbolized; nested values are not. ```ruby -# Direct execution CalculateShipping.execute(weight: 2.5, destination: "CA") - -# Instance creation -CalculateShipping.new(weight: 2.5, "destination" => "CA") ``` -!!! warning "Important" +!!! note - String keys convert to symbols automatically. Prefer symbols for consistency. + `Context.build` passes an existing un-frozen `Context` through unchanged, unwraps anything that responds to `#context` (Task, Result), and wraps hash-likes in a fresh `Context`. ## Accessing Data -Access context data using method notation, hash keys, or safe accessors: +Access context data using method notation, hash keys, or safe accessors. `ctx` is a shorthand alias for `context` on task instances. ```ruby class CalculateShipping < CMDx::Task def work # Method style access (preferred) weight = context.weight - destination = context.destination + destination = ctx.destination + + # Predicate style — truthy check, never raises on missing keys + context.weight? #=> true + context.missing_field? #=> false # Hash style access service_type = context[:service_type] @@ -60,35 +38,32 @@ class CalculateShipping < CMDx::Task carrier = context.dig(:options, :carrier) # Fetch or set a default (returns existing value, or stores and returns the default) - context.fetch_or_store(:attempt_count, 0) - - # Check key existence - context.key?(:weight) #=> true - - # Iteration - context.each { |key, value| logger.debug("#{key}: #{value}") } - keys = context.map { |key, _| key } - - # Shorter alias - cost = ctx.weight * ctx.rate_per_pound # ctx aliases context + context.retrieve(:attempt_count, 0) + context.retrieve(:correlation_id) { SecureRandom.uuid } + + # Inspection helpers + context.key?(:weight) #=> true + context.keys #=> [:weight, :destination, ...] + context.values #=> [2.5, "CA", ...] + context.size #=> 2 + context.empty? #=> false end end ``` -!!! warning "Important" +!!! note - Undefined attributes return `nil` instead of raising errors—perfect for optional data. + Method-style access returns `nil` for unknown keys rather than raising. `Context` includes `Enumerable`, yielding `[key, value]` pairs through `each`; `each_key` and `each_value` iterate one side. ## Modifying Context -Context supports dynamic modification during task execution: +Mutate freely inside `work` — the root task's context is frozen only after Runtime teardown: ```ruby class CalculateShipping < CMDx::Task def work # Direct assignment context.carrier = Carrier.find_by(code: context.carrier_code) - context.package = Package.new(weight: context.weight) context.calculated_at = Time.now # Hash-style assignment @@ -98,36 +73,25 @@ class CalculateShipping < CMDx::Task # Conditional assignment context.insurance_included ||= false - # Batch updates - context.merge!( + # Batch updates (mutates in place; returns self) + context.merge( status: "completed", shipping_cost: calculate_cost, estimated_delivery: Time.now + 3.days ) - # Remove sensitive data - context.delete!(:credit_card_token) + # Remove a key + context.delete(:credit_card_token) # Clear all data - context.clear! - end - - private - - def calculate_cost - base_rate = context.weight * context.rate_per_pound - base_rate + (base_rate * context.tax_percentage) + context.clear end end ``` -!!! tip +## Sharing Between Tasks - Use context for both input values and intermediate results. This creates natural data flow through your task execution pipeline. - -## Data Sharing - -Share context across tasks for seamless data flow: +Context flows through nested executions. A sub-task invoked with `execute(context)` (or `execute(task)` / `execute(result)`) reuses the same underlying `Context`, so writes compound. ```ruby # During execution @@ -157,4 +121,6 @@ CreateShippingLabel.execute(result) !!! warning "Important" - When passing `context`, a `Result`, or a `Task` to another task, the context is **shared by reference**—not copied. Mutations in one task are visible in the other. This enables natural data flow in pipelines but can cause surprises if you expect isolation. Use `context.to_h` to pass a snapshot instead. + Passing a live `Context`, `Task`, or `Result` shares the context by reference — writes in the callee are visible to the caller. Use `context.deep_dup` when you need an isolated snapshot. + + `context.to_h` exposes the backing hash by reference. `Context.build(context.to_h)` rebuilds a fresh top-level table (symbolized keys) but nested mutable values are still shared — use `deep_dup` for full isolation. diff --git a/docs/basics/execution.md b/docs/basics/execution.md index 4b959c23a..84d66726d 100644 --- a/docs/basics/execution.md +++ b/docs/basics/execution.md @@ -1,6 +1,6 @@ # Basics - Execution -CMDx offers two execution methods with different error handling approaches. Choose based on your needs: safe result handling or exception-based control flow. See also [Result](../outcomes/result.md) for the full result API, [Halt](../interruptions/halt.md) for `skip!`/`fail!`, and [Retries](../retries.md) for automatic retry behavior. +CMDx offers two execution methods with different error handling approaches. Choose based on your needs: safe result handling or exception-based control flow. ## Execution Methods @@ -8,8 +8,10 @@ Both methods return results, but handle failures differently: | Method | Returns | Exceptions | Use Case | |--------|---------|------------|----------| -| `execute` | Always returns `CMDx::Result` | Never raises | Predictable result handling | -| `execute!` | Returns `CMDx::Result` on success | Raises `CMDx::Fault` when skipped or failed | Exception-based control flow | +| `execute` | Always returns `CMDx::Result` | Never raises | Branch on `result.success?` / `failed?` / `skipped?` | +| `execute!` | Returns `CMDx::Result` on success or skip | Raises `CMDx::Fault` (or the underlying exception) when failed | Exception-based control flow | + +`call` / `call!` are aliases. `execute` / `execute!` also accept a block — when given, the block receives the `Result` and its return value is returned instead of the result. ```mermaid flowchart LR @@ -25,8 +27,7 @@ flowchart LR end subgraph Raises [Raises CMDx::Fault] - FailFault - SkipFault + Fault end E --> Success @@ -34,137 +35,38 @@ flowchart LR E --> Skipped EB --> Success - EB --> FailFault - EB --> SkipFault + EB --> Skipped + EB --> Fault ``` +`Skipped` is **not** a failure — `execute!` returns the skipped `Result` rather than raising. Only `failed?` results raise. + ## Non-bang Execution -Always returns a `CMDx::Result`, never raises exceptions. Perfect for most use cases. +Always returns a `CMDx::Result`, never raises. Default choice for most call sites. ```ruby result = CreateAccount.execute(email: "user@example.com") +result.context.email #=> "user@example.com" -# Check execution state -result.success? #=> true/false -result.failed? #=> true/false -result.skipped? #=> true/false - -# Access result data -result.context.email #=> "user@example.com" -result.state #=> "complete" -result.status #=> "success" +# Block form — returns whatever the block returns +CreateAccount.execute(email: "user@example.com") do |result| + result.success? ? result.context.account_id : nil +end ``` ## Bang Execution -Raises `CMDx::Fault` exceptions on failure or skip. Returns results only on success. - -| Exception | Raised When | -|-----------|-------------| -| `CMDx::FailFault` | Task execution fails | -| `CMDx::SkipFault` | Task execution is skipped | - -!!! warning "Important" - - Behavior depends on `task_breakpoints` or `workflow_breakpoints` config. Default: only failures raise exceptions. +Raises `CMDx::Fault` on failure (or the originating `StandardError` if one was captured as the cause). Returns the result on success or skip. ```ruby begin result = CreateAccount.execute!(email: "user@example.com") SendWelcomeEmail.execute(result.context) -rescue CMDx::FailFault => e - ScheduleAccountRetryJob.perform_later(e.result.context.email) -rescue CMDx::SkipFault => e - Rails.logger.info("Account creation skipped: #{e.result.reason}") -rescue Exception => e +rescue CMDx::Fault => e + Rails.logger.warn("#{e.task} failed: #{e.message}") + ScheduleAccountRetryJob.perform_later(email: "user@example.com") +rescue StandardError => e ErrorTracker.capture(unhandled_exception: e) end ``` - -## Block Yield - -Execute tasks with direct result access via a block: - -```ruby -CreateAccount.execute(email: "user@example.com") do |result| - if result.success? - redirect_to dashboard_path - else - render :new, alert: result.reason - end -end -``` - -## Direct Instantiation - -Tasks can be instantiated directly for advanced use cases, testing, and custom execution patterns: - -```ruby -# Direct instantiation -task = CreateAccount.new(email: "user@example.com", send_welcome: true) - -# Access properties before execution -task.id #=> "abc123..." (unique task ID) -task.context.email #=> "user@example.com" -task.context.send_welcome #=> true -task.result.state #=> "initialized" -task.result.status #=> "success" - -# Manual execution -task.execute -# or -task.execute! - -task.result.success? #=> true/false -``` - -!!! tip - - Shorthand aliases are available on task instances: `ctx` for `context` and `res` for `result`. - -## Result Details - -The `Result` object provides comprehensive execution information: - -```ruby -result = CreateAccount.execute(email: "user@example.com") - -# Execution metadata -result.id #=> "abc123..." (unique execution ID) -result.task #=> CreateAccount instance (frozen) -result.chain #=> Task execution chain - -# Context and metadata -result.context #=> Context with all task data -result.metadata #=> Hash with execution metadata -``` - -## Dry Run - -Execute tasks in dry-run mode to simulate execution without performing side effects. Pass `dry_run: true` in the context when initializing or executing the task. - -Inside your task, use the `dry_run?` method to conditionally skip side effects. - -```ruby -class CloseStripeCard < CMDx::Task - def work - context.stripe_result = - if dry_run? - FactoryBot.build(:stripe_closed_card) - else - StripeApi.close_card(context.card_id) - end - end -end - -# Execute in dry-run mode -result = CloseStripeCard.execute(card_id: "card_abc123", dry_run: true) -result.success? # => true - -# FactoryBot object -result.context.stripe_result = { - card_id: "card_abc123", - status: "closed" -} -``` diff --git a/docs/basics/setup.md b/docs/basics/setup.md index 2dcf638b9..e0d4cf3c2 100644 --- a/docs/basics/setup.md +++ b/docs/basics/setup.md @@ -1,6 +1,6 @@ # Basics - Setup -Tasks are the heart of CMDx—self-contained units of business logic with built-in validation, error handling, and execution tracking. +Tasks are the unit of work in CMDx: self-contained business logic with built-in input validation, error handling, and execution tracking. ## Structure @@ -14,44 +14,49 @@ class ValidateDocument < CMDx::Task end ``` -Without a `work` method, execution raises `CMDx::UndefinedMethodError`. +Without a `work` method, execution raises `CMDx::ImplementationError` from both `execute` and `execute!`. ```ruby class IncompleteTask < CMDx::Task # No `work` method defined end -IncompleteTask.execute #=> raises CMDx::UndefinedMethodError +IncompleteTask.execute #=> raises CMDx::ImplementationError +IncompleteTask.execute! #=> raises CMDx::ImplementationError ``` ## Rollback -Undo any operations linked to the given status, helping to restore a pristine state. Configure which statuses trigger rollback via [`rollback_on`](../configuration.md#rollback) (default: `["failed"]`). +Define a `rollback` method to undo side effects when the task fails. Runtime calls it after `work` (and before completion callbacks) when the signal is `failed`, flags `result.rolled_back?`, and emits the `:task_rolled_back` telemetry event. ```ruby class ChargeCard < CMDx::Task def work - # Your logic here, ex: charge $100 + context.charge = Stripe::Charge.create(amount: context.amount, source: context.source) end - # Called automatically if a later step in the workflow fails + # Called automatically when this task fails def rollback - # Your undo logic, ex: void $100 charge + Stripe::Refund.create(charge: context.charge.id) if context.charge end end ``` +!!! tip + + Rollback fires only on `failed?`. To undo on skip, halt with `fail!` instead of `skip!`, or invoke your cleanup explicitly from a callback. + ## Inheritance -Share configuration across tasks using inheritance: +Share configuration through inheritance. Every inheritable surface — `settings`, `retry_on`, `deprecation`, and the registries (`middlewares`, `callbacks`, `coercions`, `validators`, `telemetry`, `inputs`, `outputs`) — lazily clones from the superclass on first access, so subclasses extend rather than replace. ```ruby class ApplicationTask < CMDx::Task - register :middleware, SecurityMiddleware + register :middleware, SecurityMiddleware.new before_execution :initialize_request_tracking - attribute :session_id + input :session_id private @@ -69,40 +74,48 @@ end ## Lifecycle -Tasks follow a predictable execution pattern: +Tasks follow a predictable execution pattern. Halt primitives — `success!`, `skip!`, `fail!`, and `throw!` — are control-flow tokens: they `throw` a `Signal` caught by `Runtime`, so any code after a halt is unreachable. See [Signals](../interruptions/signals.md) for the full halt API. ```mermaid stateDiagram-v2 - Initialized: Instantiation - Initialized --> Validating: execute - Validating --> Executing: Valid? - Validating --> Failed: Invalid - Executing --> Success: Work done - Executing --> Skipped: skip! - Executing --> Failed: fail! / Exception - Executed - Freeze - - state Executed { + [*] --> Instantiation + Instantiation --> BeforeExecution: execute + BeforeExecution --> BeforeValidation: callbacks + BeforeValidation --> Validating: callbacks + Validating --> Working: inputs ok + Validating --> Failed: input errors + Working --> VerifyOutputs: work returned + Working --> Success: success! + Working --> Skipped: skip! + Working --> Failed: fail! / throw! / Exception + VerifyOutputs --> Success: outputs ok + VerifyOutputs --> Failed: missing/invalid outputs + + Failed --> Rollback: rollback (if defined) + Rollback --> Terminal + + state Terminal { Success Skipped Failed - Rollback - - Skipped --> Rollback - Failed --> Rollback } + + Terminal --> Freeze: completion callbacks + finalize + Freeze --> [*] ``` !!! danger "Caution" - Tasks are single-use objects. Once executed, they're frozen and immutable. - -| Stage | State | Status | Description | -|-------|-------|--------|-------------| -| **Instantiation** | `initialized` | `success` | Task created with context | -| **Validation** | `executing` | `success`/`failed` | Attributes validated | -| **Execution** | `executing` | `success`/`failed`/`skipped` | `work` method runs | -| **Completion** | `executed` | `success`/`failed`/`skipped` | Result finalized | -| **Freezing** | `executed` | `success`/`failed`/`skipped` | Task becomes immutable | -| **Rollback** | `executed` | `failed`/`skipped` | Work undone | + Tasks are single-use objects. After execution, the task, its root context, and its errors are frozen by `Runtime` teardown. + +| Stage | Description | +|-------|-------------| +| **Before execution** | `before_execution` callbacks run first | +| **Before validation** | `before_validation` callbacks run next | +| **Validation** | Inputs are coerced/validated; failures halt with `failed` | +| **Work** | `work` runs inside `catch(:cmdx_signal)`, wrapped in retries | +| **Output verification** | Declared `output` keys are coerced/validated | +| **Rollback** | `rollback` runs when the signal is `failed` (before completion callbacks) | +| **Completion callbacks** | `on_<state>`, `on_<status>`, `on_ok`/`on_ko` fire in that order | +| **Result finalization** | `Result` built and added to `Chain` (root is `unshift`ed; children are `push`ed) | +| **Teardown** | Task, root context, errors, and chain are frozen; chain reference cleared from the fiber | diff --git a/docs/blog/posts/building-production-ready-rails-with-cmdx.md b/docs/blog/posts/building-production-ready-rails-with-cmdx.md index aef871b60..c3b573156 100644 --- a/docs/blog/posts/building-production-ready-rails-with-cmdx.md +++ b/docs/blog/posts/building-production-ready-rails-with-cmdx.md @@ -9,6 +9,8 @@ slug: building-production-ready-rails-with-cmdx # Building Production-Ready Rails Applications with CMDx: A Complete Guide +*Targets CMDx v1.17.* + I've been building Ruby on Rails applications for over a decade, and if there's one thing that keeps me up at night, it's the state of business logic in most codebases. You know what I'm talking about—fat controllers, bloated models, service objects that look like they were written by five different people on five different days. We've all inherited that one `OrderService` class with 800 lines of spaghetti code and a comment at the top that says "TODO: refactor this." This guide is everything I wish I had when I started taking service objects seriously. We're going to build a complete order processing system from scratch, and by the end, you'll understand how CMDx transforms chaotic business logic into clean, observable, maintainable code. diff --git a/docs/blog/posts/cmdx-as-pragmatic-event-sourcing.md b/docs/blog/posts/cmdx-as-pragmatic-event-sourcing.md index e1ee3203a..7d540ba8f 100644 --- a/docs/blog/posts/cmdx-as-pragmatic-event-sourcing.md +++ b/docs/blog/posts/cmdx-as-pragmatic-event-sourcing.md @@ -9,6 +9,8 @@ slug: cmdx-as-pragmatic-event-sourcing # CMDx as a Pragmatic Alternative to Event Sourcing +*Targets CMDx v1.20.* + Event Sourcing is one of those ideas that sounds perfect in a conference talk and then bankrupts your sprint when you try to implement it. You need an event store, projections, snapshot strategies, a way to replay history, and a team that understands why you can't just `UPDATE` a row anymore. For some domains—banking, audit-heavy compliance, truly distributed systems—it's worth the cost. For the rest of us, it's a complexity tax we can't afford. But the *benefits* of Event Sourcing are real. An immutable record of what happened. The ability to understand *why* the system is in its current state. Traceability across complex workflows. I wanted those benefits without the infrastructure. diff --git a/docs/blog/posts/cmdx-patterns-advanced-middleware-stacks.md b/docs/blog/posts/cmdx-patterns-advanced-middleware-stacks.md index f4e55e3ae..18569caec 100644 --- a/docs/blog/posts/cmdx-patterns-advanced-middleware-stacks.md +++ b/docs/blog/posts/cmdx-patterns-advanced-middleware-stacks.md @@ -11,6 +11,8 @@ slug: cmdx-patterns-advanced-middleware-stacks *Part 2 of the CMDx Patterns series* +*Targets CMDx v1.21.* + Middleware is one of those features that's easy to understand and hard to use well. You write a simple wrapper, register it, and it works. Then you write another. And another. Before long, you've got six middlewares on every task, they're firing in an order you didn't intend, and you're spending more time debugging the middleware stack than the business logic it wraps. I've been through that cycle enough times to develop opinions about how to compose middleware stacks in CMDx. This post covers the patterns that survived contact with production Ruby applications—from simple wrappers to sophisticated multi-layer stacks. diff --git a/docs/blog/posts/cmdx-patterns-debugging-and-observability.md b/docs/blog/posts/cmdx-patterns-debugging-and-observability.md index 0ee109076..c8b2617b9 100644 --- a/docs/blog/posts/cmdx-patterns-debugging-and-observability.md +++ b/docs/blog/posts/cmdx-patterns-debugging-and-observability.md @@ -11,6 +11,8 @@ slug: cmdx-patterns-debugging-and-observability *Part 4 of the CMDx Patterns series* +*Targets CMDx v1.21.* + It's 2 AM. Your pager fires. A customer reports that their order went through but they never got a confirmation email. You open your log aggregator and search for the user ID. In a typical Rails app, you'd find a scattered trail of `puts`-style logs, maybe a Sentry exception if you're lucky, and no clear picture of what actually happened. With CMDx, you search for the `chain_id` and see every task that ran, in order, with timing, status, and metadata. The confirmation email task shows `status: "skipped"`, `reason: "User unsubscribed from order notifications"`. Mystery solved in under a minute. diff --git a/docs/blog/posts/cmdx-patterns-defensive-contracts.md b/docs/blog/posts/cmdx-patterns-defensive-contracts.md index b73870ed5..22a340a1c 100644 --- a/docs/blog/posts/cmdx-patterns-defensive-contracts.md +++ b/docs/blog/posts/cmdx-patterns-defensive-contracts.md @@ -11,6 +11,8 @@ slug: cmdx-patterns-defensive-contracts *Part 1 of the CMDx Patterns series* +*Targets CMDx v1.21.* + I have a rule when building Ruby tasks: if a task can be misused, it will be misused. Not out of malice—out of haste, incomplete documentation, or the natural entropy of a growing codebase. Someone passes a string where you expected an integer. Someone forgets to read the context key you set. Someone calls your task from a new workflow and the whole pipeline falls over because the inputs were subtly wrong. Defensive contracts are CMDx's answer to this. By combining `required`/`optional` attributes, validations, coercions, and `returns`, you build tasks that are impossible to misuse silently. Bad data fails loudly at the boundary. Missing outputs fail immediately at the source. The contract is the code, and the code enforces itself. diff --git a/docs/blog/posts/cmdx-patterns-error-handling-playbook.md b/docs/blog/posts/cmdx-patterns-error-handling-playbook.md index 4c718008d..e6658b3c2 100644 --- a/docs/blog/posts/cmdx-patterns-error-handling-playbook.md +++ b/docs/blog/posts/cmdx-patterns-error-handling-playbook.md @@ -11,6 +11,8 @@ slug: cmdx-patterns-error-handling-playbook *Part 3 of the CMDx Patterns series* +*Targets CMDx v1.21.* + I used to think error handling was simple. Something goes wrong, you rescue it, done. Then I started building systems where "something went wrong" had fifteen different flavors—each requiring a different response. A missing record is not the same as a network timeout. A user's expired subscription is not the same as a billing system outage. Treating them identically is how you end up with generic "Something went wrong" error pages and support tickets that take hours to triage. CMDx gives you four distinct mechanisms for handling problems: `skip!`, `fail!`, `throw!`, and letting exceptions propagate. Knowing which one to reach for—and when—is the difference between a system that degrades gracefully and one that falls over at the first sign of trouble. diff --git a/docs/blog/posts/cmdx-v2-the-runtime-rewrite.md b/docs/blog/posts/cmdx-v2-the-runtime-rewrite.md new file mode 100644 index 000000000..ac2995790 --- /dev/null +++ b/docs/blog/posts/cmdx-v2-the-runtime-rewrite.md @@ -0,0 +1,217 @@ +--- +date: 2026-05-13 +authors: + - drexed +categories: + - Tutorials +slug: cmdx-v2-the-runtime-rewrite +--- + +# CMDx 2.0 Is Here: Rewriting the Runtime + +v1 shipped in March 2025. Over the next year, a lot of real applications pushed it in directions I hadn't planned for: fiber-based schedulers, high-throughput workflows, middleware stacks that wanted to introspect results, pattern-matching consumers. Every one of those pressures exposed the same underlying problem — the v1 runtime was a state machine bolted onto a mutable `Result`, and the longer I tried to extend it, the more it fought back. + +v2 is the rewrite those cracks justified. Same DSL surface you already know — `required`, `optional`, `returns`, `on_success`, `settings`, `CMDx::Workflow` — but a different engine underneath. This post is about why I rewrote the runtime, what actually changed, and how to get your app onto it. + +<!-- more --> + +## Why I Rewrote the Runtime + +Four things kept biting me in v1: + +1. **State-machine halts leaked control flow.** `fail!` didn't halt — it mutated `Result` state and returned. If you forgot to `return` on the next line, the task kept running with a failed result behind it. Real bugs, hard to spot in review. + +2. **`Result` was mutable.** Any middleware, callback, or consumer could poke at `result.metadata[...] = ...` mid-execution. That made "what is the result right now?" a meaningless question and made it impossible to trust a result you received from somewhere else in the chain. + +3. **`Thread.current[:cmdx_chain]` broke under fibers.** Anyone running CMDx inside `Async`, `async-job`, or Ruby 3.3's fiber scheduler could see chains leak between logically unrelated executions. Thread-local storage has outlived its usefulness in 2026 Ruby. + +4. **The built-in middleware trio was load-bearing.** `Correlate`, `Runtime`, and `Timeout` were auto-registered. Half the users wanted them gone; the other half wanted different ones; nobody could swap them without fighting the registry. That's a sign the feature is in the wrong layer. + +None of these are fixable with surface-level changes. They're runtime-shaped problems. + +## The How: Signals, Immutable Results, Fiber-Local Chains + +v2 pivots on three ideas. Everything else falls out from them. + +### Halts Are Signals, Not State Mutations + +```ruby +# v1 — fail! mutated result.state; code after it still ran +def work + fail!("invalid email", code: :bad_input) + deliver(context) # v1 could still hit this +end +``` + +```ruby +# v2 — fail! throws a Signal; the next line is unreachable +def work + fail!("invalid email", code: :bad_input) + deliver(context) # NEVER reached +end +``` + +Under the hood, `success!` / `skip!` / `fail!` / `throw!` now do `throw(Signal::TAG, signal)`. Runtime wraps `work` in a `catch(Signal::TAG) { ... }` and builds the final `Result` once, at the end, from whatever signal (or normal return) escaped. Halts terminate. The mental model matches what the word "halt" always suggested. + +### Result Is Frozen at Construction + +```ruby +result = MyTask.execute(...) + +result.task.frozen? #=> true +result.errors.frozen? #=> true +result.context.frozen? #=> true (root only) +result.metadata[:x] = 1 #=> FrozenError +``` + +`Result` exposes no mutating API. All state lives in an embedded `Signal` — a frozen value object — built exactly once during `Runtime#finalize_result`. A `Result` you hold is the same `Result` everyone else holds. That's a prerequisite for every tool I wanted to add on top: pattern matching, telemetry subscribers, structured failure references, parallel workflow merges. + +### Chain Is Fiber-Local + +```ruby +# v1 +Thread.current[:cmdx_chain] + +# v2 +Fiber[:cmdx_chain] +CMDx::Chain.current # accessor +CMDx::Chain.clear # cleared automatically on root teardown +``` + +`Chain` is now `Enumerable`, has a `Mutex` guarding `push` / `unshift`, and gets cleared when the outermost task finishes. Parallel workflow groups share the parent fiber's chain so every child result still correlates to the same `chain_id`. + +## What You Write Differently + +A condensed cheat sheet. The [full migration guide](https://drexed.github.io/cmdx/v2-migration/) has the rest. + +| Area | v1 | v2 | +|---|---|---| +| Entry point | `MyTask.call` / `def call` | `MyTask.execute` / `def work` (old names aliased) | +| Inputs | `attribute :email, type: :string` | `input :email, coerce: :string` (`required` / `optional` unchanged) | +| Outputs | `returns :user` (presence only) | `output :user, required: true, coerce: :..., validate: ...` | +| Middleware | `def call(task, options, &block)` | `def call(task); yield; end` (one arg, must `yield`) | +| Built-in middlewares | `Correlate`, `Runtime`, `Timeout` auto-registered | removed — subscribe to Telemetry or register your own | +| Callbacks | `on_good`, `on_bad`, `on_executed` | `on_ok`, `on_ko` (`on_executed` removed) | +| Chain ID | `result.chain_id` | `result.chain.id` | +| Halt reach | code after `fail!` could still run | code after `fail!` is unreachable | +| Result mutability | mutable (`result.metadata[:x] = ...`) | frozen | +| Breakpoints | `task_breakpoints`, `workflow_breakpoints` | removed — `execute!` is strict mode | + +If you're already on 1.21, you've done `def call` → `def work` already — v2 keeps the `.call` / `.call!` aliases so you can migrate module-by-module. + +## New Capabilities You Didn't Have + +### Telemetry pub/sub + +Observability belongs out of the middleware stack. v2 ships a dedicated bus with five events that only fire when subscribed (zero cost otherwise): + +```ruby +CMDx.configure do |config| + config.telemetry.subscribe(:task_executed) do |event| + StatsD.timing("cmdx.#{event.task_class}", event.payload[:result].duration) + end + + config.telemetry.subscribe(:task_retried) do |event| + Rails.logger.warn("retry #{event.payload[:attempt]} for #{event.task_class}") + end +end +``` + +Events: `:task_started`, `:task_deprecated`, `:task_retried`, `:task_rolled_back`, `:task_executed`. Each event carries `chain_id`, `chain_root`, `task_type`, `task_class`, `task_id`, `name`, `payload`, and `timestamp`. + +### Parallel workflow groups + +```ruby +class FanOutWorkflow < CMDx::Task + include CMDx::Workflow + + task LoadInvoice + tasks ChargeCard, EmailReceipt, + strategy: :parallel, + pool_size: 4 + task FinalizeOrder +end +``` + +Workers `deep_dup` the workflow context, run in parallel, and merge successful child contexts back into the parent in declaration order. The first failed child halts the pipeline via `throw!`. Shared fiber-local chain — every worker shows up in `result.chain` under the same `chain_id`. + +### `Task#rollback` + +```ruby +class ChargeCard < CMDx::Task + required :amount + + def work + context.charge = Stripe::Charge.create!(amount: amount) + end + + def rollback + Stripe::Refund.create!(charge: context.charge.id) if context.charge + end +end +``` + +Define `rollback` and Runtime calls it automatically on failure. Surfaces via `result.rolled_back?` and the `:task_rolled_back` Telemetry event. No more hand-rolling rescue/retry/refund ceremonies. + +### Pattern matching on Result + +```ruby +case result +in { status: "success" } then deliver(result.context) +in { status: "failed", metadata: { code: :rate_limited } } then schedule_retry +in { status: "failed", reason: } then alert(reason) +end +``` + +`Result` implements `deconstruct` and `deconstruct_keys`, so controllers and job handlers can dispatch on outcome without brittle `if result.success? && result.metadata[:code] == ...` ladders. + +### The full output pipeline + +```ruby +# v1 — presence check only +returns :user, :token + +# v2 — required + coerce + validate + default + transform +output :user, required: true +output :token, required: true, coerce: :string, length: { min: 32 } +``` + +`output` reuses the input pipeline. Missing required outputs record `outputs.missing` on `task.errors` and become a failed signal automatically. + +## Performance + +The rewrite wasn't a perf project, but the numbers came along for the ride: halts are roughly 2.5× faster, workflow failures ~3×, allocations down 50–80% depending on the workload. Full methodology and results in [`benchmark/RESULTS.md`](https://github.com/drexed/cmdx/blob/main/benchmark/RESULTS.md). Most of the allocation win is from not building intermediate `Result` objects during state transitions — there are no state transitions anymore. + +## Upgrading + +The [migration guide](https://drexed.github.io/cmdx/v2-migration/) is the single source of truth. Read it top-to-bottom — every section is a recipe you can apply independently, and there's an **Automated Migration Prompt** at the bottom that mechanizes most of the tedious parts if you feed it to an agent. + +The minimum viable diff for most apps: + +```ruby +# Gemfile +gem "cmdx", "~> 2.0" +``` + +Then, across your task classes: + +- Rename `attribute :x, type: :string` → `input :x, coerce: :string` +- Rename `returns :user` → `output :user, required: true` +- Update middlewares from `call(task, options, &block)` to `call(task) { yield }`, and register instances (`register :middleware, Foo.new`) instead of classes +- Replace `result.chain_id` with `result.chain.id`, `result.good?` with `result.ok?`, `result.bad?` with `result.ko?` +- Drop `task_breakpoints` / `workflow_breakpoints` settings — use `execute!` where you want strict mode +- Re-register `Correlate` / `Runtime` / `Timeout` equivalents as Telemetry subscribers or custom middlewares (or delete them — `result.duration` is built in) + +If the suite was green on 1.21, it will tell you exactly where each of these lives. + +Happy coding! + +## References + +- [Upgrading from v1.x to v2.0](https://drexed.github.io/cmdx/v2-migration/) +- [CHANGELOG (2.0.0)](https://github.com/drexed/cmdx/blob/main/CHANGELOG.md) +- [Interruptions - Signals](https://drexed.github.io/cmdx/interruptions/signals/) +- [Outputs](https://drexed.github.io/cmdx/outputs/) +- [Configuration - Telemetry](https://drexed.github.io/cmdx/configuration/#telemetry) +- [Workflows](https://drexed.github.io/cmdx/workflows/) +- [Benchmarks](https://github.com/drexed/cmdx/blob/main/benchmark/RESULTS.md) diff --git a/docs/blog/posts/getting-started-with-cmdx.md b/docs/blog/posts/getting-started-with-cmdx.md index 80a641ce1..d4e83b77c 100644 --- a/docs/blog/posts/getting-started-with-cmdx.md +++ b/docs/blog/posts/getting-started-with-cmdx.md @@ -9,6 +9,8 @@ slug: getting-started-with-cmdx # Getting Started with CMDx: Taming Business Logic in Ruby +*Targets CMDx v1.13.* + I've spent years wrestling with service objects. You know the pattern—create a class, throw some business logic in a `call` method, cross your fingers, and hope for the best. The problem? Every team member writes them differently. Every project invents its own conventions. And when something breaks at 2 AM, good luck tracing what actually happened. That frustration led me to create CMDx. diff --git a/docs/blog/posts/mastering-cmdx-attributes.md b/docs/blog/posts/mastering-cmdx-attributes.md index 17e12075f..2eb712de2 100644 --- a/docs/blog/posts/mastering-cmdx-attributes.md +++ b/docs/blog/posts/mastering-cmdx-attributes.md @@ -9,6 +9,8 @@ slug: mastering-cmdx-attributes # Mastering CMDx Attributes: Your Task's Contract with the World +*Targets CMDx v1.15.* + Attributes in CMDx are deceptively simple. You define what data your task needs, and the framework handles the rest—coercion, validation, defaults, the works. But there's real depth here. After building dozens of production systems with CMDx, I've found that well-designed attributes are the difference between tasks that "just work" and tasks that fight you at every turn. Let me show you what I mean. diff --git a/docs/blog/posts/mastering-cmdx-callbacks-and-middlewares.md b/docs/blog/posts/mastering-cmdx-callbacks-and-middlewares.md index cd2b51ee9..38ec32ba7 100644 --- a/docs/blog/posts/mastering-cmdx-callbacks-and-middlewares.md +++ b/docs/blog/posts/mastering-cmdx-callbacks-and-middlewares.md @@ -9,6 +9,8 @@ slug: mastering-cmdx-callbacks-and-middlewares # Mastering CMDx Callbacks and Middlewares: Hooks and Wrappers +*Targets CMDx v1.16.* + When I'm writing complex business logic in Ruby, I often find that the core "work" is only half the battle. The other half is everything around it: logging, error handling, notifications, database transactions, and performance tracking. If you put all that code inside your main method, you end up with a mess. That's where CMDx's **Callbacks** and **Middlewares** come in. They let you separate the "what" from the "how" and the "when," keeping your tasks clean and focused. diff --git a/docs/blog/posts/mastering-cmdx-fundamentals.md b/docs/blog/posts/mastering-cmdx-fundamentals.md index 1a979c35c..cfc183245 100644 --- a/docs/blog/posts/mastering-cmdx-fundamentals.md +++ b/docs/blog/posts/mastering-cmdx-fundamentals.md @@ -9,6 +9,8 @@ slug: mastering-cmdx-fundamentals # Mastering CMDx Fundamentals: Tasks, Context, Execution, and Chains +*Targets CMDx v1.13.* + When I first started building CMDx, I focused obsessively on four concepts: tasks, context, execution, and chains. These aren't just implementation details—they're the mental model that makes everything else click. Once you understand how they work together, you'll write cleaner business logic and debug issues faster. Let me walk you through each piece, building from a simple task to a fully orchestrated task. diff --git a/docs/blog/posts/mastering-cmdx-interruptions.md b/docs/blog/posts/mastering-cmdx-interruptions.md index d65f5da2a..15e52adb1 100644 --- a/docs/blog/posts/mastering-cmdx-interruptions.md +++ b/docs/blog/posts/mastering-cmdx-interruptions.md @@ -9,6 +9,8 @@ slug: mastering-cmdx-interruptions # Mastering CMDx Interruptions: Controlling Flow When Things Go Sideways +*Targets CMDx v1.14.* + Business logic isn't always a straight line. Orders get cancelled. Users don't have permissions. External APIs timeout. What separates robust code from fragile code is how gracefully you handle these interruptions. CMDx gives you three tools for this: halt methods (`skip!` and `fail!`), exception handling, and faults. Together, they form a complete system for controlling execution flow—whether you're stopping intentionally, handling errors, or propagating failures across tasks. diff --git a/docs/blog/posts/mastering-cmdx-logging.md b/docs/blog/posts/mastering-cmdx-logging.md index 8dc7d9df7..2c6f9bf5e 100644 --- a/docs/blog/posts/mastering-cmdx-logging.md +++ b/docs/blog/posts/mastering-cmdx-logging.md @@ -9,6 +9,8 @@ slug: mastering-cmdx-logging # Mastering CMDx: The Art of Observability with Logging +*Targets CMDx v1.17.* + We've all been there. A production incident report lands on your desk. "Transaction failed for user X." You open the logs, grep for the user ID, and... silence. Or worse, a wall of unstructured text that tells you everything except what you need to know. Logging is often an afterthought—something we sprinkle in `rescue` blocks when we're debugging. But in CMDx, the Ruby framework for business logic, observability isn't an add-on; it's a first-class citizen. diff --git a/docs/blog/posts/mastering-cmdx-outcomes.md b/docs/blog/posts/mastering-cmdx-outcomes.md index 34c67c631..882d68eb4 100644 --- a/docs/blog/posts/mastering-cmdx-outcomes.md +++ b/docs/blog/posts/mastering-cmdx-outcomes.md @@ -9,6 +9,8 @@ slug: mastering-cmdx-outcomes # Mastering CMDx Outcomes: Results, States, and Statuses +*Targets CMDx v1.15.* + If you've ever found yourself asking "What does this service object actually return?", you're not alone. Does it return `true`? The record it created? A hash with errors? Or does it just raise an exception and hope someone catches it? In my experience, inconsistent return values are the silent killers of maintainable Ruby code. That's why CMDx standardizes everything into a single, powerful concept: the **Result**. diff --git a/docs/blog/posts/mastering-cmdx-retries-deprecation-i18n.md b/docs/blog/posts/mastering-cmdx-retries-deprecation-i18n.md index 548fc8b57..1599e711e 100644 --- a/docs/blog/posts/mastering-cmdx-retries-deprecation-i18n.md +++ b/docs/blog/posts/mastering-cmdx-retries-deprecation-i18n.md @@ -9,6 +9,8 @@ slug: mastering-cmdx-retries-deprecation-i18n # Mastering CMDx: Retries, Deprecation, and Internationalization +*Targets CMDx v1.16.* + As developers, we often obsess over the "happy path"—that perfect scenario where networks never time out, requirements never change, and every user speaks English. But the real world isn't so accommodating. Services fail, code evolves, and your application needs to speak more than just one language. In this post, I want to dive into three CMDx features that help you handle these realities: **Retries** for resilience, **Deprecation** for lifecycle management, and **Internationalization** for global reach. These tools might seem distinct, but together they elevate your business logic from "functional" to "production-grade." diff --git a/docs/blog/posts/mastering-cmdx-workflows.md b/docs/blog/posts/mastering-cmdx-workflows.md index b9e723a84..4967dc37f 100644 --- a/docs/blog/posts/mastering-cmdx-workflows.md +++ b/docs/blog/posts/mastering-cmdx-workflows.md @@ -9,6 +9,8 @@ slug: mastering-cmdx-workflows # Mastering CMDx Workflows: Orchestrating Complex Business Logic +*Targets CMDx v1.15.* + I remember when my service objects started getting messy. I'd have a `PlaceOrder` service that began as a simple 10-line script but slowly mutated into a 500-line monster handling validation, payments, inventory, shipping, and a dozen notification types. It was a nightmare to test and even harder to read. That's exactly why I built CMDx Workflows. They allow you to decompose complex processes into small, focused tasks and orchestrate them declaratively. It turns your business logic from a tangled mess of `if` statements into a clean, readable pipeline. diff --git a/docs/blog/posts/real-world-cmdx-background-jobs.md b/docs/blog/posts/real-world-cmdx-background-jobs.md index cb3976f07..99d5cc497 100644 --- a/docs/blog/posts/real-world-cmdx-background-jobs.md +++ b/docs/blog/posts/real-world-cmdx-background-jobs.md @@ -1,5 +1,5 @@ --- -date: 2026-05-27 +date: 2026-06-03 authors: - drexed categories: @@ -11,6 +11,8 @@ slug: real-world-cmdx-background-jobs *Part 3 of the Real-World CMDx series* +*Built on CMDx 2.0 — see the [v2 release post](cmdx-v2-the-runtime-rewrite.md). v2 ships only one Fault class (no `FailFault`/`SkipFault`), drops the built-in `Correlate` middleware in favor of [Telemetry pub/sub](https://drexed.github.io/cmdx/configuration/), and exposes `result.duration` directly.* + There's a natural tension between CMDx tasks and background jobs. Tasks are synchronous, deterministic, and observable. Jobs are asynchronous, retry-prone, and fire-and-forget. When you need both—and you almost always do—the question is how to combine them without losing what makes each one good. I've seen two bad extremes. The first: every task becomes a Sidekiq job, scattering your business logic across `app/jobs/` and `app/tasks/` with duplicated validation and no shared observability. The second: a single monolithic job that calls a workflow synchronously, tying up a Sidekiq thread for 30 seconds while it processes an order end-to-end. @@ -36,15 +38,19 @@ class Users::SendVerificationJob end ``` -That's it. The job deserializes the ID into an object, the task validates inputs, sends the email, and logs the execution. If the task raises a `CMDx::FailFault` or any other exception, Sidekiq retries it. +That's it. The job deserializes the ID into an object, the task validates inputs, sends the email, and logs the execution. If the task raises a `CMDx::Fault` or any other exception, Sidekiq retries it. -But there's a subtlety here. `execute!` raises on *any* failure — including business logic failures from `fail!`. You probably don't want Sidekiq retrying a task that failed because the user doesn't exist. It'll fail every time. +But there's a subtlety here. `execute!` raises on every failure — both business failures from `fail!` and infrastructure exceptions. You probably don't want Sidekiq retrying a task that failed because the user doesn't exist. It'll fail every time. (Skipped tasks don't raise — `execute!` only re-raises when `result.failed?`, see [`Runtime#raise_signal!`](https://github.com/drexed/cmdx/blob/main/lib/cmdx/runtime.rb).) ## Selective Retries -The fix is to catch business failures and only let infrastructure failures propagate: +The fix is to catch business failures and only let infrastructure failures propagate. v2's `CMDx::Fault.matches?` builds a matcher subclass you can use directly in `rescue` (see [`lib/cmdx/fault.rb`](https://github.com/drexed/cmdx/blob/main/lib/cmdx/fault.rb)): ```ruby +PermanentBusinessFailure = CMDx::Fault.matches? do |fault| + %i[user_not_found already_verified].include?(fault.result.metadata[:code]) +end + class Users::SendVerificationJob include Sidekiq::Job @@ -53,18 +59,13 @@ class Users::SendVerificationJob def perform(user_id) user = User.find(user_id) Users::SendVerification.execute!(user: user) - rescue CMDx::FailFault => e - case e.result.metadata[:code] - when :user_not_found, :already_verified - logger.warn "Skipping retry: #{e.message}" - else - raise - end + rescue PermanentBusinessFailure => e + logger.warn "Skipping retry: #{e.message}" end end ``` -Business failures that are permanent (user not found, already verified) get logged and discarded. Transient failures (mail server down, timeout) re-raise and Sidekiq retries them. +Permanent failures (user not found, already verified) get logged and discarded — `rescue` only catches the subclass. Transient failures (mail server down, timeout) bypass the matcher, propagate as `CMDx::Fault`, and Sidekiq retries them. ## The Self-Enqueueing Task @@ -76,11 +77,11 @@ class Reports::GenerateMonthly < ApplicationTask sidekiq_options queue: :reports, retry: 3 - required :account_id, type: :integer - required :month, type: :integer, numeric: { within: 1..12 } - required :year, type: :integer, numeric: { min: 2020 } + required :account_id, coerce: :integer + required :month, coerce: :integer, numeric: { within: 1..12 } + required :year, coerce: :integer, numeric: { min: 2020 } - returns :report + output :report, required: true def work account = Account.find(account_id) @@ -113,43 +114,47 @@ Synchronous for tests and console debugging. Asynchronous for production. Same v ## Chain Correlation Across Async Boundaries -Here's a problem: when a workflow enqueues a background job, the job runs in a different thread (or process). CMDx chains are thread-local, so the background job starts a new chain. You lose the correlation. - -The solution is the `Correlate` middleware: - -```ruby -class ApplicationTask < CMDx::Task - register :middleware, CMDx::Middlewares::Correlate -end -``` +Here's a problem: when a workflow enqueues a background job, the job runs in a different fiber (or process). CMDx chains are fiber-local, so the background job starts a new chain. You lose the correlation. -Now every task execution gets a `correlation_id` in its metadata. Pass it across the async boundary: +v2 dropped the built-in `Correlate` middleware ([migration guide](https://drexed.github.io/cmdx/v2-migration/#built-ins-removed)) — the replacement is a tiny module that stores the id in fiber-local storage: ```ruby -class Orders::PlaceOrder < CMDx::Task - include CMDx::Workflow +module Correlate + KEY = :correlation_id - task Orders::ValidateCart - task Orders::CreateOrder - task Orders::ChargePayment - task Orders::EnqueueFulfillment + def self.id + Fiber[KEY] ||= SecureRandom.uuid_v7 + end - private + def self.use(id) + previous = Fiber[KEY] + Fiber[KEY] = id + yield + ensure + Fiber[KEY] = previous + end - def physical_goods? - context.items.any?(&:physical?) + def call(task) + id # ensure populated for the duration of this task + yield end end +class ApplicationTask < CMDx::Task + register :middleware, Correlate +end +``` + +Pass it across the async boundary: + +```ruby class Orders::EnqueueFulfillment < ApplicationTask required :order def work - correlation_id = CMDx::Middlewares::Correlate.id - Fulfillment::ProcessJob.perform_async( "order_id" => order.id, - "correlation_id" => correlation_id + "correlation_id" => Correlate.id ) logger.info "Enqueued fulfillment for order #{order.id}" @@ -166,14 +171,14 @@ class Fulfillment::ProcessJob sidekiq_options queue: :fulfillment, retry: 5 def perform(args) - CMDx::Middlewares::Correlate.use(args["correlation_id"]) do + Correlate.use(args["correlation_id"]) do Fulfillment::Process.execute!(order_id: args["order_id"]) end end end ``` -Now both the synchronous workflow and the background fulfillment share the same `correlation_id`. Search for it in your log aggregator and you see the entire request lifecycle — from cart validation through payment through async fulfillment — as one continuous trace. +Now both the synchronous workflow and the background fulfillment share the same correlation id. Search for it in your log aggregator and you see the entire request lifecycle — from cart validation through payment through async fulfillment — as one continuous trace. (For zero-cost observability without a custom middleware, subscribe to `:task_started` Telemetry events instead — see [Telemetry](https://drexed.github.io/cmdx/configuration/).) ## Idempotency for Background Jobs @@ -183,53 +188,40 @@ Background jobs retry. That means your tasks can run multiple times. For tasks t ```ruby class Idempotency - def call(task, options) - key = build_key(task, options) - ttl = options[:ttl] || 300 + def initialize(key:, ttl: 300) + @key_fn = key.respond_to?(:call) ? key : ->(t) { t.context[key] } + @ttl = ttl + end + + def call(task) + redis_key = "cmdx:idempotency:#{task.class.name}:#{@key_fn.call(task)}" - if Redis.current.set(key, "processing", nx: true, ex: ttl) + if Redis.current.set(redis_key, "processing", nx: true, ex: @ttl) begin - yield.tap { |result| Redis.current.set(key, result.status, xx: true, ex: ttl) } - rescue => e - Redis.current.del(key) + yield + Redis.current.set(redis_key, "complete", xx: true, ex: @ttl) + rescue + Redis.current.del(redis_key) raise end else - status = Redis.current.get(key) - if status == "processing" - task.result.tap { |r| r.skip!("Already processing") } - else - task.result.tap { |r| r.skip!("Already completed (#{status})") } - end + status = Redis.current.get(redis_key) + throw(CMDx::Signal::TAG, CMDx::Signal.skipped("Already #{status} (idempotency guard)")) end end - - private - - def build_key(task, options) - id = if options[:key].respond_to?(:call) - options[:key].call(task) - elsif options[:key].is_a?(Symbol) - task.send(options[:key]) - else - task.context[:idempotency_key] - end - - "cmdx:idempotency:#{task.class.name}:#{id}" - end end ``` +Middlewares cannot mutate `Result` in v2 (it's frozen, constructed once at the end of the lifecycle). The way to short-circuit a task from middleware is to throw `CMDx::Signal::TAG` directly — Runtime's `catch(Signal::TAG)` block builds the appropriate Result from whatever signal escapes ([`lib/cmdx/runtime.rb`](https://github.com/drexed/cmdx/blob/main/lib/cmdx/runtime.rb)). + Register it on tasks that must not duplicate: ```ruby class Payments::ChargeCard < Stripe::BaseTask - register :middleware, Idempotency, - key: ->(t) { t.context[:idempotency_key] }, - ttl: 3600 + register :middleware, Idempotency.new(key: :idempotency_key, ttl: 3600) required :stripe_customer - required :amount_cents, type: :integer + required :amount_cents, coerce: :integer optional :idempotency_key, default: -> { SecureRandom.uuid } def work @@ -242,52 +234,7 @@ class Payments::ChargeCard < Stripe::BaseTask end ``` -First execution processes normally. Retries hit the Redis guard and skip. The task logs `status: "skipped"` with the reason, so you see it in your observability pipeline. - -## Dry Run for Job Previews - -Before enqueuing a potentially expensive job, preview what it would do: - -```ruby -class Billing::GenerateInvoices < ApplicationTask - include Sidekiq::Job - - required :billing_period, type: :date - - returns :invoice_count - - def work - accounts = Account.active.where(billing_day: billing_period.day) - - if dry_run? - context.invoice_count = accounts.count - context.estimated_total = accounts.sum(:current_balance) - logger.info "Dry run: would generate #{context.invoice_count} invoices" - return - end - - invoices = accounts.map do |account| - Invoice.create!(account: account, period: billing_period, amount: account.current_balance) - end - - context.invoice_count = invoices.size - end - - def perform(context = {}) - self.class.execute!(context) - end -end -``` - -```ruby -preview = Billing::GenerateInvoices.execute(billing_period: Date.today, dry_run: true) -preview.context.invoice_count #=> 847 -preview.context.estimated_total #=> 1_234_567 - -Billing::GenerateInvoices.perform_async("billing_period" => Date.today.to_s) -``` - -Preview synchronously, execute asynchronously. Same task, different mode. +First execution processes normally. Retries hit the Redis guard and skip. The task's result has `status: "skipped"` with the reason, visible in your observability pipeline alongside `result.duration` (built into v2 — no middleware required). ## Scheduled Workflows with Cron Jobs @@ -300,10 +247,7 @@ class DailyReconciliation < CMDx::Task sidekiq_options queue: :critical, retry: 1 - settings( - workflow_breakpoints: ["failed"], - tags: ["reconciliation", "daily"] - ) + settings(tags: ["reconciliation", "daily"]) task Reconciliation::FetchBankTransactions task Reconciliation::MatchPayments @@ -350,41 +294,34 @@ class OrderProcessingJob AdminNotifier.alert("Order #{order_id} failed permanently: #{exception.message}") end + PermanentFailure = CMDx::Fault.matches? do |f| + %i[out_of_stock address_invalid].include?(f.result.metadata[:code]) + end + def perform(args) Orders::Fulfill.execute!(order_id: args["order_id"]) - rescue CMDx::SkipFault => e - # Skips are fine — order was already fulfilled - logger.info "Order #{args['order_id']} skipped: #{e.message}" - rescue CMDx::FailFault => e - case e.result.metadata[:code] - when :out_of_stock, :address_invalid - # Permanent business failures — don't retry - Order.find(args["order_id"]).update!(status: :failed, failure_reason: e.message) - else - raise - end + rescue PermanentFailure => e + Order.find(args["order_id"]).update!(status: :failed, failure_reason: e.message) end end ``` -- **Skips**: Task decided there's nothing to do. Log and move on. -- **Permanent failures**: Business rules that won't change on retry. Update the record and stop. -- **Transient failures**: Re-raise for Sidekiq to retry with backoff. -- **Exhausted retries**: Update the order status and alert the team. +- **Skips**: Task decided there's nothing to do. `execute!` doesn't raise on skip (only on `failed?`), so there's no rescue arm to write — the job returns successfully. +- **Permanent failures**: Business rules that won't change on retry. Caught by the `PermanentFailure` matcher, update the record and stop. +- **Transient failures**: Bypass the matcher, propagate as `CMDx::Fault`, Sidekiq retries with backoff. +- **Exhausted retries**: `sidekiq_retries_exhausted` updates the order status and alerts the team. ## Key Takeaways 1. **Tasks are the unit of work, jobs are the execution engine.** Keep business logic in tasks, use jobs as thin wrappers. -2. **Use `execute!` in jobs.** It raises on failure, which is what Sidekiq needs for retry decisions. - -3. **Catch `CMDx::FailFault` selectively.** Permanent business failures shouldn't retry. Transient failures should. +2. **Use `execute!` in jobs.** It raises on `failed?`, which is what Sidekiq needs for retry decisions. Skipped tasks return successfully — no rescue needed. -4. **Pass `correlation_id` across async boundaries.** Use `CMDx::Middlewares::Correlate.use` to maintain tracing continuity. +3. **Use `Fault.matches?` to catch permanent failures.** Build a matcher subclass that filters on `metadata[:code]`; everything else propagates and triggers Sidekiq retries. -5. **Add idempotency guards for non-idempotent operations.** Redis-based middleware prevents duplicate charges, emails, or any operation that shouldn't repeat. +4. **Pass a correlation id across async boundaries.** A 10-line `Correlate` module + `Fiber[]` storage gives you the same observability the v1 middleware did. -6. **Preview with dry run, execute with perform_async.** Same task, two modes. +5. **Add idempotency guards as middleware.** Throw `CMDx::Signal::TAG` from inside the middleware to short-circuit safely; `Result` is frozen and can't be mutated externally. Background jobs don't have to be a black hole of observability. With CMDx, every async execution is logged, correlated, and traceable — same as synchronous. @@ -395,4 +332,5 @@ Happy coding! - [Execution](https://drexed.github.io/cmdx/basics/execution/) - [Middlewares](https://drexed.github.io/cmdx/middlewares/) - [Faults](https://drexed.github.io/cmdx/interruptions/faults/) -- [Logging](https://drexed.github.io/cmdx/logging/) +- [Telemetry](https://drexed.github.io/cmdx/configuration/) +- [v2 Migration: Built-ins Removed](https://drexed.github.io/cmdx/v2-migration/#built-ins-removed) diff --git a/docs/blog/posts/real-world-cmdx-external-apis.md b/docs/blog/posts/real-world-cmdx-external-apis.md index e490f210e..e22e2aba5 100644 --- a/docs/blog/posts/real-world-cmdx-external-apis.md +++ b/docs/blog/posts/real-world-cmdx-external-apis.md @@ -1,5 +1,5 @@ --- -date: 2026-05-20 +date: 2026-05-27 authors: - drexed categories: @@ -11,6 +11,8 @@ slug: real-world-cmdx-external-apis *Part 2 of the Real-World CMDx series* +*Built on CMDx 2.0 — see the [v2 release post](cmdx-v2-the-runtime-rewrite.md) for the runtime changes this post depends on, especially [`Task#rollback`](cmdx-v2-the-runtime-rewrite.md) and the [`output` pipeline](https://drexed.github.io/cmdx/outputs/).* + External APIs are where clean code goes to die. You write a beautiful service object, ship it, and then the real world hits: Stripe times out, the shipping API returns HTML instead of JSON, the geocoding service rate-limits you at 2 PM on a Tuesday. Suddenly your elegant `PaymentService` is a nest of `rescue` blocks, retry loops, and sleep statements. I've been on the receiving end of enough 3 AM pages to know that the problem isn't the API—it's how we integrate with it. CMDx gives you a layered defense against flaky I/O: retries for transient failures, timeouts for slow responses, circuit breakers for cascading failures, and structured error handling for everything else. Let me build a complete Stripe payment integration in Ruby to show you how these pieces fit together. @@ -57,15 +59,25 @@ With CMDx, we separate infrastructure concerns from business logic using middlew ### Timeout -Don't let a slow API call hold up your web request: +Don't let a slow API call hold up your web request. v2 ships no built-in `Timeout` middleware (the v1 trio was removed — see the [migration guide](https://drexed.github.io/cmdx/v2-migration/#built-ins-removed)), but the replacement is six lines: ```ruby +class Timeout + def initialize(seconds:) = @seconds = seconds + + def call(task) + ::Timeout.timeout(@seconds) { yield } + rescue ::Timeout::Error => e + throw(CMDx::Signal::TAG, CMDx::Signal.failed("timed out after #{@seconds}s", cause: e)) + end +end + class ExternalApiTask < ApplicationTask - register :middleware, CMDx::Middlewares::Timeout, seconds: 10 + register :middleware, Timeout.new(seconds: 10) end ``` -Any task inheriting from `ExternalApiTask` will fail after 10 seconds with a `CMDx::TimeoutError`. The caller gets a structured failure—no hanging requests, no thread starvation. +Any task inheriting from `ExternalApiTask` fails after 10 seconds with a structured `Signal.failed`. The caller gets a real failure result—no hanging requests, no thread starvation. Throwing `Signal::TAG` short-circuits Runtime cleanly; you do not (and cannot) mutate `task.result` from inside a middleware in v2. ### Circuit Breaker @@ -73,26 +85,25 @@ When Stripe is down, stop hammering it: ```ruby class CircuitBreaker - def call(task, options) - service_name = options[:name] || task.class.name - light = Stoplight(service_name) - light.run { yield } + def initialize(name:) = @name = name + + def call(task) + Stoplight(@name).run { yield } rescue Stoplight::Error::RedLight => e - task.result.tap { |r| r.fail!("[#{e.class}] #{e.message}", cause: e) } + throw(CMDx::Signal::TAG, CMDx::Signal.failed("[#{e.class}] #{e.message}", cause: e)) end end class Stripe::BaseTask < ExternalApiTask - register :middleware, CircuitBreaker, name: "stripe" + register :middleware, CircuitBreaker.new(name: "stripe") - settings( - retries: 3, - retry_on: [Stripe::APIConnectionError, Net::OpenTimeout, Faraday::ConnectionFailed], - retry_jitter: ->(retry_num) { 2**retry_num } - ) + retry_on Stripe::APIConnectionError, Net::OpenTimeout, Faraday::ConnectionFailed, + limit: 3, delay: 1.0, jitter: :exponential, max_delay: 30 end ``` +`Task.retry_on` is the v2 retry API; `:jitter` accepts the built-in `:exponential | :half_random | :full_random | :bounded_random` strategies, or any `#call`-able. The `settings(...)` DSL is for tags and logger config only. + The inheritance chain builds naturally: ``` @@ -107,28 +118,29 @@ Every Stripe task gets all of this automatically. ```ruby class ErrorTracking - def call(task, options) + def call(task) Sentry.with_scope do |scope| scope.set_tags( task_class: task.class.name, - task_id: task.id, - chain_id: task.chain.id + chain_id: CMDx::Chain.current&.id ) - yield.tap do |result| - if result.failed? && result.cause && !result.cause.is_a?(CMDx::Fault) - Sentry.capture_exception(result.cause) - end - end + yield end - rescue => e - Sentry.capture_exception(e) - raise + end +end + +CMDx.configure do |config| + config.telemetry.subscribe(:task_executed) do |event| + result = event.payload[:result] + next unless result.failed? && result.cause + + Sentry.capture_exception(result.cause) end end ``` -The `!result.cause.is_a?(CMDx::Fault)` check is key. When `fail!` is called, CMDx wraps the result's cause in a `FailFault`. We don't want to report those to Sentry — they're intentional business logic, not bugs. Only unexpected exceptions (caught by `execute` and stored as the raw exception) should trigger an alert. +In v2, exceptions are surfaced via `result.cause` ([lib/cmdx/runtime.rb#L162](https://github.com/drexed/cmdx/blob/main/lib/cmdx/runtime.rb)) and `fail!` populates `signal.cause` only when you pass `cause:` explicitly — bare `fail!("...")` calls (intentional business failures) carry no cause, so the subscriber naturally ignores them. Telemetry is the v2-native place for cross-cutting reporting; the middleware just adds Sentry scope tags. ## The Payment Tasks @@ -140,7 +152,7 @@ With infrastructure handled, the tasks themselves are pure business logic. class Stripe::CreateCustomer < Stripe::BaseTask required :user - returns :stripe_customer + output :stripe_customer, required: true def work if user.stripe_customer_id.present? @@ -173,12 +185,12 @@ The `rollback` method reverses the operation if a downstream task fails. CMDx ca ```ruby class Stripe::ChargeCard < Stripe::BaseTask required :stripe_customer - required :amount_cents, type: :integer, numeric: { min: 50, max: 99_999_999 } + required :amount_cents, coerce: :integer, numeric: { min: 50, max: 99_999_999 } required :currency, inclusion: { in: %w[usd eur gbp] } optional :description optional :idempotency_key, default: -> { SecureRandom.uuid } - returns :charge + output :charge, required: true def work context.charge = ::Stripe::Charge.create( @@ -217,10 +229,10 @@ The `idempotency_key` default ensures that retries don't create duplicate charge class Payments::Record < ApplicationTask required :user required :charge - required :amount_cents, type: :integer + required :amount_cents, coerce: :integer required :currency - returns :payment + output :payment, required: true def work context.payment = Payment.create!( @@ -261,10 +273,7 @@ end class Payments::Charge < CMDx::Task include CMDx::Workflow - settings( - workflow_breakpoints: ["failed"], - tags: ["payments", "stripe"] - ) + settings(tags: ["payments", "stripe"]) task Stripe::CreateCustomer task Stripe::ChargeCard @@ -355,31 +364,18 @@ The charge was real money. CMDx's automatic rollback calls `Stripe::ChargeCard#r ## Reusing the Stack for Other APIs -The middleware stack isn't Stripe-specific. Build base classes for any external service: +The middleware stack isn't Stripe-specific. Build a base class per external service with the appropriate resilience settings: ```ruby class Shipping::BaseTask < ExternalApiTask - register :middleware, CircuitBreaker, name: "shippo" + register :middleware, CircuitBreaker.new(name: "shippo") - settings( - retries: 2, - retry_on: [Shippo::ConnectionError, Net::ReadTimeout], - retry_jitter: 1 - ) -end - -class Geocoding::BaseTask < ExternalApiTask - register :middleware, CircuitBreaker, name: "google_maps" - - settings( - retries: 1, - retry_on: [Google::Apis::TransmissionError], - retry_jitter: 2 - ) + retry_on Shippo::ConnectionError, Net::ReadTimeout, + limit: 2, delay: 1.0, jitter: :half_random end ``` -Same pattern, different thresholds. A shipping API might be slower (allow more retries), while geocoding is a nice-to-have (fail fast with fewer retries). +Same pattern as `Stripe::BaseTask`, different thresholds. A geocoding nice-to-have can `limit: 1`; a shipping API that's slow but reliable can bump `delay:` and `max_delay:`. The `CircuitBreaker.new(name:)` argument scopes the Stoplight per service, so an outage in one doesn't trip the others. ## Testing External APIs @@ -437,7 +433,7 @@ Test the middleware stack separately. Test the workflow as an integration. The e 4. **Rollback compensates for real side effects.** When you charge a credit card and a downstream step fails, the rollback issues a refund. CMDx calls it automatically. -5. **Build reusable base classes per service.** `Stripe::BaseTask`, `Shipping::BaseTask`, `Geocoding::BaseTask` — each with appropriate resilience settings. +5. **Build reusable base classes per service.** One per external API (`Stripe::BaseTask`, `Shipping::BaseTask`, ...) — each with appropriate `retry_on` thresholds and a service-named circuit breaker. The middleware stack does the hard, boring, critical work of making external API calls reliable. Your tasks just do the work. @@ -446,6 +442,7 @@ Happy coding! ## References - [Middlewares](https://drexed.github.io/cmdx/middlewares/) -- [Configuration](https://drexed.github.io/cmdx/configuration/) -- [Halt](https://drexed.github.io/cmdx/interruptions/halt/) +- [Outputs](https://drexed.github.io/cmdx/outputs/) +- [Retries](https://drexed.github.io/cmdx/retries/) - [Faults](https://drexed.github.io/cmdx/interruptions/faults/) +- [Built-ins Removed in v2](https://drexed.github.io/cmdx/v2-migration/#built-ins-removed) diff --git a/docs/blog/posts/real-world-cmdx-multi-tenant-saas.md b/docs/blog/posts/real-world-cmdx-multi-tenant-saas.md index 812102618..7ecf737dc 100644 --- a/docs/blog/posts/real-world-cmdx-multi-tenant-saas.md +++ b/docs/blog/posts/real-world-cmdx-multi-tenant-saas.md @@ -1,5 +1,5 @@ --- -date: 2026-06-03 +date: 2026-06-10 authors: - drexed categories: @@ -11,6 +11,8 @@ slug: real-world-cmdx-multi-tenant-saas *Part 4 of the Real-World CMDx series* +*Built on CMDx 2.0 — see the [v2 release post](cmdx-v2-the-runtime-rewrite.md). v2's frozen `Result` makes [pattern matching on tenant metadata](https://drexed.github.io/cmdx/outcomes/result/) safe across threads, and the per-fiber chain isolates tenant scopes naturally.* + Multi-tenancy changes everything. That simple `Users::Register` task you wrote? Now it needs to know which tenant it's operating on. Your database queries need scoping. Your logging needs tenant context. Your middleware stack needs to enforce tenant isolation. And if you get any of it wrong, Customer A sees Customer B's data, and you're writing an incident report instead of features. I've built three multi-tenant SaaS products with Ruby and CMDx. Each one taught me something about where tenant boundaries belong and, more importantly, where they don't. The pattern I've settled on keeps tenant concerns out of business logic entirely — tasks don't know they're multi-tenant. The middleware and base classes handle it. @@ -65,17 +67,14 @@ For defense-in-depth, add middleware that ensures tenant scoping is active: ```ruby class TenantIsolation - def call(task, options) + def call(task) tenant = task.context[:tenant] - unless tenant - task.result.tap { |r| r.fail!("Tenant context missing", code: :tenant_required) } - return + if tenant.nil? + throw(CMDx::Signal::TAG, CMDx::Signal.failed("Tenant context missing", metadata: { code: :tenant_required })) end - ActsAsTenant.with_tenant(tenant) do - yield - end + ActsAsTenant.with_tenant(tenant) { yield } end end ``` @@ -84,11 +83,13 @@ Register it globally for extra safety: ```ruby CMDx.configure do |config| - config.middlewares.register TenantIsolation + config.middlewares.register TenantIsolation.new end ``` -The middleware uses `with_tenant` which scopes all ActiveRecord queries within the block and restores the previous tenant when the block exits. This is safer than setting `current_tenant` directly — if the task raises, the tenant scope is still restored. +In v2, middlewares can't mutate `Result` — it's frozen, built once at the end of the lifecycle. To fail a task from middleware, throw `CMDx::Signal::TAG` directly; Runtime's `catch(Signal::TAG)` block converts it into a failed Result. + +The middleware uses `with_tenant`, which scopes all ActiveRecord queries within the block and restores the previous tenant when the block exits — safer than setting `current_tenant` directly: if the task raises, the tenant scope is still restored. ### Why Both Callback and Middleware? @@ -96,40 +97,26 @@ The `before_execution` callback sets the tenant for the task's `work` method. Th ## Tenant-Scoped Logging -Add the tenant to every log entry via a middleware that injects tenant context into metadata: +Subscribe to the `:task_executed` Telemetry event and ship the tenant slug to your log aggregator. `Result` is frozen in v2, so middleware can't bolt fields onto `metadata` after the fact — Telemetry is the v2-native seam: ```ruby -class TenantLogging - def call(task, options) - tenant = task.context[:tenant] - - yield.tap do |result| - result.metadata[:tenant_slug] = tenant.slug if tenant - end - end -end - -class TenantTask < ApplicationTask - required :tenant - - register :middleware, TenantLogging - before_execution :set_tenant_scope - - private - - def set_tenant_scope - ActsAsTenant.current_tenant = tenant +CMDx.configure do |config| + config.telemetry.subscribe(:task_executed) do |event| + tenant = event.payload[:result].context[:tenant] + next unless tenant + + Rails.logger.info( + chain_id: event.chain_id, + task: event.task_class.name, + tenant_slug: tenant.slug, + status: event.payload[:result].status, + duration: event.payload[:result].duration + ) end end ``` -Now every log entry includes the tenant: - -```json -{"chain_id":"abc123","class":"Orders::Create","status":"success","metadata":{"tenant_slug":"acme","runtime":34}} -``` - -Filter your log aggregator by `metadata.tenant_slug:"acme"` and see all task executions for that tenant. Cross-reference with `chain_id` to trace a single request. +Filter your log aggregator by `tenant_slug:"acme"` and see every task execution for that tenant. Cross-reference with `chain_id` to trace a single request — the per-fiber chain ([`lib/cmdx/chain.rb`](https://github.com/drexed/cmdx/blob/main/lib/cmdx/chain.rb)) keeps concurrent tenant requests isolated automatically. ## Per-Tenant Configuration @@ -137,14 +124,9 @@ Different tenants have different needs. Enterprise tenants might need longer tim ```ruby class TenantConfigMiddleware - def call(task, options) + def call(task) tenant = task.context[:tenant] - return yield unless tenant - - if tenant.feature?(:enhanced_logging) - task.logger.level = :debug - end - + task.logger.level = :debug if tenant&.feature?(:enhanced_logging) yield end end @@ -156,7 +138,7 @@ For tasks that behave differently per tenant: class Reports::Generate < TenantTask required :report_type, inclusion: { in: %w[summary detailed] } - returns :report + output :report, required: true def work context.report = case report_type @@ -181,10 +163,7 @@ Workflows compose tenant-scoped tasks naturally: class Onboarding::SetupTenant < CMDx::Task include CMDx::Workflow - settings( - workflow_breakpoints: ["failed"], - tags: ["onboarding", "tenant-setup"] - ) + settings(tags: ["onboarding", "tenant-setup"]) task Tenants::Create task Tenants::ProvisionDatabase, if: :dedicated_database? @@ -210,7 +189,7 @@ class Tenants::Create < ApplicationTask required :plan, inclusion: { in: %w[starter growth enterprise] } required :owner_email, format: { with: URI::MailTo::EMAIL_REGEXP } - returns :tenant + output :tenant, required: true def work fail!("Slug already taken", code: :slug_taken) if Tenant.exists?(slug: slug) @@ -229,7 +208,7 @@ end ```ruby class Tenants::SeedDefaultData < TenantTask - returns :seed_summary + output :seed_summary, required: true def work roles = Role.insert_all([ @@ -268,7 +247,7 @@ end class Tenants::CreateAdminUser < TenantTask required :owner_email - returns :admin_user + output :admin_user, required: true def work context.admin_user = User.create!( @@ -293,11 +272,11 @@ class Admin::BaseTask < ApplicationTask end class Admin::GenerateUsageReport < Admin::BaseTask - required :billing_period, type: :date + required :billing_period, coerce: :date settings(tags: ["admin", "billing"]) - returns :report + output :report, required: true def work context.report = Tenant.active.map do |tenant| @@ -318,7 +297,7 @@ By deregistering `TenantIsolation`, admin tasks can query across all tenants. Th ## Tenant-Aware Background Jobs -Combine the patterns from [Part 3](real-world-cmdx-background-jobs.md) with tenant scoping: +Combine the patterns from [Part 3](real-world-cmdx-background-jobs.md) (which defines the lightweight `Correlate` module) with tenant scoping: ```ruby class TenantJob @@ -327,10 +306,11 @@ class TenantJob def perform(args) tenant = Tenant.find(args["tenant_id"]) - CMDx::Middlewares::Correlate.use(args["correlation_id"]) do + Correlate.use(args["correlation_id"]) do ActsAsTenant.with_tenant(tenant) do - task_class = args["task_class"].constantize - task_class.execute!(args["context"].merge("tenant" => tenant)) + args["task_class"].constantize.execute!( + args["context"].merge("tenant" => tenant) + ) end end end @@ -342,13 +322,11 @@ Enqueue with tenant context: ```ruby class Billing::EnqueueInvoiceGeneration < TenantTask def work - correlation_id = CMDx::Middlewares::Correlate.id - TenantJob.perform_async( - "tenant_id" => tenant.id, - "correlation_id" => correlation_id, - "task_class" => "Billing::GenerateInvoice", - "context" => { "billing_period" => Date.today.to_s } + "tenant_id" => tenant.id, + "correlation_id" => Correlate.id, + "task_class" => "Billing::GenerateInvoice", + "context" => { "billing_period" => Date.today.to_s } ) logger.info "Enqueued invoice generation for tenant #{tenant.slug}" @@ -356,7 +334,7 @@ class Billing::EnqueueInvoiceGeneration < TenantTask end ``` -The tenant ID is serialized with the job. When it executes, the tenant scope is restored before the task runs. The `correlation_id` bridges the async boundary for tracing. +The tenant ID is serialized with the job. When it executes, the tenant scope is restored before the task runs. The correlation id bridges the async boundary for tracing. ## Testing Multi-Tenant Tasks @@ -390,7 +368,7 @@ RSpec.describe Orders::Create do ) expect(result).to be_failed - expect(result.metadata[:errors][:messages]).to have_key(:tenant) + expect(result.errors[:tenant]).not_to be_empty end end ``` @@ -431,9 +409,11 @@ ApplicationTask < CMDx::Task TenantTask < ApplicationTask └── required :tenant - └── TenantLogging middleware └── before_execution :set_tenant_scope +Telemetry subscribers + └── :task_executed → tenant-scoped log emission + Admin::BaseTask < ApplicationTask └── deregister TenantIsolation (cross-tenant access) @@ -452,5 +432,5 @@ Happy coding! - [Middlewares](https://drexed.github.io/cmdx/middlewares/) - [Callbacks](https://drexed.github.io/cmdx/callbacks/) -- [Configuration](https://drexed.github.io/cmdx/configuration/) -- [Tips and Tricks](https://drexed.github.io/cmdx/tips_and_tricks/) +- [Telemetry](https://drexed.github.io/cmdx/configuration/) +- [Pattern Matching on Result](https://drexed.github.io/cmdx/outcomes/result/) diff --git a/docs/blog/posts/real-world-cmdx-user-onboarding.md b/docs/blog/posts/real-world-cmdx-user-onboarding.md index 459a14e73..86e647e8b 100644 --- a/docs/blog/posts/real-world-cmdx-user-onboarding.md +++ b/docs/blog/posts/real-world-cmdx-user-onboarding.md @@ -1,5 +1,5 @@ --- -date: 2026-05-13 +date: 2026-05-20 authors: - drexed categories: @@ -11,6 +11,8 @@ slug: real-world-cmdx-user-onboarding *Part 1 of the Real-World CMDx series* +*Built on CMDx 2.0 — see the [v2 release post](cmdx-v2-the-runtime-rewrite.md) for the runtime changes this post depends on.* + User onboarding is one of those features that sounds simple until you actually build it. "Just create a user and send them an email." Sure—until you add email verification, trial activation, referral tracking, welcome sequences, analytics events, and a dozen conditional paths based on plan type, invite status, and geographic regulations. I've built this feature in Ruby at least six times across different projects, and it always follows the same trajectory: starts as a single service object, grows tentacles, and eventually becomes the thing nobody wants to touch. This time, I'm building it with CMDx from the start—decomposed into focused tasks, orchestrated as a workflow, with full observability baked in. @@ -44,7 +46,7 @@ class Users::Register < CMDx::Task optional :referral_code optional :invite_token - returns :user + output :user, required: true def work fail!("Email already taken", code: :duplicate) if User.exists?(email: email) @@ -59,7 +61,7 @@ class Users::Register < CMDx::Task end ``` -Three layers of defense here: attribute validations catch malformed inputs, the `fail!` catches business rule violations, and `returns :user` guarantees downstream tasks always have the user available. +Three layers of defense here: input validations catch malformed inputs, the `fail!` catches business rule violations, and `output :user, required: true` guarantees downstream tasks always have the user available — Runtime verifies it's present after `work` returns and fails the task otherwise. ### Send Verification Email @@ -67,7 +69,7 @@ Three layers of defense here: attribute validations catch malformed inputs, the class Users::SendVerification < CMDx::Task required :user - returns :verification_token + output :verification_token, required: true def work context.verification_token = user.generate_verification_token! @@ -88,7 +90,7 @@ This task only runs for trial plans. That conditional logic lives in the workflo class Users::ActivateTrial < CMDx::Task required :user - returns :trial_ends_at + output :trial_ends_at, required: true def work trial_duration = case user.plan @@ -186,10 +188,7 @@ Now we compose these tasks into a pipeline: class Users::Onboard < CMDx::Task include CMDx::Workflow - settings( - workflow_breakpoints: ["failed"], - tags: ["onboarding"] - ) + settings(tags: ["onboarding"]) task Users::Register task Users::SendVerification @@ -244,11 +243,11 @@ class RegistrationsController < ApplicationController in { status: "success" } sign_in(result.context.user) redirect_to dashboard_path, notice: "Welcome! Check your email to verify your account." - in { status: "failed", metadata: { errors: { messages: Hash => msgs } } } - @errors = msgs - render :new, status: :unprocessable_entity in { status: "failed", metadata: { code: :duplicate } } redirect_to login_path, alert: "An account with that email already exists." + in { status: "failed" } if result.errors.any? + @errors = result.errors.to_h + render :new, status: :unprocessable_entity in { status: "failed" } redirect_to new_registration_path, alert: result.reason end @@ -256,7 +255,7 @@ class RegistrationsController < ApplicationController end ``` -Pattern matching makes the controller clean. Each failure type gets a specific response. +Pattern matching makes the controller clean. Each failure type gets a specific response — `metadata[:code]` for tagged business failures, `result.errors` for input/output validation failures, and `result.reason` as the fallback. ## Handling Partial Failures @@ -264,9 +263,7 @@ What happens when the referral code is invalid but everything else succeeds? Rig That might not be what we want. A bad referral code shouldn't block registration. -With `workflow_breakpoints: ["failed"]`, every failure halts the pipeline. That's correct for `Users::Register` (can't continue without a user) but too strict for referrals. We can't just remove `"failed"` from breakpoints — we need critical steps to halt and non-critical steps to pass through. - -The solution is to let the task decide its own severity: +In v2, failure always halts the pipeline — there's no opt-out toggle. That's correct for `Users::Register` (can't continue without a user) but too strict for referrals. The fix is to let the task decide its own severity: ```ruby class Users::ApplyReferralBonus < CMDx::Task @@ -298,19 +295,27 @@ This is the approach I prefer. The task decides its own severity. Critical steps ## Observability for Free -Run the workflow and check the logs: +Configure the JSON log formatter (the default `Line` formatter is human-readable; switch when you want machine-parseable output): + +```ruby +CMDx.configure do |config| + config.log_formatter = CMDx::LogFormatters::JSON.new +end +``` + +Run the workflow and the message field of each log line is the serialized `result.to_h`: ```json -{"index":1,"chain_id":"abc123","class":"Users::Register","status":"success","metadata":{"runtime":45}} -{"index":2,"chain_id":"abc123","class":"Users::SendVerification","status":"success","metadata":{"runtime":12}} -{"index":3,"chain_id":"abc123","class":"Users::ActivateTrial","status":"success","metadata":{"runtime":8}} -{"index":4,"chain_id":"abc123","class":"Users::ApplyReferralBonus","status":"skipped","reason":"Invalid referral code — skipping bonus","metadata":{"runtime":3}} -{"index":5,"chain_id":"abc123","class":"Users::SendWelcome","status":"success","metadata":{"runtime":6}} -{"index":6,"chain_id":"abc123","class":"Users::TrackRegistration","tags":["analytics","onboarding"],"status":"success","metadata":{"runtime":2}} -{"index":0,"chain_id":"abc123","class":"Users::Onboard","tags":["onboarding"],"status":"success","metadata":{"runtime":76}} +{"chain_id":"abc123","chain_index":1,"chain_root":false,"type":"Task","task":"Users::Register","status":"success","duration":45.2, ...} +{"chain_id":"abc123","chain_index":2,"chain_root":false,"type":"Task","task":"Users::SendVerification","status":"success","duration":12.1, ...} +{"chain_id":"abc123","chain_index":3,"chain_root":false,"type":"Task","task":"Users::ActivateTrial","status":"success","duration":8.0, ...} +{"chain_id":"abc123","chain_index":4,"chain_root":false,"type":"Task","task":"Users::ApplyReferralBonus","status":"skipped","reason":"Invalid referral code — skipping bonus","duration":3.4, ...} +{"chain_id":"abc123","chain_index":5,"chain_root":false,"type":"Task","task":"Users::SendWelcome","status":"success","duration":6.7, ...} +{"chain_id":"abc123","chain_index":6,"chain_root":false,"type":"Task","task":"Users::TrackRegistration","tags":["analytics","onboarding"],"status":"success","duration":2.1, ...} +{"chain_id":"abc123","chain_index":0,"chain_root":true,"type":"Workflow","task":"Users::Onboard","tags":["onboarding"],"status":"success","duration":76.5, ...} ``` -One `chain_id` links every step. The skipped referral bonus is visible without digging through exception trackers. The workflow still reports `success` because skips are considered good outcomes. +One `chain_id` links every step. The skipped referral bonus is visible without digging through exception trackers. The root workflow result is at `chain_index: 0` (Runtime `unshift`s the root onto the chain). The workflow still reports `success` because skips are considered good outcomes (`result.ok?`). ## Testing the Pipeline @@ -370,7 +375,7 @@ RSpec.describe Users::Onboard do expect(result).to be_success expect(result.context.user).to be_persisted - referral_result = result.chain.results.find { |r| r.task.is_a?(Users::ApplyReferralBonus) } + referral_result = result.chain.results.find { |r| r.task == Users::ApplyReferralBonus } expect(referral_result).to be_skipped end diff --git a/docs/blog/posts/returns-and-contracts.md b/docs/blog/posts/returns-and-contracts.md index f530680fa..da1c5fc6b 100644 --- a/docs/blog/posts/returns-and-contracts.md +++ b/docs/blog/posts/returns-and-contracts.md @@ -9,6 +9,8 @@ slug: returns-and-contracts # Returns and Contracts: Making Your Tasks Predictable +*Targets CMDx v1.20.* + I used to have a recurring nightmare. Not the falling kind—the kind where I'm staring at a service object and trying to figure out what it puts into the context. The `work` method sets `context.user` on line 12, `context.token` on line 28, but only if the conditional on line 15 passes. Oh, and there's a `context.session_id` that gets set inside a private method three screens down. Every consumer of that task is making an implicit assumption about what the context will contain after execution. When those assumptions break, the error shows up somewhere else entirely—a `NoMethodError` in a downstream task, a nil where a mailer expected a user object. diff --git a/docs/blog/posts/structuring-large-cmdx-codebases.md b/docs/blog/posts/structuring-large-cmdx-codebases.md index e1fe19aa5..ba3df8494 100644 --- a/docs/blog/posts/structuring-large-cmdx-codebases.md +++ b/docs/blog/posts/structuring-large-cmdx-codebases.md @@ -9,6 +9,8 @@ slug: structuring-large-cmdx-codebases # Structuring Large CMDx Codebases +*Targets CMDx v1.20.* + Your first CMDx task is easy. Your tenth is manageable. But what about your hundredth? I've seen projects where the `app/tasks/` directory becomes a dumping ground—flat files with no organization, inconsistent naming, duplicated middleware registrations, and base classes that try to do everything. Scaling a CMDx codebase isn't about the framework. It's about the conventions you establish early and enforce consistently. This post is the playbook I wish I had when my first CMDx project grew from 10 tasks to 200. diff --git a/docs/blog/posts/testing-cmdx-tasks-like-a-pro.md b/docs/blog/posts/testing-cmdx-tasks-like-a-pro.md index 9715ce619..2aa458711 100644 --- a/docs/blog/posts/testing-cmdx-tasks-like-a-pro.md +++ b/docs/blog/posts/testing-cmdx-tasks-like-a-pro.md @@ -9,6 +9,8 @@ slug: testing-cmdx-tasks-like-a-pro # Testing CMDx Tasks Like a Pro +*Targets CMDx v1.19.* + I have a confession: I used to skip tests for service objects. Not because I didn't care, but because testing them was painful. Mock the database, stub the API, wrestle with instance variables, pray the test actually exercises the code path you think it does. The friction was real, and it showed in our coverage numbers. When I built CMDx, I made a promise to myself—if the framework isn't dead simple to test, it's not done. Every task takes data in and pushes a result out. No hidden state, no side-channel mutations, no surprises. That makes testing almost enjoyable. Almost. diff --git a/docs/blog/posts/why-i-switched-to-cmdx.md b/docs/blog/posts/why-i-switched-to-cmdx.md index 845a9e056..e5290e9f9 100644 --- a/docs/blog/posts/why-i-switched-to-cmdx.md +++ b/docs/blog/posts/why-i-switched-to-cmdx.md @@ -9,6 +9,8 @@ slug: why-i-switched-to-cmdx # Why I Switched to CMDx (and How You Can Too) +*Targets CMDx v1.20.* + If you've been writing Ruby long enough, you've probably used at least one service object gem. Maybe you started with Interactor back when it was the default choice. Maybe you moved to ActiveInteraction for its ActiveModel-like validations. Maybe you tried Actor or LightService. I've used all of them in production, and each one taught me something about what I actually need from a command framework. This isn't a hit piece on any of those gems—they're well-built tools that solve real problems. But after years of using them, I kept hitting the same walls. So I built CMDx to knock those walls down. diff --git a/docs/callbacks.md b/docs/callbacks.md index bdbb9a347..a2f3dd2f8 100644 --- a/docs/callbacks.md +++ b/docs/callbacks.md @@ -1,6 +1,10 @@ # Callbacks -Run custom logic at specific points during task execution. Callbacks have full access to task context and results, making them perfect for logging, notifications, cleanup, and more. +Run custom logic at specific points during task execution. Callbacks have full access to the task and its context — perfect for logging, notifications, and cleanup. + +!!! note + + The `Result` isn't built yet when callbacks run, so `task.result` isn't available. Branch on outcome by registering separate `on_success` / `on_failed` / `on_skipped` callbacks, or subscribe to Telemetry's `:task_executed` event when you need the finalized result. See [Global Configuration](configuration.md#callbacks) for framework-wide callback setup. @@ -13,15 +17,15 @@ See [Global Configuration](configuration.md#callbacks) for framework-wide callba Callbacks execute in a predictable lifecycle order: ```ruby -1. before_validation # Pre-validation setup -2. before_execution # Prepare for execution +1. before_execution # Prepare for execution +2. before_validation # Pre-validation setup -# --- Task#work executes --- +# --- inputs resolved, Task#work runs (with retries), outputs verified --- +# --- #rollback runs here when failed --- 3. on_[complete|interrupted] # State-based (execution lifecycle) -4. on_executed # Always runs after work completes -5. on_[success|skipped|failed] # Status-based (business outcome) -6. on_[good|bad] # Outcome-based (success/skip vs fail) +4. on_[success|skipped|failed] # Status-based (business outcome) +5. on_[ok|ko] # Outcome-based (success/skip vs fail) ``` ## Declarations @@ -48,11 +52,11 @@ class ProcessBooking < CMDx::Task end def notify_guest - GuestNotifier.call(context.guest, result) + GuestNotifier.call(context.guest) end def update_availability - AvailabilityService.update(context.room_ids, result) + AvailabilityService.update(context.room_ids) end end ``` @@ -78,11 +82,13 @@ Implement reusable callback logic in dedicated modules and classes: ```ruby class BookingConfirmationCallback def call(task) - if task.result.success? - MessagingApi.send_confirmation(task.context.guest) - else - MessagingApi.send_issue_alert(task.context.manager) - end + MessagingApi.send_confirmation(task.context.guest) + end +end + +class BookingIssueCallback + def call(task) + MessagingApi.send_issue_alert(task.context.manager) end end @@ -91,7 +97,7 @@ class ProcessBooking < CMDx::Task on_success BookingConfirmationCallback # Instance - on_interrupted BookingConfirmationCallback.new + on_interrupted BookingIssueCallback.new end ``` @@ -111,7 +117,7 @@ class ProcessBooking < CMDx::Task before_execution :notify_guest, if: :messaging_enabled?, unless: :messaging_blocked? # Proc - on_failure :increment_failure, if: -> { Rails.env.production? && self.class.name.include?("Legacy") } + on_failed :increment_failure, if: -> { Rails.env.production? && self.class.name.include?("Legacy") } # Lambda on_success :ping_housekeeping, if: proc { context.rooms_need_cleaning? } @@ -140,18 +146,36 @@ end ## Callback Removal -Remove unwanted callbacks dynamically: - -!!! warning "Important" - - Each `deregister` call removes one callback. Use multiple calls for batch removals. +`deregister :callback, event` drops **every** callback for the event. Pass an +optional callable to drop only matching entries — matched by `==`, which works +for Symbol method names and classes/modules (Procs/Lambdas match by identity, +so you must hold the original reference). Unknown events raise `ArgumentError`; +unknown callables are a silent no-op. ```ruby class ProcessBooking < CMDx::Task - # Symbol + # Drops every :before_execution callback (inherited or local) + deregister :callback, :before_execution + + # Drops only the :notify_guest method callback for :before_execution deregister :callback, :before_execution, :notify_guest - # Class or Module (no instances) + # Drops only the BookingConfirmationCallback class for :on_complete deregister :callback, :on_complete, BookingConfirmationCallback end ``` + +!!! note + + Procs and Lambdas are matched by identity, so removing them requires + holding the original reference: + + ```ruby + HOOK = ->(task) { Audit.log(task) } + + class ProcessBooking < CMDx::Task + on_success HOOK + deregister :callback, :on_success, HOOK # works + # deregister :callback, :on_success, ->(task) { Audit.log(task) } # would NOT match + end + ``` diff --git a/docs/comparison.md b/docs/comparison.md index 724202195..495c483f5 100644 --- a/docs/comparison.md +++ b/docs/comparison.md @@ -2,43 +2,42 @@ ## Alternative Frameworks -CMDx stands apart by combining zero external dependencies with production-grade observability and a comprehensive type coercion system—all in a single, cohesive package. While other gems excel in specific areas, CMDx delivers the full stack: structured logging with correlation IDs, automatic runtime metrics, 20+ built-in type coercers, extensible middleware, and fault tolerance patterns—without pulling in a single additional dependency. +CMDx bundles structured logging, telemetry hooks, type coercion, middleware, and retry/fault primitives into a single package with a stdlib-only runtime footprint. The table below maps feature coverage across the common service-object gems — use it to pick based on what you actually need, not on marketing. | Feature | [CMDx](https://github.com/drexed/cmdx) | [Actor](https://github.com/sunny/actor) | [Interactor](https://github.com/collectiveidea/interactor) | [ActiveInteraction](https://github.com/AaronLasseigne/active_interaction) | [LightService](https://github.com/adomokos/light-service) | |---------|------|------------|------------|-------------------|--------------| -| Zero dependencies | ✅ | ❌ | ✅ | ❌ | ❌ | -| Typed attributes | ✅ | ✅ | ❌ | ✅ | ❌ | +| Stdlib-only runtime deps | ✅ | ❌ | ✅ | ❌ | ❌ | +| Typed inputs | ✅ | ✅ | ❌ | ✅ | ❌ | | Type coercion | ✅ | ❌ | ❌ | ✅ | ❌ | -| Attribute validation | ✅ | ✅ | ❌ | ✅ | ❌ | +| Input validation | ✅ | ✅ | ❌ | ✅ | ❌ | | Built-in logging | ✅ | ❌ | ❌ | ❌ | ✅ | -| Correlation IDs | ✅ | ❌ | ❌ | ❌ | ❌ | +| Telemetry hooks | ✅ | ❌ | ❌ | ❌ | ❌ | | Runtime metrics | ✅ | ❌ | ❌ | ❌ | ❌ | | Middleware system | ✅ | ❌ | ❌ | ❌ | ✅ | -| Batch execution | ✅ | ✅ | ✅ | ✅ | ✅ | +| Workflow execution | ✅ | ✅ | ✅ | ✅ | ✅ | | Fault tolerance | ✅ | ❌ | ❌ | ❌ | ❌ | | Lifecycle callbacks | ✅ | ✅ | ✅ | ✅ | ✅ | -| RBS type signatures | ✅ | ❌ | ❌ | ❌ | ❌ | -**Key differentiators:** +**What you get in the box:** -- **Observability out of the box** — Structured logging, chain correlation, and runtime metrics are built-in, not bolted on. Trace complex workflows across services without additional instrumentation. +- **Observability** — structured logging, telemetry events, and chain-aware result tracking, no extra instrumentation required. -- **Comprehensive type system** — 20+ coercers handle everything from primitives to dates, arrays, and custom types. Validation rules like `numeric`, `format`, and `inclusion` ensure data integrity before execution. +- **Type system** — 13 built-in coercers (primitives, dates, arrays, hashes, etc.) and 7 validators (`absence`, `exclusion`, `format`, `inclusion`, `length`, `numeric`, `presence`), both pluggable. -- **Extensible middleware** — Inject cross-cutting concerns (authentication, rate limiting, telemetry) without modifying task logic. Middleware composes cleanly and executes in predictable order. +- **Middleware** — wrap the task lifecycle for auth, caching, telemetry, etc., without touching `work`. -- **Fault tolerance patterns** — Built-in retry policies with configurable jitter, timeout middleware, and graceful degradation via `skip!`/`fail!`. Production-ready resilience without external gems. +- **Retries and faults** — declarative `retry_on` with configurable jitter, halt primitives (`success!` / `skip!` / `fail!`), and `throw!` for propagating peer failures. -- **Framework agnostic** — Works seamlessly with Rails, Hanami, Sinatra, or plain Ruby. No ActiveSupport dependency, no framework lock-in. +- **Framework agnostic** — runs under Rails, Hanami, Sinatra, or plain Ruby. Runtime deps are limited to `bigdecimal` and `logger`; no ActiveSupport requirement. ## Event Sourcing Replacement -Traditional Event Sourcing architectures impose a significant "complexity tax"—requiring specialized event stores, snapshots, and complex state rehydration logic. CMDx offers a pragmatic alternative: **Log-Based Event Sourcing**. +Full Event Sourcing requires an event store, snapshots, and rehydration logic. If you don't need strict replay guarantees, routing state changes through CMDx tasks and shipping the structured logs to a durable sink gets you most of the benefit for a fraction of the complexity. -By ensuring all state changes occur through CMDx tasks, your structured logs become a complete, immutable ledger of system behavior. +CMDx supplies the structured payload; your log sink and retention policy supply the durability. -- **Audit Trail:** Every execution is automatically logged with its inputs, status, and metadata. This provides a detailed history of *intent* (arguments) and *outcome* (success/failure) without extra coding. +- **Audit trail** — every execution is logged with its inputs, status, and metadata, giving you a record of both intent (arguments) and outcome (status/reason). -- **Reconstructability** Because commands encapsulate all inputs required for an action, you can reconstruct past system states or replay business logic by inspecting the command history, giving you the traceability of Event Sourcing without the infrastructure overhead. +- **Reconstructability** — because tasks capture all inputs required for an action, you can rebuild past state or replay logic by walking the log stream. -- **Simplified Architecture** Keep your standard relational database for current state queries (the "Read Model") while using CMDx logs as your historical record (the "Write Model"). This gives you CQRS-like benefits without the complexity of maintaining separate projections. +- **Simpler architecture** — keep the relational database for the read model and treat the log stream as the write model. You get CQRS-style separation without maintaining bespoke projections. diff --git a/docs/configuration.md b/docs/configuration.md index 992045ea5..564ed150a 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1,386 +1,375 @@ # Configuration -Configure CMDx to customize framework behavior, register components, and control execution flow through global defaults with task-level overrides. +Configure CMDx to register components, control logging, and customize framework behavior. Configuration lives at two levels: global defaults and per-class overrides. ## Configuration Hierarchy -CMDx uses a straightforward two-tier configuration system: +CMDx uses a two-tier configuration system: -1. **Global Configuration** — Framework-wide defaults -2. **Task Settings** — Class-level overrides using `settings` +1. **Global Configuration** — Framework-wide defaults via `CMDx.configure` +2. **Class-level overrides** — On the task class via `settings`, `register`, `deregister`, `retry_on`, `deprecation` !!! warning "Important" - Task settings take precedence over global config. Settings are inherited from parent classes and can be overridden in subclasses. + Class-level registries (`middlewares`, `callbacks`, `coercions`, `validators`, `telemetry`) are **lazily duplicated** from the parent class (or from the global configuration at the top of the hierarchy) on first access. Configure globals before any task first touches a registry, or call `CMDx.reset_configuration!` in test setup to invalidate the cached copies on `Task`. ## Global Configuration -Configure framework-wide defaults that apply to all tasks. These settings come with sensible defaults out of the box. - ### Default Values | Setting | Default | Description | |---------|---------|-------------| -| `task_breakpoints` | `["failed"]` | Statuses that cause `execute!` to raise | -| `workflow_breakpoints` | `["failed"]` | Statuses that halt workflow pipelines | -| `rollback_on` | `["failed"]` | Statuses that trigger `rollback` | -| `dump_context` | `false` | Include `context` in hash representation | -| `freeze_results` | `true` | Freeze results after execution | +| `logger` | `Logger.new($stdout, progname: "cmdx", formatter: Line.new, level: INFO)` | Logger instance | +| `log_level` | `Logger::INFO` | Logger severity | +| `log_formatter` | `CMDx::LogFormatters::Line.new` | Formatter instance | | `default_locale` | `"en"` | Locale for built-in translation fallbacks | -| `backtrace` | `false` | Include backtraces for non-fault exceptions | -| `backtrace_cleaner` | `nil` | Callable to clean backtraces (Rails: `Rails.backtrace_cleaner.clean`) | -| `exception_handler` | `nil` | Callable invoked on non-fault exceptions | -| `logger` | `Logger.new($stdout, level: INFO, formatter: Line)` | Logger instance | +| `backtrace_cleaner` | `nil` | Callable to clean fault backtraces | +| `middlewares` | `Middlewares.new` (empty) | Middleware registry | +| `callbacks` | `Callbacks.new` (empty) | Callback registry | +| `coercions` | `Coercions.new` (13 built-ins) | Coercion registry | +| `validators` | `Validators.new` (7 built-ins) | Validator registry | +| `telemetry` | `Telemetry.new` (empty) | Telemetry pub/sub | -### Breakpoints +### Default Locale -Control when `execute!` raises a `CMDx::Fault` based on task status. +Set the locale used for built-in translation fallbacks when the `I18n` gem isn't loaded. See [Internationalization](internationalization.md) for the full locale list. ```ruby CMDx.configure do |config| - config.task_breakpoints = "failed" # String or Array[String] + config.default_locale = "es" end ``` -For workflows, configure which statuses halt the execution pipeline: +!!! note -```ruby -CMDx.configure do |config| - config.workflow_breakpoints = ["skipped", "failed"] -end -``` + When `I18n` is loaded, CMDx delegates to `I18n.translate` and `default_locale` is unused — locale comes from `I18n.locale`. Without `I18n`, all built-in messages (validation errors, coercion errors, etc.) resolve from this setting. -### Rollback +### Backtrace Cleaner -Control when a `rollback` of task execution is called. +Trim noise from `Fault` backtraces with any callable that takes `Array<String>` and returns a cleaned array. ```ruby CMDx.configure do |config| - config.rollback_on = ["failed"] # String or Array[String] -end -``` - -### Dump Context - -Include context in hash representations and by extension log output. + config.backtrace_cleaner = ->(bt) { bt.reject { |l| l.include?("/gems/") } } -```ruby -CMDx.configure do |config| - config.dump_context = true + # Rails: + config.backtrace_cleaner = ->(bt) { Rails.backtrace_cleaner.clean(bt) } end ``` !!! note - Large context can make dumps and logs very noisy, so it is only include it if explicitly enabled. This option is especially helpful for debugging context mutations. - -### Result Freezing + Rails apps wire this automatically via `CMDx::Railtie`. -By default, results, context, and chains are frozen after execution to enforce immutability. There are very rare instances where disabling this is needed so take great care. +### Logging ```ruby CMDx.configure do |config| - config.freeze_results = false + config.logger = Logger.new($stdout, progname: "cmdx") + config.log_level = Logger::DEBUG + config.log_formatter = CMDx::LogFormatters::JSON.new end ``` -!!! tip - - Only disable `freeze_results` in tests. Frozen results prevent accidental mutation in production code. +Built-in formatters live under `CMDx::LogFormatters`: `Line` (default), `JSON`, `KeyValue`, `Logstash`, `Raw`. See [Logging](logging.md) for the emitted fields and sample output. -### Default Locale +### Middlewares -Set the locale used for CMDx's built-in translation fallbacks when the `I18n` gem is not available. See [Internationalization](internationalization.md) for the full locale list. +Middlewares wrap the entire task lifecycle. The signature is `call(task) { ... }` — call `yield` (or `next_link.call` from a Proc) to invoke the next link. ```ruby CMDx.configure do |config| - config.default_locale = "es" + # Class with #call(task) + config.middlewares.register CustomMiddleware + + # Instance + config.middlewares.register CustomMiddleware.new(threshold: 1000) + + # Proc / Lambda — must declare &next_link to pass the block + config.middlewares.register(proc do |task, &next_link| + started = Process.clock_gettime(Process::CLOCK_MONOTONIC) + next_link.call + ensure + duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started + Rails.logger.debug { "#{task.class} ran in #{(duration * 1000).round(2)}ms" } + end) + + # Insert at a specific position + config.middlewares.register MyOuterMiddleware, at: 0 + + # Remove + config.middlewares.deregister CustomMiddleware end ``` -!!! note +!!! danger "Caution" - When the `I18n` gem is loaded, CMDx delegates to `I18n.t` and this setting is only used for fallback defaults. Without `I18n`, all built-in messages (validation errors, coercion errors, etc.) are resolved from the configured locale file. + A middleware that forgets to yield raises `CMDx::MiddlewareError` — the task body is never invoked, so silent skips are caught immediately. -### Backtraces +See the [Middlewares](middlewares.md) docs for class-level configuration. -Enable detailed backtraces for non-fault exceptions to improve debugging. Optionally clean up stack traces to remove framework noise. +### Callbacks -!!! note +Callbacks fire at specific lifecycle points. Valid events: - In Rails environments, `backtrace_cleaner` defaults to `Rails.backtrace_cleaner.clean`. +| Event | When | +|-------|------| +| `:before_execution` | First lifecycle step inside `measure_duration` | +| `:before_validation` | Right after `:before_execution`, before input resolution | +| `:on_complete` | When `state == "complete"` (success path) | +| `:on_interrupted` | When `state == "interrupted"` (skip or fail) | +| `:on_success` | When `status == "success"` | +| `:on_skipped` | When `status == "skipped"` | +| `:on_failed` | When `status == "failed"` | +| `:on_ok` | Success or skipped (`signal.ok?`) | +| `:on_ko` | Skipped or failed (`signal.ko?`) | ```ruby CMDx.configure do |config| - # Truthy - config.backtrace = true + # Symbol — dispatched as task.send(:method) + config.callbacks.register :before_execution, :initialize_session + + # Class / instance with #call(task) + config.callbacks.register :on_success, LogUserActivity - # Via callable (must respond to `call(backtrace)`) - config.backtrace_cleaner = AdvanceCleaner.new + # Proc / Lambda — instance_exec'd on the task; receives task as block arg. + # The Result isn't built yet during callbacks; subscribe to Telemetry's + # :task_executed event when you need result data like duration. + config.callbacks.register(:on_complete, proc do |task| + StatsD.increment("task.completed", tags: ["task:#{task.class}"]) + end) - # Via proc or lambda - config.backtrace_cleaner = ->(backtrace) { backtrace[0..5] } + # Remove every callback for an event + config.callbacks.deregister :on_success + + # Or remove a specific entry — match by `==` (Procs/Lambdas by identity) + config.callbacks.deregister :on_success, LogUserActivity end ``` -### Exception Handlers +!!! note -Register handlers that run when non-fault exceptions occur during `execute` (non-bang). The handler receives the **task instance** and the **exception** — access the result via `task.result`. + `deregister(event)` drops every callback for that event; pass a second argument to remove only matching entries (matched by `==`). Unknown events raise `ArgumentError`; unmatched callables are a silent no-op. -!!! tip +See [Callbacks](callbacks.md) for class-level usage. - Use exception handlers to send errors to your APM of choice. +### Telemetry -!!! note +Pub/sub for runtime lifecycle events. Subscribers receive a `Telemetry::Event` data object with `chain_id`, `chain_root`, `task_type`, `task_class`, `task_id`, `name`, `payload`, and `timestamp`. - Exception handlers only run for non-fault `StandardError` exceptions caught by `execute`. Faults (`skip!`/`fail!`) and `execute!` exceptions do not trigger the handler. +| Event | Payload | +|-------|---------| +| `:task_started` | empty | +| `:task_deprecated` | empty | +| `:task_retried` | `{ attempt: Integer }` | +| `:task_rolled_back` | empty | +| `:task_executed` | `{ result: Result }` | ```ruby CMDx.configure do |config| - # Via callable (must respond to `call(task, exception)`) - config.exception_handler = NewRelicReporter - - # Via proc or lambda - config.exception_handler = proc do |task, exception| - APMService.report(exception, extra_data: { - task: task.class.name, - id: task.id, - status: task.result.status - }) - end + config.telemetry.subscribe(:task_executed, ->(event) { + StatsD.timing("cmdx.task", event.payload[:result].duration, tags: [ + "class:#{event.task_class}", + "status:#{event.payload[:result].status}" + ]) + }) + + config.telemetry.subscribe(:task_retried, ->(event) { + Rails.logger.warn("[cmdx] retry ##{event.payload[:attempt]} for #{event.task_class}") + }) + + config.telemetry.unsubscribe(:task_executed, my_subscriber) end ``` -### Logging +!!! tip -```ruby -CMDx.configure do |config| - config.logger = CustomLogger.new($stdout) -end -``` + Runtime emits events **only** when subscribers exist for them, so unused events have zero overhead. -### Middlewares +### Coercions -See the [Middlewares](middlewares.md#declarations) docs for task level configurations. +Custom coercions are callables receiving `(value, **options)` and returning the coerced value or `CMDx::Coercions::Failure.new(message)` on failure. ```ruby CMDx.configure do |config| - # Via callable (must respond to `call(task, options)`) - config.middlewares.register CMDx::Middlewares::Timeout - - # Via proc or lambda - config.middlewares.register proc { |task, options| - start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) - result = yield - end_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) - Rails.logger.debug { "task completed in #{((end_time - start_time) * 1000).round(2)}ms" } - result - } - - # With options - config.middlewares.register AuditTrailMiddleware, service_name: "document_processor" - - # Remove middleware - config.middlewares.deregister CMDx::Middlewares::Timeout + config.coercions.register :currency, CurrencyCoercion + + config.coercions.register(:tag_list, proc do |value, **opts| + delimiter = opts[:delimiter] || "," + max_tags = opts[:max_tags] || 50 + value.to_s.split(delimiter).map(&:strip).reject(&:empty?).first(max_tags) + end) + + config.coercions.deregister :currency end ``` -!!! note - - Middlewares are executed in registration order. Each middleware wraps the next, creating an execution chain around task logic. +See [Inputs - Coercions](inputs/coercions.md) for usage. -### Callbacks +### Validators -See the [Callbacks](callbacks.md#declarations) docs for task level configurations. +Custom validators are callables receiving `(value, options)` (options is a positional hash). Return `CMDx::Validators::Failure.new(message)` to mark the value invalid; any other return value (including `nil`) is treated as success. ```ruby CMDx.configure do |config| - # Via method - config.callbacks.register :before_execution, :initialize_user_session - - # Via callable (must respond to `call(task)`) - config.callbacks.register :on_success, LogUserActivity + config.validators.register :uuid, UuidValidator - # Via proc or lambda - config.callbacks.register :on_complete, proc { |task| - execution_time = task.metadata[:runtime] - Metrics.timer("task.execution_time", execution_time, tags: ["task:#{task.class.name.underscore}"]) - } + config.validators.register(:access_token, proc do |value, options| + prefix = options[:prefix] || "tok_" + min = options[:min_length] || 40 - # With options - config.callbacks.register :on_failure, :send_alert_notification, if: :critical_task? + unless value.is_a?(String) && value.start_with?(prefix) && value.length >= min + CMDx::Validators::Failure.new("invalid access token") + end + end) - # Remove callback - config.callbacks.deregister :on_success, LogUserActivity + config.validators.deregister :uuid end ``` -### Coercions - -See the [Attributes - Coercions](attributes/coercions.md#declarations) docs for task level configurations. +See [Inputs - Validations](inputs/validations.md) for usage. -```ruby -CMDx.configure do |config| - # Via callable (must respond to `call(value, options)`) - config.coercions.register :currency, CurrencyCoercion +## Class-Level Configuration - # Via method (must match signature `def coordinates_coercion(value, options)`) - config.coercions.register :coordinates, :coordinates_coercion +### Settings - # Via proc or lambda - config.coercions.register :tag_list, proc { |value, options| - delimiter = options[:delimiter] || ',' - max_tags = options[:max_tags] || 50 +`Settings` exposes a small set of per-class overrides for logger and tagging: - tags = value.to_s.split(delimiter).map(&:strip).reject(&:empty?) - tags.first(max_tags) - } +```ruby +class GenerateInvoice < CMDx::Task + settings( + logger: CustomLogger.new($stdout), + log_formatter: CMDx::LogFormatters::JSON.new, + log_level: Logger::DEBUG, + backtrace_cleaner: ->(bt) { bt.first(8) }, + tags: ["billing", "financial"] + ) - # Remove coercion - config.coercions.deregister :currency + def work + # ... + end end ``` -### Validators - -See the [Attributes - Validations](attributes/validations.md#declarations) docs for task level configurations. +Every getter falls back to the global configuration when an option isn't set. Subclasses inherit and may layer on top — multiple `settings(...)` calls compose (each merges on top of the previous). ```ruby -CMDx.configure do |config| - # Via callable (must respond to `call(value, options)`) - config.validators.register :username, UsernameValidator - - # Via method (must match signature `def url_validator(value, options)`) - config.validators.register :url, :url_validator - - # Via proc or lambda - config.validators.register :access_token, proc { |value, options| - expected_prefix = options[:prefix] || "tok_" - minimum_length = options[:min_length] || 40 - - value.start_with?(expected_prefix) && value.length >= minimum_length - } +class BaseTask < CMDx::Task + settings(tags: ["api"]) +end - # Remove validator - config.validators.deregister :username +class ChildTask < BaseTask + settings(tags: ["billing"], log_level: Logger::DEBUG) + # tags = ["billing"] (child wins; settings.build does Hash#merge) end ``` -## Task Configuration +!!! note -### Settings + `Settings` only stores `:logger`, `:log_formatter`, `:log_level`, `:backtrace_cleaner`, and `:tags`. Other class-level config uses dedicated DSL (`retry_on`, `deprecation`, `register`, `before_execution`, …). -Override global configuration for specific tasks: +### Retry -```ruby -class GenerateInvoice < CMDx::Task - settings( - # Global configuration overrides - task_breakpoints: ["failed"], # Breakpoint override - workflow_breakpoints: ["failed"], # Breakpoint override - dump_context: false, # Include context - backtrace: true, # Toggle backtrace - backtrace_cleaner: ->(bt) { bt[0..5] }, # Backtrace cleaner - logger: CustomLogger.new($stdout), # Custom logger - - # Task configuration settings - breakpoints: ["failed"], # Contextual pointer for :task_breakpoints and :workflow_breakpoints - log_level: :info, # Log level override - log_formatter: CMDx::LogFormatters::Json.new # Log formatter override - tags: ["billing", "financial"], # Logging tags - deprecate: true, # Task deprecations - retries: 3, # Non-fault exception retries - retry_on: [External::ApiError], # List of exceptions to retry on - retry_jitter: 1, # Space between retry iteration, eg: current retry num + 1 - rollback_on: ["failed", "skipped"], # Rollback on override - returns: [:user, :account_number] # Predefines expected return values - ) +Configure exception-based retries with `retry_on`. Accumulates across inheritance. - def work - # Your logic here... +```ruby +class FetchInvoice < CMDx::Task + retry_on Net::OpenTimeout, Net::ReadTimeout, + limit: 3, + delay: 0.5, + max_delay: 5.0, + jitter: :exponential # :exponential, :half_random, :full_random, :bounded_random + + retry_on External::ApiError, limit: 5 do |attempt, delay| + delay * (attempt + 1) # custom jitter block end end ``` -!!! danger "Caution" +!!! note + + `jitter:` takes precedence over a custom block — pass one or the other, not both, or the block is silently ignored. - `settings` is initialized **once per class** on first access. Subsequent calls return the existing instance and ignore any new overrides. Define all overrides in a single `settings` call. +### Deprecation + +See [Deprecation](deprecation.md). Declared via the class-level `deprecation` DSL — **not** via `settings`. ```ruby -class MyTask < CMDx::Task - settings(retries: 3, tags: ["api"]) # These apply - settings(retries: 5) # Ignored — settings already initialized +class LegacyTask < CMDx::Task + deprecation :error, if: -> { Rails.env.production? } end ``` ### Registrations -Register or deregister middlewares, callbacks, coercions, and validators for specific tasks: +Register or deregister middlewares, callbacks, coercions, and validators on a specific task class. ```ruby class SendCampaignEmail < CMDx::Task # Middlewares - register :middleware, CMDx::Middlewares::Timeout - deregister :middleware, AuditTrailMiddleware + register :middleware, AuditTrailMiddleware + deregister :middleware, GlobalLoggingMiddleware - # Callbacks - register :callback, :on_complete, proc { |task| - runtime = task.metadata[:runtime] - Analytics.track("email_campaign.sent", runtime, tags: ["task:#{task.class.name}"]) - } - deregister :callback, :before_execution, :initialize_user_session + # Callbacks (use the dedicated DSL OR register :callback explicitly) + before_execution :find_campaign + on_complete proc { |task| Analytics.track("email_sent", task.context.recipient) } + register :callback, :on_failed, :send_alert # Coercions register :coercion, :currency, CurrencyCoercion - deregister :coercion, :coordinates # Validators - register :validator, :username, :username_validator - deregister :validator, :url + register :validator, :uuid, UuidValidator - def work - # Your logic here... - end + # Inputs / outputs (per-class schemas) + register :input, :recipient_id, coerce: :integer, presence: true + register :output, :delivered_at, presence: true end ``` +See [Inputs - Definitions](inputs/definitions.md) and [Outputs](outputs.md) for the full schema DSL — the dedicated `required` / `optional` / `output` helpers are usually preferred over `register :input` / `register :output`. + +!!! note + + `deregister` mirrors `register`'s arity per registry. For callbacks: `deregister :callback, event` clears every entry for that event, or pass a third arg (`deregister :callback, event, callable`) to drop only matching entries (matched by `==`). For middlewares: `deregister :middleware, callable_or_class` (or `at:` index) matches by reference. + ## Configuration Management ### Access ```ruby -# Global configuration access -CMDx.configuration.logger #=> <Logger instance> -CMDx.configuration.task_breakpoints #=> ["failed"] -CMDx.configuration.middlewares.registry #=> [<Middleware>, ...] +# Global +CMDx.configuration.logger #=> <Logger instance> +CMDx.configuration.middlewares.size #=> 0 +CMDx.configuration.coercions.registry #=> { array: ..., big_decimal: ..., ... } -# Task configuration access +# Class-level class ProcessUpload < CMDx::Task - settings(tags: ["files", "storage"]) + settings(tags: ["files"]) def work - self.class.settings.logger #=> Global configuration value - self.class.settings.tags #=> Task configuration value => ["files", "storage"] + self.class.settings.tags #=> ["files"] + self.class.settings.logger #=> falls back to CMDx.configuration.logger + self.class.middlewares.size #=> inherited count end end ``` ### Resetting -Resetting affects the global CMDx configuration settings. +`CMDx.reset_configuration!` replaces the global config with a fresh instance and invalidates the cached registries on `Task` so subclasses rebuild from the new config on next access. ```ruby -# Reset to framework defaults CMDx.reset_configuration! -# Verify reset -CMDx.configuration.task_breakpoints #=> ["failed"] (default) -CMDx.configuration.middlewares.registry #=> Empty registry - -# Commonly used in test setup (RSpec example) +# Test setup (RSpec) RSpec.configure do |config| config.before(:each) do CMDx.reset_configuration! end end ``` + +!!! warning "Important" + + `reset_configuration!` clears `@middlewares`, `@callbacks`, `@coercions`, `@validators`, and `@telemetry` on `Task` only — subclasses that already cached their own copy keep them. In tests, prefer letting each example use freshly defined task classes (e.g. via `stub_const` or anonymous classes). diff --git a/docs/deprecation.md b/docs/deprecation.md index 14b29b7a9..cfdbf10f2 100644 --- a/docs/deprecation.md +++ b/docs/deprecation.md @@ -1,143 +1,192 @@ # Task Deprecation -Manage legacy tasks gracefully with built-in deprecation support. Choose how to handle deprecated tasks—log warnings for awareness, issue Ruby warnings for development, or prevent execution entirely. +Mark legacy tasks for graceful migration. Choose how to handle deprecated execution—log warnings for awareness, emit Ruby warnings during development, or block execution entirely. + +Deprecation is declared at the **class level** with `deprecation`, **not** through `settings(...)`. Subclasses inherit their parent's deprecation declaration unless they override it. When the action fires, `result.deprecated?` is `true` and a `:task_deprecated` telemetry event is emitted. + +!!! note + + Deprecation runs after middlewares (and the `:task_started` telemetry event) but **before** callbacks, input resolution, and `work`. Conditional gates (`:if` / `:unless`) therefore can't read inputs — only the raw `context` and the task instance. ## Modes -### Raise +### Error -Prevent task execution completely. Perfect for tasks that must no longer run. +Prevent the task from executing. Use for tasks that must no longer run. !!! warning - Use `:raise` mode carefully—it will break existing workflows immediately. + `:error` breaks every caller of the task immediately. Roll out behind a feature flag (via `:if`) when in doubt. ```ruby class ProcessObsoleteAPI < CMDx::Task - settings(deprecate: :raise) + deprecation :error def work - # Will never execute... + # never executes end end -result = ProcessObsoleteAPI.execute +ProcessObsoleteAPI.execute #=> raises CMDx::DeprecationError: "ProcessObsoleteAPI usage prohibited" ``` ### Log -Allow execution while tracking deprecation in logs. Ideal for gradual migrations. +Allow execution and log a warning. Ideal for gradual migrations. ```ruby class ProcessLegacyFormat < CMDx::Task - settings(deprecate: :log) - settings(deprecate: true) + deprecation :log def work - # Executes but logs deprecation warning... + # executes; a warning is written to the task logger end end result = ProcessLegacyFormat.execute result.success? #=> true - -# Deprecation warning appears in logs: -# WARN -- : DEPRECATED: ProcessLegacyFormat - migrate to replacement or discontinue use +# logger.warn: "DEPRECATED: ProcessLegacyFormat - migrate to a replacement or discontinue use" ``` ### Warn -Issue Ruby warnings visible during development and testing. Keeps production logs clean while alerting developers. +Emit a Ruby warning to stderr. Visible during development and testing without polluting production logs. ```ruby class ProcessOldData < CMDx::Task - settings(deprecate: :warn) + deprecation :warn def work - # Executes but emits Ruby warning... + # executes; warning written to stderr end end result = ProcessOldData.execute result.success? #=> true - -# Ruby warning appears in stderr: -# [ProcessOldData] DEPRECATED: migrate to a replacement or discontinue use +# Kernel.warn: "[ProcessOldData] DEPRECATED: migrate to a replacement or discontinue use" ``` ## Declarations -### Symbol or String +### Symbol ```ruby class OutdatedConnector < CMDx::Task - # Symbol - settings(deprecate: :raise) - - # String - settings(deprecate: "warn") + deprecation :error + # or :log, :warn end ``` -### Boolean or Nil - -```ruby -class OutdatedConnector < CMDx::Task - # Deprecates with default :log mode - settings(deprecate: true) - - # Skips deprecation - settings(deprecate: false) - settings(deprecate: nil) -end -``` +### Method Reference -### Method +Dispatched as `task.send(name)`. The method must perform the action itself (raise, log, warn, or no-op); its return value is discarded: ```ruby class OutdatedConnector < CMDx::Task - # Symbol - settings(deprecate: :deprecated?) + deprecation :handle_deprecation def work - # Your logic here... + # ... end private - def deprecated? - Time.now.year > 2024 ? :raise : false + def handle_deprecation + raise CMDx::DeprecationError, "#{self.class} retired" if Time.now.year > 2026 + + logger.warn("#{self.class} pending retirement") end end ``` ### Proc or Lambda +`instance_exec`'d on the task with the task as the sole block argument: + ```ruby class OutdatedConnector < CMDx::Task - # Proc - settings(deprecate: proc { Rails.env.development? ? :raise : :log }) + deprecation proc { |task| + Rails.env.development? ? raise(CMDx::DeprecationError, "#{task.class} retired") : task.logger.warn("legacy") + } - # Lambda - settings(deprecate: -> { Current.tenant.legacy_mode? ? :warn : :raise }) + deprecation ->(task) { task.context.tenant.legacy_mode? ? warn("legacy") : nil } end ``` ### Class or Module +Anything that responds to `#call(task)`: + ```ruby class OutdatedTaskDeprecator def call(task) - task.class.name.include?("Outdated") + return unless task.class.name.include?("Outdated") + + raise CMDx::DeprecationError, "#{task.class} usage prohibited" end end class OutdatedConnector < CMDx::Task - # Class or Module - settings(deprecate: OutdatedTaskDeprecator) + deprecation OutdatedTaskDeprecator # class + deprecation OutdatedTaskDeprecator.new # instance +end +``` + +## Conditional Gating + +Pass `:if` / `:unless` to skip the deprecation action when the gate fails. Both accept a Symbol (method name), Proc/Lambda, or any callable, and are evaluated against the task instance via `Util.satisfied?`: + +```ruby +class OutdatedConnector < CMDx::Task + deprecation :error, if: -> { Rails.env.production? } + deprecation :log, unless: :tenant_grandfathered? + + private + + def tenant_grandfathered? + context.tenant&.grandfathered? + end +end +``` + +!!! note + + Only the **most recent** `deprecation` call wins — there's a single `@deprecation` per class. Combine modes with conditions inside a single Proc when you need branching. + +## Inheritance + +Subclasses inherit the parent's deprecation. Re-declare to override. `deprecation nil` is a read (returns the inherited value rather than clearing it), so opt out by passing a no-op callable — note that this still marks the result as `deprecated?` and emits `:task_deprecated`, it just suppresses the visible log/warn/raise: + +```ruby +class BaseLegacyTask < CMDx::Task + deprecation :log +end - # Instance - settings(deprecate: OutdatedTaskDeprecator.new) +class StillSupported < BaseLegacyTask + # inherits :log end + +class FullyRetired < BaseLegacyTask + deprecation :error +end + +class Excluded < BaseLegacyTask + deprecation ->(_) {} # opt out +end +``` + +## Telemetry + +When deprecation fires (and conditions pass), Runtime emits the `:task_deprecated` telemetry event before the action runs, and the resulting `Result` reports `deprecated?` as `true`: + +```ruby +CMDx.configure do |config| + config.telemetry.subscribe(:task_deprecated, ->(event) { + StatsD.increment("cmdx.deprecated", tags: ["task:#{event.task_class}"]) + }) +end + +result = ProcessLegacyFormat.execute +result.deprecated? #=> true ``` diff --git a/docs/exceptions.md b/docs/exceptions.md deleted file mode 100644 index e44a3cb0d..000000000 --- a/docs/exceptions.md +++ /dev/null @@ -1,131 +0,0 @@ -# Exceptions Reference - -CMDx defines a clear exception hierarchy for distinguishing between different failure types. Understanding this hierarchy is essential for writing correct `rescue` clauses. - -## Hierarchy - -``` -StandardError -└── CMDx::Error (alias: CMDx::Exception) - ├── CMDx::CoercionError - ├── CMDx::DeprecationError - ├── CMDx::UndefinedMethodError - ├── CMDx::ValidationError - └── CMDx::Fault - ├── CMDx::SkipFault - └── CMDx::FailFault - -Interrupt -└── CMDx::TimeoutError -``` - -## Exception Types - -### CMDx::Error - -Base class for all CMDx exceptions. Also aliased as `CMDx::Exception`. - -```ruby -rescue CMDx::Error => e - # Catches any CMDx-specific error -end -``` - -### CMDx::CoercionError - -Raised internally when a type coercion fails. Custom coercions must raise this to integrate with CMDx's error reporting. - -```ruby -class MoneyCoercion - def self.call(value, options = {}) - Money.parse(value) - rescue ArgumentError - raise CMDx::CoercionError, "could not convert into money" - end -end -``` - -### CMDx::ValidationError - -Raised internally when a custom validator rejects a value. Custom validators must raise this to integrate with CMDx's error reporting. - -```ruby -class PhoneValidator - def self.call(value, options = {}) - unless value.match?(/\A\+?[\d\s\-()]+\z/) - raise CMDx::ValidationError, "is not a valid phone number" - end - end -end -``` - -### CMDx::DeprecationError - -Raised when a task with `settings(deprecate: :raise)` is executed. See [Deprecation](deprecation.md). - -```ruby -begin - LegacyTask.execute(data: payload) -rescue CMDx::DeprecationError => e - puts "Task prohibited: #{e.message}" -end -``` - -### CMDx::UndefinedMethodError - -Raised when a task is executed without defining a `work` method. - -```ruby -class IncompleteTask < CMDx::Task - # No `work` method -end - -IncompleteTask.execute #=> raises CMDx::UndefinedMethodError -``` - -### CMDx::Fault - -Base class for execution interruptions raised by `execute!`. All faults carry a `result` with full execution context. See [Faults](interruptions/faults.md) for advanced matching. - -| Subclass | Triggered By | Raised When | -|----------|--------------|-------------| -| `CMDx::SkipFault` | `skip!` | Task was intentionally skipped | -| `CMDx::FailFault` | `fail!`, validation errors, exceptions | Task execution failed | - -```ruby -begin - MyTask.execute!(args) -rescue CMDx::FailFault => e - e.result #=> CMDx::Result with full execution data - e.task #=> The task instance - e.context #=> Task context - e.chain #=> Execution chain -rescue CMDx::SkipFault => e - e.result.reason #=> "Reason for skipping" -rescue CMDx::Fault => e - # Catch-all for any interruption -end -``` - -### CMDx::TimeoutError - -Raised by the [Timeout middleware](middlewares.md#timeout) when a task exceeds its time limit. - -!!! danger "Caution" - - `TimeoutError` inherits from `Interrupt`, **not** `StandardError`. This means `rescue StandardError` will **not** catch timeouts. You must rescue `CMDx::TimeoutError` or `Interrupt` explicitly. - -```ruby -begin - SlowTask.execute!(data: large_dataset) -rescue CMDx::TimeoutError => e - puts "Task timed out: #{e.message}" -rescue CMDx::FailFault => e - # Timeouts caught by `execute` are wrapped in a FailFault - puts "Task failed: #{e.result.reason}" -end -``` - -!!! note - - When using non-bang `execute`, timeouts are caught internally and converted to a failed result. The `TimeoutError` distinction matters primarily for `execute!` or custom middleware error handling. diff --git a/docs/getting_started.md b/docs/getting_started.md index 3bcc514da..8a3f6e110 100644 --- a/docs/getting_started.md +++ b/docs/getting_started.md @@ -14,21 +14,21 @@ CMDx is a Ruby framework for building maintainable, observable business logic th **Common challenges:** -- Inconsistent service object patterns across your codebase -- Black boxes make debugging a nightmare +- Inconsistent service object patterns across the codebase +- Opaque control flow makes debugging hard - Fragile error handling erodes confidence **What you get:** -- Consistent, standardized architecture +- A standardized task contract - Built-in flow control and error handling - Composable, reusable workflows -- Comprehensive logging for observability -- Attribute validation with type coercions +- Structured logging for observability +- Input validation with type coercions ## Requirements -- Ruby: MRI 3.1+ or JRuby 9.4+ +- Ruby: MRI 3.3+ or a compatible JRuby/TruffleRuby release - Dependencies: None Rails support is built-in, but it's framework-agnostic at its core. @@ -63,7 +63,7 @@ A self-contained example you can run in `irb` or a plain Ruby script — no Rail require "cmdx" class Greet < CMDx::Task - required :name, type: :string, presence: true + required :name, coerce: :string, presence: true def work context.greeting = "Hello, #{name}!" @@ -76,25 +76,27 @@ result.context.greeting #=> "Hello, World!" result = Greet.execute(name: "") result.failed? #=> true -result.reason #=> "Invalid" +result.reason #=> "name cannot be empty" +result.errors.to_h #=> { name: ["cannot be empty"] } ``` -From here, you can progressively add features as needed: +From here, layer in features as you need them: -| Need | Feature | Docs | -|------|---------|------| -| Type safety on inputs | [Coercions](attributes/coercions.md) | `type: :integer` | -| Input constraints | [Validations](attributes/validations.md) | `numeric: { min: 1 }` | -| Conditional stops | [Halt](interruptions/halt.md) | `skip!`, `fail!` | +| Need | Feature | Example | +|------|---------|---------| +| Type safety on inputs | [Coercions](inputs/coercions.md) | `coerce: :integer` | +| Input constraints | [Validations](inputs/validations.md) | `numeric: { min: 1 }` | +| Conditional stops | [Signals](interruptions/signals.md) | `skip!`, `fail!` | | Multi-task pipelines | [Workflows](workflows.md) | `include CMDx::Workflow` | | Cross-cutting concerns | [Middlewares](middlewares.md) | `register :middleware` | | Lifecycle hooks | [Callbacks](callbacks.md) | `on_success`, `before_execution` | -| Output contracts | [Returns](returns.md) | `returns :user, :token` | +| Output contracts | [Outputs](outputs.md) | `output :user, :token` | +| Retry policies | [Retries](retries.md) | `retry_on Net::OpenTimeout, limit: 3` | | Structured logs | [Logging](logging.md) | Automatic | ## The CERO Pattern -CMDx embraces the Compose, Execute, React, Observe (CERO, pronounced "zero") pattern—a simple yet powerful approach to building reliable business logic. +CMDx organizes business logic around the Compose, Execute, React, Observe (CERO, pronounced "zero") pattern. ```mermaid flowchart LR @@ -105,21 +107,21 @@ flowchart LR ### Compose -Build reusable, single-responsibility tasks with typed attributes, validation, and callbacks. Tasks can be chained together in workflows to create complex business processes from simple building blocks. +Build single-responsibility tasks with typed inputs, validation, and callbacks. Compose them into workflows to assemble larger processes from small, reusable pieces. === "Full Featured Task" ```ruby class AnalyzeMetrics < CMDx::Task - register :middleware, CMDx::Middlewares::Correlate, id: -> { Current.request_id } + retry_on Net::OpenTimeout, limit: 3, jitter: :exponential on_success :track_analysis_completion! - required :dataset_id, type: :integer, numeric: { min: 1 } + required :dataset_id, coerce: :integer, numeric: { min: 1 } optional :analysis_type, default: "standard" - returns :result, :analyzed_at + output :result, :analyzed_at def work if dataset.nil? @@ -159,12 +161,12 @@ Build reusable, single-responsibility tasks with typed attributes, validation, a ### Execute -Invoke tasks with a consistent API that always returns a result object. Execution automatically handles validation, type coercion, error handling, and logging. Arguments are validated and coerced before your task logic runs. +Every task invocation returns a `Result`. Runtime coerces and validates inputs, runs your `work`, handles exceptions, verifies declared outputs, and logs the outcome — automatically. === "With args" ```ruby - result = AnalyzeMetrics.execute(model: "blackbox", "sensitivity" => 3) + result = AnalyzeMetrics.execute(dataset_id: 42, analysis_type: "bayesian") ``` === "Without args" @@ -175,43 +177,41 @@ Invoke tasks with a consistent API that always returns a result object. Executio ### React -Every execution returns a result object with a clear outcome. Check the result's state (`success?`, `failed?`, `skipped?`) and access returned values, error messages, and metadata to make informed decisions. +Branch on the result's status (`success?`, `skipped?`, `failed?`) and read values, reasons, or metadata from it. See [Outcomes](outcomes/result.md) for the full surface. ```ruby if result.success? puts "Metrics analyzed at #{result.context.analyzed_at}" elsif result.skipped? - puts "Skipping analyzation due to: #{result.reason}" + puts "Skipped: #{result.reason}" elsif result.failed? - puts "Analyzation failed due to: #{result.reason} with code #{result.metadata[:code]}" + puts "Failed: #{result.reason} (code #{result.metadata[:code]})" end ``` ### Observe -Every task execution generates structured logs with execution chains, runtime metrics, and contextual metadata. Logs can be automatically correlated using chain IDs, making it easy to trace complex workflows and debug issues. +Every execution emits a structured log line with the chain id, task identity, state, status, reason, metadata, duration, and tags — enough to correlate nested tasks and reconstruct what happened. See [Logging](logging.md) for the full field reference. ```log -I, [2022-07-17T18:42:37.000000 #3784] INFO -- CMDx: -index=1 chain_id="018c2b95-23j4-2kj3-32kj-3n4jk3n4jknf" type="Task" class="SendAnalyzedEmail" state="complete" status="success" metadata={runtime: 347} +I, [2026-04-19T18:42:37.000000Z #3784] INFO -- cmdx: chain_id="018c2b95-b764-7fff-a1d2-..." chain_index=1 chain_root=false type="Task" task=SendAnalyzedEmail id="018c2b95-c091-..." state="complete" status="success" reason=nil metadata={} duration=347.21 ... -I, [2022-07-17T18:43:15.000000 #3784] INFO -- CMDx: -index=0 chain_id="018c2b95-b764-7615-a924-cc5b910ed1e5" type="Task" class="AnalyzeMetrics" state="complete" status="success" metadata={runtime: 187} +I, [2026-04-19T18:42:37.535000Z #3784] INFO -- cmdx: chain_id="018c2b95-b764-7fff-a1d2-..." chain_index=0 chain_root=true type="Task" task=AnalyzeMetrics id="018c2b95-b764-..." state="complete" status="success" reason=nil metadata={} duration=1872.04 ... ``` !!! note - This represents a log-only event-sourcing approach, enabling full traceability and a complete, time-ordered view of system behavior. + With a durable log sink, these lines double as a log-only event sourcing record — a time-ordered history of every task execution, its inputs, and its outcome. ## Domain Driven Design -CMDx facilitates Domain Driven Design (DDD) by making business processes explicit and structural. +CMDx makes business processes explicit and structural — a natural fit for Domain Driven Design (DDD). -- **Ubiquitous Language:** Task names like `ApproveLoan` or `ShipOrder` mirror the language of domain experts, creating a shared vocabulary that eliminates translation gaps between business requirements and code. +- **Ubiquitous Language:** Task names like `ApproveLoan` or `ShipOrder` mirror the language of domain experts. -- **Bounded Contexts:** Namespaces naturally enforce boundaries. `Billing::GenerateInvoice` and `Shipping::GenerateLabel` encapsulate logic within their specific domains, preventing leakage and "God objects." +- **Bounded Contexts:** Namespaces enforce boundaries — `Billing::GenerateInvoice` and `Shipping::GenerateLabel` keep logic within their domains. -- **Rich Domain Layer:** Move orchestration and rules out of Controllers and ActiveRecord models. Entities focus on state; CMDx tasks handle behavior. This separation prevents "Fat Models" and keeps business logic testable and isolated. +- **Rich Domain Layer:** Move orchestration out of Controllers and ActiveRecord models. Entities hold state; tasks hold behavior. Business logic stays testable and isolated. ## Task Generator @@ -245,7 +245,7 @@ The generator inherits from `ApplicationTask` if defined, falling back to `CMDx: ```ruby # app/tasks/application_task.rb class ApplicationTask < CMDx::Task - register :middleware, CMDx::Middlewares::Correlate + retry_on Net::OpenTimeout, Net::ReadTimeout, limit: 3, jitter: :exponential before_execution :set_request_context @@ -261,11 +261,10 @@ end Use **present tense verbs + noun** for task names, eg: `ModerateBlogPost`, `ScheduleAppointment`, `ValidateDocument` -## Type safety +## Documentation & Editor Support -CMDx includes built-in RBS (Ruby Type Signature) inline annotations throughout the codebase, providing type information for static analysis and editor support. +The codebase ships with comprehensive YARD annotations on every public class, method, and option. Combined with the structured DSL (`required`, `optional`, `output`, `coerce:`, `validate:`, `on_success`, ...), this gives you: -- **Type checking** — Catch type errors before runtime using tools like Steep or TypeProf -- **Better IDE support** — Enhanced autocomplete, navigation, and inline documentation -- **Self-documenting code** — Clear method signatures and return types -- **Refactoring confidence** — Type-aware refactoring reduces bugs +- **Self-documenting tasks** — declared inputs and outputs read like a contract +- **IDE awareness** — autocomplete and inline docs in editors that consume YARD (Solargraph, RubyMine, etc.) +- **Generated reference** — run `bundle exec yard doc` (or browse [the published docs](https://drexed.github.io/cmdx/api/index.html)) diff --git a/docs/index.md b/docs/index.md index 15c45ebcc..10b8ad87a 100644 --- a/docs/index.md +++ b/docs/index.md @@ -8,15 +8,15 @@ template: home.html ```ruby class ApproveLoan < CMDx::Task - register :middleware, CMDx::Middlewares::Runtime + register :middleware, DeeplI18nMiddleware - required :application_id, type: :integer + required :application_id, coerce: :integer optional :override_checks, default: false on_success :notify_applicant! - returns :approved_at + output :approved_at, presence: true def work if application.nil? diff --git a/docs/inputs/coercions.md b/docs/inputs/coercions.md new file mode 100644 index 000000000..976429405 --- /dev/null +++ b/docs/inputs/coercions.md @@ -0,0 +1,176 @@ +# Inputs - Coercions + +Automatically convert inputs to expected types using `coerce:`. + +See [Global Configuration](../configuration.md#coercions) for custom coercion setup. + +## Usage + +Use `coerce:` to enable automatic coercion on a declared input: + +```ruby +class ParseMetrics < CMDx::Task + # Coerce into a symbol + input :measurement_type, coerce: :symbol + + # Coerce into a rational, fall back to big decimal + input :value, coerce: %i[rational big_decimal] + + # Coerce with options + input :recorded_at, coerce: { date: { strptime: "%m-%d-%Y" } } + + def work + measurement_type #=> :temperature + recorded_at #=> #<Date 2024-01-23> + value #=> Rational(493, 5) + end +end + +ParseMetrics.execute( + measurement_type: "temperature", + recorded_at: "01-23-2024", + value: "98.6" +) +``` + +!!! tip + + Pass an array to `coerce:` to attempt multiple types in order. CMDx returns the first successful coercion. + +## Built-in Coercions + +| Type | Options | Description | Examples | +|------|---------|-------------|----------| +| `:array` | | Array conversion with JSON support; non-array JSON results fall back to wrapping | `"val"` → `["val"]`<br>`"[1,2,3]"` → `[1, 2, 3]` | +| `:big_decimal` | `:precision` (default `14`) | High-precision decimal | `"123.456"` → `BigDecimal("123.456")` | +| `:boolean` | | Boolean with text patterns | `"yes"` → `true`, `"no"` → `false` | +| `:complex` | `:imaginary` (default `0`) | Complex numbers | `"1+2i"` → `Complex(1, 2)` | +| `:date` | `:strptime` | Date objects | `"2024-01-23"` → `Date.new(2024, 1, 23)` | +| `:date_time` | `:strptime` | DateTime objects | `"2024-01-23 10:30"` → `DateTime.new(2024, 1, 23, 10, 30)` | +| `:float` | | Floating-point numbers | `"123.45"` → `123.45` | +| `:hash` | | Hash conversion with JSON support (`nil` → `{}`) | `'{"a":1}'` → `{"a" => 1}` | +| `:integer` | | Integer via `Kernel#Integer` (hex/octal with explicit prefix) | `"0xFF"` → `255`, `"0o77"` → `63` | +| `:rational` | `:denominator` (default `1`) | Rational numbers | `"1/2"` → `Rational(1, 2)` | +| `:string` | | String conversion | `123` → `"123"` | +| `:symbol` | | Symbol conversion | `"abc"` → `:abc` | +| `:time` | `:strptime` | Time objects; `Numeric` treated as epoch seconds | `"2024-01-23 10:30"` → `Time.new(2024, 1, 23, 10, 30)` | + +## Declarations + +!!! warning "Important" + + Custom coercions must return the coerced value on success or `CMDx::Coercions::Failure.new("message")` on failure. Returning a `Failure` records the message on `task.errors` under the input's name. + +!!! note "Call signatures" + + Registered coercions (via `register :coercion, ...`) receive `(value, **options)` and pick up per-declaration options like `precision:` or `strptime:`. Inline `:coerce` callables (see below) instead receive `(value, task)` and have no options hash. + +### Proc or Lambda + +Use anonymous functions for simple coercion logic: + +```ruby +class TransformCoordinates < CMDx::Task + # Proc + register :coercion, :geolocation, proc do |value, **options| + Geolocation(value) + rescue StandardError + CMDx::Coercions::Failure.new("could not convert into a geolocation") + end + + # Lambda + register :coercion, :geolocation, ->(value, **options) { + begin + Geolocation(value) + rescue StandardError + CMDx::Coercions::Failure.new("could not convert into a geolocation") + end + } +end +``` + +### Class or Module + +Register custom coercion logic for specialized type handling: + +```ruby +class GeolocationCoercion + def self.call(value, **options) + Geolocation(value) + rescue StandardError + CMDx::Coercions::Failure.new("could not convert into a geolocation") + end +end + +class TransformCoordinates < CMDx::Task + register :coercion, :geolocation, GeolocationCoercion + + input :latitude, coerce: :geolocation +end +``` + +### Inline `:coerce` callable + +For one-off coercions that don't need a registered name, pass a `Symbol` (instance method), `Proc`, or any callable directly to `coerce:`. Symbols receive `(value)`, Procs are `instance_exec`'d with `(value)` (`self` is the task), and `#call`-able objects receive `(value, task)`: + +```ruby +class TransformCoordinates < CMDx::Task + input :latitude, coerce: :parse_lat # instance method + input :longitude, coerce: ->(v) { Float(v).round(6) } # lambda + input :elevation, coerce: ElevationParser # callable: call(value, task) + + private + + def parse_lat(value) + Float(value).clamp(-90.0, 90.0) + end +end + +class ElevationParser + def self.call(value, task) + Float(value).round(task.context.precision || 2) + end +end +``` + +## Removals + +Remove unwanted coercions: + +!!! warning + + Each `deregister` call removes one coercion. Use multiple calls for batch removals. + +```ruby +class TransformCoordinates < CMDx::Task + deregister :coercion, :geolocation +end +``` + +## Error Handling + +Coercion failures accumulate on `task.errors`. When resolution finishes and errors exist, Runtime throws a failed signal: the joined sentence becomes `result.reason`; structured details live on `result.errors`. + +```ruby +class AnalyzePerformance < CMDx::Task + input :iterations, coerce: :integer + input :score, coerce: %i[float big_decimal] + + def work + # Your logic here... + end +end + +result = AnalyzePerformance.execute( + iterations: "not-a-number", + score: "invalid-float" +) + +result.state #=> "interrupted" +result.status #=> "failed" +result.reason #=> "iterations could not coerce into an integer. score could not coerce into one of: float, big decimal" +result.errors.to_h #=> { + # iterations: ["could not coerce into an integer"], + # score: ["could not coerce into one of: float, big decimal"] + # } +``` diff --git a/docs/inputs/defaults.md b/docs/inputs/defaults.md new file mode 100644 index 000000000..b4abcbfd5 --- /dev/null +++ b/docs/inputs/defaults.md @@ -0,0 +1,97 @@ +# Inputs - Defaults + +Provide fallback values for optional inputs. Defaults kick in when values aren't provided or are `nil`. + +## Declarations + +Defaults work seamlessly with coercions, validations, and nested inputs: + +### Static Values + +```ruby +class OptimizeDatabase < CMDx::Task + input :strategy, default: :incremental + input :level, default: "basic" + input :notify_admin, default: true + input :timeout_minutes, default: 30 + input :indexes, default: [] + input :options, default: {} + + def work + strategy #=> :incremental + level #=> "basic" + notify_admin #=> true + timeout_minutes #=> 30 + indexes #=> [] + options #=> {} + end +end +``` + +### Symbol References + +Reference instance methods by symbol for dynamic default values: + +```ruby +class ProcessAnalytics < CMDx::Task + input :granularity, default: :default_granularity + + def work + # Your logic here... + end + + private + + def default_granularity + Current.user.premium? ? "hourly" : "daily" + end +end +``` + +### Proc or Lambda + +Use anonymous functions for dynamic default values: + +```ruby +class CacheContent < CMDx::Task + # Proc + input :expire_hours, default: proc { Current.tenant.cache_duration || 24 } + + # Lambda + input :compression, default: -> { Current.tenant.premium? ? "gzip" : "none" } +end +``` + +### Class or Module + +Any object responding to `#call(task)` works as a default: + +```ruby +class TenantDefaults + def self.call(task) + Current.tenant.cache_duration || 24 + end +end + +class CacheContent < CMDx::Task + input :expire_hours, default: TenantDefaults +end +``` + +## Coercions and Validations + +Defaults flow through the same pipeline as provided values — coercion, transform, then validation: + +```ruby +class ScheduleBackup < CMDx::Task + # Default is coerced through :integer + input :retention_days, default: "7", coerce: :integer + + # Default is validated against the inclusion list + input :frequency, default: "daily", inclusion: { in: %w[hourly daily weekly monthly] } +end +``` + +!!! note + + Defaults only apply when the resolved value is `nil`. An explicitly provided `nil` is treated as missing and the default fires. diff --git a/docs/inputs/definitions.md b/docs/inputs/definitions.md new file mode 100644 index 000000000..3fc54c95f --- /dev/null +++ b/docs/inputs/definitions.md @@ -0,0 +1,354 @@ +# Inputs - Definitions + +Inputs declare the task's interface. Each declaration generates an accessor and wires up coercion, validation, defaults, transforms, and `:if`/`:unless` gates. + +## Declarations + +!!! warning "Important" + + Inputs are order-dependent. If one input references another as a source or condition, the referenced input must be defined first. + +```ruby +# Correct: credentials defined before connection_string +required :credentials, source: :database_config +input :connection_string, source: :credentials + +# Wrong: connection_string references credentials before it exists +input :connection_string, source: :credentials +required :credentials, source: :database_config +``` + +!!! warning "Important" + + Input names that conflict with existing Ruby or CMDx methods will raise an error. Use `:as`, `:prefix`, or `:suffix` to resolve naming conflicts. See [Naming](naming.md). + +!!! tip + + Prefer the `required` and `optional` shorthands over `inputs(..., required: …)` — they read better and make intent obvious at a glance. + +### Optional + +Optional inputs return `nil` when not provided. + +```ruby +class ScheduleEvent < CMDx::Task + input :title + inputs :duration, :location + + # Shorthand for inputs ..., required: false (preferred) + optional :description + optional :visibility, :attendees + + def work + title #=> "Team Standup" + duration #=> 30 + location #=> nil + description #=> nil + visibility #=> nil + attendees #=> ["alice@company.com", "bob@company.com"] + end +end + +# Inputs passed as keyword arguments +ScheduleEvent.execute( + title: "Team Standup", + duration: 30, + attendees: ["alice@company.com", "bob@company.com"] +) +``` + +### Required + +Required inputs must be provided in call arguments or task execution will fail. + +```ruby +class PublishArticle < CMDx::Task + input :title, required: true + inputs :content, :author_id, required: true + + # Shorthand for inputs ..., required: true (preferred) + required :category + required :status, :tags + + # Conditionally required + required :publisher, if: :magazine? + input :approver, required: true, unless: proc { status == :published } + + def work + title #=> "Getting Started with Ruby" + content #=> "This is a comprehensive guide..." + author_id #=> 42 + category #=> "programming" + status #=> :published + tags #=> ["ruby", "beginner"] + publisher #=> "Eastbay" + approver #=> #<Editor ...> + end + + private + + def magazine? + context.title.end_with?("[M]") + end +end +``` + +!!! note + + When a required input's condition evaluates to `false`, the input behaves as optional. All other input features such as coercions, validations, defaults, and transformations still apply normally. + +## Removals + +Remove inherited or previously defined inputs and their accessor methods via `deregister`. The lookup key is always the **original input name** — `:as`, `:prefix`, and `:suffix` only affect the generated accessor, not the registry key: + +```ruby +class ApplicationTask < CMDx::Task + required :tenant_id + optional :debug_mode + required :user_id, as: :customer_id # accessor: customer_id +end + +class PublicTask < ApplicationTask + deregister :input, :tenant_id + deregister :input, :debug_mode + deregister :input, :user_id # deregister by original name, NOT :customer_id + + def work + # tenant_id, debug_mode, and user_id (customer_id) are no longer defined + end +end +``` + +!!! warning "Important" + + `deregister :input, *names` accepts one or more names in a single call. Removing an input also removes any nested children defined under it. Passing a name that wasn't registered raises `NoMethodError` — make sure every name you pass exists on the inheritance chain. + +## Introspection + +Inspect the full input schema for tooling, documentation generation, or debugging: + +```ruby +class CreateUser < CMDx::Task + required :email, coerce: :string, format: /\A.+@.+\z/ + optional :role, default: "member", inclusion: { in: %w[member admin] } +end + +CreateUser.inputs_schema +#=> { +# email: { name: :email, description: nil, required: true, +# options: { required: true, coerce: :string, format: /\A.+@.+\z/ }, +# children: [] }, +# role: { name: :role, ... } +# } +``` + +Each entry exposes `:name` (the accessor name, post-`:as`/`:prefix`/`:suffix`), `:description`, `:required`, the raw declaration `:options`, and any nested `:children` recursively. + +## Sources + +Inputs read from any accessible object — not just context. The default source is `:context`; override with `source:` to pull data from a method, proc, callable class, or another already-defined input: + +### Context + +```ruby +class BackupDatabase < CMDx::Task + # Default source is :context + required :database_name + optional :compression_level + + # Explicitly specify context source + input :backup_path, source: :context + + def work + database_name #=> context.database_name + backup_path #=> context.backup_path + compression_level #=> context.compression_level + end +end +``` + +### Symbol References + +Reference instance methods by symbol for dynamic source values: + +```ruby +class BackupDatabase < CMDx::Task + inputs :host, :credentials, source: :database_config + + # Access from declared inputs + input :connection_string, source: :credentials + + def work + # Your logic here... + end + + private + + def database_config + @database_config ||= DatabaseConfig.find(context.database_name) + end +end +``` + +### Proc or Lambda + +Use anonymous functions for dynamic source values: + +```ruby +class BackupDatabase < CMDx::Task + # Proc + input :timestamp, source: proc { Time.current } + + # Lambda + input :server, source: -> { Current.server } +end +``` + +### Class or Module + +For complex source logic, use classes or modules: + +```ruby +class DatabaseResolver + def self.call(task) + Database.find(task.context.database_name) + end +end + +class BackupDatabase < CMDx::Task + # Class or Module + input :schema, source: DatabaseResolver + + # Instance + input :metadata, source: DatabaseResolver.new +end +``` + +## Description + +Add metadata to inputs for documentation or introspection purposes. + +```ruby +class CreateUser < CMDx::Task + required :email, description: "The user's primary email address" + + # Alias :desc + optional :phone, desc: "Primary contact number" + + # Bulk definition - description applies to all + inputs :first_name, :last_name, desc: "Part of user's legal name" +end +``` + +## Nesting + +Build complex structures with nested inputs. Children inherit their parent as source and support all input options: + +!!! note + + Nested inputs support all features: naming, coercions, validations, defaults, and more. + +```ruby +class ConfigureServer < CMDx::Task + # Required parent with required children + required :network_config do + required :hostname, :port, :protocol, :subnet + optional :load_balancer + input :firewall_rules + end + + # Optional parent with conditional children + optional :ssl_config do + required :certificate_path, :private_key # Only required if ssl_config provided + optional :enable_http2, prefix: true + end + + # Multi-level nesting + input :monitoring do + required :provider + + optional :alerting do + required :threshold_percentage + optional :notification_channel + end + end + + def work + network_config #=> { hostname: "api.company.com" ... } + hostname #=> "api.company.com" + load_balancer #=> nil + end +end + +ConfigureServer.execute( + server_id: "srv-001", + network_config: { + hostname: "api.company.com", + port: 443, + protocol: "https", + subnet: "10.0.1.0/24", + firewall_rules: "allow_web_traffic" + }, + monitoring: { + provider: "datadog", + alerting: { + threshold_percentage: 85.0, + notification_channel: "slack" + } + } +) +``` + +!!! warning "Important" + + Child requirements only apply when the parent is provided — perfect for optional structures. + +## Error Handling + +Resolution failures (missing required inputs, coercion failures, validator failures) accumulate on `task.errors`. When resolution finishes and errors exist, Runtime throws a failed signal: the joined sentence becomes `result.reason`; the structured map is exposed on `result.errors`. + +!!! note + + Nested inputs are only resolved when their parent is present and non-`nil`. + +```ruby +class ConfigureServer < CMDx::Task + required :server_id, :environment + required :network_config do + required :hostname, :port + end + + def work + # Your logic here... + end +end + +# Missing required top-level inputs +result = ConfigureServer.execute(server_id: "srv-001") + +result.state #=> "interrupted" +result.status #=> "failed" +result.reason #=> "environment is required. network_config is required" +result.metadata #=> {} +result.errors.to_h #=> { + # environment: ["is required"], + # network_config: ["is required"] + # } +result.errors.full_messages +#=> { +# environment: ["environment is required"], +# network_config: ["network_config is required"] +# } + +# Missing required nested inputs +result = ConfigureServer.execute( + server_id: "srv-001", + environment: "production", + network_config: { hostname: "api.company.com" } # Missing port +) + +result.state #=> "interrupted" +result.status #=> "failed" +result.reason #=> "port is required" +result.errors.to_h #=> { port: ["is required"] } +``` diff --git a/docs/inputs/naming.md b/docs/inputs/naming.md new file mode 100644 index 000000000..7b0e2a3bc --- /dev/null +++ b/docs/inputs/naming.md @@ -0,0 +1,76 @@ +# Inputs - Naming + +Customize accessor method names to avoid conflicts and improve clarity. Affixing changes only the generated reader methods — not the original input names used by callers. + +!!! note + + Use naming when inputs conflict with existing methods or need better clarity in your code. + +## Prefix + +Adds a prefix to the generated accessor method name. + +```ruby +class GenerateReport < CMDx::Task + # Dynamic from input source (defaults to :context) + input :template, prefix: true + + # Static + input :format, prefix: "report_" + + # Combined with a custom :source — prefix derives from the source name + input :owner, source: :account, prefix: true + + def work + context_template #=> "monthly_sales" + report_format #=> "pdf" + account_owner #=> account.owner + end +end + +# Inputs passed under their original names +GenerateReport.execute(template: "monthly_sales", format: "pdf") +``` + +## Suffix + +Adds a suffix to the generated accessor method name. + +```ruby +class DeployApplication < CMDx::Task + # Dynamic from input source (defaults to :context) + input :branch, suffix: true + + # Static + input :version, suffix: "_tag" + + def work + branch_context #=> "main" + version_tag #=> "v1.2.3" + end +end + +# Inputs passed under their original names +DeployApplication.execute(branch: "main", version: "v1.2.3") +``` + +## As + +Completely renames the generated accessor method. Useful when the natural input name collides with a reserved word or an existing method: + +```ruby +class ScheduleMaintenance < CMDx::Task + input :scheduled_at, as: :scheduled_time + + def work + scheduled_time #=> #<DateTime> + end +end + +# Input still passed under its original name +ScheduleMaintenance.execute(scheduled_at: DateTime.new(2024, 12, 15, 2, 0, 0)) +``` + +!!! warning "Important" + + `:as` overrides `:prefix` and `:suffix` — when all three are given, `:as` wins and the affixes are ignored. diff --git a/docs/inputs/transformations.md b/docs/inputs/transformations.md new file mode 100644 index 000000000..3f84f417b --- /dev/null +++ b/docs/inputs/transformations.md @@ -0,0 +1,82 @@ +# Inputs - Transformations + +Modify input values after coercion but before validation. Perfect for normalization, formatting, and data cleanup. + +## Processing Pipeline + +Each input flows through a fixed pipeline: + +```mermaid +flowchart LR + Source --> Default --> Coerce --> Transform --> Validate +``` + +| Stage | Description | +|-------|-------------| +| **Source** | Resolve value from context, method, proc, or callable | +| **Default** | Apply `default:` when the resolved value is `nil` | +| **Coerce** | Convert via `coerce:` (e.g., string → integer) | +| **Transform** | Apply `transform:` to the coerced value | +| **Validate** | Run validators (presence, format, etc.) on the final value | + +This means transformations receive already-coerced values, and validators see the final transformed output. + +## Declarations + +### Symbol References + +Reference a method by symbol. The value's own method takes precedence (called as `value.send(symbol)` with no arguments); if the value doesn't respond to it, CMDx falls back to `task.send(symbol, value)`: + +```ruby +class ProcessAnalytics < CMDx::Task + input :options, transform: :compact_blank # value.compact_blank or task#compact_blank(value) +end +``` + +### Proc or Lambda + +Use anonymous functions for inline transformations: + +```ruby +class CacheContent < CMDx::Task + # Proc + input :expire_hours, transform: proc { |v| v * 2 } + + # Lambda + input :compression, transform: ->(v) { v.to_s.upcase.strip[0..2] } +end +``` + +### Class or Module + +Use any object that responds to `#call(value, task)` for reusable transformation logic: + +```ruby +class EmailNormalizer + def call(value, task) + value.to_s.downcase.strip + end +end + +class ProcessContacts < CMDx::Task + # Class or Module + input :email, transform: EmailNormalizer + + # Instance + input :email, transform: EmailNormalizer.new +end +``` + +## Pipeline Position + +Validations run on transformed values, ensuring data consistency: + +```ruby +class ScheduleBackup < CMDx::Task + # Coerce then clamp + input :retention_days, coerce: :integer, transform: proc { |v| v.clamp(1, 5) } + + # Downcase then validate inclusion + optional :frequency, transform: :downcase, inclusion: { in: %w[hourly daily weekly monthly] } +end +``` diff --git a/docs/inputs/validations.md b/docs/inputs/validations.md new file mode 100644 index 000000000..4819d5f43 --- /dev/null +++ b/docs/inputs/validations.md @@ -0,0 +1,376 @@ +# Inputs - Validations + +Ensure inputs meet requirements before execution. Validations run after coercions and transformations, giving you declarative data integrity checks. + +See [Global Configuration](../configuration.md#validators) for custom validator setup. + +## Usage + +Define validation rules on inputs to enforce data requirements: + +```ruby +class ProcessSubscription < CMDx::Task + # Required field with presence validation + input :user_id, presence: true + + # String with length constraints + optional :preferences, length: { min: 10, max: 500 } + + # Numeric range validation + required :tier_level, inclusion: { in: 1..5 } + + # Format validation for email + input :contact_email, format: /\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i + + def work + user_id #=> "98765" + preferences #=> "Send weekly digest emails" + tier_level #=> 3 + contact_email #=> "user@company.com" + end +end + +ProcessSubscription.execute( + user_id: "98765", + preferences: "Send weekly digest emails", + tier_level: 3, + contact_email: "user@company.com" +) +``` + +## Built-in Validators + +### Common Options + +`:allow_nil` and `:message` cover the simple cases: + +```ruby +class ProcessProduct < CMDx::Task + input :tier_level, inclusion: { in: 1..5, allow_nil: true } + + input :title, length: { within: 5..100, message: "must be in optimal size" } +end +``` + +`:if` / `:unless` gate validators on Symbol, Proc, or `#call`-able objects: + +```ruby +class ProcessProduct < CMDx::Task + # Proc: instance_exec'd on task; arg = value + optional :contact_email, format: { + with: /\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i, + if: ->(value) { value.include?("@") } + } + + # Symbol: invoked as task.product_sunsetted?(value) + required :status, exclusion: { in: %w[recalled archived], unless: :product_sunsetted? } + + private + + def product_sunsetted?(value) + context.company.out_of_business? || value == "deprecated" + end +end +``` + +This list of options is available to all built-in validators: + +| Option | Description | +|--------|-------------| +| `:allow_nil` | Skip validation when value is `nil` | +| `:if` | Symbol, Proc, or callable gate (see table below) — must evaluate truthy for validation to run | +| `:unless` | Symbol, Proc, or callable gate (see table below) — must evaluate falsy for validation to run | +| `:message` | Custom error message for validation failures | + +| `:if` / `:unless` form | How it's invoked | Effective signature | +|------------------------|------------------|---------------------| +| `Symbol` (e.g. `:method_name`) | `task.send(method_name, value)` | `def method_name(value)` | +| `Proc` / lambda | `task.instance_exec(value, &proc)` (`self` is the task) | `->(value) { ... }` | +| `#call`-able object/class | `callable.call(task, value)` | `def call(task, value)` | + +!!! note + + Short-form values are normalized before reaching any validator: a `Hash` passes through as options, an `Array` becomes `{ in: array }`, a `Regexp` becomes `{ with: regexp }`, `true` is `{}`, and `false`/`nil` skips the validator entirely. + +### Absence + +```ruby +class CreateUser < CMDx::Task + input :honey_pot, absence: true + # Or with a custom message: absence: { message: "must be empty" } + + def work + # Your logic here... + end +end +``` + +| Options | Description | +|---------|-------------| +| `true` | Ensures value is nil, empty string, empty collection, or whitespace-only | + +### Exclusion + +```ruby +class ProcessProduct < CMDx::Task + input :status, exclusion: { in: %w[recalled archived] } + + def work + # Your logic here... + end +end +``` + +| Options | Description | +|---------|-------------| +| `:in` | The collection of forbidden values or range | +| `:within` | Alias for :in option | +| `:of_message` | Custom message for discrete value exclusions | +| `:in_message` | Custom message for range-based exclusions | +| `:within_message` | Alias for :in_message option | + +### Format + +```ruby +class ProcessProduct < CMDx::Task + input :sku, format: /\A[A-Z]{3}-[0-9]{4}\z/ + + input :sku, format: { with: /\A[A-Z]{3}-[0-9]{4}\z/ } + + def work + # Your logic here... + end +end +``` + +| Options | Description | +|---------|-------------| +| `:with` | Regex pattern that the value must match | +| `:without` | Regex pattern that the value must not match | + +### Inclusion + +```ruby +class ProcessProduct < CMDx::Task + input :availability, inclusion: { in: %w[available limited] } + + def work + # Your logic here... + end +end +``` + +| Options | Description | +|---------|-------------| +| `:in` | The collection of allowed values or range | +| `:within` | Alias for :in option | +| `:of_message` | Custom message for discrete value inclusions | +| `:in_message` | Custom message for range-based inclusions | +| `:within_message` | Alias for :in_message option | + +### Length + +```ruby +class CreateBlogPost < CMDx::Task + input :title, length: { within: 5..100 } + + def work + # Your logic here... + end +end +``` + +| Options | Description | +|---------|-------------| +| `:within` | Range that the length must fall within (inclusive) | +| `:not_within` | Range that the length must not fall within | +| `:in` | Alias for :within | +| `:not_in` | Alias for :not_within | +| `:min` / `:gte` | Minimum allowed length (inclusive, >=) | +| `:max` / `:lte` | Maximum allowed length (inclusive, <=) | +| `:gt` | Length must be strictly greater than value | +| `:lt` | Length must be strictly less than value | +| `:is` / `:eq` | Exact required length | +| `:is_not` / `:not_eq` | Length that is not allowed | +| `:nil_message` | Custom message when value does not respond to `#length` | + +Each rule supports a matching `<rule>_message` override (e.g. `:min_message`, `:within_message`, `:gt_message`); aliases share their target's message key (e.g. `:gte_message` → `:min_message`). + +### Numeric + +```ruby +class CreateBlogPost < CMDx::Task + input :word_count, numeric: { min: 100 } + + def work + # Your logic here... + end +end +``` + +| Options | Description | +|---------|-------------| +| `:within` | Range that the value must fall within (inclusive) | +| `:not_within` | Range that the value must not fall within | +| `:in` | Alias for :within option | +| `:not_in` | Alias for :not_within option | +| `:min` / `:gte` | Minimum allowed value (inclusive, >=) | +| `:max` / `:lte` | Maximum allowed value (inclusive, <=) | +| `:gt` | Value must be strictly greater than bound | +| `:lt` | Value must be strictly less than bound | +| `:is` / `:eq` | Exact value that must match | +| `:is_not` / `:not_eq` | Value that must not match | +| `:nil_message` | Custom message when value is `nil` | + +Each rule supports a matching `<rule>_message` override (e.g. `:min_message`, `:within_message`, `:gt_message`); aliases share their target's message key (e.g. `:gte_message` → `:min_message`). + +### Presence + +```ruby +class CreateBlogPost < CMDx::Task + input :content, presence: true + # Or with a custom message: presence: { message: "cannot be blank" } + + def work + # Your logic here... + end +end +``` + +| Options | Description | +|---------|-------------| +| `true` | Ensures value is not nil, empty collection, or whitespace-only string | + +## Declarations + +!!! warning "Important" + + Return `CMDx::Validators::Failure.new("message")` to mark the value invalid; any other return value (including `nil`) is treated as success. Returning a `Failure` records the message on `task.errors` under the input's name. + +### Proc or Lambda + +Use anonymous functions for simple validation logic: + +```ruby +class SetupApplication < CMDx::Task + # Proc + register :validator, :api_key, proc do |value, options = {}| + unless value.match?(/\A[a-zA-Z0-9]{32}\z/) + CMDx::Validators::Failure.new(options[:message] || "invalid API key format") + end + end + + # Lambda + register :validator, :api_key, ->(value, options = {}) { + unless value.match?(/\A[a-zA-Z0-9]{32}\z/) + CMDx::Validators::Failure.new(options[:message] || "invalid API key format") + end + } +end +``` + +### Class or Module + +Register custom validation logic for specialized requirements: + +```ruby +class ApiKeyValidator + def self.call(value, options = {}) + return if value.match?(/\A[a-zA-Z0-9]{32}\z/) + + CMDx::Validators::Failure.new(options[:message] || "invalid API key format") + end +end + +class SetupApplication < CMDx::Task + register :validator, :api_key, ApiKeyValidator + + input :access_key, api_key: true +end +``` + +### Inline `:validate` callable + +For one-off validations that don't need a registered name, pass a `Symbol` (instance method), `Proc`, or any callable directly to `validate:`. Pass an array to chain several. Symbols receive `(value)`, Procs are `instance_exec`'d with `(value)` (`self` is the task), and `#call`-able objects receive `(value, task)`: + +!!! warning "Arity asymmetry" + + `:if` / `:unless` callables receive `(task, value)`, but inline `:validate` callables receive `(value, task)`. The arguments are swapped — mirror the same gotcha that applies to inline `:coerce`. + +```ruby +class CreateUser < CMDx::Task + input :slug, validate: ->(v) { + CMDx::Validators::Failure.new("must be lowercase") unless v == v.downcase + } + + input :handle, validate: [:not_reserved, SlugReservationCheck] + + private + + def not_reserved(value) + return if %w[admin root].exclude?(value) + + CMDx::Validators::Failure.new("is reserved") + end +end + +class SlugReservationCheck + def self.call(value, task) + task.context.reserved_slugs.include?(value) && + CMDx::Validators::Failure.new("is reserved") + end +end +``` + +## Removals + +Remove unwanted validators: + +!!! warning + + Each `deregister` call removes one validator. Use multiple calls for batch removals. + +```ruby +class SetupApplication < CMDx::Task + deregister :validator, :api_key +end +``` + +## Error Handling + +Validation failures accumulate on `task.errors`. When resolution finishes and errors exist, Runtime throws a failed signal: the joined sentence becomes `result.reason`; the structured map is exposed on `result.errors`. + +```ruby +class CreateProject < CMDx::Task + input :project_name, + presence: true, + length: { min: 3, max: 50 } + optional :budget, + numeric: { min: 1000, max: 1_000_000 } + required :priority, + inclusion: { in: %i[low medium high] } + input :contact_email, + format: /\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i + + def work + # Your logic here... + end +end + +result = CreateProject.execute( + project_name: "AB", # Too short + budget: 500, # Too low + priority: :urgent, # Not in allowed list + contact_email: "invalid-email" # Invalid format +) + +result.state #=> "interrupted" +result.status #=> "failed" +result.reason #=> "project_name length must be within 3 and 50. budget must be within 1000 and 1000000. priority must be one of: :low, :medium, :high. contact_email is an invalid format" +result.errors.to_h #=> { + # project_name: ["length must be within 3 and 50"], + # budget: ["must be within 1000 and 1000000"], + # priority: ["must be one of: :low, :medium, :high"], + # contact_email: ["is an invalid format"] + # } +``` diff --git a/docs/internationalization.md b/docs/internationalization.md index eefe12e05..2d415298c 100644 --- a/docs/internationalization.md +++ b/docs/internationalization.md @@ -1,33 +1,70 @@ # Internationalization (i18n) -CMDx supports 90+ languages out of the box for all error messages, validations, coercions, and faults. Error messages automatically adapt to the current `I18n.locale`, making it easy to build applications for global audiences. +All built-in messages — coercion errors, validation errors, output verification, required-input errors, and the fallback fault reason — are routed through `CMDx::I18nProxy`. When the `i18n` gem is loaded, CMDx delegates to `I18n.translate` and messages adapt automatically to the current `I18n.locale`. Otherwise, CMDx loads the YAML for `config.default_locale` in-process and percent-interpolates. + +CMDx itself ships only `en`. Install [cmdx-i18n](https://github.com/drexed/cmdx-i18n) for 85+ additional translations, or register your own locale directory (see [Custom Locale Paths](#custom-locale-paths)). ## Usage -All error messages are automatically localized based on your current locale: +All built-in messages are localized via the current locale: ```ruby class ProcessQuote < CMDx::Task - attribute :price, type: :float + required :price, coerce: :float def work - # Your logic here... + # ... end end I18n.with_locale(:fr) do result = ProcessQuote.execute(price: "invalid") - result.metadata[:errors][:messages][:price] #=> ["impossible de contraindre en float"] + result.failed? #=> true + result.errors[:price] #=> ["impossible de contraindre en float"] + result.reason #=> "price impossible de contraindre en float" end ``` +!!! note + + Coercion and validation failures accumulate on `task.errors` (a `CMDx::Errors` instance). `result.reason` is built from `errors.to_s` (`Errors#full_messages` joined with `". "`). + +## Translation Keys + +All CMDx built-ins live under the `cmdx.*` namespace. Override any key in your own locale files to customize messages app-wide: + +| Key | Used by | +|-----|---------| +| `cmdx.attributes.required` | Missing required input | +| `cmdx.coercions.into_a` / `into_an` | Single-type coercion failure (`%{type}`) | +| `cmdx.coercions.into_any` | Multi-type coercion failure (`%{types}`) | +| `cmdx.outputs.missing` | Declared output not set on context | +| `cmdx.reasons.unspecified` | Fallback fault reason | +| `cmdx.types.<name>` | Human-readable coercion type names (`array`, `big_decimal`, `boolean`, `complex`, `date`, `date_time`, `float`, `hash`, `integer`, `rational`, `string`, `symbol`, `time`) | +| `cmdx.validators.absence` / `presence` / `format` | Standalone validator messages | +| `cmdx.validators.inclusion.{of,within}` | Inclusion validator messages | +| `cmdx.validators.exclusion.{of,within}` | Exclusion validator messages | +| `cmdx.validators.length.{is,is_not,min,max,gt,lt,within,not_within,nil_value}` | Length validator messages | +| `cmdx.validators.numeric.{is,is_not,min,max,gt,lt,within,not_within,nil_value}` | Numeric validator messages | + +!!! tip + + Prefer the per-input `:message` / `:<rule>_message` option (see [Validations](inputs/validations.md#common-options)) when you only need to customize one attribute. Overriding the `cmdx.*` key changes the message everywhere. + ## Configuration -CMDx uses the `I18n` gem for localization. In Rails, locales load automatically. +### Rails -### Default Locale +The CMDx railtie appends its bundled locale files to `I18n.load_path` on boot — but only for locales listed in `config.i18n.available_locales`, so you don't pay for translations you don't ship: -When the `I18n` gem is not available (e.g. plain Ruby scripts), CMDx resolves built-in messages from its own locale files using the `default_locale` setting: +```ruby +# config/application.rb +config.i18n.available_locales = [:en, :fr, :es] +``` + +### Default Locale (plain Ruby) + +Without the `i18n` gem (e.g. scripts, CLIs, background workers with no Rails), CMDx loads the YAML for `config.default_locale` directly: ```ruby CMDx.configure do |config| @@ -35,104 +72,26 @@ CMDx.configure do |config| end ``` -See [Configuration](configuration.md#default-locale) for more details. +Only one locale is active at a time in this mode — there is no `I18n.with_locale` equivalent. If the key is absent, `I18nProxy#translate` returns `"Translation missing: <key>"`. See [Configuration](configuration.md#default-locale) for more details. -### Copy Locale Files +### Custom Locale Paths -Copy locale files to your Rails application's `config/locales` directory: +Register additional directories of `<locale>.yml` files with `I18nProxy.register`. Later registrations take precedence during deep-merge, so you can override individual keys without copying the whole file: -```bash -rails generate cmdx:locale [LOCALE] +```ruby +# lib/locales/en.yml +en: + cmdx: + attributes: + required: "is mandatory" -# Eg: generate french locale -rails generate cmdx:locale fr +CMDx::I18nProxy.register(File.expand_path("lib/locales", __dir__)) ``` -### Available Locales - -- af - Afrikaans -- ar - Arabic -- az - Azerbaijani -- be - Belarusian -- bg - Bulgarian -- bn - Bengali -- bs - Bosnian -- ca - Catalan -- cnr - Montenegrin -- cs - Czech -- cy - Welsh -- da - Danish -- de - German -- dz - Dzongkha -- el - Greek -- en - English -- eo - Esperanto -- es - Spanish -- et - Estonian -- eu - Basque -- fa - Persian -- fi - Finnish -- fr - French -- fy - Western Frisian -- gd - Scottish Gaelic -- gl - Galician -- he - Hebrew -- hi - Hindi -- hr - Croatian -- hu - Hungarian -- hy - Armenian -- id - Indonesian -- is - Icelandic -- it - Italian -- ja - Japanese -- ka - Georgian -- kk - Kazakh -- km - Khmer -- kn - Kannada -- ko - Korean -- lb - Luxembourgish -- lo - Lao -- lt - Lithuanian -- lv - Latvian -- mg - Malagasy -- mk - Macedonian -- ml - Malayalam -- mn - Mongolian -- mr-IN - Marathi (India) -- ms - Malay -- nb - Norwegian Bokmål -- ne - Nepali -- nl - Dutch -- nn - Norwegian Nynorsk -- oc - Occitan -- or - Odia -- pa - Punjabi -- pl - Polish -- pt - Portuguese -- rm - Romansh -- ro - Romanian -- ru - Russian -- sc - Sardinian -- sk - Slovak -- sl - Slovenian -- sq - Albanian -- sr - Serbian -- st - Southern Sotho -- sv - Swedish -- sw - Swahili -- ta - Tamil -- te - Telugu -- th - Thai -- tl - Tagalog -- tr - Turkish -- tt - Tatar -- ug - Uyghur -- uk - Ukrainian -- ur - Urdu -- uz - Uzbek -- vi - Vietnamese -- wo - Wolof -- zh-CN - Chinese (Simplified) -- zh-HK - Chinese (Hong Kong) -- zh-TW - Chinese (Traditional) -- zh-YUE - Chinese (Yue) +!!! note + + `register` only affects the plain-Ruby fallback. When the `i18n` gem is loaded, CMDx delegates to `I18n.translate` and these paths are ignored — add your directories to `I18n.load_path` instead. + +## Available Locales + +The [cmdx-i18n](https://github.com/drexed/cmdx-i18n) companion gem provides community-maintained translations for 85+ locales (Arabic, Chinese, French, German, Japanese, Portuguese, Spanish, and more). Add it to your `Gemfile` and the locales become available to `I18n.translate` wherever CMDx is used; see the gem's README for the authoritative list. diff --git a/docs/interruptions/exceptions.md b/docs/interruptions/exceptions.md index 2b82c4222..7f3628a98 100644 --- a/docs/interruptions/exceptions.md +++ b/docs/interruptions/exceptions.md @@ -1,48 +1,142 @@ # Interruptions - Exceptions -Exception handling differs between `execute` and `execute!`. Choose the method that matches your error handling strategy. See the [Exceptions Reference](../exceptions.md) for the full exception hierarchy. +CMDx defines a small, flat exception hierarchy. Every exception the framework raises descends from `CMDx::Error`, so a single `rescue CMDx::Error` catches everything without trapping unrelated `StandardError`s. How they surface depends on whether you call `execute` (safe) or `execute!` (strict). -## Exception Hierarchy +!!! warning "Important" -CMDx defines a clear exception hierarchy. See [Exceptions Reference](../exceptions.md) for the full tree. + Prefer `skip!` and `fail!` over raising exceptions — they signal intent more clearly and carry structured `reason`/`metadata`. See [Signals](signals.md). -!!! danger "Caution" +## Hierarchy - `CMDx::TimeoutError` inherits from `Interrupt`, **not** `StandardError`. This means `rescue StandardError` will not catch timeouts. Rescue `CMDx::TimeoutError` or `Interrupt` explicitly when using the Timeout middleware. +``` +StandardError +└── CMDx::Error (alias: CMDx::Exception) + ├── CMDx::DefinitionError + ├── CMDx::DeprecationError + ├── CMDx::ImplementationError + ├── CMDx::MiddlewareError + └── CMDx::Fault +``` -## Exception Handling +!!! note -!!! warning "Important" + `execute!` only raises `Fault` on `failed?` results — skipped results return normally. Coercion and validation errors do **not** raise; they accumulate on `task.errors` and surface as a failed result (a `Fault` under `execute!`). - Prefer `skip!` and `fail!` over raising exceptions—they signal intent more clearly. +## Exception Types -### Non-bang execution +### CMDx::Error -Captures all exceptions and returns them as failed results: +Base class for every CMDx exception. Aliased as `CMDx::Exception`. ```ruby -class CompressDocument < CMDx::Task +begin + ProcessOrder.execute!(order_id: 42) +rescue CMDx::Error => e + # Catches every CMDx-raised exception +end +``` + +### CMDx::DefinitionError + +Raised at class-load time when an input declaration would clash with an existing accessor on the task (e.g. `:context`, `:errors`, or any user-defined method). + +```ruby +class ConflictingTask < CMDx::Task + required :context #=> raises CMDx::DefinitionError + # "cannot define input :context: #context is already defined on ConflictingTask" +end +``` + +### CMDx::DeprecationError + +Raised by [`deprecation :error`](../deprecation.md) when a class marked as prohibited is executed. + +```ruby +class LegacyTask < CMDx::Task + deprecation :error + def work - document = Document.find(context.document_id) - document.compress! + # never executes end end -result = CompressDocument.execute(document_id: "unknown-doc-id") -result.state #=> "interrupted" -result.status #=> "failed" -result.failed? #=> true -result.reason #=> "[ActiveRecord::NotFoundError] record not found" -result.cause #=> <ActiveRecord::NotFoundError> +begin + LegacyTask.execute! +rescue CMDx::DeprecationError => e + e.message #=> "LegacyTask usage prohibited" +end ``` -!!! note +### CMDx::ImplementationError - Use `exception_handler` with `execute` to send exceptions to APM tools before they become failed results. +Raised when a subclass fails its abstract contract: -### Bang execution +| Trigger | When it's raised | Message | +|---------|------------------|---------| +| Defining `#work` on a `Workflow` | at class-load time (via `method_added`) | `cannot define <Class>#work in a workflow` | +| Calling `Task#work` without overriding it | inside `work` at execution time | `undefined method <Class>#work` | + +```ruby +class IncompleteTask < CMDx::Task + # no #work defined +end + +IncompleteTask.execute #=> raises CMDx::ImplementationError +IncompleteTask.execute! #=> raises CMDx::ImplementationError +``` + +### CMDx::MiddlewareError + +Raised by the middleware chain when a registered middleware forgets to yield to `next_link`. Without this guard, a buggy middleware would silently bypass the task body. + +```ruby +class BrokenMiddleware + def call(task) + # forgot to yield + end +end + +class MyTask < CMDx::Task + register :middleware, BrokenMiddleware + def work; end +end + +MyTask.execute! +#=> raises CMDx::MiddlewareError: "middleware did not yield the next_link" +``` + +!!! warning "Important" + + Always `yield` (or call `next_link.call`) inside your middleware — `MiddlewareError` is raised outside the signal `catch` and propagates from both `execute` and `execute!`. + +### CMDx::Fault + +The only exception raised by `execute!` on `failed?` results. `Fault` carries the **originating** failed [`Result`](../outcomes/result.md) and delegates `task`, `context`, and `chain` to it. For workflows, the originating result is the deepest leaf that failed — not the workflow itself — so matchers like `Fault.for?(LeafTask)` work uniformly across flat and nested executions. + +```ruby +begin + ProcessOrder.execute!(order_id: 42) +rescue CMDx::Fault => e + e.task #=> ProcessOrder (the task class, not an instance) + e.result #=> the failed Result that originated the failure + e.result.state #=> "interrupted" + e.result.status #=> "failed" + e.result.reason #=> "payment declined" + e.result.metadata #=> { code: "INSUFFICIENT_FUNDS" } + e.result.cause #=> the underlying exception when one was rescued (or nil) + e.result.origin #=> the upstream result this signal was echoed from + e.context #=> the failing task's frozen context + e.chain #=> the full Chain of Results from the run + e.message #=> e.result.reason (or the localized "unspecified" fallback) + e.backtrace #=> cleaned via Settings#backtrace_cleaner when configured +end +``` + +## Execute vs Execute! -Lets exceptions propagate naturally for standard Ruby error handling: +### Non-bang execution + +`Runtime` rescues every non-framework `StandardError` raised inside `work` and converts it into a failed result. The exception is preserved on `result.cause`; its class and message become `result.reason`. Any `CMDx::Error` subclass propagates instead—framework errors are never swallowed: ```ruby class CompressDocument < CMDx::Task @@ -52,9 +146,47 @@ class CompressDocument < CMDx::Task end end +result = CompressDocument.execute(document_id: "unknown-doc-id") +result.failed? #=> true +result.reason #=> "[ActiveRecord::RecordNotFound] Couldn't find Document with 'id'=unknown-doc-id" +result.cause #=> #<ActiveRecord::RecordNotFound> +``` + +### Bang execution + +`execute!` re-raises on failure. When `Runtime` had captured an underlying exception (`result.cause` is set), that **original** exception is re-raised; otherwise a `CMDx::Fault` carrying the failed result is raised: + +```ruby begin CompressDocument.execute!(document_id: "unknown-doc-id") -rescue ActiveRecord::NotFoundError => e +rescue ActiveRecord::RecordNotFound => e puts "Handle exception: #{e.message}" end ``` + +See [Faults](faults.md) for `Fault.for?` / `Fault.matches?` matchers. + +## When Each Path Raises + +| Trigger | `execute` (safe) | `execute!` (strict) | +|---------|------------------|---------------------| +| `success!` | success result | success result | +| `skip!` | skipped result | skipped result (no raise) | +| `fail!` | failed result | raises `Fault` | +| `throw!(failed_result)` | failed result | raises `Fault` | +| Coercion / validation error on input | failed result | raises `Fault` | +| Non-framework `StandardError` inside `work` | failed result with `cause` | re-raises the **original** exception | +| Any `CMDx::Error` subclass inside `work` (`ImplementationError`, `DeprecationError`, `MiddlewareError`) | propagates | propagates | +| `ImplementationError` from `Workflow.method_added` | propagates at class-load time | propagates at class-load time | +| `DefinitionError` from a conflicting input declaration | propagates at class-load time | propagates at class-load time | +| Non-`StandardError` (e.g. `Interrupt`, `SignalException`) | propagates | propagates | + +## Backtrace Cleaning + +`Fault` backtraces are passed through the configured `backtrace_cleaner` (set on `CMDx.configuration.backtrace_cleaner` or per-task via `settings`). This is useful for stripping framework frames in Rails apps: + +```ruby +CMDx.configure do |config| + config.backtrace_cleaner = ->(bt) { Rails.backtrace_cleaner.clean(bt) } +end +``` diff --git a/docs/interruptions/faults.md b/docs/interruptions/faults.md index db10fc192..cc5a21612 100644 --- a/docs/interruptions/faults.md +++ b/docs/interruptions/faults.md @@ -1,121 +1,98 @@ # Interruptions - Faults -Faults are exceptions raised by `execute!` when tasks halt. They carry rich context about execution state, enabling sophisticated error handling patterns. See the [Exceptions Reference](../exceptions.md) for the full hierarchy. +`CMDx::Fault` is the exception `execute!` raises on failure. Skipped and successful results never raise. A Fault wraps the **originating** failed `Result` — the leaf at the bottom of any propagation chain — and delegates `task`, `context`, and `chain` to it. See [Exceptions](exceptions.md) for the full hierarchy. -## Fault Types +## What's on a Fault -| Type | Triggered By | Use Case | -|------|--------------|----------| -| `CMDx::Fault` | Base class | Catch-all for any interruption | -| `CMDx::SkipFault` | `skip!` method | Optional processing, early returns | -| `CMDx::FailFault` | `fail!` method | Validation errors, processing failures | +| Accessor | Returns | Notes | +|----------|---------|-------| +| `fault.result` | `CMDx::Result` | The failed result that originated the failure (after walking `origin`) | +| `fault.task` | `Class<CMDx::Task>` | The failing task **class** (`fault.result.task`) | +| `fault.context` | `CMDx::Context` | The failing task's frozen context | +| `fault.chain` | `CMDx::Chain` | The chain of every result produced during the run | +| `fault.message` | `String` | The result's `reason`, or the localized `cmdx.reasons.unspecified` when `nil` | +| `fault.backtrace` | `Array<String>` | Cleaned via `task.settings.backtrace_cleaner` when configured | -!!! warning "Important" +Use `fault.result` to read the failed outcome's `reason`, `metadata`, `cause`, `origin`, `state`, and `status`. - All faults inherit from `CMDx::Fault` and expose result, task, context, and chain data. +!!! note + + `Fault` wraps failures originating from `fail!`, `throw!`, or explicit `errors.add`. When Runtime rescued an ordinary `StandardError` (so `result.cause` is a non-`Fault`), `execute!` re-raises that **original** exception instead of wrapping it. For workflows, `fault.task` always points at the leaf that failed — not the workflow class — so matchers like `Fault.for?(LeafTask)` work the same in flat and nested executions. ## Fault Handling ```ruby begin ProcessTicket.execute!(ticket_id: 456) -rescue CMDx::SkipFault => e - logger.info "Ticket processing skipped: #{e.message}" - schedule_retry(e.context.ticket_id) -rescue CMDx::FailFault => e - logger.error "Ticket processing failed: #{e.message}" - notify_admin(e.context.assigned_agent, e.result.metadata[:error_code]) rescue CMDx::Fault => e - logger.warn "Ticket processing interrupted: #{e.message}" - rollback_changes + logger.error "Ticket processing failed: #{e.message}" + logger.info "Failing task: #{e.task}" + notify_admin(e.result.metadata[:error_code]) end ``` -## Data Access - -Access rich execution data from fault exceptions: +When you need to keep working with the result rather than rescuing, use `execute` and inspect it directly: ```ruby -begin - LicenseActivation.execute!(license_key: key, machine_id: machine) -rescue CMDx::Fault => e - # Result information - e.result.state #=> "interrupted" - e.result.status #=> "failed" or "skipped" - e.result.reason #=> "License key already activated" - - # Task information - e.task.class #=> <LicenseActivation> - e.task.id #=> "abc123..." - - # Context data - e.context.license_key #=> "ABC-123-DEF" - e.context.machine_id #=> "[FILTERED]" - - # Chain information - e.chain.id #=> "def456..." - e.chain.size #=> 3 +result = ProcessTicket.execute(ticket_id: 456) + +result.on(:failed) do |r| + logger.error "Ticket processing failed: #{r.reason}" + notify_admin(r.metadata[:error_code], context: r.context) end ``` +Either form gives you the same data: with `execute!`, reach for `fault.result` / `fault.context` / `fault.chain`; with `execute`, work with the returned `result` directly. + ## Advanced Matching ### Task-Specific Matching -Handle faults only from specific tasks using `for?`: +`Fault.for?(*task_classes)` returns an anonymous matcher subclass suitable for `rescue`. It matches any fault whose `task` is (or inherits from) one of the given classes: ```ruby begin DocumentWorkflow.execute!(document_data: data) -rescue CMDx::FailFault.for?(FormatValidator, ContentProcessor) => e +rescue CMDx::Fault.for?(FormatValidator, ContentProcessor) => e # Handle only document-related failures - retry_with_alternate_parser(e.context) -rescue CMDx::SkipFault.for?(VirusScanner, ContentFilter) => e - # Handle security-related skips - quarantine_for_review(e.context.document_id) + retry_with_alternate_parser(e.result.metadata) end ``` ### Custom Logic Matching +`Fault.matches?` takes a block returning `true`/`false` against the fault. Use it for arbitrary predicates — metadata, status, cause class, etc.: + ```ruby begin ReportGenerator.execute!(report: report_data) -rescue CMDx::Fault.matches? { |f| f.context.data_size > 10_000 } => e - escalate_large_dataset_failure(e) -rescue CMDx::FailFault.matches? { |f| f.result.metadata[:attempt_count] > 3 } => e +rescue CMDx::Fault.matches? { |f| f.result.metadata[:attempt_count].to_i > 3 } => e abandon_report_generation(e) rescue CMDx::Fault.matches? { |f| f.result.metadata[:error_type] == "memory" } => e increase_memory_and_retry(e) end ``` -## Fault Propagation +!!! note -Propagate failures with `throw!` to preserve context and maintain the error chain. + Each call to `for?` / `matches?` returns a fresh anonymous matcher subclass, so they can be stacked across multiple `rescue` clauses but cannot be combined into a single matcher. -!!! warning "Important" +## Fault Propagation - `throw!` requires a `CMDx::Result` argument. Passing any other object raises a `TypeError`. +Use `throw!` to re-raise an upstream failed result through the current task. The propagated signal mirrors the original's state, status, and reason and attaches the current `caller_locations` as the backtrace. It's a no-op when the argument isn't `failed?` — skipped or successful results are never converted into failures. ### Basic Propagation ```ruby class ReportGenerator < CMDx::Task def work - # Throw if skipped or failed - validation_result = DataValidator.execute(context) - throw!(validation_result) + # No-op when the upstream result wasn't failed + throw!(DataValidator.execute(context)) - # Only throw if skipped - check_permissions = CheckPermissions.execute(context) - throw!(check_permissions) if check_permissions.skipped? + # Or guard explicitly + perms = CheckPermissions.execute(context) + throw!(perms) if perms.failed? - # Only throw if failed - data_result = DataProcessor.execute(context) - throw!(data_result) if data_result.failed? - - # Continue processing generate_report end end @@ -123,17 +100,20 @@ end ### Additional Metadata +Pass keyword args to attach extra metadata to the propagated signal: + ```ruby class BatchProcessor < CMDx::Task def work step_result = FileValidation.execute(context) if step_result.failed? - throw!(step_result, { + throw!( + step_result, batch_stage: "validation", can_retry: true, next_step: "file_repair" - }) + ) end continue_batch @@ -143,31 +123,20 @@ end ## Chain Analysis -Trace fault origins and propagation through the execution chain: +`Fault` exposes the originating `Result` via `fault.result`, plus the full `chain` it belongs to. From either, you can walk failure propagation with `origin`, `caused_failure`, and `threw_failure`. See [Result - Chain Analysis](../outcomes/result.md#chain-analysis) for the full API. ```ruby -result = DocumentWorkflow.execute(invalid_data) +begin + DocumentWorkflow.execute!(document_data: data) +rescue CMDx::Fault => e + puts "Originated by #{e.task}: #{e.message}" + puts "Workflow result: #{e.chain.first.task}" +end +# Or via non-bang execute: +result = DocumentWorkflow.execute(invalid_data) if result.failed? - # Trace the original failure - original = result.caused_failure - if original - puts "Original failure: #{original.task.class.name}" - puts "Reason: #{original.reason}" - end - - # Find what propagated the failure - thrower = result.threw_failure - puts "Propagated by: #{thrower.task.class.name}" if thrower - - # Analyze failure type - case - when result.caused_failure? - puts "This task was the original source" - when result.threw_failure? - puts "This task propagated a failure" - when result.thrown_failure? - puts "This task failed due to propagation" - end + origin = result.caused_failure + puts "Originated by #{origin.task}: #{origin.reason}" end ``` diff --git a/docs/interruptions/halt.md b/docs/interruptions/halt.md deleted file mode 100644 index 1eceb9b34..000000000 --- a/docs/interruptions/halt.md +++ /dev/null @@ -1,256 +0,0 @@ -# Interruptions - Halt - -Stop task execution intentionally using `skip!` or `fail!`. Both methods signal clear intent about why execution stopped. - -## Skipping - -Use `skip!` when the task doesn't need to run. It's a no-op, not an error. - -!!! warning "Important" - - Skipped tasks are considered "good" outcomes—they succeeded by doing nothing. - -```ruby -class ProcessInventory < CMDx::Task - def work - # Without a reason - skip! if Array(ENV["DISABLED_TASKS"]).include?(self.class.name) - - # With a reason - skip!("Warehouse closed") unless Time.now.hour.between?(8, 18) - - inventory = Inventory.find(context.inventory_id) - - if inventory.already_counted? - skip!("Inventory already counted today") - else - inventory.count! - end - end -end - -result = ProcessInventory.execute(inventory_id: 456) - -# Executed -result.status #=> "skipped" - -# Without a reason -result.reason #=> "Unspecified" - -# With a reason -result.reason #=> "Warehouse closed" -``` - -## Failing - -Use `fail!` when the task can't complete successfully. It signals controlled, intentional failure: - -```ruby -class ProcessRefund < CMDx::Task - def work - # Without a reason - fail! if Array(ENV["DISABLED_TASKS"]).include?(self.class.name) - - refund = Refund.find(context.refund_id) - - # With a reason - if refund.expired? - fail!("Refund period has expired") - elsif !refund.amount.positive? - fail!("Refund amount must be positive") - else - refund.process! - end - end -end - -result = ProcessRefund.execute(refund_id: 789) - -# Executed -result.status #=> "failed" - -# Without a reason -result.reason #=> "Unspecified" - -# With a reason -result.reason #=> "Refund period has expired" -``` - -## Metadata Enrichment - -Enrich halt calls with metadata for better debugging and error handling: - -```ruby -class ProcessRenewal < CMDx::Task - def work - license = License.find(context.license_id) - - if license.already_renewed? - # Without metadata - skip!("License already renewed") - end - - unless license.renewal_eligible? - # With metadata - fail!( - "License not eligible for renewal", - error_code: "LICENSE.NOT_ELIGIBLE", - retry_after: Time.current + 30.days - ) - end - - process_renewal - end -end - -result = ProcessRenewal.execute(license_id: 567) - -# Without metadata -result.metadata #=> {} - -# With metadata -result.metadata #=> { - # error_code: "LICENSE.NOT_ELIGIBLE", - # retry_after: <Time 30 days from now> - # } -``` - -## Idempotency - -Halt methods are safe to call when the result is already in the target status: - -```ruby -class ProcessOrder < CMDx::Task - def work - fail!("Out of stock") if out_of_stock? - fail!("Insufficient funds") if insufficient_funds? - # Second fail! is a no-op if already failed above - end -end -``` - -!!! warning "Important" - - `skip!` and `fail!` are no-ops when the result is already in the matching status. However, calling `skip!` after `fail!` (or vice versa) raises a `RuntimeError` — status transitions are unidirectional. - -## State Transitions - -Halt methods trigger specific state and status transitions: - -| Method | State | Status | Outcome | -|--------|-------|--------|---------| -| `skip!` | `interrupted` | `skipped` | `good? = true`, `bad? = true` | -| `fail!` | `interrupted` | `failed` | `good? = false`, `bad? = true` | - -```ruby -result = ProcessRenewal.execute(license_id: 567) - -# State information -result.state #=> "interrupted" -result.status #=> "skipped" or "failed" -result.interrupted? #=> true -result.complete? #=> false - -# Outcome categorization -result.good? #=> true for skipped, false for failed -result.bad? #=> true for both skipped and failed -``` - -## Execution Behavior - -Halt methods behave differently depending on the call method used: - -### Non-bang execution - -Returns result object without raising exceptions: - -```ruby -result = ProcessRefund.execute(refund_id: 789) - -case result.status -when "success" - puts "Refund processed: $#{result.context.refund.amount}" -when "skipped" - puts "Refund skipped: #{result.reason}" -when "failed" - puts "Refund failed: #{result.reason}" - handle_refund_error(result.metadata[:error_code]) -end -``` - -### Bang execution - -Raises exceptions for halt conditions based on `task_breakpoints` configuration: - -```ruby -begin - result = ProcessRefund.execute!(refund_id: 789) - puts "Success: Refund processed" -rescue CMDx::SkipFault => e - puts "Skipped: #{e.message}" -rescue CMDx::FailFault => e - puts "Failed: #{e.message}" - handle_refund_failure(e.result.metadata[:error_code]) -end -``` - -## Best Practices - -Always provide a reason for better debugging and clearer exception messages: - -```ruby -# Good: Clear, specific reason -skip!("Document processing paused for compliance review") -fail!("File format not supported by processor", code: "FORMAT_UNSUPPORTED") - -# Acceptable: Generic, non-specific reason -skip!("Paused") -fail!("Unsupported") - -# Bad: Default, cannot determine reason -skip! #=> "Unspecified" -fail! #=> "Unspecified" -``` - -## Manual Errors - -For rare cases, manually add errors before halting. - -!!! warning "Important" - - Manual errors don't stop execution—you still need to call `fail!` or `skip!`. - -### Errors API - -The `errors` object provides methods for querying and formatting error messages: - -```ruby -errors.add(:email, "is invalid") -errors.add(:email, "is required") -errors.add(:name, "is too short") - -errors.any? #=> true -errors.empty? #=> false -errors.size #=> 2 (number of attributes with errors) -errors.for?(:email) #=> true -errors.for?(:phone) #=> false - -errors.to_h #=> { email: ["is invalid", "is required"], name: ["is too short"] } -errors.full_messages #=> { email: ["email is invalid", "email is required"], name: ["name is too short"] } -errors.to_s #=> "email is invalid. email is required. name is too short" -``` - -### Usage - -```ruby -class ProcessRenewal < CMDx::Task - def work - if document.nonrenewable? - errors.add(:document, "not renewable") - fail!("document could not be renewed") - else - document.renew! - end - end -end -``` diff --git a/docs/interruptions/signals.md b/docs/interruptions/signals.md new file mode 100644 index 000000000..1fd047b30 --- /dev/null +++ b/docs/interruptions/signals.md @@ -0,0 +1,294 @@ +# Interruptions - Signals + +Halt `work` intentionally with `success!`, `skip!`, `fail!`, or `throw!`. Each signals a clear intent and can carry a reason and metadata. + +Internally these methods `throw` a `CMDx::Signal` that Runtime catches around `work`, breaking out of the current call stack the moment they fire — nothing after them runs. + +!!! note + + `success!` is the third halt method; it produces a `complete`/`success` result with a custom reason and metadata. See [Annotating a Successful Result](../outcomes/result.md#annotating-a-successful-result). + +## Skipping + +Use `skip!` when the task doesn't need to run. It's a controlled no-op, not an error. + +!!! warning "Important" + + Skipped tasks are considered "ok" outcomes (`result.ok? #=> true`). `execute!` does **not** raise on a skip — only on a failure. + +```ruby +class ProcessInventory < CMDx::Task + def work + # Without a reason + skip! if Array(ENV["DISABLED_TASKS"]).include?(self.class.name) + + # With a reason + skip!("Warehouse closed") unless Time.now.hour.between?(8, 18) + + inventory = Inventory.find(context.inventory_id) + + if inventory.already_counted? + skip!("Inventory already counted today") + else + inventory.count! + end + end +end + +result = ProcessInventory.execute(inventory_id: 456) + +# Executed +result.status #=> "skipped" + +# Without a reason +result.reason #=> nil + +# With a reason +result.reason #=> "Warehouse closed" +``` + +## Failing + +Use `fail!` when the task can't complete successfully. It signals controlled, intentional failure: + +```ruby +class ProcessRefund < CMDx::Task + def work + # Without a reason + fail! if Array(ENV["DISABLED_TASKS"]).include?(self.class.name) + + refund = Refund.find(context.refund_id) + + # With a reason + if refund.expired? + fail!("Refund period has expired") + elsif !refund.amount.positive? + fail!("Refund amount must be positive") + else + refund.process! + end + end +end + +result = ProcessRefund.execute(refund_id: 789) + +# Executed +result.status #=> "failed" + +# Without a reason +result.reason #=> nil + +# With a reason +result.reason #=> "Refund period has expired" +``` + +!!! note + + `result.reason` is exactly what you passed (or `nil`). The `"Unspecified"` fallback only appears on `Fault#message` when `execute!` raises with no reason. + +## Metadata Enrichment + +Enrich halt calls with metadata for better debugging and error handling: + +```ruby +class ProcessRenewal < CMDx::Task + def work + license = License.find(context.license_id) + + if license.already_renewed? + # Without metadata + skip!("License already renewed") + end + + unless license.renewal_eligible? + # With metadata + fail!( + "License not eligible for renewal", + error_code: "LICENSE.NOT_ELIGIBLE", + retry_after: Time.current + 30.days + ) + end + + process_renewal + end +end + +result = ProcessRenewal.execute(license_id: 567) + +# Without metadata +result.metadata #=> {} + +# With metadata +result.metadata #=> { + # error_code: "LICENSE.NOT_ELIGIBLE", + # retry_after: <Time 30 days from now> + # } +``` + +## Short-Circuit Behavior + +Halt methods always `throw` — they never return. The first one to fire ends `work` immediately, so any subsequent halt calls are unreachable: + +```ruby +class ProcessOrder < CMDx::Task + def work + fail!("Out of stock") if out_of_stock? + fail!("Insufficient funds") if insufficient_funds? + # If both conditions are true, only the first fail! ever runs. + end +end +``` + +!!! warning "Important" + + Halt methods only work inside `work` (and anything called from it). Runtime's `catch(:cmdx_signal)` wraps just the work body; throwing from rollback, callbacks, or middlewares raises `UncaughtThrowError`. On a frozen task (post-teardown) they raise `FrozenError`. + +## State Transitions + +Halt methods trigger specific state and status transitions: + +| Method | State | Status | Outcome | +|--------|-------|--------|---------| +| `skip!` | `interrupted` | `skipped` | `ok? = true`, `ko? = true` | +| `fail!` | `interrupted` | `failed` | `ok? = false`, `ko? = true` | + +```ruby +result = ProcessRenewal.execute(license_id: 567) + +# State information +result.state #=> "interrupted" +result.status #=> "skipped" or "failed" +result.interrupted? #=> true +result.complete? #=> false + +# Outcome categorization +result.ok? #=> true for skipped, false for failed +result.ko? #=> true for both skipped and failed +``` + +## Execution Behavior + +Halt methods behave differently depending on the entry point used: + +### Non-bang execution + +Returns the result object regardless of outcome; nothing raises: + +```ruby +result = ProcessRefund.execute(refund_id: 789) + +case result.status +when "success" + puts "Refund processed: $#{result.context.refund.amount}" +when "skipped" + puts "Refund skipped: #{result.reason}" +when "failed" + puts "Refund failed: #{result.reason}" + handle_refund_error(result.metadata[:error_code]) +end +``` + +### Bang execution + +Raises `CMDx::Fault` only when the result is `failed?`. Skipped results return normally: + +```ruby +begin + result = ProcessRefund.execute!(refund_id: 789) + + if result.skipped? + puts "Skipped: #{result.reason}" + else + puts "Success: Refund processed" + end +rescue CMDx::Fault => e + puts "Failed: #{e.message}" + handle_refund_failure(e.result.metadata[:error_code]) +end +``` + +## Rethrowing a Peer Failure + +Use `throw!` to halt the current task by echoing another task's failed result. It's a no-op when the other result isn't `failed?`: + +```ruby +class ReportMonthlyMetrics < CMDx::Task + def work + result = BuildReport.execute(context) + throw!(result) # bubbles up with the same reason, metadata, origin + + # ...happy path continues here when result isn't failed + end +end +``` + +The resulting `Result` carries the upstream failure in `result.origin`; `result.thrown_failure?` is `true`. See [Result - Chain Analysis](../outcomes/result.md#chain-analysis). + +## Best Practices + +Prefer specific reasons — they become `result.reason`, `Fault#message`, and end up in logs and telemetry: + +```ruby +# Best: Specific reason + structured metadata +fail!("File format not supported by processor", code: "FORMAT_UNSUPPORTED") + +# Good: Clear reason +skip!("Document processing paused for compliance review") + +# Avoid: nil reason (Fault#message falls back to the localized "Unspecified") +skip! +fail! +``` + +## Manual Errors + +Add structured errors to `task.errors` to fail the task with a rich, multi-key error sentence. Errors accumulated during `work` are inspected after `work` returns; if any are present, `Runtime` automatically throws a failed signal whose reason is the joined error messages. + +!!! note + + You don't need to call `fail!` after `errors.add` — the post-`work` check does it for you. Calling `fail!` explicitly still works and short-circuits immediately. + +### Errors API + +The `errors` object is keyed by attribute name; each value is a deduplicating set of messages: + +```ruby +errors.add(:email, "is invalid") +errors.add(:email, "is required") +errors.add(:name, "is too short") + +errors.empty? #=> false +errors.any? #=> true (via Enumerable) +errors.size #=> 2 (number of keys) +errors.count #=> 3 (total messages across all keys) +errors.key?(:email) #=> true +errors.key?(:phone) #=> false +errors.added?(:email, "is invalid") #=> true +errors[:email] #=> ["is invalid", "is required"] + +errors.to_h #=> { email: ["is invalid", "is required"], name: ["is too short"] } +errors.full_messages #=> { email: ["email is invalid", "email is required"], name: ["name is too short"] } +errors.to_s #=> "email is invalid. email is required. name is too short" +``` + +### Usage + +```ruby +class ProcessRenewal < CMDx::Task + def work + document = Document.find(context.document_id) + + if document.nonrenewable? + errors.add(:document, "is not renewable") + return # Runtime sees errors and throws a failed signal automatically + end + + document.renew! + end +end + +result = ProcessRenewal.execute(document_id: 42) +result.status #=> "failed" +result.reason #=> "document is not renewable" +result.errors.to_h #=> { document: ["is not renewable"] } +``` diff --git a/docs/logging.md b/docs/logging.md index 4439b6a5d..0b8807834 100644 --- a/docs/logging.md +++ b/docs/logging.md @@ -1,89 +1,147 @@ # Logging -CMDx automatically logs every task execution with structured data, making debugging and monitoring effortless. Choose from multiple formatters to match your logging infrastructure. +CMDx logs every task execution at `INFO` with the full `Result#to_h` payload, giving you a structured event stream for free. Pick a formatter that matches your logging infrastructure. ## Formatters -Choose the format that works best for your logging system: - | Formatter | Use Case | Output Style | |-----------|----------|--------------| -| `Line` | Traditional logging | Single-line format | -| `Json` | Structured systems | Compact JSON | -| `KeyValue` | Log parsing | `key=value` pairs | -| `Logstash` | ELK stack | JSON with @version/@timestamp | -| `Raw` | Minimal output | Message content only | +| `Line` | Traditional logging (default) | Single-line `Logger::Formatter` style | +| `JSON` | Structured systems | Compact JSON, one object per line | +| `KeyValue` | Log parsing | `key=value.inspect` pairs | +| `Logstash` | ELK stack | JSON with `@version` / `@timestamp` | +| `Raw` | Minimal output | Message body only (no severity/timestamp) | + +!!! note + + The class name is `CMDx::LogFormatters::JSON` (uppercase). The other formatter classes use CamelCase: `Line`, `KeyValue`, `Logstash`, `Raw`. + +=== "Line (default)" + + ```log + I, [2026-04-19T10:30:45.123456Z #12345] INFO -- cmdx: chain_id="..." chain_index=0 ... state="complete" status="success" ... + ``` + +=== "JSON" + + ```json + {"severity":"INFO","timestamp":"2026-04-19T10:30:45.123456Z","progname":"cmdx","pid":12345,"message":{"chain_id":"...","chain_index":0,"chain_root":true,"type":"Task","task":"MyTask","id":"...","state":"complete","status":"success","reason":null,"metadata":{},"strict":false,"deprecated":false,"retried":false,"retries":0,"duration":12.34,"tags":[]}} + ``` + +=== "KeyValue" + + ```log + severity="INFO" timestamp="2026-04-19T10:30:45.123456Z" progname="cmdx" pid=12345 message={chain_id: "...", chain_index: 0, ...} + ``` + +=== "Logstash" + + ```json + {"severity":"INFO","progname":"cmdx","pid":12345,"message":{...},"@version":"1","@timestamp":"2026-04-19T10:30:45.123456Z"} + ``` + +=== "Raw" -Sample output: + ```log + chain_id="..." chain_index=0 chain_root=true type="Task" task=MyTask id="..." state="complete" status="success" ... + ``` + +## Sample Lifecycle + +A single chain emitting a successful task, a skipped task, a failed leaf, and the workflow that propagated the failure: ```log -<!-- Success (INFO level) --> -I, [2025-12-23T17:04:07.292614Z #20108] INFO -- cmdx: {index: 1, chain_id: "019b4c2b-087b-79be-8ef2-96c11b659df5", type: "Task", tags: [], class: "GenerateInvoice", dry_run: false, id: "019b4c2b-0878-704d-ba0b-daa5410123ec", state: "complete", status: "success", outcome: "success", metadata: {runtime: 187}} +# Success +I, [2026-04-19T17:04:07.292614Z #20108] INFO -- cmdx: chain_id="019b4c2b-087b-79be-8ef2-96c11b659df5" chain_index=0 chain_root=true type="Task" task=GenerateInvoice id="019b4c2b-0878-704d-ba0b-daa5410123ec" context=#<CMDx::Context ...> state="complete" status="success" reason=nil metadata={} strict=false deprecated=false retried=false retries=0 duration=12.34 tags=[] -<!-- Skipped (INFO level) --> -I, [2025-12-23T17:04:11.496881Z #20139] INFO -- cmdx: {index: 2, chain_id: "019b4c2b-18e8-7af6-a38b-63b042c4fbed", type: "Task", tags: [], class: "ValidateCustomer", dry_run: false, id: "019b4c2b-18e5-7230-af7e-5b4a4bd7cda2", state: "interrupted", status: "skipped", outcome: "skipped", metadata: {}, reason: "Customer already validated", cause: #<CMDx::SkipFault: Customer already validated>, rolled_back: false} +# Skipped +I, [2026-04-19T17:04:11.496881Z #20139] INFO -- cmdx: chain_id="019b4c2b-18e8-7af6-a38b-63b042c4fbed" chain_index=0 chain_root=true type="Task" task=ValidateCustomer id="019b4c2b-18e5-7230-af7e-5b4a4bd7cda2" context=#<CMDx::Context ...> state="interrupted" status="skipped" reason="Customer already validated" metadata={} strict=false deprecated=false retried=false retries=0 duration=2.18 tags=[] -<!-- Failed (INFO level) --> -I, [2025-12-23T17:04:15.875306Z #20173] INFO -- cmdx: {index: 3, chain_id: "019b4c2b-2a02-7dbc-b713-b20a7379704f", type: "Task", tags: [], class: "CalculateTax", dry_run: false, id: "019b4c2b-2a00-70b7-9fab-2f14db9139ef", state: "interrupted", status: "failed", outcome: "failed", metadata: {error_code: "TAX_SERVICE_UNAVAILABLE"}, reason: "Validation failed", cause: #<CMDx::FailFault: Validation failed>, rolled_back: false} +# Failed (root cause via fail!) +I, [2026-04-19T17:04:15.875306Z #20173] INFO -- cmdx: chain_id="019b4c2b-2a02-7dbc-b713-b20a7379704f" chain_index=1 chain_root=false type="Task" task=CalculateTax id="019b4c2b-2a00-70b7-9fab-2f14db9139ef" context=#<CMDx::Context ...> state="interrupted" status="failed" reason="tax service unavailable" metadata={error_code: "TAX_SERVICE_UNAVAILABLE"} strict=false deprecated=false retried=false retries=0 duration=8.92 tags=[] cause=nil origin=nil threw_failure=<CalculateTax 019b4c2b-2a00-...> caused_failure=<CalculateTax 019b4c2b-2a00-...> rolled_back=false -<!-- Failed Chain --> -I, [2025-12-23T17:04:20.972539Z #20209] INFO -- cmdx: {index: 0, chain_id: "019b4c2b-3de9-71f7-bcc3-2a98836bcfd7", type: "Workflow", tags: [], class: "BillingWorkflow", dry_run: false, id: "019b4c2b-3de6-70b9-9c16-5be13b1a463c", state: "interrupted", status: "failed", outcome: "interrupted", metadata: {}, reason: "Validation failed", cause: #<CMDx::FailFault: Validation failed>, rolled_back: false, threw_failure: {index: 3, chain_id: "019b4c2b-3de9-71f7-bcc3-2a98836bcfd7", type: "Task", tags: [], class: "CalculateTax", id: "019b4c2b-3dec-70b3-969b-c5b7896e3b27", state: "interrupted", status: "failed", outcome: "failed", metadata: {error_code: "TAX_SERVICE_UNAVAILABLE"}, reason: "Validation failed", cause: #<CMDx::FailFault: Validation failed>, rolled_back: false}, caused_failure: {index: 3, chain_id: "019b4c2b-3de9-71f7-bcc3-2a98836bcfd7", type: "Task", tags: [], class: "CalculateTax", id: "019b4c2b-3dec-70b3-969b-c5b7896e3b27", state: "interrupted", status: "failed", outcome: "failed", metadata: {error_code: "TAX_SERVICE_UNAVAILABLE"}, reason: "Validation failed", cause: #<CMDx::FailFault: Validation failed>, rolled_back: false}} +# Failed workflow that propagated the above failure +I, [2026-04-19T17:04:15.876012Z #20173] INFO -- cmdx: chain_id="019b4c2b-2a02-7dbc-b713-b20a7379704f" chain_index=0 chain_root=true type="Workflow" task=BillingWorkflow id="019b4c2b-3de6-70b9-9c16-5be13b1a463c" ... state="interrupted" status="failed" reason="tax service unavailable" cause=nil origin=<CalculateTax 019b4c2b-2a00-...> threw_failure=<CalculateTax 019b4c2b-2a00-...> caused_failure=<CalculateTax 019b4c2b-2a00-...> rolled_back=false ``` !!! tip - Use logging as a low-level event stream to track all tasks in a request. Combine with correlation for powerful distributed tracing. + Use logging as a low-level event stream to track every task in a request. Pair `chain_id` with your APM's correlation field for distributed tracing. + +!!! note + + A rescued `StandardError` (not a `fail!` call) sets `cause=#<TheError: …>` and rewrites `reason` to `"[TheError] message"` with empty metadata. ## Structure -Every log entry includes rich metadata. Available fields depend on execution context and outcome. +Every log entry is built from `Result#to_h`. Available fields: -### Core Fields +### Severity / Time (added by formatter) | Field | Description | Example | |-------|-------------|---------| -| `severity` | Log level | `INFO`, `WARN`, `ERROR` | -| `timestamp` | ISO 8601 execution time | `2022-07-17T18:43:15.000000` | +| `severity` | Logger level name | `INFO`, `WARN`, `ERROR` | +| `timestamp` | UTC ISO 8601 with microseconds | `2026-04-19T18:43:15.000000Z` | | `pid` | Process ID | `3784` | +| `progname` | Logger progname | `cmdx` (default) | -### Task Information +`Raw` is the exception — it emits only the `message` body. + +### Identity | Field | Description | Example | |-------|-------------|---------| -| `index` | Execution sequence position | `0`, `1`, `2` | -| `chain_id` | Unique execution chain ID | `018c2b95-b764-7615...` | -| `type` | Execution unit type | `Task`, `Workflow` | -| `class` | Task class name | `GenerateInvoiceTask` | -| `id` | Unique task instance ID | `018c2b95-b764-7615...` | -| `tags` | Custom categorization | `["billing", "financial"]` | +| `chain_id` | Chain UUID (uuid_v7) | `"018c2b95-b764-7615-..."` | +| `chain_index` | Position in chain (root is 0) | `0`, `1`, `2` | +| `chain_root` | `true` for the root task's result | `true`, `false` | +| `type` | `"Task"` or `"Workflow"` | `"Task"` | +| `task` | Task class | `GenerateInvoice` | +| `id` | Result UUID (uuid_v7) | `"018c2b95-..."` | +| `context` | Frozen `CMDx::Context` (root teardown) | `#<CMDx::Context ...>` | +| `tags` | Tags from `settings(tags: [...])` | `["billing"]` | -### Execution Data +### Outcome | Field | Description | Example | |-------|-------------|---------| -| `state` | Lifecycle state | `complete`, `interrupted` | -| `status` | Business outcome | `success`, `skipped`, `failed` | -| `outcome` | Final classification | `success`, `interrupted` | -| `metadata` | Custom task data | `{order_id: 123, amount: 99.99}` | +| `state` | Lifecycle state | `"complete"`, `"interrupted"` | +| `status` | Business outcome | `"success"`, `"skipped"`, `"failed"` | +| `reason` | String passed to halt method | `"payment declined"` or `nil` | +| `metadata` | Hash passed to halt method | `{ code: "INSUFFICIENT_FUNDS" }` | -### Failure Chain +### Lifecycle + +| Field | Description | Example | +|-------|-------------|---------| +| `strict` | `true` when produced via `execute!` | `false` | +| `deprecated` | `true` when the task class is deprecated | `false` | +| `retried` | `true` when at least one retry happened | `false` | +| `retries` | Number of retry attempts performed | `0` | +| `duration` | Lifecycle duration in milliseconds | `12.34` | + +### Failure-only fields + +These are present **only** when `status == "failed"`: | Field | Description | |-------|-------------| -| `reason` | Reason given for the stoppage | -| `caused` | Cause exception details | -| `caused_failure` | Original failing task details | -| `threw_failure` | Task that propagated the failure | +| `cause` | Underlying exception (or `nil` for `fail!`) | +| `origin` | `{ task:, id: }` of the upstream `Result` this failure was echoed from, or `nil` for a locally originated failure | +| `threw_failure` | `{ task:, id: }` of the nearest upstream failed result, or this result | +| `caused_failure` | `{ task:, id: }` of the originating failed result, or this result | +| `rolled_back` | `true` when the task's `#rollback` ran | -## Formatter Configuration +## Configuration -Set the formatter globally or per-task: +Configure the formatter and level globally or per-task: === "Global" ```ruby CMDx.configure do |config| - config.logger.formatter = CMDx::LogFormatters::Json.new + config.log_formatter = CMDx::LogFormatters::JSON.new + config.log_level = Logger::DEBUG + config.logger = Logger.new($stdout, progname: "cmdx") end ``` @@ -91,62 +149,70 @@ Set the formatter globally or per-task: ```ruby class ProcessSubscription < CMDx::Task - settings(log_formatter: CMDx::LogFormatters::Json.new) + settings( + log_formatter: CMDx::LogFormatters::JSON.new, + log_level: Logger::DEBUG + ) end ``` -### Formatter Output Examples - -=== "Line (default)" - - ```log - I, [2025-01-15T10:30:45.123456Z #12345] INFO -- cmdx: {index: 0, class: "MyTask", state: "complete", ...} - ``` +!!! note -=== "Json" + When a task overrides `:log_level` or `:log_formatter`, `LoggerProxy` `dup`s the global logger so settings don't leak across sibling tasks. - ```json - {"severity":"INFO","timestamp":"2025-01-15T10:30:45.123456Z","progname":"cmdx","pid":12345,"message":{...}} - ``` +### Custom Logger -=== "KeyValue" +Swap the underlying `Logger` instance per task to route lifecycle entries to a different sink (separate file, syslog, structured-log gem, etc.): - ```log - severity="INFO" timestamp="2025-01-15T10:30:45.123456Z" progname="cmdx" pid=12345 message={...} - ``` +```ruby +class AuditTransfer < CMDx::Task + settings( + logger: Logger.new(Rails.root.join("log/audit.log"), progname: "audit"), + log_formatter: CMDx::LogFormatters::JSON.new + ) +end +``` -=== "Logstash" +The given logger is used as-is — `LoggerProxy` only `dup`s it when `:log_level` or `:log_formatter` differ from what the logger already has set. - ```json - {"severity":"INFO","progname":"cmdx","pid":12345,"message":{...},"@version":"1","@timestamp":"2025-01-15T10:30:45.123456Z"} - ``` +### Silencing a Task -=== "Raw" +Raise the per-task level above `INFO` to suppress the lifecycle log line: - ```log - {index: 0, class: "MyTask", state: "complete", status: "success", ...} - ``` +```ruby +class QuietTask < CMDx::Task + settings(log_level: Logger::WARN) +end +``` ## Log Levels -CMDx logs task execution at `INFO` level, retries at `WARN`, and backtraces at `ERROR`. Override the log level per-task: +CMDx logs each task result at `INFO` once the lifecycle completes. The framework itself emits no `WARN` or `ERROR` lines — use callbacks (`on_failed`, `on_skipped`) or telemetry subscribers (`:task_retried`, `:task_executed`) to log at higher severities. ```ruby class VerboseTask < CMDx::Task - settings(log_level: :debug) + settings(log_level: Logger::DEBUG) + + def work + logger.debug { "feature flags: #{Features.active_flags.inspect}" } + # ... + end end ``` -## Usage +!!! note + + Strict-mode failures (`execute!`) still produce the lifecycle log line and the `:task_executed` telemetry event — `Runtime` finalizes the result *before* re-raising the `Fault`. + +## Usage Inside Work -Access the framework logger directly within tasks: +`Task#logger` returns the per-task `Logger` (with the task's overrides applied): ```ruby class ProcessSubscription < CMDx::Task def work - logger.debug { "Activated feature flags: #{Features.active_flags}" } - # Your logic here... - logger.info("Subscription processed") + logger.debug { "subscriber: #{context.subscriber_id}" } + logger.info { "starting subscription processing" } end end ``` diff --git a/docs/middlewares.md b/docs/middlewares.md index c61b98186..93f2174a9 100644 --- a/docs/middlewares.md +++ b/docs/middlewares.md @@ -1,16 +1,28 @@ # Middlewares -Wrap task execution with middleware for cross-cutting concerns like authentication, caching, timeouts, and monitoring. Think Rack middleware, but for your business logic. +Wrap task execution with middleware for cross-cutting concerns like authentication, caching, telemetry, and timeouts. Think Rack middleware, but for your business logic. See [Global Configuration](configuration.md#middlewares) for framework-wide setup. -## Execution Order +## Signature -Middleware wraps task execution in layers, like an onion: +Every middleware receives the task and a block: `call(task) { ... }`. Invoke `yield` (or `next_link.call` from a Proc) to run the next link; skipping it raises `CMDx::MiddlewareError`. Middlewares see only the `task` — `Result` is built after the chain unwinds, so read `task.context` / `task.errors` from inside, or subscribe to Telemetry's `:task_executed` event when you need the finalized result. -!!! note +```ruby +class TelemetryMiddleware + def call(task) + started = Process.clock_gettime(Process::CLOCK_MONOTONIC) + yield + ensure + duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started + StatsD.timing("task.duration", duration, tags: ["class:#{task.class.name}"]) + end +end +``` + +## Execution Order - First registered = outermost wrapper. They execute in registration order. +Middleware wraps task execution in layers, like an onion. **First registered = outermost wrapper**, executing in registration order: ```ruby class ProcessCampaign < CMDx::Task @@ -19,7 +31,7 @@ class ProcessCampaign < CMDx::Task register :middleware, CacheMiddleware # 3rd: innermost wrapper def work - # Your logic here... + # ... end end @@ -27,7 +39,7 @@ end # 1. AuditMiddleware (before) # 2. AuthorizationMiddleware (before) # 3. CacheMiddleware (before) -# 4. [task execution] +# 4. [task lifecycle: callbacks → work → callbacks] # 5. CacheMiddleware (after) # 6. AuthorizationMiddleware (after) # 7. AuditMiddleware (after) @@ -35,215 +47,165 @@ end ## Declarations -### Proc or Lambda +### Class or Instance -Use anonymous functions for simple middleware logic: +For reusable middleware logic, use classes (or pass an instance for stateful middleware): ```ruby class ProcessCampaign < CMDx::Task - # Proc - register :middleware, proc do |task, options, &block| - result = block.call - Analytics.track(result.status) - result - end + register :middleware, TelemetryMiddleware + register :middleware, TelemetryMiddleware.new - # Lambda - register :middleware, ->(task, options, &block) { - result = block.call - Analytics.track(result.status) - result - } + register :middleware, MonitoringMiddleware.new(ENV["MONITORING_KEY"]) end ``` -### Class or Module +### Proc or Lambda -For complex middleware logic, use classes or modules: +Procs and lambdas need an explicit `&next_link` parameter to capture the block (Procs can't `yield` directly): ```ruby -class TelemetryMiddleware - def call(task, options) - result = yield - Telemetry.record(result.status) - ensure - result # Always return result - end -end - class ProcessCampaign < CMDx::Task - # Class or Module - register :middleware, TelemetryMiddleware - - # Instance - register :middleware, TelemetryMiddleware.new + register :middleware, proc { |task, &next_link| + Rails.logger.info "[middleware] starting #{task.class}" + next_link.call + } - # With options - register :middleware, MonitoringMiddleware, service_key: ENV["MONITORING_KEY"] - register :middleware, MonitoringMiddleware.new(ENV["MONITORING_KEY"]) + register :middleware, ->(task, &next_link) { + started = Process.clock_gettime(Process::CLOCK_MONOTONIC) + next_link.call + ensure + Analytics.track( + "task.completed", + class: task.class.name, + duration: Process.clock_gettime(Process::CLOCK_MONOTONIC) - started + ) + } end ``` -## Ordering +### Inline Block -Control middleware insertion position with the `at:` parameter. First registered is outermost (default: append to end). +`register :middleware` accepts a block directly: ```ruby class ProcessCampaign < CMDx::Task - register :middleware, AuditMiddleware # Position 0 (outermost) - register :middleware, CacheMiddleware # Position 1 - register :middleware, PriorityMiddleware, at: 0 # Inserted at position 0, pushes others down + register :middleware do |task, &next_link| + Tenant.with_id(task.context.tenant_id) { next_link.call } + end end - -# Execution order: PriorityMiddleware → AuditMiddleware → CacheMiddleware → [task] → ... ``` -## Safety +## Ordering -CMDx detects middlewares that fail to yield or return the result. If a middleware swallows the block call, the task is automatically marked as failed with a descriptive error. +Control insertion position with `at:`. With no `at:`, middlewares append (innermost). The index supports negative values and is clamped to the registry size: ```ruby -class BrokenMiddleware - def call(task, options) - # Forgot to call `yield` — CMDx catches this - end +class ProcessCampaign < CMDx::Task + register :middleware, AuditMiddleware # appended at position 0 + register :middleware, CacheMiddleware # appended at position 1 + register :middleware, PriorityMiddleware, at: 0 # inserted at 0; pushes others down end -result = MyTask.execute -result.failed? #=> true -result.reason #=> "[RuntimeError] ..." +# Execution order: PriorityMiddleware → AuditMiddleware → CacheMiddleware → [task] → ... ``` -!!! danger "Caution" - - Always call `yield` inside your middleware and return the result. Swallowed execution is treated as a failure. - -## Removals - -Remove class or module-based middleware globally or per-task: - -!!! warning - - Each `deregister` call removes one middleware. Use multiple calls for batch removals. +Remove by reference or by index: ```ruby class ProcessCampaign < CMDx::Task - # Class or Module (no instances) - deregister :middleware, TelemetryMiddleware + deregister :middleware, TelemetryMiddleware # by reference + deregister :middleware, at: 0 # by index end ``` -## Built-in - -### Timeout - -Prevent tasks from running too long: - -```ruby -class ProcessReport < CMDx::Task - # Default timeout: 3 seconds - register :middleware, CMDx::Middlewares::Timeout - - # Seconds (takes Numeric, Symbol, Proc, Lambda, Class, Module) - register :middleware, CMDx::Middlewares::Timeout, seconds: :max_processing_time +!!! note - # If or Unless (takes Symbol, Proc, Lambda, Class, Module) - register :middleware, CMDx::Middlewares::Timeout, unless: -> { self.class.name.include?("Quick") } + `register` requires either a callable or a block (not both). `deregister` requires either a `middleware` argument or `at:` (not both). Both raise `ArgumentError` otherwise. - def work - # Your logic here... - end +## Safety - private +If a middleware forgets to call `yield` (or `next_link.call`), the chain raises `CMDx::MiddlewareError` instead of silently bypassing the task body: - def max_processing_time - Rails.env.production? ? 2 : 10 +```ruby +class BrokenMiddleware + def call(task) + # forgot to yield end end -# Slow task -result = ProcessReport.execute +class MyTask < CMDx::Task + register :middleware, BrokenMiddleware + def work; end +end -result.state #=> "interrupted" -result.status #=> "failed" -result.reason #=> "[CMDx::TimeoutError] execution exceeded 3 seconds" -result.cause #=> <CMDx::TimeoutError> -result.metadata #=> { limit: 3 } +MyTask.execute! +#=> raises CMDx::MiddlewareError: "middleware did not yield the next_link" ``` -### Correlate +!!! danger "Caution" -Add correlation IDs for distributed tracing and request tracking: + `MiddlewareError` propagates from both `execute` and `execute!` — it's raised *outside* the `catch(Signal::TAG)` boundary and never becomes a failed result. Always yield in every code path of your middleware (including `rescue` / `ensure` blocks where the call could be skipped). -```ruby -class ProcessExport < CMDx::Task - # Default correlation ID generation - register :middleware, CMDx::Middlewares::Correlate +!!! note - # Seconds (takes Object, Symbol, Proc, Lambda, Class, Module) - register :middleware, CMDx::Middlewares::Correlate, id: proc { |task| task.context.session_id } + Any other exception raised by a middleware (or by an inner link) propagates out through the chain — outer middlewares' after-yield code is skipped unless wrapped in `ensure`. Treat middlewares like Rack: put cleanup in `ensure`. - # If or Unless (takes Symbol, Proc, Lambda, Class, Module) - register :middleware, CMDx::Middlewares::Correlate, if: :correlation_enabled? +## Common Patterns - def work - # Your logic here... - end +### Conditional wrapping - private +Middlewares **must** yield on every code path — skipping `yield` raises +`CMDx::MiddlewareError`. To gate side-effects on a condition, branch around the +extra work but always invoke the next link: - def correlation_enabled? - ENV["CORRELATION_ENABLED"] == "true" +```ruby +class FeatureFlag + def initialize(flag) + @flag = flag + end + + def call(task) + if Flipper.enabled?(@flag) + Tracker.record(:experimental_path, task.class) { yield } + else + yield + end end end -result = ProcessExport.execute -result.metadata #=> { correlation_id: "550e8400-e29b-41d4-a716-446655440000" } +class ExperimentalTask < CMDx::Task + register :middleware, FeatureFlag.new(:experimental_path) +end ``` -#### Class-Level API +If you actually need to short-circuit `work` itself (skip the body but still +produce a result), do it from inside the task with `skip!` / `success!` — not +from a middleware. -Manage correlation IDs directly for cross-boundary tracing (e.g., from a controller into multiple tasks): +### Wrapping with thread-local state ```ruby -# Read or set the current correlation ID -CMDx::Middlewares::Correlate.id #=> current ID or nil -CMDx::Middlewares::Correlate.id = "custom-id" - -# Scoped block — restores the previous ID after the block -CMDx::Middlewares::Correlate.use("request-123") do - ProcessExport.execute # uses "request-123" - SendNotification.execute # same correlation ID -end -# previous ID is restored here - -# Clear the current ID -CMDx::Middlewares::Correlate.clear +register :middleware, ->(task, &next_link) { + Thread.current[:current_user_id] = task.context.user_id + next_link.call +ensure + Thread.current[:current_user_id] = nil +} ``` -### Runtime +### Recording every result -Track task execution time in milliseconds using a monotonic clock: +`Result` data isn't visible from middleware — subscribe to `:task_executed` telemetry instead: ```ruby -class PerformanceMonitoringCheck - def call(task) - task.context.tenant.monitoring_enabled? - end +CMDx.configuration.telemetry.subscribe(:task_executed) do |event| + result = event.payload[:result] + AuditLog.create!( + task_class: event.task_class.name, + status: result.status, + duration: result.duration, + chain_id: event.chain_id + ) end - -class ProcessExport < CMDx::Task - # Default timeout is 3 seconds - register :middleware, CMDx::Middlewares::Runtime - - # If or Unless (takes Symbol, Proc, Lambda, Class, Module) - register :middleware, CMDx::Middlewares::Runtime, if: PerformanceMonitoringCheck -end - -result = ProcessExport.execute -result.metadata #=> { - # started_at: "2026-04-01T14:56:58Z", - # ended_at: "2026-04-01T14:56:59Z" - # runtime: 1247 (ms) - # } ``` diff --git a/docs/outcomes/result.md b/docs/outcomes/result.md index af087be45..ce2150ecd 100644 --- a/docs/outcomes/result.md +++ b/docs/outcomes/result.md @@ -1,150 +1,127 @@ # Outcomes - Result -Results are your window into task execution. They expose everything: outcome, state, timing, context, and metadata. +A `Result` is the read-only outcome of a task execution. It exposes the signal (state, status, reason, metadata, cause), the owning chain, the task's context, and lifecycle metadata (retries, duration, rollback, deprecation). ## Result Attributes -Access essential execution information: - -!!! warning "Important" +!!! note - Results are immutable after execution completes. + Results are immutable. Runtime teardown freezes the `Task`, `Errors`, and — for the root — the `Context` and `Chain`. The backing signal's payload is frozen at construction. ```ruby result = BuildApplication.execute(version: "1.2.3") -# Object data -result.task #=> <BuildApplication> -result.context #=> <CMDx::Context> -result.chain #=> <CMDx::Chain> - -# Execution data +# Identity +result.id #=> "0190..." (uuid_v7 for this execution) +result.task #=> BuildApplication (the task class) +result.type #=> "Task" (or "Workflow") +result.context #=> #<CMDx::Context ...> (frozen on root teardown) +result.ctx #=> alias for #context +result.errors #=> #<CMDx::Errors ...> (frozen on teardown) + +# Chain placement +result.chain #=> #<CMDx::Chain ...> +result.chain.id #=> "0190..." +result.chain_index #=> 0 (root is always 0; children are 1+ in completion order) +result.chain_root? #=> true when this result is the root of its chain + +# Signal data result.state #=> "interrupted" result.status #=> "failed" - -# Fault data result.reason #=> "Build tool not found" -result.cause #=> <CMDx::FailFault> result.metadata #=> { error_code: "BUILD_TOOL.NOT_FOUND" } -``` +result.cause #=> nil, the rescued StandardError, or the propagated Fault -## Lifecycle Information +# Lifecycle metadata +result.duration #=> 12.34 (milliseconds, monotonic) +result.retries #=> 2 +result.retried? #=> true +result.strict? #=> false (true when produced via execute!) +result.deprecated? #=> false +result.rolled_back? #=> false +result.tags #=> [] (from settings(tags: [...])) +``` -Check execution state, status, and rollback with predicate methods: +## Lifecycle Predicates ```ruby result = BuildApplication.execute(version: "1.2.3") -# State predicates (execution lifecycle) -result.complete? #=> true (successful completion) -result.interrupted? #=> false (no interruption) -result.executed? #=> true (execution finished) +# State predicates +result.complete? #=> true on success +result.interrupted? #=> true on skip or fail -# Status predicates (execution outcome) -result.success? #=> true (successful execution) -result.failed? #=> false (no failure) -result.skipped? #=> false (not skipped) +# Status predicates +result.success? #=> true when status == "success" +result.skipped? #=> true when status == "skipped" +result.failed? #=> true when status == "failed" # Outcome categorization -result.good? #=> true (success or skipped) -result.ok? #=> true (alias for good?) -result.bad? #=> false (skipped or failed) - -# Retries -result.retries #=> 2 -result.retried? #=> true (execution was retried) - -# Strict -result.strict? #=> false (complete execution when using `execute!`) - -# Rollback -result.rolled_back? #=> true (execution was rolled back) +result.ok? #=> true for success or skipped +result.ko? #=> true for skipped or failed ``` -## Outcome Analysis - -Get a unified outcome string combining state and status: - -```ruby -result = BuildApplication.execute(version: "1.2.3") +!!! note -result.outcome #=> "success" (state and status) -``` + There are no `executed?` / `executing?` predicates — a `Result` only exists post-finalization, so every result is by definition already executed. ## Chain Analysis -Trace fault origins and propagation: +Failure propagation is tracked as `origin` — the upstream `Result` this one was echoed from (set automatically by `Task#throw!` and by `Runtime` when it rescues a `Fault` inside `work`). The chain helpers all return `nil` when the result isn't `failed?`: ```ruby result = DeploymentWorkflow.execute(app_name: "webapp") if result.failed? - # Find the original cause of failure - if original_failure = result.caused_failure - puts "Root cause: #{original_failure.task.class.name}" - puts "Reason: #{original_failure.reason}" - end - - # Find what threw the failure to this result - if throwing_task = result.threw_failure - puts "Failure source: #{throwing_task.task.class.name}" - puts "Reason: #{throwing_task.reason}" - end - - # Failure classification - result.caused_failure? #=> true if this result was the original cause - result.threw_failure? #=> true if this result threw a failure - result.thrown_failure? #=> true if this result received a thrown failure + result.origin #=> immediate upstream Result, or nil if locally originated + result.threw_failure #=> origin || self (nearest upstream failed result) + result.caused_failure #=> walks origin recursively to the deepest leaf + result.caused_failure? #=> true when this result originated the failure chain (no origin) + result.thrown_failure? #=> true when this result re-threw an upstream failure (has an origin) end ``` -## Index and Position +For a nested workflow where leaf `ChargeCard` fails inside `PaymentWorkflow`, which is run inside `CheckoutWorkflow`: -Results track their position within execution chains: +| Result | `origin` | `threw_failure` | `caused_failure` | `caused_failure?` | `thrown_failure?` | +|---------------------|--------------------|--------------------|------------------|-------------------|-------------------| +| `ChargeCard` | `nil` | `self` | `self` | `true` | `false` | +| `PaymentWorkflow` | `ChargeCard` | `ChargeCard` | `ChargeCard` | `false` | `true` | +| `CheckoutWorkflow` | `PaymentWorkflow` | `PaymentWorkflow` | `ChargeCard` | `false` | `true` | -```ruby -result = BuildApplication.execute(version: "1.2.3") +`threw_failure` always points one level up; `caused_failure` walks all the way down to the originator. -# Position in execution sequence -result.index #=> 0 (first task in chain) +## Annotating a Successful Result -# Access via chain -result.chain.results[result.index] == result #=> true -``` - -## Success Annotation - -Use `success!` to annotate a successful result with a reason and metadata without changing state or status: +`success!` halts `work` early with a custom reason and metadata, just like `skip!` / `fail!` — the difference is that the produced signal has `status: "success"` and `state: "complete"`: ```ruby class ImportRecords < CMDx::Task def work count = import_all(context.records) success!("Imported #{count} records", rows: count) + # Anything below is unreachable end end result = ImportRecords.execute(records: data) result.success? #=> true +result.complete? #=> true result.reason #=> "Imported 42 records" result.metadata #=> { rows: 42 } ``` !!! note - `success!` will halt any further execution by default. - -!!! warning "Important" - - `success!` can only be called while the result status is `success`. Calling it after `skip!` or `fail!` raises a `RuntimeError`. + `success!` `throw`s out of `work` like every other halt method — it is **not** a "set fields without halting" call. To attach metadata mid-`work` without halting, mutate `context` instead. ## Block Yield -Execute code with direct result access: +`execute` and `execute!` both accept a block; the block receives the result and its return value becomes the call's return value: ```ruby -BuildApplication.execute(version: "1.2.3") do |result| +deploy_url = BuildApplication.execute(version: "1.2.3") do |result| if result.success? notify_deployment_ready(result) elsif result.failed? @@ -155,60 +132,51 @@ BuildApplication.execute(version: "1.2.3") do |result| end ``` -## Handlers - -Handle outcomes with functional-style methods. Handlers return the result for chaining. +## Predicate Dispatch -!!! warning "Important" - - The `on` method requires a block. Calling `on` without a block raises `ArgumentError`. +`Result#on(*keys, &block)` yields `self` when any key matches a truthy predicate. Returns `self` for chaining: ```ruby result = BuildApplication.execute(version: "1.2.3") -# Status-based handlers -result - .on(:success) { |result| notify_deployment_ready(result) } - .on(:failed) { |result| handle_build_failure(result) } - .on(:skipped) { |result| log_skip_reason(result) } - -# State-based handlers -result - .on(:complete) { |result| update_build_status(result) } - .on(:interrupted) { |result| cleanup_partial_artifacts(result) } - .on(:executed) { |result| alert_operations_team(result) } #=> .on(:complete, :interrupted) - -# Outcome-based handlers result - .on(:good) { |result| increment_success_counter(result) } #=> .on(:success, :skipped) - .on(:bad) { |result| alert_operations_team(result) } #=> .on(:failed, :skipped) + .on(:success) { |r| notify_deployment_ready(r) } + .on(:failed) { |r| handle_build_failure(r) } + .on(:skipped) { |r| log_skip_reason(r) } + .on(:complete) { |r| update_build_status(r) } + .on(:interrupted) { |r| cleanup_partial_artifacts(r) } + .on(:ok) { |r| increment_success_counter(r) } # success or skipped + .on(:ko) { |r| alert_operations_team(r) } # skipped or failed ``` -## Pattern Matching +!!! warning "Important" -Use Ruby 3.0+ pattern matching for elegant outcome handling: + `on` requires a block (raises `ArgumentError` otherwise) and accepts only these event keys: `:complete`, `:interrupted`, `:success`, `:skipped`, `:failed`, `:ok`, `:ko`. Unknown keys raise `ArgumentError`. -!!! warning "Important" +## Pattern Matching - Pattern matching works with both array and hash deconstruction. +`Result` supports both array and hash deconstruction (Ruby 3.0+). ### Array Pattern +`deconstruct` returns `[type, task, state, status, reason, metadata, cause, origin]`: + ```ruby result = BuildApplication.execute(version: "1.2.3") case result -in ["complete", "success"] - redirect_to build_success_page -in ["interrupted", "failed"] - retry_build_with_backoff(result) -in ["interrupted", "skipped"] - log_skip_and_continue +in [_, _, "complete", "success", *] then redirect_to(build_success_page) +in [_, _, "interrupted", "failed", reason, *] then retry_build_with_backoff(result, reason) +in [_, _, "interrupted", "skipped", *] then log_skip_and_continue +in ["Workflow", BuildApplication, *] then handle_build_workflow(result) end ``` ### Hash Pattern +`deconstruct_keys` exposes: +`:chain_root, :type, :task, :state, :status, :reason, :metadata, :cause, :origin, :strict, :deprecated, :retries, :rolled_back, :duration`. + ```ruby result = BuildApplication.execute(version: "1.2.3") @@ -217,8 +185,10 @@ in { state: "complete", status: "success" } celebrate_build_success in { status: "failed", metadata: { retryable: true } } schedule_build_retry(result) -in { bad: true, metadata: { reason: String => reason } } +in { status: "failed", reason: String => reason } escalate_build_error("Build failed: #{reason}") +in { chain_root: true, rolled_back: true } + alert_root_rollback(result) end ``` @@ -230,7 +200,30 @@ in { status: "failed", metadata: { attempts: n } } if n < 3 retry_build_with_delay(result, n * 2) in { status: "failed", metadata: { attempts: n } } if n >= 3 mark_build_permanently_failed(result) -in { metadata: { runtime: Integer => time } } if time > performance_threshold +in { duration: Float => ms } if ms > performance_threshold investigate_build_performance(result) end ``` + +## Serialization + +`to_h` returns a memoized hash suitable for telemetry sinks and structured logs. `to_s` is the space-separated `key=value.inspect` rendering that `Runtime` writes to the task logger after `task_executed`. + +```ruby +result.to_h +#=> { +# chain_id: "0190...", chain_index: 0, chain_root: true, +# type: "Task", task: BuildApplication, id: "0190...", +# context: #<CMDx::Context ...>, +# state: "complete", status: "success", +# reason: nil, metadata: {}, +# strict: false, deprecated: false, +# retried: false, retries: 0, +# duration: 12.34, tags: [] +# } + +result.to_s +#=> "chain_id=\"0190...\" chain_index=0 ... state=\"complete\" status=\"success\" ..." +``` + +On `failed?` results, `to_h` additionally includes `:cause`, `:origin`, `:threw_failure`, `:caused_failure`, and `:rolled_back`. The `_failure` and `:origin` entries are compact `{ task:, id: }` hashes (and render as `<TaskClass uuid>` in `to_s`) to avoid serializing entire upstream results. `:origin` is `nil` when the failure is locally originated. diff --git a/docs/outcomes/states.md b/docs/outcomes/states.md index 36aff8dba..430bf824a 100644 --- a/docs/outcomes/states.md +++ b/docs/outcomes/states.md @@ -1,66 +1,50 @@ # Outcomes - States -States track where a task is in its execution lifecycle—from creation through completion or interruption. +States track the lifecycle dimension of a result: did `work` run end-to-end, or did something interrupt it? There are exactly two states. Transient stages (`initialized`/`executing`) aren't modeled — `Result` is constructed once, after `Runtime` has finalized the task. ## Definitions | State | Description | | ----- | ----------- | -| `initialized` | Task created but execution not yet started. Default state for new tasks. | -| `executing` | Task is actively running its business logic. Transient state during execution. | -| `complete` | Task finished execution successfully without any interruption or halt. | -| `interrupted` | Task execution was stopped due to a fault, exception, or explicit halt. | +| `complete` | Task finished `work` (and output verification) without any `skip!` / `fail!` / exception. | +| `interrupted` | Task halted via `skip!`, `fail!`, an unrescued `StandardError`, or accumulated `task.errors`. | State-Status combinations: | State | Status | Meaning | | ----- | ------ | ------- | -| `initialized` | `success` | Task created, not yet executed | -| `executing` | `success` | Task currently running | | `complete` | `success` | Task finished successfully | -| `complete` | `skipped` | Task finished by skipping execution | -| `interrupted` | `failed` | Task stopped due to failure | -| `interrupted` | `skipped` | Task stopped by skip condition | +| `interrupted` | `skipped` | Task halted via `skip!` | +| `interrupted` | `failed` | Task halted via `fail!`, `throw!`, an exception, or validation/coercion errors | -## Transitions +!!! note -!!! danger "Caution" - - States are managed automatically—never modify them manually. - -```ruby -# Valid state transition flow -initialized → executing → complete (successful execution) -initialized → executing → interrupted (skipped/failed execution) -``` + `complete` only ever pairs with `success`, and `interrupted` only ever pairs with `skipped` or `failed`. There is no `complete` + `skipped` or `interrupted` + `success` combination. ## Predicates -Use state predicates to check the current execution lifecycle: - ```ruby result = ProcessVideoUpload.execute -# Individual state checks -result.initialized? #=> false (after execution) -result.executing? #=> false (after execution) -result.complete? #=> true (successful completion) -result.interrupted? #=> false (no interruption) - -# State categorization -result.executed? #=> true (complete OR interrupted) +result.complete? #=> true on success, false otherwise +result.interrupted? #=> true on skip or fail, false otherwise ``` ## Handlers -Handle lifecycle events with state-based handlers. Use `on(:executed)` for cleanup that runs regardless of outcome: +State-based dispatch with `on(:complete)` / `on(:interrupted)`. Pass multiple keys to one call when you want either to match: ```ruby result = ProcessVideoUpload.execute -# Individual state handlers result - .on(:complete) { |result| send_upload_notification(result) } - .on(:interrupted) { |result| cleanup_temp_files(result) } - .on(:executed) { |result| log_upload_metrics(result) } #=> .on(:complete, :interrupted) + .on(:complete) { |r| send_upload_notification(r) } + .on(:interrupted) { |r| cleanup_temp_files(r) } + +# Run the same handler for either state — useful for cleanup/metrics +result.on(:complete, :interrupted) { |r| log_upload_metrics(r) } ``` + +!!! warning "Important" + + `on` raises `ArgumentError` for unknown event keys. Valid keys: `:complete`, `:interrupted`, `:success`, `:skipped`, `:failed`, `:ok`, `:ko`. diff --git a/docs/outcomes/statuses.md b/docs/outcomes/statuses.md index 2cefe3dc3..1add2cef1 100644 --- a/docs/outcomes/statuses.md +++ b/docs/outcomes/statuses.md @@ -1,69 +1,68 @@ # Outcomes - Statuses -Statuses represent the business outcome—did the task succeed, skip, or fail? This differs from state, which tracks the execution lifecycle. +Statuses represent the business outcome — did the task succeed, skip, or fail? This is independent of state, which only tracks whether the lifecycle ran to completion or was interrupted. ## Definitions | Status | Description | | ------ | ----------- | -| `success` | Task execution completed successfully with expected business outcome. Default status for all tasks. | -| `skipped` | Task intentionally stopped execution because conditions weren't met or continuation was unnecessary. | -| `failed` | Task stopped execution due to business rule violations, validation errors, or exceptions. | +| `success` | Task `work` ran to completion (and any declared outputs verified). Default outcome. | +| `skipped` | Task halted via `skip!`. Treated as a non-failure outcome. | +| `failed` | Task halted via `fail!`, `throw!`, an unrescued `StandardError`, or accumulated `task.errors`. | -## Transitions +!!! note + + `throw!` isn't a primitive halt — it re-throws a peer's already-`failed?` result through the current task. See [Fault Propagation](../interruptions/faults.md#fault-propagation). -!!! warning "Important" +## Single Final Status - Status transitions are final and unidirectional. Once skipped or failed, tasks can't return to success. +Statuses don't transition. The first `skip!` / `fail!` inside `work` throws out of the call stack, so the result is built once with a single, final status: ```ruby -# Valid status transitions -success → skipped # via skip! -success → failed # via fail! or exception - -# Invalid transitions (will raise errors) -skipped → success # ❌ Cannot transition -skipped → failed # ❌ Cannot transition -failed → success # ❌ Cannot transition -failed → skipped # ❌ Cannot transition +def work + fail!("first") # Runtime catches this and finalizes the result + skip!("second") # Unreachable +end ``` -## Predicates +!!! note + + Calling `skip!` or `fail!` on a frozen task (after `Runtime` teardown) raises `FrozenError` — they can't mutate a finalized result. -Use status predicates to check execution outcomes: +## Predicates ```ruby result = ProcessNotification.execute -# Individual status checks -result.success? #=> true/false -result.skipped? #=> true/false -result.failed? #=> true/false +# Direct status checks +result.success? #=> true / false +result.skipped? #=> true / false +result.failed? #=> true / false # Outcome categorization -result.good? #=> true if success OR skipped (alias: ok?) -result.bad? #=> true if skipped OR failed (not success) +result.ok? #=> true for success and skipped (anything but failed) +result.ko? #=> true for skipped and failed (anything but success) ``` !!! note - `skipped` is intentionally both `good?` and `bad?`. This reflects that skipping is a valid outcome (good — nothing broke) but also a non-success (bad — work wasn't done). Use `success?` when you need a strict success check. + `skipped` is intentionally both `ok?` and `ko?`. It's a valid outcome (`ok` — nothing broke) and a non-success (`ko` — work wasn't done). Use `success?` when you need a strict success check. ## Handlers -Branch business logic with status-based handlers. Use `on(:good)` and `on(:bad)` for success/skip vs failed outcomes: +Branch business logic with status-based handlers. `:ok` and `:ko` are first-class event keys — not aliases of any combination: ```ruby result = ProcessNotification.execute -# Individual status handlers +# Direct status handlers result - .on(:success) { |result| mark_notification_sent(result) } - .on(:skipped) { |result| log_notification_skipped(result) } - .on(:failed){ |result| queue_retry_notification(result) } + .on(:success) { |r| mark_notification_sent(r) } + .on(:skipped) { |r| log_notification_skipped(r) } + .on(:failed) { |r| queue_retry_notification(r) } # Outcome-based handlers result - .on(:good) { |result| update_message_stats(result) } #=> .on(:success, :skipped) - .on(:bad) { |result| track_delivery_failure(result) } #=> .on(:failed, :skipped) + .on(:ok) { |r| update_message_stats(r) } # success or skipped + .on(:ko) { |r| track_delivery_failure(r) } # skipped or failed ``` diff --git a/docs/outputs.md b/docs/outputs.md new file mode 100644 index 000000000..02dcfc342 --- /dev/null +++ b/docs/outputs.md @@ -0,0 +1,177 @@ +# Outputs + +Outputs declare keys the task is expected to write to `context`. After `work` succeeds, the runtime verifies each declared output: it can enforce presence, coerce the value, and run validators against it. Coerced values are written back to context. + +## Declaration + +Use `output` (singular) or `outputs` (they're aliases) to declare one or more keys: + +```ruby +class AuthenticateUser < CMDx::Task + required :email, :password + + output :source + output :user, :token, required: true + + def work + context.source = email.include?("@mycompany.com") ? :admin_portal : :user_portal + context.user = User.authenticate(email, password) + context.token = JwtService.encode(user_id: context.user.id) + end +end +``` + +### Options + +| Option | Default | Description | +|---------------|---------|----------------------------------------------------------------------------| +| `required:` | `false` | When `true`, fails the task if the key is missing from context after `work`| +| `default:` | — | Fallback value, Symbol, Proc, or `#call(task)`-able; applied when `context[name]` is `nil` or the key is absent | +| `coerce:` | — | Same as inputs — single coercer or array applied to the written value | +| `transform:` | — | Symbol, Proc, or `#call(value, task)`-able; applied after coercion and before validation | +| `validate:` | — | Inline callable validator (Symbol, Proc, lambda, or `#call`-able) | +| `if:` / `unless:` | — | Skip verification (and required-ness) when the predicate isn't satisfied | +| `description:` (alias `desc:`) | — | Documentation surfaced via `outputs_schema` | + +Validator keys (`presence:`, `absence:`, `numeric:`, `format:`, `inclusion:`, `exclusion:`, `length:`) and any custom validators registered via `register :validator, ...` work the same as on inputs — see [Inputs - Validations](inputs/validations.md) for the full list. + +```ruby +output :total, coerce: :big_decimal, numeric: { min: 0.01 } +output :report_path, required: true, presence: true +output :exported_at, required: true, if: ->(t) { t.context.persist? } +``` + +### Defaults + +Defaults let you declare constants or derived values alongside the output instead of computing them inside `work`. A default fires during verification whenever the resolved value is `nil` — both "key absent" and "task wrote nil" — flows through coercion and transform, and can satisfy `:required`. + +```ruby +class ComputeRecommendations < CMDx::Task + output :version, default: "v2" # literal + output :source, default: :default_source # Symbol → task#default_source + output :generated_at, default: -> { Time.now } # Proc → instance_exec on task + output :retention_days, default: "7", coerce: :integer # defaults flow through coerce + output :tenant, default: TenantDefaults # anything responding to #call(task) + + def work + # Defaults are applied during verification (after work). Assign in work + # to override a default; leave absent or nil to let the default fill in. + end + + private + + def default_source = self.class.name +end +``` + +See [Inputs - Defaults](inputs/defaults.md) for the long-form treatment of each shape — the resolution rules are identical. + +### Transformations + +Transformations normalize whatever `work` wrote before validators run. Same pipeline as inputs: + +```mermaid +flowchart LR + Write --> Default --> Coerce --> Transform --> Validate --> WriteBack +``` + +```ruby +class CreateUser < CMDx::Task + output :email, coerce: :string, transform: :downcase + output :tags, coerce: :array, transform: proc { |v| v.uniq.sort } + output :days, coerce: :integer, transform: DayClamper, + numeric: { min: 1, max: 30 } + + def work + context.email = params[:email] + context.tags = params[:tags] + context.days = params[:days] + end +end +``` + +Symbol transforms try `value.send(sym)` first, then fall back to `task.send(sym, value)`. Proc transforms run via `instance_exec(value)` — lambdas must accept exactly one argument. Anything else must respond to `#call(value, task)` (use this path when you need access to the task). See [Inputs - Transformations](inputs/transformations.md) for the same semantics applied to inputs. + +## Removals + +Outputs inherit through subclasses. Remove inherited declarations with `deregister` — pass one or more keys per call: + +```ruby +class ApplicationTask < CMDx::Task + output :audit_log, required: true + output :request_id, required: true +end + +class LightweightTask < ApplicationTask + deregister :output, :audit_log, :request_id + + def work + # No longer required to set context.audit_log or context.request_id + end +end +``` + +## Verification Behavior + +Verification runs **after** `work` completes successfully. If `work` threw a `skip!`, `fail!`, or `throw!` signal, outputs are not verified. + +```mermaid +flowchart LR + W[work] --> R{signal thrown?} + R -->|skip! / fail! / throw!| Done[Skip output verification] + R -->|no| V[Verify each output] + V --> E{errors?} + E -->|no| S[Signal.success] + E -->|yes| F[Signal.failed reason=errors.to_s] +``` + +For each output, in declaration order: if `:if`/`:unless` excludes it, skip entirely; otherwise follow the diagram. When the resolved value is `nil`, `:default` fires before the required check. A coercion failure adds an error and short-circuits `:transform` and validation; otherwise the pipeline runs `:transform`, then validators, then writes the final value back to `context[name]`. + +Verification errors fold into the same failed signal that input/validation failures use — `result.reason` is `task.errors.to_s` and `result.errors` exposes the structured map. Under `execute!`, the same failure raises `CMDx::Fault`. + +### Missing Output + +```ruby +class CreateUser < CMDx::Task + output :user, required: true + + def work + # Forgot to set context.user + end +end + +result = CreateUser.execute +result.failed? #=> true +result.reason #=> "user must be set in the context" +result.errors.to_h #=> { user: ["must be set in the context"] } +``` + +### With Bang Execution + +A failing output verification raises `CMDx::Fault`: + +```ruby +begin + CreateUser.execute! +rescue CMDx::Fault => e + e.message #=> "user must be set in the context" + e.result.errors[:user] #=> ["must be set in the context"] + e.task #=> CreateUser (the failing task class) +end +``` + +## Schema Introspection + +`Task.outputs_schema` returns a serialized definition of every declared output, useful for documentation generation or runtime introspection: + +```ruby +class CreateUser < CMDx::Task + output :user, required: true, description: "the persisted user" +end + +CreateUser.outputs_schema +# => { user: { name: :user, +# description: "the persisted user", +# required: true, +# options: { required: true, description: "the persisted user" } } } +``` diff --git a/docs/overrides/home.html b/docs/overrides/home.html index 69118ea65..cde5043fb 100644 --- a/docs/overrides/home.html +++ b/docs/overrides/home.html @@ -2,488 +2,1520 @@ {% block content %} <style> -/* Reset & Base */ -.md-main__inner { - margin-top: 0 !important; - max-width: none; -} +/* ============================================================ + Reset & Base + ============================================================ */ +.md-main__inner { margin-top: 0 !important; max-width: none; } .md-content__inner { padding: 0; } .md-content__inner > :first-child { margin-top: 0; } +.md-content__button { display: none; } +/* ============================================================ + Design Tokens + ============================================================ */ :root { --cmdx-red: #fe1817; --cmdx-red-glow: rgba(254, 24, 23, 0.5); - --cmdx-gradient: linear-gradient(135deg, #fe1817 0%, #ff5f57 100%); - --cmdx-surface: #1a1a1a; - --cmdx-surface-hover: #252525; - --cmdx-border: rgba(255, 255, 255, 0.1); + --cmdx-green: #28c840; + --cmdx-amber: #ffbd2e; + --cmdx-terminal-bg: #0d0d0d; + --cmdx-terminal-surface: #161616; + --cmdx-terminal-fg: #d4d4d4; + --cmdx-terminal-dim: #7a8290; + --cmdx-terminal-border: rgba(255, 255, 255, 0.08); + --cmdx-terminal-rule: rgba(255, 255, 255, 0.04); + --cmdx-mono: var(--md-code-font-family, ui-monospace, SFMono-Regular, Menlo, monospace); } [data-md-color-scheme="default"] { - --cmdx-surface: #ffffff; - --cmdx-surface-hover: #f8f8f8; - --cmdx-border: rgba(0, 0, 0, 0.1); + --cmdx-terminal-bg: #fafafa; + --cmdx-terminal-surface: #ffffff; + --cmdx-terminal-fg: #1a1a1a; + --cmdx-terminal-dim: #6a6a6a; + --cmdx-terminal-border: rgba(0, 0, 0, 0.08); + --cmdx-terminal-rule: rgba(0, 0, 0, 0.04); +} + +.home { + background: var(--cmdx-terminal-bg); + color: var(--cmdx-terminal-fg); + font-family: var(--md-text-font-family); +} + +.home a { text-decoration: none; } + +.home p code, +.home h1 code, +.home h2 code, +.home h3 code, +.home li code, +.home .tagline code, +.home .section-sub code { + background: rgba(254, 24, 23, 0.08); + border: 1px solid rgba(254, 24, 23, 0.2); + color: var(--cmdx-red); + padding: 0.08em 0.4em; + border-radius: 4px; + font-size: 0.88em; + font-family: var(--cmdx-mono); +} + +[data-md-color-scheme="default"] .home p code, +[data-md-color-scheme="default"] .home h1 code, +[data-md-color-scheme="default"] .home h2 code, +[data-md-color-scheme="default"] .home h3 code, +[data-md-color-scheme="default"] .home li code, +[data-md-color-scheme="default"] .home .tagline code, +[data-md-color-scheme="default"] .home .section-sub code { + background: rgba(254, 24, 23, 0.06); + border-color: rgba(254, 24, 23, 0.18); +} + +/* ============================================================ + Window chrome (shared) + ============================================================ */ +.win { + background: var(--cmdx-terminal-surface); + border: 1px solid var(--cmdx-terminal-border); + border-radius: 10px; + overflow: hidden; +} + +.win-bar { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.65rem 1rem; + border-bottom: 1px solid var(--cmdx-terminal-border); + font-family: var(--cmdx-mono); + font-size: 0.78rem; + color: var(--cmdx-terminal-dim); +} + +.win-dots { display: flex; gap: 0.35rem; } +.win-dots span { + width: 11px; height: 11px; border-radius: 50%; + background: var(--cmdx-terminal-border); +} +.win-dots span:nth-child(1) { background: #ff5f57; } +.win-dots span:nth-child(2) { background: var(--cmdx-amber); } +.win-dots span:nth-child(3) { background: var(--cmdx-green); } + +.win-path { margin-left: 0.75rem; } +.win-tag { + margin-left: auto; + color: var(--cmdx-red); + letter-spacing: 0.1em; + font-weight: 700; + font-size: 0.72rem; } -/* Hero Section */ +/* ============================================================ + 1. HERO + ============================================================ */ .hero { position: relative; - padding: 6rem 1rem 4rem; + padding: 5rem 1.5rem 4rem; text-align: center; overflow: hidden; - background: radial-gradient(circle at 50% 0%, rgba(254, 24, 23, 0.08) 0%, transparent 60%); - border-bottom: 1px solid var(--md-default-fg-color--lightest); + background: + radial-gradient(ellipse 800px 400px at 50% 0%, rgba(254, 24, 23, 0.12) 0%, transparent 70%), + var(--cmdx-terminal-bg); + border-bottom: 1px solid var(--cmdx-terminal-border); } -.hero .logo { - margin-bottom: 2.5rem; - animation: fadeUp 0.8s ease-out; +.hero::before { + content: ""; + position: absolute; inset: 0; + background-image: + linear-gradient(var(--cmdx-terminal-rule) 1px, transparent 1px), + linear-gradient(90deg, var(--cmdx-terminal-rule) 1px, transparent 1px); + background-size: 48px 48px; + mask-image: radial-gradient(ellipse 60% 60% at 50% 50%, black, transparent); + -webkit-mask-image: radial-gradient(ellipse 60% 60% at 50% 50%, black, transparent); + pointer-events: none; +} + +.hero-inner { + position: relative; + max-width: 960px; + margin: 0 auto; +} + +.hero-badge { + display: inline-flex; + align-items: center; + gap: 0.6rem; + padding: 0.35rem 0.8rem; + border: 1px solid var(--cmdx-red); + border-radius: 999px; + font-family: var(--cmdx-mono); + font-size: 0.78rem; + color: var(--cmdx-red) !important; + letter-spacing: 0.08em; + margin-bottom: 2rem; + text-transform: uppercase; + text-decoration: none; + transition: background 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease; +} + +.hero-badge:hover { + background: rgba(254, 24, 23, 0.08); + box-shadow: 0 0 0 4px rgba(254, 24, 23, 0.08); + transform: translateY(-1px); } +.hero-badge .dot { + width: 8px; height: 8px; border-radius: 50%; + background: var(--cmdx-red); + box-shadow: 0 0 12px var(--cmdx-red-glow); + transition: box-shadow 0.2s ease; +} + +.hero-badge:hover .dot { box-shadow: 0 0 18px var(--cmdx-red); } + +.hero-badge .arrow { + opacity: 0.6; + transition: transform 0.2s ease, opacity 0.2s ease; +} + +.hero-badge:hover .arrow { opacity: 1; transform: translateX(2px); } + .hero h1 { - font-size: clamp(2.5rem, 6vw, 4.5rem); + font-size: clamp(2.4rem, 6.5vw, 5rem); font-weight: 800; - letter-spacing: -0.02em; - line-height: 1.1; - margin-bottom: 1.5rem; - color: var(--md-typeset-color); - animation: fadeUp 0.8s ease-out 0.1s backwards; + letter-spacing: -0.035em; + line-height: 1; + margin: 0 0 1.25rem; + color: var(--cmdx-terminal-fg); } -.hero h1 span { - background: var(--cmdx-gradient); - -webkit-background-clip: text; - -webkit-text-fill-color: transparent; - background-clip: text; - display: inline-block; +.hero h1 .accent { + color: var(--cmdx-red); } .hero .tagline { - font-size: clamp(1.1rem, 2vw, 1.4rem); - color: var(--md-typeset-color); - opacity: 0.7; - max-width: 700px; - margin: 0 auto 3rem; + font-family: var(--cmdx-mono); + font-size: clamp(0.95rem, 1.6vw, 1.15rem); + color: var(--cmdx-terminal-dim); + max-width: 620px; + margin: 0 auto 2.5rem; line-height: 1.6; - animation: fadeUp 0.8s ease-out 0.2s backwards; } -.hero .buttons { +/* Hero chain */ +.hero-chain { + margin: 0 auto 2.5rem; + max-width: 720px; + padding: 0 1rem; +} + +.hero-chain svg { width: 100%; height: auto; display: block; } + +.chain-node circle { fill: var(--cmdx-terminal-surface); stroke: var(--cmdx-terminal-border); stroke-width: 1.5; } +.chain-node text { fill: var(--cmdx-terminal-dim); font-family: var(--cmdx-mono); font-size: 9px; text-anchor: middle; } +.chain-line { stroke: var(--cmdx-terminal-border); stroke-width: 1.5; fill: none; } + +.chain-signal { + fill: var(--cmdx-red); + filter: drop-shadow(0 0 6px var(--cmdx-red-glow)); +} + +/* Buttons */ +.hero-cta { display: flex; - gap: 1rem; + gap: 0.75rem; justify-content: center; - margin-bottom: 0; - animation: fadeUp 0.8s ease-out 0.3s backwards; + flex-wrap: wrap; + font-family: var(--cmdx-mono); } -.buttons a { - padding: 0.9rem 2.2rem; - border-radius: 50px; +.cmd-btn { + display: inline-flex; + align-items: center; + gap: 0.55rem; + padding: 0.85rem 1.5rem; + border-radius: 8px; + font-size: 0.95rem; font-weight: 600; - font-size: 1.05rem; - text-decoration: none; - transition: all 0.2s ease; + transition: transform 0.15s ease, box-shadow 0.2s ease, border-color 0.2s ease; + font-family: var(--cmdx-mono); + border: 1px solid transparent; } -.buttons .primary { +.cmd-btn .prompt { color: inherit; opacity: 0.6; } + +.cmd-btn.primary { background: var(--cmdx-red); - color: white !important; - box-shadow: 0 4px 20px rgba(254, 24, 23, 0.3); + color: #fff !important; + box-shadow: 0 0 0 0 var(--cmdx-red-glow), 0 8px 30px -10px var(--cmdx-red-glow); } - -.buttons .primary:hover { +.cmd-btn.primary:hover { transform: translateY(-2px); - box-shadow: 0 8px 30px rgba(254, 24, 23, 0.4); + box-shadow: 0 0 0 3px rgba(254,24,23,0.18), 0 12px 40px -8px var(--cmdx-red-glow); } -.buttons .secondary { +.cmd-btn.ghost { background: transparent; - color: var(--md-typeset-color) !important; - border: 1px solid var(--md-default-fg-color--lightest); + color: var(--cmdx-terminal-fg) !important; + border-color: var(--cmdx-terminal-border); } - -.buttons .secondary:hover { +.cmd-btn.ghost:hover { border-color: var(--cmdx-red); color: var(--cmdx-red) !important; } -/* See it in Action — Tabbed Showcase */ -.showcase-section { - background: var(--md-code-bg-color); - border-top: 1px solid var(--md-default-fg-color--lightest); - border-bottom: 1px solid var(--md-default-fg-color--lightest); +.hero-caret { + display: inline-block; + margin-top: 1.75rem; + font-family: var(--cmdx-mono); + font-size: 0.9rem; + color: var(--cmdx-terminal-dim); +} +.hero-caret .blink { + display: inline-block; + width: 8px; height: 1rem; + background: var(--cmdx-terminal-fg); + vertical-align: text-bottom; + margin-left: 3px; +} + +/* ============================================================ + Section shell + ============================================================ */ +.section { + max-width: 1200px; + margin: 0 auto; + padding: 6rem 1.5rem; } -.showcase-tabs { +.section-eyebrow { + display: inline-block; + font-family: var(--cmdx-mono); + font-size: 0.75rem; + color: var(--cmdx-red); + letter-spacing: 0.2em; + text-transform: uppercase; + margin-bottom: 1rem; +} + +.section-title { + font-size: clamp(1.8rem, 3.5vw, 2.75rem); + font-weight: 800; + letter-spacing: -0.02em; + line-height: 1.1; + margin: 0 0 1rem; + color: var(--cmdx-terminal-fg); +} + +.section-sub { + color: var(--cmdx-terminal-dim); + font-family: var(--cmdx-mono); + font-size: 0.95rem; + max-width: 620px; + line-height: 1.65; + margin: 0; +} + +.section-head { margin-bottom: 3rem; } +.section-head.center { text-align: center; } +.section-head.center .section-sub { margin: 0 auto; } + +/* ============================================================ + 2. THE PROBLEM (before / after) + ============================================================ */ +.problem-section { + position: relative; + background: var(--cmdx-terminal-surface); + border-top: 1px solid var(--cmdx-terminal-border); + border-bottom: 1px solid var(--cmdx-terminal-border); +} + +.problem-wrap { + max-width: 1280px; + margin: 0 auto; + padding: 6rem 1.5rem; +} + +.problem-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1.5rem; + margin-top: 3rem; + position: relative; +} + +.problem-arrow { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 44px; height: 44px; + border-radius: 50%; + background: var(--cmdx-red); + color: #fff; display: flex; + align-items: center; justify-content: center; + font-family: var(--cmdx-mono); + font-size: 1.1rem; + font-weight: 700; + box-shadow: 0 0 0 6px var(--cmdx-terminal-surface), 0 0 20px var(--cmdx-red-glow); + z-index: 3; + pointer-events: none; +} + +.problem-panel { + min-width: 0; + display: flex; + flex-direction: column; + border: 1px solid var(--cmdx-terminal-border); + border-radius: 12px; + overflow: hidden; + background: var(--cmdx-terminal-bg); +} + +.problem-panel.before { border-color: rgba(255, 189, 46, 0.25); } +.problem-panel.after { border-color: rgba(254, 24, 23, 0.35); box-shadow: 0 0 0 1px rgba(254, 24, 23, 0.05); } + +.panel-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + padding: 0.75rem 1.1rem; + border-bottom: 1px solid var(--cmdx-terminal-border); + font-family: var(--cmdx-mono); + font-size: 0.72rem; + letter-spacing: 0.15em; + text-transform: uppercase; + color: var(--cmdx-terminal-dim); + background: var(--cmdx-terminal-surface); +} + +.problem-panel.before .panel-head { color: var(--cmdx-amber); } +.problem-panel.after .panel-head { color: var(--cmdx-red); } + +.panel-head .tag { + font-size: 0.65rem; + padding: 0.18rem 0.5rem; + border-radius: 999px; + border: 1px solid currentColor; + letter-spacing: 0.1em; + opacity: 0.85; +} + +.problem-code { + overflow-x: auto; + margin: 0; + padding: 1.25rem 1.35rem; + background: #0d0d0d; + font-family: var(--cmdx-mono); + font-size: 0.78rem; + line-height: 1.7; + color: #d4d4d4; + flex: 1; +} + +[data-md-color-scheme="default"] .problem-code { + background: #ffffff; + color: #1a1a1a; +} +[data-md-color-scheme="default"] .problem-code .k { color: #cf222e; } +[data-md-color-scheme="default"] .problem-code .nc { color: #953800; } +[data-md-color-scheme="default"] .problem-code .n { color: #1a1a1a; } +[data-md-color-scheme="default"] .problem-code .s { color: #0a3069; } +[data-md-color-scheme="default"] .problem-code .c { color: #6e7781; } +[data-md-color-scheme="default"] .problem-code .ss { color: #0550ae; } +[data-md-color-scheme="default"] .problem-code .mi { color: #0550ae; } +[data-md-color-scheme="default"] .problem-code .o { color: #cf222e; } + +.problem-code code { font-family: inherit; color: inherit; } +.problem-code .k { color: #c586c0; } +.problem-code .nc { color: #4ec9b0; } +.problem-code .n { color: #9cdcfe; } +.problem-code .s { color: #ce9178; } +.problem-code .c { color: #6a9955; } +.problem-code .ss { color: #4fc1ff; } +.problem-code .mi { color: #b5cea8; } +.problem-code .o { color: #d4d4d4; } + +.problem-annos { + list-style: none; + padding: 1rem 1.35rem 1.35rem; + margin: 0; + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.4rem 1rem; + background: var(--cmdx-terminal-bg); + border-top: 1px solid var(--cmdx-terminal-border); + font-family: var(--cmdx-mono); + font-size: 0.72rem; +} + +.problem-annos li { + display: flex; + align-items: center; + gap: 0.45rem; + color: var(--cmdx-terminal-dim); +} + +.problem-annos li::before { + content: ""; + width: 6px; height: 6px; border-radius: 50%; + display: inline-block; + flex-shrink: 0; +} + +.problem-panel.before .problem-annos li::before { background: var(--cmdx-amber); opacity: 0.7; } +.problem-panel.after .problem-annos li::before { background: var(--cmdx-green); } + +.problem-panel.after .problem-annos li { color: var(--cmdx-terminal-fg); } + +.problem-caller { + margin: 2.5rem auto 0; + max-width: 760px; + border-color: var(--cmdx-terminal-border); + box-shadow: none; + position: relative; +} + +.problem-caller::before { + content: ""; + position: absolute; + top: -2.5rem; + left: 50%; + width: 1px; + height: 2.5rem; + background: linear-gradient(to bottom, transparent, rgba(254, 24, 23, 0.4)); +} + +.problem-caller .caller-label { + display: inline-flex; + align-items: center; + gap: 0.5rem; + font-family: var(--cmdx-mono); + font-size: 0.7rem; + letter-spacing: 0.15em; + text-transform: uppercase; + color: var(--cmdx-terminal-dim); + padding: 0.85rem 1.35rem 0.25rem; +} + +.problem-caller .caller-label::before { + content: ""; + width: 6px; height: 6px; border-radius: 50%; + background: var(--cmdx-red); + box-shadow: 0 0 8px var(--cmdx-red-glow); +} + +.problem-caller .problem-code { + padding: 0.5rem 1.35rem 1.35rem; + background: transparent; + border: none; +} + +[data-md-color-scheme="default"] .problem-caller .problem-code { background: transparent; } + +/* ============================================================ + 3. CERO PIPELINE + ============================================================ */ +.cero-section { + background: var(--cmdx-terminal-bg); +} + +.cero-rail { + display: grid; + grid-template-columns: repeat(4, 1fr); gap: 0; - border-bottom: 1px solid var(--md-default-fg-color--lightest); + margin-top: 3rem; + position: relative; + align-items: stretch; +} + +.cero-stage { + padding: 1.75rem 1.25rem; + background: var(--cmdx-terminal-surface); + border: 1px solid var(--cmdx-terminal-border); + position: relative; + min-width: 0; +} + +.cero-stage:not(:last-child) { border-right: none; } +.cero-stage:first-child { border-radius: 12px 0 0 12px; } +.cero-stage:last-child { border-radius: 0 12px 12px 0; } + +.cero-stage::after { + content: ""; + position: absolute; + top: 50%; right: -8px; + width: 16px; height: 16px; + background: var(--cmdx-terminal-bg); + border-top: 1px solid var(--cmdx-terminal-border); + border-right: 1px solid var(--cmdx-terminal-border); + transform: translateY(-50%) rotate(45deg); + z-index: 2; +} +.cero-stage:last-child::after { display: none; } + +.cero-stage .idx { + font-family: var(--cmdx-mono); + font-size: 0.7rem; + color: var(--cmdx-red); + letter-spacing: 0.15em; + margin-bottom: 0.5rem; +} + +.cero-stage h3 { + font-size: 1.1rem; + font-weight: 700; + margin: 0 0 0.5rem; + color: var(--cmdx-terminal-fg); +} + +.cero-stage p { + font-size: 0.85rem; + line-height: 1.55; + color: var(--cmdx-terminal-dim); + margin: 0 0 0.85rem; +} + +.cero-stage code { + display: block; + font-family: var(--cmdx-mono); + font-size: 0.72rem; + line-height: 1.5; + color: var(--cmdx-terminal-fg); + background: var(--cmdx-terminal-bg); + border: 1px solid var(--cmdx-terminal-border); + border-radius: 6px; + padding: 0.45rem 0.6rem; + overflow-wrap: break-word; + word-break: break-word; +} + +.cero-stage code .tk { color: var(--cmdx-red); } + +.cero-pulse { + position: absolute; + top: 50%; + left: 0; right: 0; + height: 2px; + transform: translateY(-50%); + background: linear-gradient(90deg, transparent, var(--cmdx-red) 50%, transparent); + opacity: 0.15; + z-index: 1; + pointer-events: none; +} + +/* ============================================================ + 4. SHOWCASE (IDE tabs) + ============================================================ */ +.showcase { + background: var(--cmdx-terminal-surface); + border-top: 1px solid var(--cmdx-terminal-border); + border-bottom: 1px solid var(--cmdx-terminal-border); +} + +.showcase-wrap { + max-width: 1280px; + margin: 0 auto; + padding: 5rem 0 0; +} + +.showcase-wrap .section-head { + padding: 0 1.5rem; + margin-bottom: 2.5rem; +} + +.showcase-frame { + margin: 0 1.5rem 5rem; + background: #0d0d0d; + border: 1px solid var(--cmdx-terminal-border); + border-radius: 10px; + overflow: hidden; +} + +.showcase-tabbar { + display: flex; + align-items: center; + background: #161616; + border-bottom: 1px solid var(--cmdx-terminal-border); overflow-x: auto; -webkit-overflow-scrolling: touch; scrollbar-width: none; } - -.showcase-tabs::-webkit-scrollbar { display: none; } +.showcase-tabbar::-webkit-scrollbar { display: none; } .showcase-tab { - padding: 1rem 1.6rem; - font-size: 0.95rem; - font-weight: 600; - color: var(--md-default-fg-color--light); - background: none; + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.75rem 1.1rem; + background: transparent; border: none; - border-bottom: 2px solid transparent; + border-right: 1px solid var(--cmdx-terminal-border); + border-top: 2px solid transparent; + color: #8a8a8a; + font-family: var(--cmdx-mono); + font-size: 0.82rem; cursor: pointer; - transition: all 0.2s ease; white-space: nowrap; - font-family: var(--md-text-font-family); + transition: color 0.15s ease, background 0.15s ease; } -.showcase-tab:hover { - color: var(--cmdx-red); -} +.showcase-tab:hover { color: #d4d4d4; background: rgba(255,255,255,0.02); } .showcase-tab.active { - color: var(--cmdx-red); - border-bottom-color: var(--cmdx-red); + background: #0d0d0d; + color: #ffffff; + border-top-color: var(--cmdx-red); } -.showcase-panels { - position: relative; +.showcase-tab .ext { color: var(--cmdx-red); opacity: 0.8; } + +[data-md-color-scheme="default"] .showcase-frame { background: #ffffff; } +[data-md-color-scheme="default"] .showcase-tabbar { background: #fafafa; } +[data-md-color-scheme="default"] .showcase-tab { color: #57606a; } +[data-md-color-scheme="default"] .showcase-tab:hover { + color: #1a1a1a; + background: rgba(0, 0, 0, 0.04); +} +[data-md-color-scheme="default"] .showcase-tab.active { + background: #ffffff; + color: #1a1a1a; } +.showcase-body { position: relative; } + .showcase-panel { display: none; - animation: fadeUp 0.4s ease-out; } +.showcase-panel.active { display: grid; } -.showcase-panel.active { - display: flex; - align-items: stretch; +.showcase-panel { + grid-template-columns: 1fr; gap: 0; } -@media (max-width: 900px) { - .showcase-panel.active { - flex-direction: column; - } +.showcase-panel.has-output { + grid-template-columns: 1.35fr 1fr; +} + +@media (max-width: 1024px) { + .showcase-panel.has-output { grid-template-columns: 1fr; } +} + +.showcase-code { + overflow-x: auto; + border-right: 1px solid var(--cmdx-terminal-border); +} +.showcase-panel:not(.has-output) .showcase-code { border-right: none; } + +.showcase-code pre { + margin: 0 !important; + border-radius: 0 !important; + border: none !important; + padding: 1.75rem 1.75rem 2.25rem !important; + font-size: 0.85rem !important; + line-height: 1.7 !important; + background: #0d0d0d !important; + min-height: 100%; +} + +.showcase-code code { + font-family: var(--cmdx-mono) !important; + color: #d4d4d4 !important; } -.showcase-info { - flex: 0 0 320px; - padding: 2.5rem 2rem; +.showcase-code .k { color: #c586c0; } +.showcase-code .nc { color: #4ec9b0; } +.showcase-code .nb { color: #4fc1ff; } +.showcase-code .n { color: #9cdcfe; } +.showcase-code .s { color: #ce9178; } +.showcase-code .c { color: #6a9955; } +.showcase-code .ss { color: #4fc1ff; } +.showcase-code .mi { color: #b5cea8; } +.showcase-code .o { color: #d4d4d4; } +.showcase-code .p { color: #d4d4d4; } + +[data-md-color-scheme="default"] .showcase-code pre { background: #ffffff !important; } +[data-md-color-scheme="default"] .showcase-code code { color: #1a1a1a !important; } +[data-md-color-scheme="default"] .showcase-code .k { color: #cf222e; } +[data-md-color-scheme="default"] .showcase-code .nc { color: #953800; } +[data-md-color-scheme="default"] .showcase-code .nb { color: #0550ae; } +[data-md-color-scheme="default"] .showcase-code .n { color: #1a1a1a; } +[data-md-color-scheme="default"] .showcase-code .s { color: #0a3069; } +[data-md-color-scheme="default"] .showcase-code .c { color: #6e7781; } +[data-md-color-scheme="default"] .showcase-code .ss { color: #0550ae; } +[data-md-color-scheme="default"] .showcase-code .mi { color: #0550ae; } +[data-md-color-scheme="default"] .showcase-code .o { color: #1a1a1a; } +[data-md-color-scheme="default"] .showcase-code .p { color: #1a1a1a; } + +.showcase-output { + background: #0a0a0a; + padding: 1.75rem; + font-family: var(--cmdx-mono); + font-size: 0.8rem; + line-height: 1.7; + color: #d4d4d4; + overflow-x: auto; +} + +.showcase-output .label { + display: block; + color: #6a6a6a; + font-size: 0.7rem; + letter-spacing: 0.15em; + text-transform: uppercase; + margin-bottom: 0.85rem; +} + +.showcase-output .line { display: block; padding: 0.1rem 0; white-space: pre; } +.showcase-output .ok { color: var(--cmdx-green); } +.showcase-output .warn { color: var(--cmdx-amber); } +.showcase-output .err { color: var(--cmdx-red); } +.showcase-output .dim { color: #6a6a6a; } +.showcase-output .level-i { color: #4fc1ff; } +.showcase-output .level-w { color: var(--cmdx-amber); } +.showcase-output .level-e { color: var(--cmdx-red); } +.showcase-output .ts { color: #6a6a6a; } +.showcase-output .kv { color: #9cdcfe; } +.showcase-output .val { color: #ce9178; } +.showcase-output .okv { color: var(--cmdx-green); } +.showcase-output .skv { color: var(--cmdx-amber); } +.showcase-output .erv { color: var(--cmdx-red); } + +[data-md-color-scheme="default"] .showcase-output { + background: #fafafa; + color: #1a1a1a; +} +[data-md-color-scheme="default"] .showcase-output .label, +[data-md-color-scheme="default"] .showcase-output .dim, +[data-md-color-scheme="default"] .showcase-output .ts { color: #6a6a6a; } +[data-md-color-scheme="default"] .showcase-output .level-i { color: #0550ae; } +[data-md-color-scheme="default"] .showcase-output .level-w, +[data-md-color-scheme="default"] .showcase-output .skv { color: #9a6700; } +[data-md-color-scheme="default"] .showcase-output .kv { color: #0550ae; } +[data-md-color-scheme="default"] .showcase-output .val { color: #0a3069; } + +/* ============================================================ + 5. FEATURES GRID + ============================================================ */ +.features { + background: var(--cmdx-terminal-bg); +} + +.features-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); + gap: 1.25rem; + margin-top: 3rem; +} + +@media (min-width: 900px) { + .features-grid { grid-template-columns: repeat(3, 1fr); } +} + +.feature-card { + padding: 1.75rem; + border: 1px solid var(--cmdx-terminal-border); + border-radius: 12px; + background: var(--cmdx-terminal-surface); + position: relative; + transition: border-color 0.25s ease, transform 0.25s ease, box-shadow 0.25s ease; display: flex; flex-direction: column; + gap: 1rem; +} + +.feature-card:hover { + border-color: var(--cmdx-red); + transform: translateY(-3px); + box-shadow: 0 10px 30px -14px var(--cmdx-red-glow); +} + +.feature-icon { + width: 44px; + height: 44px; + border-radius: 10px; + background: rgba(254, 24, 23, 0.08); + border: 1px solid rgba(254, 24, 23, 0.2); + display: flex; + align-items: center; justify-content: center; - border-right: 1px solid var(--md-default-fg-color--lightest); + color: var(--cmdx-red); } -@media (max-width: 900px) { - .showcase-info { - flex: none; - border-right: none; - border-bottom: 1px solid var(--md-default-fg-color--lightest); - padding: 2rem 1.5rem; - } +.feature-icon svg { + width: 22px; + height: 22px; + fill: none; + stroke: currentColor; + stroke-width: 1.75; + stroke-linecap: round; + stroke-linejoin: round; } -.showcase-info h3 { - font-size: 1.3rem; +.feature-card h3 { + font-size: 1.05rem; font-weight: 700; - margin: 0 0 0.75rem; - color: var(--md-typeset-color); + margin: 0; + color: var(--cmdx-terminal-fg); } -.showcase-info p { - color: var(--md-default-fg-color--light); +.feature-card p { + font-size: 0.88rem; line-height: 1.6; + color: var(--cmdx-terminal-dim); margin: 0; - font-size: 0.95rem; } -.showcase-code { - flex: 1; - min-width: 0; - overflow-x: auto; +.features-footer { + margin-top: 2.5rem; + font-family: var(--cmdx-mono); + font-size: 0.88rem; + color: var(--cmdx-terminal-dim); + text-align: center; } -.showcase-code pre { - margin: 0 !important; - border-radius: 0 !important; - border: none !important; - padding: 2rem !important; - font-size: 0.85rem !important; - line-height: 1.6 !important; - background: #1e1e1e !important; - min-height: 100%; +.features-footer a { + color: var(--cmdx-red); + border-bottom: 1px solid transparent; + transition: border-color 0.2s ease; } +.features-footer a:hover { border-bottom-color: var(--cmdx-red); } -.showcase-code code { - font-family: var(--md-code-font-family) !important; - color: #d4d4d4 !important; +/* ============================================================ + 6. TELEMETRY STREAM + ============================================================ */ +.telemetry { + position: relative; + background: #070707; + border-top: 1px solid var(--cmdx-terminal-border); + border-bottom: 1px solid var(--cmdx-terminal-border); + overflow: hidden; + padding: 2rem 0; } -/* Syntax highlighting colors */ -.showcase-code .k { color: #c586c0; } /* keyword */ -.showcase-code .nc { color: #4ec9b0; } /* class name */ -.showcase-code .nb { color: #4fc1ff; } /* builtin */ -.showcase-code .n { color: #9cdcfe; } /* name */ -.showcase-code .s { color: #ce9178; } /* string */ -.showcase-code .c { color: #6a9955; } /* comment */ -.showcase-code .ss { color: #4fc1ff; } /* symbol */ -.showcase-code .mi { color: #b5cea8; } /* integer */ -.showcase-code .o { color: #d4d4d4; } /* operator */ -.showcase-code .p { color: #d4d4d4; } /* punctuation */ - -/* Features Grid */ -.section { - padding: 6rem 2rem; +[data-md-color-scheme="default"] .telemetry { + background: #fafafa; + color: #1a1a1a; +} +[data-md-color-scheme="default"] .telemetry-head { color: #57606a; } +[data-md-color-scheme="default"] .telemetry-track .line { color: #1a1a1a; } +[data-md-color-scheme="default"] .telemetry-track .level-i { color: #0550ae; } +[data-md-color-scheme="default"] .telemetry-track .level-w, +[data-md-color-scheme="default"] .telemetry-track .skv { color: #9a6700; } +[data-md-color-scheme="default"] .telemetry-track .ts { color: #6a6a6a; } +[data-md-color-scheme="default"] .telemetry-track .kv { color: #0550ae; } +[data-md-color-scheme="default"] .telemetry-track .val { color: #0a3069; } + +.telemetry-head { max-width: 1200px; - margin: 0 auto; + margin: 0 auto 1.25rem; + padding: 0 1.5rem; + display: flex; + align-items: center; + gap: 0.75rem; + font-family: var(--cmdx-mono); + font-size: 0.72rem; + color: #7a8290; + letter-spacing: 0.2em; + text-transform: uppercase; } -.section-header { - text-align: center; - margin-bottom: 4rem; +.telemetry-head .live-dot { + width: 8px; height: 8px; + border-radius: 50%; + background: var(--cmdx-green); + box-shadow: 0 0 10px rgba(40, 200, 64, 0.6); } -.section-header h2 { - font-size: 2.5rem; - font-weight: 700; - margin-bottom: 1rem; +.telemetry-viewport { + position: relative; + height: 220px; + overflow: hidden; + mask-image: linear-gradient(180deg, transparent 0%, #000 15%, #000 85%, transparent 100%); + -webkit-mask-image: linear-gradient(180deg, transparent 0%, #000 15%, #000 85%, transparent 100%); } -.section-header p { - font-size: 1.2rem; - color: var(--md-default-fg-color--light); - max-width: 600px; +.telemetry-track { + display: flex; + flex-direction: column; + gap: 0.3rem; + max-width: 1200px; margin: 0 auto; + padding: 0 1.5rem; } -.features-grid { +.telemetry-track .line { + font-family: var(--cmdx-mono); + font-size: 0.78rem; + line-height: 1.6; + color: #d4d4d4; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.telemetry-track .level-i { color: #4fc1ff; } +.telemetry-track .level-w { color: var(--cmdx-amber); } +.telemetry-track .level-e { color: var(--cmdx-red); } +.telemetry-track .ts { color: #6a6a6a; } +.telemetry-track .kv { color: #9cdcfe; } +.telemetry-track .val { color: #ce9178; } +.telemetry-track .okv { color: var(--cmdx-green); } +.telemetry-track .skv { color: var(--cmdx-amber); } +.telemetry-track .erv { color: var(--cmdx-red); } + +/* ============================================================ + 7. USE CASES + ============================================================ */ +.usecases { + background: var(--cmdx-terminal-bg); +} + +.usecase-grid { display: grid; - grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); - gap: 2rem; + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); + gap: 1.25rem; + margin-top: 3rem; } -.feature-card { - padding: 2rem; - border-radius: 16px; - background: var(--md-default-bg-color); - border: 1px solid var(--md-default-fg-color--lightest); - transition: all 0.3s ease; +@media (min-width: 900px) { + .usecase-grid { grid-template-columns: repeat(3, 1fr); } +} + +.usecase-card { + padding: 1.75rem; + border: 1px solid var(--cmdx-terminal-border); + border-radius: 12px; + background: var(--cmdx-terminal-surface); position: relative; + transition: border-color 0.25s ease, transform 0.25s ease, box-shadow 0.25s ease; + display: flex; + flex-direction: column; + gap: 1rem; overflow: hidden; } -.feature-card:hover { - transform: translateY(-5px); - border-color: var(--cmdx-red); - box-shadow: 0 10px 40px -10px rgba(254, 24, 23, 0.15); +.usecase-card::before { + content: ""; + position: absolute; + top: 0; left: 0; + width: 3px; height: 100%; + background: var(--cmdx-red); + opacity: 0; + transition: opacity 0.25s ease; } -.feature-icon { - width: 48px; - height: 48px; - background: rgba(254, 24, 23, 0.1); - border-radius: 12px; +.usecase-card:hover { + border-color: rgba(254, 24, 23, 0.4); + transform: translateY(-3px); + box-shadow: 0 10px 30px -14px var(--cmdx-red-glow); +} + +.usecase-card:hover::before { opacity: 1; } + +.usecase-header { + display: flex; + align-items: center; + gap: 0.85rem; +} + +.usecase-icon { + width: 40px; + height: 40px; + border-radius: 8px; + background: rgba(254, 24, 23, 0.08); + border: 1px solid rgba(254, 24, 23, 0.2); display: flex; align-items: center; justify-content: center; - margin-bottom: 1.5rem; color: var(--cmdx-red); - font-size: 1.5rem; + flex-shrink: 0; } -.feature-card h3 { - font-size: 1.3rem; +.usecase-icon svg { + width: 20px; + height: 20px; + fill: none; + stroke: currentColor; + stroke-width: 1.75; + stroke-linecap: round; + stroke-linejoin: round; +} + +.usecase-card h3 { + font-size: 1.02rem; font-weight: 700; - margin-bottom: 1rem; + margin: 0; + color: var(--cmdx-terminal-fg); } -.feature-card p { - color: var(--md-default-fg-color--light); +.usecase-card p { + font-size: 0.87rem; line-height: 1.6; + color: var(--cmdx-terminal-dim); margin: 0; } -/* Comparison / Stats Section */ -.stats-section { - background: var(--md-code-bg-color); - border: 1px solid var(--md-default-fg-color--lightest); +.usecase-card .tag-row { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; + margin-top: auto; + padding-top: 0.5rem; +} + +.usecase-card .tag-row span { + font-family: var(--cmdx-mono); + font-size: 0.68rem; + color: var(--cmdx-terminal-dim); + padding: 0.2rem 0.5rem; + border: 1px solid var(--cmdx-terminal-border); + border-radius: 999px; } -.stats-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); - gap: 3rem; +/* ============================================================ + 8. FOOTER CTA + ============================================================ */ +.cta-footer { + background: var(--cmdx-terminal-surface); + border-top: 1px solid var(--cmdx-terminal-border); + padding: 5rem 1.5rem 6rem; text-align: center; } -.stat-item .number { - font-size: 3.5rem; +.cta-footer h2 { + font-size: clamp(1.6rem, 3vw, 2.25rem); font-weight: 800; - background: var(--cmdx-gradient); - -webkit-background-clip: text; - -webkit-text-fill-color: transparent; - background-clip: text; - display: block; - margin-bottom: 0.5rem; + letter-spacing: -0.02em; + margin: 0 0 2rem; + color: var(--cmdx-terminal-fg); } -.stat-item .label { - font-size: 1.1rem; - font-weight: 600; - color: var(--md-default-fg-color--light); +.install-box { + display: inline-flex; + align-items: center; + gap: 0.85rem; + padding: 0.85rem 1rem 0.85rem 1.15rem; + background: var(--cmdx-terminal-bg); + border: 1px solid var(--cmdx-terminal-border); + border-radius: 10px; + font-family: var(--cmdx-mono); + font-size: 1rem; + color: var(--cmdx-terminal-fg); + max-width: 100%; + overflow: hidden; } -/* CERO Pattern Section */ -.cero-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); - gap: 2rem; - margin: 3rem auto 0; - max-width: 1200px; +.install-box .prompt { color: var(--cmdx-red); } + +.install-box code { + background: transparent; + padding: 0; + border: none; + color: inherit; + font-family: inherit; + white-space: nowrap; } -.cero-step { - text-align: center; - padding: 1.5rem; - position: relative; +.copy-btn { + display: inline-flex; + align-items: center; + gap: 0.4rem; + padding: 0.45rem 0.75rem; + background: transparent; + border: 1px solid var(--cmdx-terminal-border); + border-radius: 6px; + color: var(--cmdx-terminal-dim); + cursor: pointer; + font-family: var(--cmdx-mono); + font-size: 0.78rem; + transition: border-color 0.2s ease, color 0.2s ease; } +.copy-btn:hover { border-color: var(--cmdx-red); color: var(--cmdx-red); } +.copy-btn.copied { border-color: var(--cmdx-green); color: var(--cmdx-green); } -.cero-step::after { - content: "→"; - position: absolute; - right: -1rem; - top: 50%; - transform: translateY(-50%); - font-size: 2rem; - color: var(--md-default-fg-color--lightest); +.cta-links { + margin-top: 2rem; + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 0.5rem 2rem; + font-family: var(--cmdx-mono); + font-size: 0.9rem; + color: var(--cmdx-terminal-dim); } -.cero-step:last-child::after { display: none; } +.cta-links a { + color: var(--cmdx-terminal-fg); + border-bottom: 1px solid var(--cmdx-terminal-border); + padding-bottom: 2px; + transition: color 0.2s ease, border-color 0.2s ease; +} +.cta-links a:hover { + color: var(--cmdx-red); + border-bottom-color: var(--cmdx-red); +} -@media (max-width: 1024px) { - .cero-step::after { - right: 50%; - top: auto; - bottom: -2rem; - transform: translateX(50%) rotate(90deg); - } +/* ============================================================ + Animations (gated by prefers-reduced-motion) + ============================================================ */ +@keyframes fadeUp { + from { opacity: 0; transform: translateY(16px); } + to { opacity: 1; transform: translateY(0); } } -.cero-icon { - font-size: 2.5rem; - margin-bottom: 1rem; - display: block; +@keyframes blink { + 0%, 49% { opacity: 1; } + 50%, 100% { opacity: 0; } } -.cero-title { - font-weight: 700; - font-size: 1.2rem; - margin-bottom: 0.5rem; - color: var(--cmdx-red); +@keyframes signalPulse { + 0% { offset-distance: 0%; opacity: 0; } + 8% { opacity: 1; } + 92% { opacity: 1; } + 100% { offset-distance: 100%; opacity: 0; } } -/* Comparison Table */ -.comparison-table { - width: 100%; - border-collapse: collapse; - margin-top: 2rem; - background: var(--md-default-bg-color); - border-radius: 12px; - overflow: hidden; - box-shadow: 0 4px 20px rgba(0,0,0,0.05); +@keyframes ceroPulse { + 0% { transform: translate(0, -50%) scaleX(0); opacity: 0; transform-origin: left; } + 20% { opacity: 1; } + 100% { transform: translate(0, -50%) scaleX(1); opacity: 0; transform-origin: left; } } -.comparison-table th, -.comparison-table td { - padding: 1rem 1.5rem; - text-align: left; - border-bottom: 1px solid var(--md-default-fg-color--lightest); +@keyframes chainDotFlash { + 0%, 100% { fill: var(--cmdx-terminal-surface); } + 50% { fill: var(--cmdx-green); } } -.comparison-table th { - background: var(--md-code-bg-color); - font-weight: 600; +@keyframes scrollLogs { + from { transform: translateY(0); } + to { transform: translateY(-50%); } } -.comparison-table tr:last-child td { border-bottom: none; } +@media (prefers-reduced-motion: no-preference) { + .hero h1, + .hero .tagline, + .hero-cta, + .hero-badge, + .hero-chain, + .hero-caret { + animation: fadeUp 0.7s ease-out both; + } + .hero-badge { animation-delay: 0s; } + .hero h1 { animation-delay: 0.08s; } + .hero .tagline { animation-delay: 0.16s; } + .hero-chain { animation-delay: 0.24s; } + .hero-cta { animation-delay: 0.32s; } + .hero-caret { animation-delay: 0.4s; } + + .hero-caret .blink { animation: blink 1.1s steps(1) infinite; } + + .chain-signal { + offset-path: path("M 50,30 L 190,30 L 330,30 L 470,30 L 610,30"); + animation: signalPulse 5.5s ease-in-out infinite; + } + + .chain-node.n1 circle { animation: chainDotFlash 5.5s ease-in-out infinite; animation-delay: 0.2s; } + .chain-node.n2 circle { animation: chainDotFlash 5.5s ease-in-out infinite; animation-delay: 1.4s; } + .chain-node.n3 circle { animation: chainDotFlash 5.5s ease-in-out infinite; animation-delay: 2.6s; } + .chain-node.n4 circle { animation: chainDotFlash 5.5s ease-in-out infinite; animation-delay: 3.8s; } -.comparison-table .check { color: #28c840; } -.comparison-table .cross { color: #ff5f57; opacity: 0.5; } + .cero-pulse { animation: ceroPulse 4s ease-in-out infinite; } -/* Animations */ -@keyframes fadeUp { - from { opacity: 0; transform: translateY(20px); } - to { opacity: 1; transform: translateY(0); } + .telemetry-track { animation: scrollLogs 45s linear infinite; } + .telemetry:hover .telemetry-track { animation-play-state: paused; } +} + +/* ============================================================ + Responsive + ============================================================ */ +@media (max-width: 900px) { + .problem-grid { grid-template-columns: 1fr; gap: 1.5rem; } + .problem-arrow { display: none; } + .problem-annos { grid-template-columns: 1fr 1fr; } + .cero-rail { grid-template-columns: 1fr; } + .cero-stage { border-right: 1px solid var(--cmdx-terminal-border) !important; } + .cero-stage:first-child { border-radius: 12px 12px 0 0; } + .cero-stage:last-child { border-radius: 0 0 12px 12px; } + .cero-stage:not(:last-child) { border-bottom: none; } + .cero-stage::after { + top: auto; bottom: -9px; right: 50%; + transform: translateX(50%) rotate(135deg); + } + .cero-pulse { display: none; } } -@media (max-width: 768px) { - .hero h1 { font-size: 2.5rem; } - .buttons { flex-direction: column; } - .buttons a { width: 100%; text-align: center; } - .cero-step::after { display: none; } +@media (max-width: 640px) { + .hero { padding: 3.5rem 1rem 3rem; } + .section { padding: 4rem 1rem; } + .problem-wrap { padding: 4rem 1rem; } + .showcase-wrap { padding-top: 4rem; } + .showcase-frame, .showcase-wrap .section-head { margin-left: 1rem; margin-right: 1rem; } + .cta-footer { padding: 4rem 1rem 5rem; } + .hero-cta { flex-direction: column; } + .cmd-btn { width: 100%; justify-content: center; } + .install-box { flex-direction: column; align-items: stretch; gap: 0.6rem; } + .install-box code { text-align: center; } } </style> -<!-- Hero --> -<div class="hero"> - <div class="logo"> - <img src="assets/cmdx-light-logo.png" alt="CMDx" class="only-light" /> - <img src="assets/cmdx-dark-logo.png" alt="CMDx" class="only-dark" /> - </div> +<div class="home"> + +<!-- ===================== HERO ===================== --> +<section class="hero"> + <div class="hero-inner"> + + <a class="hero-badge" href="v2-migration/"> + <span class="dot"></span> + <span>v2.0 · Signal</span> + <span class="arrow">→</span> + </a> + + <h1> + The runtime,<br> + <span class="accent">rewritten.</span> + </h1> + + <p class="tagline"> + Signals in. Immutable results out.<br> + A business-logic framework powered by an explicit signal-based runtime. + </p> + + <div class="hero-chain" aria-hidden="true"> + <svg viewBox="0 0 660 60" preserveAspectRatio="xMidYMid meet"> + <line class="chain-line" x1="50" y1="30" x2="610" y2="30" /> + + <g class="chain-node n1"> + <circle cx="50" cy="30" r="12" /> + <text x="50" y="55">work</text> + </g> + <g class="chain-node n2"> + <circle cx="190" cy="30" r="12" /> + <text x="190" y="55">signal</text> + </g> + <g class="chain-node n3"> + <circle cx="330" cy="30" r="12" /> + <text x="330" y="55">result</text> + </g> + <g class="chain-node n4"> + <circle cx="470" cy="30" r="12" /> + <text x="470" y="55">telemetry</text> + </g> + <g class="chain-node n5"> + <circle cx="610" cy="30" r="12" style="stroke: var(--cmdx-red);" /> + <text x="610" y="55" style="fill: var(--cmdx-red);">done</text> + </g> + + <circle class="chain-signal" cx="0" cy="0" r="5" /> + </svg> + </div> - <h1> - The <span>Business Logic</span><br> - Framework for Ruby - </h1> + <div class="hero-cta"> + <a href="getting_started/" class="cmd-btn primary"> + <span class="prompt">$</span> + <span>get-started</span> + </a> + <a href="https://github.com/drexed/cmdx" class="cmd-btn ghost"> + <span class="prompt">></span> + <span>view on github</span> + </a> + </div> - <p class="tagline"> - Build maintainable systems, not just scripts. - </p> + <div class="hero-caret"> + <span>$ gem install cmdx</span><span class="blink"></span> + </div> - <div class="buttons"> - <a href="getting_started/" class="primary">Get Started</a> - <a href="https://github.com/drexed/cmdx" class="secondary">View on GitHub</a> </div> -</div> +</section> + +<!-- ===================== 01. THE PROBLEM ===================== --> +<section class="problem-section"> + <div class="problem-wrap"> + + <div class="section-head center"> + <span class="section-eyebrow">· 01 · the problem</span> + <h2 class="section-title">The code you keep rewriting.</h2> + <p class="section-sub"> + Every Rails app has a 300-line controller like this. CMDx gives it a home. + </p> + </div> + + <div class="problem-grid"> + <span class="problem-arrow" aria-hidden="true">→</span> + + <!-- BEFORE --> + <div class="problem-panel before"> + <div class="panel-head"> + <span>today, in every rails app</span> + </div> + <pre class="problem-code"><code><span class="k">class</span> <span class="nc">PaymentsController</span> <span class="o"><</span> <span class="nc">ApplicationController</span> + <span class="k">def</span> <span class="n">create</span> + <span class="n">order</span> = <span class="nc">Order</span>.<span class="n">find</span>(<span class="n">params</span>[<span class="ss">:order_id</span>]) + <span class="nc">ActiveRecord::Base</span>.<span class="n">transaction</span> <span class="k">do</span> + <span class="n">charge</span> = <span class="nc">Stripe::Charge</span>.<span class="n">create</span>( + <span class="ss">amount:</span> <span class="n">order</span>.<span class="n">total_cents</span>, + <span class="ss">currency:</span> <span class="s">"usd"</span>, + <span class="ss">source:</span> <span class="n">params</span>[<span class="ss">:token</span>] + ) + <span class="n">order</span>.<span class="n">update!</span>(<span class="ss">charged:</span> <span class="n">true</span>, <span class="ss">charge_id:</span> <span class="n">charge</span>.<span class="n">id</span>) + <span class="nc">OrderMailer</span>.<span class="n">receipt</span>(<span class="n">order</span>).<span class="n">deliver_later</span> + <span class="k">end</span> + <span class="n">redirect_to</span> <span class="n">order</span> + <span class="k">rescue</span> <span class="nc">Stripe::CardError</span> <span class="o">=></span> <span class="n">e</span> + <span class="n">flash</span>[<span class="ss">:error</span>] = <span class="n">e</span>.<span class="n">message</span> + <span class="n">render</span> <span class="ss">:new</span> + <span class="k">rescue</span> <span class="o">=></span> <span class="n">e</span> + <span class="nc">Rails</span>.<span class="n">logger</span>.<span class="n">error</span>(<span class="n">e</span>) + <span class="n">render</span> <span class="ss">:new</span> + <span class="k">end</span> +<span class="k">end</span></code></pre> + <ul class="problem-annos"> + <li>mixed concerns</li> + <li>rescue-driven flow</li> + <li>not reusable</li> + <li>untyped inputs</li> + </ul> + </div> + + <!-- AFTER --> + <div class="problem-panel after"> + <div class="panel-head"> + <span>with cmdx</span> + </div> + <pre class="problem-code"><code><span class="k">class</span> <span class="nc">ProcessPayment</span> <span class="o"><</span> <span class="nc">CMDx::Task</span> + <span class="n">required</span> <span class="ss">:order_id</span>, <span class="ss">coerce:</span> <span class="ss">:integer</span> + <span class="n">required</span> <span class="ss">:token</span>, <span class="ss">coerce:</span> <span class="ss">:string</span> + + <span class="n">output</span> <span class="ss">:order</span> + + <span class="n">on_success</span> <span class="ss">:send_receipt!</span> + + <span class="k">def</span> <span class="n">work</span> + <span class="nc">ActiveRecord::Base</span>.<span class="n">transaction</span> <span class="k">do</span> + <span class="n">order</span>.<span class="n">update!</span>(<span class="ss">charged:</span> <span class="n">true</span>, <span class="ss">charge_id:</span> <span class="n">charge</span>.<span class="n">id</span>) + <span class="k">end</span> + <span class="n">context</span>.<span class="n">order</span> = <span class="n">order</span> + <span class="k">end</span> + + <span class="k">private</span> + + <span class="k">def</span> <span class="n">order</span> = <span class="n">@order</span> <span class="o">||=</span> <span class="nc">Order</span>.<span class="n">find</span>(<span class="n">order_id</span>) + <span class="k">def</span> <span class="n">charge</span> = <span class="nc">Stripe::Charge</span>.<span class="n">create</span>(<span class="ss">amount:</span> <span class="n">order</span>.<span class="n">total_cents</span>, <span class="ss">currency:</span> <span class="s">"usd"</span>, <span class="ss">source:</span> <span class="n">token</span>) + <span class="k">def</span> <span class="n">send_receipt!</span> = <span class="nc">OrderMailer</span>.<span class="n">receipt</span>(<span class="n">order</span>).<span class="n">deliver_later</span> +<span class="k">end</span></code></pre> + <ul class="problem-annos"> + <li>typed inputs</li> + <li>one responsibility</li> + <li>reusable anywhere</li> + <li>predictable result</li> + </ul> + </div> + </div> + + <!-- Caller --> + <div class="problem-panel problem-caller"> + <div class="caller-label">your controller, now</div> + <pre class="problem-code"><code><span class="k">def</span> <span class="n">create</span> + <span class="n">result</span> = <span class="nc">ProcessPayment</span>.<span class="n">execute</span>(<span class="ss">order_id:</span> <span class="n">params</span>[<span class="ss">:order_id</span>], <span class="ss">token:</span> <span class="n">params</span>[<span class="ss">:token</span>]) + <span class="k">return</span> <span class="n">redirect_to</span>(<span class="n">result</span>.<span class="n">context</span>.<span class="n">order</span>) <span class="k">if</span> <span class="n">result</span>.<span class="n">success?</span> + + <span class="n">render</span> <span class="ss">:new</span>, <span class="ss">alert:</span> <span class="n">result</span>.<span class="n">reason</span> +<span class="k">end</span></code></pre> + </div> -<!-- See it in Action --> -<div class="showcase-section"> - <div class="section-header" style="padding: 4rem 2rem 0;"> - <h2>See it in Action</h2> - <p>From simple tasks to complex workflows, see how CMDx brings clarity to your business logic.</p> </div> +</section> + +<!-- ===================== 02. CERO PIPELINE ===================== --> +<section class="cero-section"> + <div class="section"> + + <div class="section-head center"> + <span class="section-eyebrow">· 02 · the pipeline</span> + <h2 class="section-title">CERO — the four-step flow.</h2> + <p class="section-sub">A single, repeatable shape behind every task.</p> + </div> + + <div class="cero-rail"> + <div class="cero-pulse" aria-hidden="true"></div> + + <div class="cero-stage"> + <div class="idx">01 · C</div> + <h3>Compose</h3> + <p>Define typed inputs and the business rules they enforce.</p> + <code><span class="tk">required</span> :id, <span class="tk">coerce:</span> :integer</code> + </div> + + <div class="cero-stage"> + <div class="idx">02 · E</div> + <h3>Execute</h3> + <p>Run tasks anywhere — controllers, jobs, scripts, tests.</p> + <code>Task.<span class="tk">execute</span>(id: 42)</code> + </div> + + <div class="cero-stage"> + <div class="idx">03 · R</div> + <h3>React</h3> + <p>Branch on success, skip, or fail — no rescues for control flow.</p> + <code><span class="tk">result</span>.success?<br><span class="tk">result</span>.skipped?<br><span class="tk">result</span>.failed?</code> + </div> + + <div class="cero-stage"> + <div class="idx">04 · O</div> + <h3>Observe</h3> + <p>Every run ships structured logs and telemetry.</p> + <code>configuration.telemetry.<span class="tk">subscribe</span>(...)</code> + </div> + </div> - <div class="showcase-tabs" role="tablist"> - <button class="showcase-tab active" data-panel="define" role="tab">Define a Task</button> - <button class="showcase-tab" data-panel="execute" role="tab">Execute & React</button> - <button class="showcase-tab" data-panel="workflow" role="tab">Compose Workflows</button> - <button class="showcase-tab" data-panel="retries" role="tab">Fault Tolerance</button> - <button class="showcase-tab" data-panel="observe" role="tab">Observe</button> </div> +</section> - <div class="showcase-panels"> - <!-- Define a Task --> - <div class="showcase-panel active" id="panel-define"> - <div class="showcase-info"> - <h3>Define a Task</h3> - <p>Declare typed attributes with validation, coercion, and defaults. Write your business logic in a single <code>work</code> method with built-in flow control for success, failure, and skip states.</p> +<!-- ===================== 03. SHOWCASE IDE ===================== --> +<section class="showcase"> + <div class="showcase-wrap"> + + <div class="section-head"> + <span class="section-eyebrow">· 03 · see it in action</span> + <h2 class="section-title">From one task to full workflows.</h2> + <p class="section-sub">From a single command to an orchestrated pipeline. Click through the files.</p> + </div> + + <div class="showcase-frame"> + + <div class="showcase-tabbar" role="tablist"> + <button class="showcase-tab active" data-panel="define" role="tab"> + <span>task</span><span class="ext">.rb</span> + </button> + <button class="showcase-tab" data-panel="workflow" role="tab"> + <span>workflow</span><span class="ext">.rb</span> + </button> + <button class="showcase-tab" data-panel="execute" role="tab"> + <span>execute</span><span class="ext">.sh</span> + </button> + <button class="showcase-tab" data-panel="observe" role="tab"> + <span>log</span><span class="ext">.txt</span> + </button> </div> - <div class="showcase-code"> - <pre><code><span class="k">class</span> <span class="nc">ApproveLoan</span> <span class="o"><</span> <span class="nc">CMDx::Task</span> - <span class="n">register</span> <span class="ss">:middleware</span>, <span class="nc">CMDx::Middlewares::Runtime</span> - <span class="n">required</span> <span class="ss">:application_id</span>, <span class="ss">type:</span> <span class="ss">:integer</span> + <div class="showcase-body"> + + <!-- Define --> + <div class="showcase-panel active" id="panel-define"> + <div class="showcase-code"> + <pre><code><span class="k">class</span> <span class="nc">ApproveLoan</span> <span class="o"><</span> <span class="nc">CMDx::Task</span> + <span class="n">required</span> <span class="ss">:application_id</span>, <span class="ss">coerce:</span> <span class="ss">:integer</span> <span class="n">optional</span> <span class="ss">:override_checks</span>, <span class="ss">default:</span> <span class="nb">false</span> <span class="n">on_success</span> <span class="ss">:notify_applicant!</span> - <span class="n">returns</span> <span class="ss">:approved_at</span> + <span class="n">output</span> <span class="ss">:approved_at</span> <span class="k">def</span> <span class="n">work</span> <span class="k">if</span> <span class="n">application</span>.<span class="n">nil?</span> @@ -506,45 +1538,13 @@ <h3>Define a Task</h3> <span class="nc">ApprovalMailer</span>.<span class="n">approved</span>(<span class="n">application</span>).<span class="n">deliver_later</span> <span class="k">end</span> <span class="k">end</span></code></pre> - </div> - </div> - - <!-- Execute & React --> - <div class="showcase-panel" id="panel-execute"> - <div class="showcase-info"> - <h3>Execute & React</h3> - <p>Every execution returns a standardized Result object. No more rescuing exceptions for control flow. Check the outcome and access returned values, error messages, and metadata.</p> - </div> - <div class="showcase-code"> - <pre><code><span class="c"># Execute with typed, validated arguments</span> -<span class="n">result</span> = <span class="nc">ApproveLoan</span>.<span class="n">execute</span>(<span class="ss">application_id:</span> <span class="mi">42</span>) - -<span class="c"># React to the outcome</span> -<span class="k">if</span> <span class="n">result</span>.<span class="n">success?</span> - <span class="n">puts</span> <span class="s">"Approved at </span><span class="c">#{</span><span class="n">result</span>.<span class="n">context</span>.<span class="n">approved_at</span><span class="c">}</span><span class="s">"</span> - -<span class="k">elsif</span> <span class="n">result</span>.<span class="n">skipped?</span> - <span class="n">puts</span> <span class="s">"Skipped: </span><span class="c">#{</span><span class="n">result</span>.<span class="n">reason</span><span class="c">}</span><span class="s">"</span> + </div> + </div> -<span class="k">elsif</span> <span class="n">result</span>.<span class="n">failed?</span> - <span class="n">puts</span> <span class="s">"Failed: </span><span class="c">#{</span><span class="n">result</span>.<span class="n">reason</span><span class="c">}</span><span class="s">"</span> - <span class="n">puts</span> <span class="s">"Code: </span><span class="c">#{</span><span class="n">result</span>.<span class="n">metadata</span>[<span class="ss">:code</span>]<span class="c">}</span><span class="s">"</span> -<span class="k">end</span> - -<span class="c"># Bang variant raises on failure</span> -<span class="n">result</span> = <span class="nc">ApproveLoan</span>.<span class="n">execute!</span>(<span class="ss">application_id:</span> <span class="mi">42</span>) -<span class="c"># => raises CMDx::ExecutionError on failure</span></code></pre> - </div> - </div> - - <!-- Compose Workflows --> - <div class="showcase-panel" id="panel-workflow"> - <div class="showcase-info"> - <h3>Compose Workflows</h3> - <p>Chain tasks into declarative pipelines with shared context, conditional execution, and configurable halt behavior. Run tasks in parallel for maximum throughput.</p> - </div> - <div class="showcase-code"> - <pre><code><span class="k">class</span> <span class="nc">OnboardCustomer</span> <span class="o"><</span> <span class="nc">CMDx::Task</span> + <!-- Workflow --> + <div class="showcase-panel" id="panel-workflow"> + <div class="showcase-code"> + <pre><code><span class="k">class</span> <span class="nc">OnboardCustomer</span> <span class="o"><</span> <span class="nc">CMDx::Task</span> <span class="k">include</span> <span class="nc">CMDx::Workflow</span> <span class="c"># Sequential pipeline</span> @@ -567,292 +1567,385 @@ <h3>Compose Workflows</h3> <span class="k">def</span> <span class="n">premium_plan?</span> <span class="n">context</span>.<span class="n">plan</span>.<span class="n">tier</span> == <span class="ss">:premium</span> <span class="k">end</span> +<span class="k">end</span></code></pre> + </div> + </div> + + <!-- Execute --> + <div class="showcase-panel has-output" id="panel-execute"> + <div class="showcase-code"> + <pre><code><span class="c"># Execute with typed, validated arguments</span> +<span class="n">result</span> = <span class="nc">ApproveLoan</span>.<span class="n">execute</span>(<span class="ss">application_id:</span> <span class="mi">42</span>) + +<span class="c"># React to the outcome</span> +<span class="k">if</span> <span class="n">result</span>.<span class="n">success?</span> + <span class="n">puts</span> <span class="s">"Approved at </span><span class="c">#{</span><span class="n">result</span>.<span class="n">context</span>.<span class="n">approved_at</span><span class="c">}</span><span class="s">"</span> + +<span class="k">elsif</span> <span class="n">result</span>.<span class="n">skipped?</span> + <span class="n">puts</span> <span class="s">"Skipped: </span><span class="c">#{</span><span class="n">result</span>.<span class="n">reason</span><span class="c">}</span><span class="s">"</span> + +<span class="k">elsif</span> <span class="n">result</span>.<span class="n">failed?</span> + <span class="n">puts</span> <span class="s">"Failed: </span><span class="c">#{</span><span class="n">result</span>.<span class="n">reason</span><span class="c">}</span><span class="s">"</span> + <span class="n">puts</span> <span class="s">"Code: </span><span class="c">#{</span><span class="n">result</span>.<span class="n">metadata</span>[<span class="ss">:code</span>]<span class="c">}</span><span class="s">"</span> +<span class="k">end</span> + +<span class="c"># Bang variant raises on failure</span> +<span class="n">result</span> = <span class="nc">OnboardCustomer</span>.<span class="n">execute!</span>(<span class="ss">email:</span> <span class="s">"jane@co.com"</span>) +<span class="c"># => raises CMDx::Fault on failure</span></code></pre> + </div> + <aside class="showcase-output"> + <span class="label">> stdout</span> + <span class="line"><span class="dim">$</span> bin/execute.sh</span> + <span class="line"><span class="ok">Approved at 2026-04-20 14:22:01 UTC</span></span> + <span class="line"> </span> + <span class="line"><span class="dim">#</span> result.success? <span class="ok">=> true</span></span> + <span class="line"><span class="dim">#</span> result.state => <span class="val">"complete"</span></span> + <span class="line"><span class="dim">#</span> result.duration => 12</span> + <span class="line"><span class="dim">#</span> result.chain.id => <span class="val">"018c..e5"</span></span> + </aside> + </div> + + <!-- Observe --> + <div class="showcase-panel has-output" id="panel-observe"> + <div class="showcase-code"> + <pre><code><span class="c"># Plug into any observability stack</span> +<span class="nc">CMDx</span>.<span class="n">configure</span> <span class="k">do</span> |<span class="n">c</span>| + <span class="n">c</span>.<span class="n">telemetry</span>.<span class="n">subscribe</span>(<span class="ss">:task_executed</span>) <span class="k">do</span> |<span class="n">event</span>| + <span class="nc">Datadog</span>.<span class="n">increment</span>(<span class="s">"cmdx.executed"</span>, <span class="ss">tags:</span> [ + <span class="s">"task:</span><span class="c">#{</span><span class="n">event</span>.<span class="n">task_class</span><span class="c">}</span><span class="s">"</span>, + <span class="s">"status:</span><span class="c">#{</span><span class="n">event</span>.<span class="n">payload</span>[<span class="ss">:result</span>].<span class="n">status</span><span class="c">}</span><span class="s">"</span> + ]) + <span class="k">end</span> <span class="k">end</span> -<span class="c"># Run the entire workflow</span> -<span class="n">result</span> = <span class="nc">OnboardCustomer</span>.<span class="n">execute</span>(<span class="ss">email:</span> <span class="s">"jane@co.com"</span>)</code></pre> +<span class="c"># Every event ships with a chain_id, task_id,</span> +<span class="c"># runtime, origin, and correlation metadata.</span> + +<span class="c"># Structured logs are emitted automatically.</span> +<span class="nc">OnboardCustomer</span>.<span class="n">execute</span>(<span class="ss">email:</span> <span class="s">"jane@co.com"</span>)</code></pre> + </div> + <aside class="showcase-output"> + <span class="label">> stderr</span> + <span class="line"><span class="level-i">I,</span> <span class="ts">[14:22:01.000]</span> <span class="kv">task</span>=<span class="val">CreateAccount</span> <span class="kv">state</span>=<span class="val">complete</span> <span class="kv">status</span>=<span class="okv">success</span> <span class="kv">runtime</span>=12</span> + <span class="line"><span class="level-i">I,</span> <span class="ts">[14:22:01.050]</span> <span class="kv">task</span>=<span class="val">SetupBilling</span> <span class="kv">state</span>=<span class="val">complete</span> <span class="kv">status</span>=<span class="okv">success</span> <span class="kv">runtime</span>=45</span> + <span class="line"><span class="level-i">I,</span> <span class="ts">[14:22:01.200]</span> <span class="kv">task</span>=<span class="val">SendWelcomeSms</span> <span class="kv">state</span>=<span class="val">interrupted</span> <span class="kv">status</span>=<span class="skv">skipped</span> <span class="kv">runtime</span>=2</span> + <span class="line"><span class="level-i">I,</span> <span class="ts">[14:22:01.210]</span> <span class="kv">task</span>=<span class="val">SendWelcomeEmail</span> <span class="kv">state</span>=<span class="val">complete</span> <span class="kv">status</span>=<span class="okv">success</span> <span class="kv">runtime</span>=18</span> + <span class="line"><span class="level-i">I,</span> <span class="ts">[14:22:01.250]</span> <span class="kv">task</span>=<span class="val">OnboardCustomer</span> <span class="kv">state</span>=<span class="val">complete</span> <span class="kv">status</span>=<span class="okv">success</span> <span class="kv">runtime</span>=250</span> + </aside> + </div> + </div> </div> - <!-- Fault Tolerance --> - <div class="showcase-panel" id="panel-retries"> - <div class="showcase-info"> - <h3>Fault Tolerance</h3> - <p>Handle transient failures automatically with configurable retries, selective exception matching, and exponential backoff. No manual intervention needed.</p> - </div> - <div class="showcase-code"> - <pre><code><span class="k">class</span> <span class="nc">ProcessPayment</span> <span class="o"><</span> <span class="nc">CMDx::Task</span> - <span class="n">settings</span> <span class="ss">retries:</span> <span class="mi">5</span>, - <span class="ss">retry_on:</span> [<span class="nc">Stripe::RateLimitError</span>, - <span class="nc">Net::ReadTimeout</span>], - <span class="ss">retry_jitter:</span> <span class="ss">:exponential_backoff</span> + </div> +</section> + +<!-- ===================== 04. FEATURES ===================== --> +<section class="features"> + <div class="section"> - <span class="n">required</span> <span class="ss">:payment_id</span>, <span class="ss">type:</span> <span class="ss">:integer</span> + <div class="section-head center"> + <span class="section-eyebrow">· 04 · features</span> + <h2 class="section-title">Everything your tasks need.</h2> + <p class="section-sub">Batteries included. No external dependencies required.</p> + </div> - <span class="k">def</span> <span class="n">work</span> - <span class="n">payment</span> = <span class="nc">Payment</span>.<span class="n">find</span>(<span class="n">payment_id</span>) - <span class="n">context</span>.<span class="n">charge</span> = <span class="nc">Stripe::Charge</span>.<span class="n">create</span>( - <span class="ss">amount:</span> <span class="n">payment</span>.<span class="n">amount_cents</span>, - <span class="ss">currency:</span> <span class="s">"usd"</span>, - <span class="ss">source:</span> <span class="n">payment</span>.<span class="n">token</span> - ) - <span class="k">end</span> + <div class="features-grid"> - <span class="k">private</span> + <div class="feature-card"> + <div class="feature-icon"> + <svg viewBox="0 0 24 24"> + <path d="M4 5 h16 l-6 7 v6 l-4 2 v-8 z" /> + </svg> + </div> + <h3>Typed inputs</h3> + <p>Declare inputs with coercion, defaults, and validation. Invalid data fails before <code>work</code> runs, never mid-method.</p> + </div> - <span class="k">def</span> <span class="n">exponential_backoff</span>(<span class="n">current_retry</span>) - <span class="mi">2</span> ** <span class="n">current_retry</span> <span class="c"># 2s, 4s, 8s, 16s, 32s</span> - <span class="k">end</span> -<span class="k">end</span> + <div class="feature-card"> + <div class="feature-icon"> + <svg viewBox="0 0 24 24"> + <path d="M21 12 a9 9 0 1 1 -3.5 -7.1" /> + <polyline points="21 3 21 9 15 9" /> + </svg> + </div> + <h3>Automatic retries</h3> + <p>Configurable retries with exponential backoff, scoped to the exception classes you choose. Declared per task with <code>retry_on</code>.</p> + </div> -<span class="n">result</span> = <span class="nc">ProcessPayment</span>.<span class="n">execute</span>(<span class="ss">payment_id:</span> <span class="mi">99</span>) -<span class="n">result</span>.<span class="n">retried?</span> <span class="c"># => true</span> -<span class="n">result</span>.<span class="n">retries</span> <span class="c"># => 2</span></code></pre> + <div class="feature-card"> + <div class="feature-icon"> + <svg viewBox="0 0 24 24"> + <circle cx="5" cy="12" r="2.5" /> + <circle cx="12" cy="5" r="2.5" /> + <circle cx="12" cy="19" r="2.5" /> + <circle cx="19" cy="12" r="2.5" /> + <path d="M7 11 L10 6.5 M7 13 L10 17.5 M14 6.5 L17 11 M14 17.5 L17 13" /> + </svg> + </div> + <h3>Workflows</h3> + <p>Chain tasks into sequential or parallel pipelines. Conditional steps, shared context, halt-aware error propagation.</p> + </div> + + <div class="feature-card"> + <div class="feature-icon"> + <svg viewBox="0 0 24 24"> + <rect x="3" y="4" width="18" height="16" rx="2" /> + <line x1="7" y1="9" x2="14" y2="9" /> + <line x1="7" y1="13" x2="17" y2="13" /> + <line x1="7" y1="17" x2="12" y2="17" /> + </svg> + </div> + <h3>Structured logging</h3> + <p>Each execution emits a chain id, task, state, status, runtime, and metadata as a structured key-value line.</p> </div> - </div> - <!-- Observe --> - <div class="showcase-panel" id="panel-observe"> - <div class="showcase-info"> - <h3>Observe Everything</h3> - <p>Every execution automatically generates structured logs with chain IDs, runtime metrics, and contextual metadata. Trace complex workflows and debug issues with ease.</p> + <div class="feature-card"> + <div class="feature-icon"> + <svg viewBox="0 0 24 24"> + <polygon points="12 3 22 8 12 13 2 8 12 3" /> + <polyline points="2 13 12 18 22 13" /> + <polyline points="2 18 12 23 22 18" /> + </svg> + </div> + <h3>Middleware & callbacks</h3> + <p>Wrap tasks with auth, caching, timing, and other cross-cutting concerns. Hook lifecycle events with <code>on_success</code>, <code>on_skipped</code>, and <code>on_failed</code>.</p> </div> - <div class="showcase-code"> - <pre><code><span class="c"># Structured logs are generated automatically</span> -<span class="c"># with correlation IDs for full traceability</span> - -<span class="c">I, [2026-02-06T14:22:01.000 #4891] INFO -- CMDx:</span> -<span class="n">index</span>=<span class="mi">1</span> <span class="n">chain_id</span>=<span class="s">"018c..e5"</span> -<span class="n">type</span>=<span class="s">"Task"</span> <span class="n">class</span>=<span class="s">"CreateAccount"</span> -<span class="n">state</span>=<span class="s">"complete"</span> <span class="n">status</span>=<span class="s">"success"</span> -<span class="n">metadata</span>={<span class="ss">runtime:</span> <span class="mi">12</span>} - -<span class="c">I, [2026-02-06T14:22:01.050 #4891] INFO -- CMDx:</span> -<span class="n">index</span>=<span class="mi">2</span> <span class="n">chain_id</span>=<span class="s">"018c..e5"</span> -<span class="n">type</span>=<span class="s">"Task"</span> <span class="n">class</span>=<span class="s">"SetupBilling"</span> -<span class="n">state</span>=<span class="s">"complete"</span> <span class="n">status</span>=<span class="s">"success"</span> -<span class="n">metadata</span>={<span class="ss">runtime:</span> <span class="mi">45</span>} - -<span class="c">W, [2026-02-06T14:22:01.200 #4891] WARN -- CMDx:</span> -<span class="n">index</span>=<span class="mi">3</span> <span class="n">chain_id</span>=<span class="s">"018c..e5"</span> -<span class="n">type</span>=<span class="s">"Task"</span> <span class="n">class</span>=<span class="s">"SendWelcomeEmail"</span> -<span class="n">state</span>=<span class="s">"complete"</span> <span class="n">status</span>=<span class="s">"skipped"</span> -<span class="n">metadata</span>={<span class="ss">runtime:</span> <span class="mi">2</span>, <span class="ss">reason:</span> <span class="s">"Email not configured"</span>} - -<span class="c">I, [2026-02-06T14:22:01.250 #4891] INFO -- CMDx:</span> -<span class="n">index</span>=<span class="mi">0</span> <span class="n">chain_id</span>=<span class="s">"018c..e5"</span> -<span class="n">type</span>=<span class="s">"Workflow"</span> <span class="n">class</span>=<span class="s">"OnboardCustomer"</span> -<span class="n">state</span>=<span class="s">"complete"</span> <span class="n">status</span>=<span class="s">"success"</span> -<span class="n">metadata</span>={<span class="ss">runtime:</span> <span class="mi">250</span>}</code></pre> + + <div class="feature-card"> + <div class="feature-icon"> + <svg viewBox="0 0 24 24"> + <circle cx="12" cy="12" r="9" /> + <path d="M3 12 h18 M12 3 a14 14 0 0 1 0 18 M12 3 a14 14 0 0 0 0 18" /> + </svg> + </div> + <h3>I18n messages</h3> + <p>Translatable error messages built on the standard I18n backend. English ships with the gem; bring your own locale files for everything else.</p> </div> + </div> - </div> -</div> -<!-- Key Features --> -<div class="section"> - <div class="section-header"> - <h2>Why CMDx?</h2> - <p>Designed for developers who value clarity, consistency, and reliability.</p> + <p class="features-footer"> + Full capability list in the <a href="https://github.com/drexed/cmdx/blob/main/CHANGELOG.md">changelog</a> → + </p> + </div> +</section> - <div class="features-grid"> - <div class="feature-card"> - <div class="feature-icon">⚡️</div> - <h3>Zero Dependencies</h3> - <p>Lightweight and pure Ruby. Drop it into Rails, Sinatra, Hanami, or any plain Ruby script without bloating your Gemfile.</p> - </div> - <div class="feature-card"> - <div class="feature-icon">🛡️</div> - <h3>Type Safety</h3> - <p>Declare inputs with strict typing, coercion, and validation. Catch bad data at the boundary, before it corrupts your logic.</p> - </div> - <div class="feature-card"> - <div class="feature-icon">🔍</div> - <h3>Observability</h3> - <p>Built-in tracing, structured logging, and error tracking. Know exactly what happened, when, and why.</p> - </div> - <div class="feature-card"> - <div class="feature-icon">🧩</div> - <h3>Composability</h3> - <p>Chain small, focused commands into complex workflows. Reuse logic across your application with confidence.</p> - </div> - <div class="feature-card"> - <div class="feature-icon">✅</div> - <h3>Predictable Results</h3> - <p>No more rescuing exceptions for control flow. Every command returns a standardized Result object.</p> - </div> - <div class="feature-card"> - <div class="feature-icon">🌍</div> - <h3>Production Ready</h3> - <p>Includes I18n, retries, deprecation handling, and middleware support out of the box.</p> +<!-- ===================== TELEMETRY STREAM (decorative) ===================== --> +<section class="telemetry" aria-hidden="true"> + <div class="telemetry-head"> + <span class="live-dot"></span> + <span>observability out of the box · tail -f chain</span> + </div> + <div class="telemetry-viewport"> + <div class="telemetry-track" id="telemetryTrack"> + <!-- duplicated at runtime for seamless loop --> </div> </div> -</div> +</section> -<!-- The CERO Pattern --> -<div class="stats-section"> - <div class="section-header"> - <h2>The CERO Pattern</h2> - <p>A simple mental model for reliable logic.</p> - </div> +<!-- ===================== 05. USE CASES ===================== --> +<section class="usecases"> + <div class="section"> - <div class="cero-grid"> - <div class="cero-step"> - <span class="cero-icon">🏗️</span> - <div class="cero-title">Compose</div> - <p>Build reusable, single-responsibility tasks with typed attributes and validation.</p> + <div class="section-head center"> + <span class="section-eyebrow">· 05 · built for</span> + <h2 class="section-title">Built for real workloads.</h2> + <p class="section-sub">From core business logic to external integrations.</p> </div> - <div class="cero-step"> - <span class="cero-icon">⚡️</span> - <div class="cero-title">Execute</div> - <p>Invoke tasks with a consistent API that handles errors and logging automatically.</p> - </div> - <div class="cero-step"> - <span class="cero-icon">🎯</span> - <div class="cero-title">React</div> - <p>Handle standardized result objects with clear success, failure, or skipped states.</p> - </div> - <div class="cero-step"> - <span class="cero-icon">📊</span> - <div class="cero-title">Observe</div> - <p>Trace execution with structured logs, correlation IDs, and runtime metrics.</p> - </div> - </div> -</div> -<!-- Comparison --> -<div class="section"> - <div class="section-header"> - <h2>How We Compare</h2> - <p>CMDx delivers the full stack without the bloat.</p> - </div> + <div class="usecase-grid"> + + <div class="usecase-card"> + <div class="usecase-header"> + <div class="usecase-icon"> + <svg viewBox="0 0 24 24"> + <rect x="3" y="6" width="18" height="13" rx="2" /> + <line x1="3" y1="10" x2="21" y2="10" /> + <line x1="7" y1="15" x2="10" y2="15" /> + </svg> + </div> + <h3>Payment processing</h3> + </div> + <p>Charges, refunds, ledgers, and reconciliation with audit-friendly result objects and automatic retries on gateway errors.</p> + <div class="tag-row"><span>stripe</span><span>braintree</span><span>ledgers</span></div> + </div> - <div style="overflow-x: auto;"> - <table class="comparison-table"> - <thead> - <tr> - <th>Feature</th> - <th>CMDx</th> - <th>Actor</th> - <th>Interactor</th> - <th>ActiveInteraction</th> - <th>LightService</th> - </tr> - </thead> - <tbody> - <tr> - <td>Zero Dependencies</td> - <td class="check">✅</td> - <td class="cross">❌</td> - <td class="check">✅</td> - <td class="cross">❌</td> - <td class="cross">❌</td> - </tr> - <tr> - <td>Typed Attributes</td> - <td class="check">✅</td> - <td class="check">✅</td> - <td class="cross">❌</td> - <td class="check">✅</td> - <td class="cross">❌</td> - </tr> - <tr> - <td>Type Coercion</td> - <td class="check">✅</td> - <td class="cross">❌</td> - <td class="cross">❌</td> - <td class="check">✅</td> - <td class="cross">❌</td> - </tr> - <tr> - <td>Built-in Logging</td> - <td class="check">✅</td> - <td class="cross">❌</td> - <td class="cross">❌</td> - <td class="cross">❌</td> - <td class="check">✅</td> - </tr> - <tr> - <td>Middleware System</td> - <td class="check">✅</td> - <td class="cross">❌</td> - <td class="cross">❌</td> - <td class="cross">❌</td> - <td class="check">✅</td> - </tr> - <tr> - <td>Fault Tolerance</td> - <td class="check">✅</td> - <td class="cross">❌</td> - <td class="cross">❌</td> - <td class="cross">❌</td> - <td class="cross">❌</td> - </tr> - </tbody> - </table> - </div> -</div> + <div class="usecase-card"> + <div class="usecase-header"> + <div class="usecase-icon"> + <svg viewBox="0 0 24 24"> + <path d="M3 21 v-2 a5 5 0 0 1 5 -5 h5 a5 5 0 0 1 5 5 v2" /> + <circle cx="10.5" cy="8" r="4" /> + <polyline points="17 11 19 13 22 10" /> + </svg> + </div> + <h3>User onboarding</h3> + </div> + <p>Orchestrate signup → billing → welcome emails with parallel fan-out and conditional steps for paid tiers.</p> + <div class="tag-row"><span>signup</span><span>billing</span><span>welcome</span></div> + </div> -<!-- Stats / Impact --> -<div class="stats-section"> - <div class="section"> - <div class="stats-grid"> - <div class="stat-item"> - <span class="number">100%</span> - <span class="label">Pure Ruby</span> + <div class="usecase-card"> + <div class="usecase-header"> + <div class="usecase-icon"> + <svg viewBox="0 0 24 24"> + <polyline points="22 8 18 12 22 16" /> + <polyline points="2 8 6 12 2 16" /> + <line x1="15" y1="4" x2="9" y2="20" /> + </svg> + </div> + <h3>Webhook handlers</h3> + </div> + <p>Validate, dispatch, and retry Stripe, GitHub, or Twilio events with one task per event type and typed payloads.</p> + <div class="tag-row"><span>stripe</span><span>github</span><span>twilio</span></div> + </div> + + <div class="usecase-card"> + <div class="usecase-header"> + <div class="usecase-icon"> + <svg viewBox="0 0 24 24"> + <circle cx="12" cy="12" r="9" /> + <polyline points="12 7 12 12 15 14" /> + </svg> + </div> + <h3>Background jobs</h3> + </div> + <p>Give Sidekiq or async-job workers structured inputs, predictable results, and retry-aware error handling.</p> + <div class="tag-row"><span>sidekiq</span><span>async-job</span><span>solid_queue</span></div> </div> - <div class="stat-item"> - <span class="number">0</span> - <span class="label">Dependencies</span> + + <div class="usecase-card"> + <div class="usecase-header"> + <div class="usecase-icon"> + <svg viewBox="0 0 24 24"> + <ellipse cx="12" cy="5" rx="8" ry="3" /> + <path d="M4 5 v14 a8 3 0 0 0 16 0 v-14" /> + <path d="M4 12 a8 3 0 0 0 16 0" /> + </svg> + </div> + <h3>ETL & data sync</h3> + </div> + <p>Compose import → transform → load pipelines with per-step checkpoints and parallel stages for throughput.</p> + <div class="tag-row"><span>extract</span><span>transform</span><span>load</span></div> </div> - <div class="stat-item"> - <span class="number">Type</span> - <span class="label">Safe Inputs</span> + + <div class="usecase-card"> + <div class="usecase-header"> + <div class="usecase-icon"> + <svg viewBox="0 0 24 24"> + <rect x="4" y="8" width="16" height="12" rx="2" /> + <path d="M12 8 v-4 M8 4 h8" /> + <circle cx="9" cy="14" r="1" /> + <circle cx="15" cy="14" r="1" /> + </svg> + </div> + <h3>AI agent flows</h3> + </div> + <p>Wrap LLM tool calls, embeddings, and vector writes with timeouts, retries, and telemetry you can observe.</p> + <div class="tag-row"><span>openai</span><span>tools</span><span>vectors</span></div> </div> + </div> - </div> -</div> -<!-- Use Cases --> -<div class="section"> - <div class="section-header"> - <h2>Built For Real World Logic</h2> - <p>From simple scripts to complex enterprise workflows.</p> + </div> +</section> + +<!-- ===================== 06. FOOTER CTA ===================== --> +<section class="cta-footer"> + <h2>Start building.</h2> + + <div class="install-box"> + <span class="prompt">$</span> + <code id="installCmd">gem install cmdx</code> + <button class="copy-btn" id="copyBtn" aria-label="Copy install command"> + <span class="icon">⎘</span> + <span class="txt">copy</span> + </button> </div> - <div class="features-grid"> - <div class="feature-card"> - <h3>🏦 Financial Operations</h3> - <p>Handle payments, loans, and ledgers with audit trails and transactional integrity.</p> - </div> - <div class="feature-card"> - <h3>🔄 Data Pipelines</h3> - <p>Structure ETL processes with clear steps, error handling, and resumption capabilities.</p> - </div> - <div class="feature-card"> - <h3>🛒 E-commerce</h3> - <p>Orchestrate order fulfillment, inventory checks, and shipping logistics reliably.</p> - </div> + <div class="cta-links"> + <a href="getting_started/">docs</a> + <a href="https://github.com/drexed/cmdx/blob/main/CHANGELOG.md">changelog</a> + <a href="https://github.com/drexed/cmdx">github</a> </div> -</div> +</section> + +</div><!-- /.home --> <script> - (function () { - var tabs = document.querySelectorAll(".showcase-tab"); - var panels = document.querySelectorAll(".showcase-panel"); - - tabs.forEach(function (tab) { - tab.addEventListener("click", function () { - tabs.forEach(function (t) { t.classList.remove("active"); }); - panels.forEach(function (p) { p.classList.remove("active"); }); - - tab.classList.add("active"); - var panel = document.getElementById("panel-" + tab.getAttribute("data-panel")); - if (panel) panel.classList.add("active"); +(function () { + /* ----------------------------------------------------------- + Tab switcher (showcase) + ----------------------------------------------------------- */ + var tabs = document.querySelectorAll(".showcase-tab"); + var panels = document.querySelectorAll(".showcase-panel"); + + tabs.forEach(function (tab) { + tab.addEventListener("click", function () { + tabs.forEach(function (t) { t.classList.remove("active"); }); + panels.forEach(function (p) { p.classList.remove("active"); }); + tab.classList.add("active"); + var panel = document.getElementById("panel-" + tab.getAttribute("data-panel")); + if (panel) panel.classList.add("active"); + }); + }); + + /* ----------------------------------------------------------- + Telemetry marquee: generate log lines and duplicate for loop + ----------------------------------------------------------- */ + var track = document.getElementById("telemetryTrack"); + if (track) { + var lines = [ + ['I', '14:22:01.000', 'CreateAccount', 'success', 12, 'ok'], + ['I', '14:22:01.050', 'SetupBilling', 'success', 45, 'ok'], + ['I', '14:22:01.100', 'AssignPlan', 'success', 22, 'ok'], + ['I', '14:22:01.200', 'SendWelcomeSms', 'skipped', 2, 'skip'], + ['I', '14:22:01.210', 'SendWelcomeEmail', 'success', 18, 'ok'], + ['I', '14:22:01.215', 'CreateDashboard', 'success', 31, 'ok'], + ['I', '14:22:01.220', 'ScheduleOnboardCall', 'success', 9, 'ok'], + ['I', '14:22:01.250', 'OnboardCustomer', 'success', 250, 'ok'], + ['I', '14:22:02.001', 'ProcessPayment', 'failed', 140, 'err'], + ['I', '14:22:02.190', 'ProcessPayment', 'success', 138, 'ok'], + ['I', '14:22:03.000', 'ApproveLoan', 'success', 12, 'ok'] + ]; + var statusClass = { ok: 'okv', skip: 'skv', err: 'erv' }; + function renderLine(l) { + var lvl = l[0]; + var lvlCls = lvl === 'W' ? 'level-w' : lvl === 'E' ? 'level-e' : 'level-i'; + var chainId = '018c' + Math.floor(Math.random() * 0xffff).toString(16).padStart(4, '0'); + return '<span class="line">' + + '<span class="' + lvlCls + '">' + lvl + ',</span> ' + + '<span class="ts">[' + l[1] + ']</span> ' + + '<span class="kv">chain_id</span>=<span class="val">"' + chainId + '"</span> ' + + '<span class="kv">task</span>=<span class="val">' + l[2] + '</span> ' + + '<span class="kv">status</span>=<span class="' + statusClass[l[5]] + '">' + l[3] + '</span> ' + + '<span class="kv">runtime</span>=' + l[4] + + '</span>'; + } + var html = lines.map(renderLine).join(''); + track.innerHTML = html + html; + } + + /* ----------------------------------------------------------- + Copy-to-clipboard for gem install + ----------------------------------------------------------- */ + var copyBtn = document.getElementById("copyBtn"); + var installCmd = document.getElementById("installCmd"); + if (copyBtn && installCmd && navigator.clipboard) { + copyBtn.addEventListener("click", function () { + navigator.clipboard.writeText(installCmd.textContent.trim()).then(function () { + copyBtn.classList.add("copied"); + copyBtn.querySelector(".txt").textContent = "copied"; + setTimeout(function () { + copyBtn.classList.remove("copied"); + copyBtn.querySelector(".txt").textContent = "copy"; + }, 1600); }); }); - })(); + } +})(); </script> {% endblock %} diff --git a/docs/retries.md b/docs/retries.md index e409953ab..ba0df0669 100644 --- a/docs/retries.md +++ b/docs/retries.md @@ -1,14 +1,14 @@ # Retries -CMDx provides automatic retry functionality for tasks that encounter transient failures. This is essential for handling temporary issues like network timeouts, rate limits, or database locks without manual intervention. +CMDx retries `work` automatically when it raises an exception that matches a class-level `retry_on` declaration. Retries are scoped to `work` itself — input resolution, output verification, and lifecycle callbacks run only once. ## Basic Usage -Configure retries upto n attempts without any delay. +`retry_on` takes one or more exception classes and an options hash. With no exceptions declared (the default), no retries happen. ```ruby class FetchExternalData < CMDx::Task - settings retries: 3 + retry_on Net::OpenTimeout, Net::ReadTimeout def work response = HTTParty.get("https://api.example.com/data") @@ -17,52 +17,70 @@ class FetchExternalData < CMDx::Task end ``` -When an exception occurs during execution, CMDx automatically retries up to the configured limit. Each retry attempt is logged at the `warn` level with retry metadata. If all retries are exhausted, the task fails with the original exception. - -## Selective Retries - -By default, CMDx retries on `StandardError` and its subclasses. Narrow this to specific exception types: +| Option | Default | Description | +|--------------|---------|----------------------------------------------------------------| +| `limit:` | `3` | Maximum retry attempts (total invocations = `limit + 1`); `0` disables retries entirely | +| `delay:` | `0.5` | Base delay in seconds; `0` disables sleeping between attempts | +| `max_delay:` | `nil` | Upper bound clamp applied after jitter is computed | +| `jitter:` | `nil` | Strategy for spreading delays — see [Jitter](#jitter) below | ```ruby class ProcessPayment < CMDx::Task - settings retries: 5, retry_on: [Stripe::RateLimitError, Net::ReadTimeout] + retry_on Stripe::RateLimitError, Net::ReadTimeout, + limit: 5, delay: 1.0, max_delay: 30.0, jitter: :exponential def work - # Your logic here... + # ... end end ``` !!! warning "Important" - Only exceptions matching the `retry_on` configuration trigger retries. Unmatched exceptions immediately fail the task. + Only exceptions matching `retry_on` retry. Anything else — or a matching exception after the limit is exhausted — is captured by Runtime and turned into a failed result with the exception attached as `result.cause`. -## Retry Jitter +## Inheritance -Add delays between retry attempts to avoid overwhelming external services or to implement exponential backoff strategies. The delay is calculated as `jitter * current_attempt` for numeric values and invoked with the current attempt count for callable types. +`retry_on` accumulates across inheritance — subclasses extend the parent's exceptions and merge options instead of replacing them. -### Fixed Value +```ruby +class ApplicationTask < CMDx::Task + retry_on Net::OpenTimeout, limit: 2 +end -Use a numeric value to calculate linear delay (`jitter * current_attempt`): +class FetchProfile < ApplicationTask + retry_on Net::ReadTimeout, max_delay: 5.0 + # Effective: [Net::OpenTimeout, Net::ReadTimeout], limit: 2, max_delay: 5.0 +end +``` + +## Jitter + +Jitter spreads delay across attempts. Strategies receive `(attempt, delay)` where `attempt` is zero-based and `delay` is the base delay. The result is clamped to `max_delay` if set. + +### Built-in Strategies ```ruby -class ImportRecords < CMDx::Task - settings retries: 3, retry_jitter: 0.5 +retry_on TransientError, delay: 1.0, jitter: :exponential +# attempt 0 → 1s, attempt 1 → 2s, attempt 2 → 4s, ... - def work - # Delays: 0.5s (attempt 1), 1.0s (attempt 2), 1.5s (attempt 3) - context.records = ExternalAPI.fetch_records - end -end +retry_on TransientError, delay: 2.0, jitter: :half_random +# delay/2 .. delay → 1.0s .. 2.0s + +retry_on TransientError, delay: 2.0, jitter: :full_random +# 0 .. delay → 0.0s .. 2.0s + +retry_on TransientError, delay: 2.0, jitter: :bounded_random +# delay .. 2*delay → 2.0s .. 4.0s ``` -### Symbol References +### Symbol (Instance Method) -Define an instance method for custom delay logic: +A `Symbol` resolves to an instance method on the task. The method receives `(attempt, delay)` and must return the desired sleep duration in seconds. ```ruby class SyncInventory < CMDx::Task - settings retries: 5, retry_jitter: :exponential_backoff + retry_on InventoryAPI::ServerError, limit: 5, jitter: :exponential_backoff def work context.inventory = InventoryAPI.sync @@ -70,94 +88,99 @@ class SyncInventory < CMDx::Task private - def exponential_backoff(current_attempt) - 2 ** current_attempt # 2s, 4s, 8s, 16s, 32s + def exponential_backoff(attempt, delay) + delay * (2**attempt) end end ``` ### Proc or Lambda -Pass a proc for inline delay calculations: +Procs and lambdas are evaluated with `instance_exec` against the task, so they have access to `context` and other instance methods. ```ruby class PollJobStatus < CMDx::Task - # Proc - settings retries: 10, retry_jitter: proc { |attempt| [attempt * 0.5, 5.0].min } - - # Lambda - settings retries: 10, retry_jitter: ->(attempt) { [attempt * 0.5, 5.0].min } + retry_on JobAPI::Pending, + limit: 10, + delay: 0.5, + max_delay: 5.0, + jitter: ->(attempt, delay) { delay * (attempt + 1) } def work - # Delays: 0.5s, 1.0s, 1.5s, 2.0s, 2.5s, 3.0s, 3.5s, 4.0s, 4.5s, 5.0s (capped) context.status = JobAPI.check_status(context.job_id) end end ``` -### Class or Module +### Callable (Class or Module) -Implement reusable delay logic in dedicated modules and classes: +Anything responding to `#call(attempt, delay)` works. The task is **not** passed in — capture state in the callable instead. ```ruby class ExponentialBackoff - def call(task, attempt) - base_delay = task.context.base_delay || 1.0 - [base_delay * (2 ** attempt), 60.0].min + def initialize(base: 1.0, cap: 60.0) + @base = base + @cap = cap + end + + def call(attempt, _delay) + [@base * (2**attempt), @cap].min end end class FetchUserProfile < CMDx::Task - # Class or Module - settings retries: 4, retry_jitter: ExponentialBackoff - - # Instance - settings retries: 4, retry_jitter: ExponentialBackoff.new + retry_on Net::ReadTimeout, limit: 4, jitter: ExponentialBackoff.new(base: 0.5) def work - # Your logic here... + # ... end end ``` -## Retry Behavior +### Custom Block -Understanding how retries work internally helps avoid surprises: +When no `:jitter` option is given, you can pass a block to `retry_on` instead. It runs in the task's instance scope. -- **Same task instance** — Retries reuse the same task object. Context and attributes from previous attempts persist. -- **Validation skipped** — `before_validation` callbacks and attribute validation only run on the first attempt. Retries go straight to `before_execution` and `work`. -- **Errors cleared** — `task.errors` is automatically cleared before each retry so errors from previous attempts don't carry over. -- **Retry count tracked** — `result.retries` increments before each retry attempt. -- **Warn-level logging** — Each retry is logged at `warn` with the exception reason and remaining retry count. +```ruby +class FetchAnalytics < CMDx::Task + retry_on Analytics::Throttled, limit: 3, delay: 1.0 do |attempt, delay| + delay + (attempt ** 1.5) + end +end +``` -!!! note "Retry + Middleware Interaction" +## Behavior - Retries happen inside the middleware stack. The `retry` keyword re-enters the execution block, so middlewares like `Timeout` still apply to each individual retry attempt. +- **Same task instance** — retries reuse the same task object. `context` and any side effects from previous attempts persist. +- **Only `work` repeats** — input resolution, output verification, and `before_execution` / `before_validation` callbacks run once. Retries wrap `work` only. +- **Errors carry over** — `task.errors` accumulates across attempts; entries added during a previous attempt remain. Clear them at the start of `work` if you re-add per attempt, otherwise a successful retry will still finalize as failed once `signal_errors!` runs. +- **Telemetry** — Runtime emits a `:task_retried` event for each retry (`attempt:` is zero-based; the initial call is `attempt = 0` and is not emitted). +- **Outside the middleware stack** — middlewares wrap the entire lifecycle (callbacks, inputs, retries, outputs, rollback). Each retried `work` call is *inside* every middleware; middlewares do not see individual attempts. -## Retry Results +## Inspecting Retries -After execution, the result object provides methods to inspect retry behavior: +`Result` exposes retry metadata after execution: ```ruby result = FetchExternalData.execute -result.retries # => 2 (number of retry attempts made) -result.retried? # => true (whether any retries occurred) +result.retries #=> 2 (number of *retry* attempts; 0 if first attempt succeeded) +result.retried? #=> true ``` -Use these methods for logging, metrics, or conditional post-processing based on retry activity. +These are also surfaced in the structured log output (`retried`, `retries`). -## Error Handling +## When Retries Are Exhausted -When all retry attempts are exhausted: +Once `limit` retries are spent, the last exception is re-raised inside `work` and Runtime converts it: -- **`execute`** — The task fails gracefully. `result.failure?` returns `true` and the exception message is captured in the result. If an `exception_handler` is configured, it is invoked. -- **`execute!`** — The original exception is re-raised after the task is marked as failed. +- `execute` — captured by `rescue StandardError`; produces a failed result with `result.cause` set to the exception and `result.reason` set to `"[ExceptionClass] message"`. +- `execute!` — same conversion, but Runtime re-raises the original exception (not a `Fault`) after the lifecycle finalizes. ```ruby result = FetchExternalData.execute -if result.failure? - Rails.logger.error("Failed after #{result.retries} retries: #{result.message}") +if result.failed? + Rails.logger.error("Failed after #{result.retries} retries: #{result.reason}") end ``` diff --git a/docs/returns.md b/docs/returns.md deleted file mode 100644 index 8aebc9ce1..000000000 --- a/docs/returns.md +++ /dev/null @@ -1,91 +0,0 @@ -# Returns - -Returns declare expected context outputs that must be present after a task executes successfully. If any declared return is missing from the context when `work` completes, the task fails with a validation error. - -## Declaration - -Use the `returns` class method to declare one or more expected context keys: - -```ruby -class AuthenticateUser < CMDx::Task - required :email, :password - - returns :source - returns :user, :token - - def work - context.source = email.include?("@mycompany.com") ? :admin_portal : :user_portal - context.user = User.authenticate(email, password) - context.token = JwtService.encode(user_id: context.user.id) - end -end -``` - -## Removals - -Remove inherited return declarations: - -```ruby -class ApplicationTask < CMDx::Task - returns :audit_log -end - -class LightweightTask < ApplicationTask - remove_returns :audit_log - - def work - # No longer required to set context.audit_log - end -end -``` - -## Validation Behavior - -Return validation runs **after** `work` completes and **only** when the result is still successful. If the task has already failed or been skipped, return validation is skipped entirely. - -```mermaid -flowchart LR - W[work] --> C{success?} - C -->|Yes| V{returns present?} - C -->|No| Done[Skip validation] - V -->|Yes| S[Success] - V -->|No| F[Fail with errors] -``` - -### Missing Returns - -When a declared return is missing from the context, the task fails with the same error format as attribute validation: - -```ruby -class CreateUser < CMDx::Task - returns :user - - def work - # Forgot to set context.user - end -end - -result = CreateUser.execute -result.failed? #=> true -result.reason #=> "Invalid" -result.metadata #=> { - # errors: { - # full_message: "user must be set in the context", - # messages: { user: ["must be set in the context"] } - # } - # } -``` - -### With Bang Execution - -Missing returns raise a `CMDx::FailFault` when using `execute!`: - -```ruby -begin - AuthenticateUser.execute!(email: "user@example.com", password: "secret") -rescue CMDx::FailFault => e - e.message #=> "Invalid" - e.result.metadata[:errors][:messages] - #=> { token: ["must be set in the context"] } -end -``` diff --git a/docs/stylesheets/extra.css b/docs/stylesheets/extra.css index 27f4a419f..daca76e69 100644 --- a/docs/stylesheets/extra.css +++ b/docs/stylesheets/extra.css @@ -41,6 +41,91 @@ --md-code-hl-generic-color: #dbb7ff; } +/* Light scheme palette aligned with docs/overrides/home.html */ +[data-md-color-scheme="default"] { + --md-default-bg-color: #fafafa; + --md-default-bg-color--light: #ffffff; + --md-default-bg-color--lighter: #f4f4f4; + --md-default-bg-color--lightest: #ececec; + + --md-default-fg-color: #1a1a1a; + --md-default-fg-color--light: rgba(26, 26, 26, 0.75); + --md-default-fg-color--lighter: rgba(26, 26, 26, 0.45); + --md-default-fg-color--lightest: rgba(0, 0, 0, 0.08); + + --md-typeset-color: #1a1a1a; + --md-typeset-a-color: #fe1817; + + --md-code-bg-color: #ffffff; + --md-code-fg-color: #1a1a1a; + + --md-footer-bg-color: #fafafa; + --md-footer-bg-color--dark: #f0f0f0; + --md-footer-fg-color: #1a1a1a; + --md-footer-fg-color--light: #6a6a6a; + --md-footer-fg-color--lighter: rgba(26, 26, 26, 0.45); + + --md-mermaid-edge-color: #57606a; + --md-mermaid-node-bg-color: #ffffff; + --md-mermaid-node-fg-color: #1a1a1a; + --md-mermaid-label-bg-color: #ffffff; + --md-mermaid-label-fg-color: #1a1a1a; + --md-mermaid-sequence-actor-bg-color: #ffffff; + --md-mermaid-sequence-actor-border-color:#1a1a1a; + --md-mermaid-sequence-actor-fg-color: #1a1a1a; + --md-mermaid-sequence-actor-line-color: #57606a; + --md-mermaid-sequence-actorman-bg-color: #ffffff; + --md-mermaid-sequence-actorman-line-color:#57606a; + --md-mermaid-sequence-box-bg-color: #fafafa; + --md-mermaid-sequence-box-fg-color: #1a1a1a; + --md-mermaid-sequence-label-bg-color: #1a1a1a; + --md-mermaid-sequence-label-fg-color: #ffffff; + --md-mermaid-sequence-loop-bg-color: #ffffff; + --md-mermaid-sequence-loop-fg-color: #1a1a1a; + --md-mermaid-sequence-loop-border-color: #57606a; + --md-mermaid-sequence-message-fg-color: #1a1a1a; + --md-mermaid-sequence-message-line-color:#57606a; + --md-mermaid-sequence-note-bg-color: #fff8c5; + --md-mermaid-sequence-note-fg-color: #1a1a1a; + --md-mermaid-sequence-note-border-color: #d4a72c; + --md-mermaid-sequence-number-bg-color: #1a1a1a; + --md-mermaid-sequence-number-fg-color: #ffffff; +} + +[data-md-color-scheme="default"] .mermaid, +[data-md-color-scheme="default"] pre.mermaid { + background: transparent !important; +} + +[data-md-color-scheme="default"] .mermaid > svg { + background-color: transparent !important; +} + +/* Dark scheme palette aligned with docs/overrides/home.html */ +[data-md-color-scheme="slate"] { + --md-default-bg-color: #0d0d0d; + --md-default-bg-color--light: #161616; + --md-default-bg-color--lighter: #1c1c1c; + --md-default-bg-color--lightest: #222222; + + --md-default-fg-color: #d4d4d4; + --md-default-fg-color--light: rgba(212, 212, 212, 0.75); + --md-default-fg-color--lighter: rgba(212, 212, 212, 0.45); + --md-default-fg-color--lightest: rgba(255, 255, 255, 0.08); + + --md-typeset-color: #d4d4d4; + --md-typeset-a-color: #fe1817; + + --md-code-bg-color: #161616; + --md-code-fg-color: #d4d4d4; + + --md-footer-bg-color: #0d0d0d; + --md-footer-bg-color--dark: #0a0a0a; + --md-footer-fg-color: #d4d4d4; + --md-footer-fg-color--light: #7a8290; + --md-footer-fg-color--lighter: rgba(212, 212, 212, 0.45); +} + /* Light/Dark mode logo switching */ .only-dark, .only-light { max-width: 240px !important; } diff --git a/docs/testing.md b/docs/testing.md index 76558736a..89e19e72d 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -1,26 +1,12 @@ # Testing -Best practices for testing CMDx tasks and workflows with RSpec. - -## Setup - -Reset global configuration between tests to prevent state leakage: - -```ruby -# spec/rails_helper.rb or spec/spec_helper.rb -RSpec.configure do |config| - config.before(:each) do - CMDx.reset_configuration! - CMDx::Chain.clear - end -end -``` +Patterns for testing CMDx tasks and workflows with RSpec. ## Testing Tasks ### Basic Execution -Test tasks by calling `execute` and asserting on the result: +Call `execute` and assert on the returned `Result`. Predicates like `success?`, `skipped?`, and `failed?` map to RSpec matchers automatically. ```ruby RSpec.describe CreateUser do @@ -36,12 +22,25 @@ RSpec.describe CreateUser do result = CreateUser.execute(email: "", name: "Ada") expect(result).to be_failed - expect(result.reason).to include("Invalid") + expect(result.reason).to eq("email cannot be empty") + expect(result.errors.to_h).to eq(email: ["cannot be empty"]) end end ``` -### Testing Skip and Fail Conditions +For multi-branch assertions, `Result#on` keeps each path scoped: + +```ruby +it "branches on outcome" do + CreateUser.execute(email: "dev@example.com", name: "Ada") + .on(:success) { |r| expect(r.context.user).to be_persisted } + .on(:failed) { |r| raise "unexpected failure: #{r.reason}" } +end +``` + +### Testing Skip and Fail + +`reason` and `metadata` come straight from the `skip!` / `fail!` arguments. ```ruby RSpec.describe ProcessRefund do @@ -67,70 +66,103 @@ end ### Testing Bang Execution +`execute!` raises `CMDx::Fault` for any failed path (validation, output verification, `fail!`, or echoed peer failure). The fault carries the failing task class and the originating `Result`. + ```ruby RSpec.describe ProcessPayment do - it "raises FailFault on failure" do + it "raises Fault on failure" do expect { ProcessPayment.execute!(amount: -1) - }.to raise_error(CMDx::FailFault) { |fault| - expect(fault.result.reason).to include("positive") + }.to raise_error(CMDx::Fault) { |fault| + expect(fault.task).to eq(ProcessPayment) + expect(fault.message).to include("amount") + expect(fault.result.errors).to have_key(:amount) } end end ``` -### Testing Attribute Validation +For paths that re-raise the original exception (an unhandled `StandardError` inside `work`), match the original class instead: + +```ruby +expect { Importer.execute!(payload: bad_payload) }.to raise_error(JSON::ParserError) +``` + +!!! note + + `Fault` exposes the originating `Result` (`fault.result`), `context`, and + `chain` so post-mortem inspection works either way. `execute` is still + handy when you want to assert on `skipped?`, `success?`, *and* `failed?` + results in the same example. + +### Testing Input Validation + +Errors from input resolution are surfaced through `result.errors` and folded into `result.reason`. ```ruby RSpec.describe CreateProject do - it "fails when required attributes are missing" do + it "fails when required inputs are missing" do result = CreateProject.execute(name: nil) expect(result).to be_failed - expect(result.reason).to eq("Invalid") - expect(result.metadata[:errors][:messages]).to have_key(:name) - end - - it "coerces string attributes to expected types" do - result = CreateProject.execute(name: "Alpha", budget: "5000") - - expect(result).to be_success - expect(result.context.budget).to eq(5000) + expect(result.errors.to_h).to have_key(:name) + expect(result.reason).to include("name") end end ``` -### Testing Returns +!!! note + + Coerced input values live on the task instance (via the generated reader), + not on `context`. `result.context.budget` returns whatever the caller + passed in — to assert on the coerced value, write it back to `context` + inside `work` (e.g. `context.budget = budget`). + +### Testing Outputs + +Missing or invalid declared outputs fail the task with the same `errors` API. ```ruby RSpec.describe AuthenticateUser do - it "fails when declared returns are missing" do - allow(User).to receive(:authenticate).and_return(nil) + it "fails when a declared output is missing" do + allow(JwtService).to receive(:encode).and_return(nil) result = AuthenticateUser.execute(email: "a@b.com", password: "pw") expect(result).to be_failed - expect(result.metadata[:errors][:messages]).to have_key(:token) + expect(result.errors.to_h).to have_key(:token) end end ``` -### Testing Dry Run +### Testing Retries + +`result.retries` and `result.retried?` expose retry activity. ```ruby -RSpec.describe ChargeCard do - it "simulates execution without side effects" do - result = ChargeCard.execute(card_id: "card_123", dry_run: true) +RSpec.describe FetchExternalData do + it "retries transient timeouts" do + call_count = 0 + allow(HTTParty).to receive(:get) do + call_count += 1 + raise Net::ReadTimeout if call_count < 3 + double(parsed_response: { ok: true }) + end + + result = FetchExternalData.execute expect(result).to be_success - expect(result.dry_run?).to be(true) + expect(result.retries).to eq(2) + expect(result.retried?).to be(true) end end ``` ## Testing Workflows -### Full Workflow +### Sequential Workflow + +The chain holds every result in execution order, with the workflow result as the root. ```ruby RSpec.describe OnboardingWorkflow do @@ -138,29 +170,42 @@ RSpec.describe OnboardingWorkflow do result = OnboardingWorkflow.execute(user_data: valid_params) expect(result).to be_success - expect(result.chain.results.size).to eq(4) - expect(result.chain.results.map { |r| r.task.class }).to eq( + expect(result.chain.size).to eq(4) + expect(result.chain.results.map(&:task)).to eq( [OnboardingWorkflow, CreateProfile, SetupPreferences, SendWelcome] ) end end ``` -### Workflow Failure Propagation +### Failure Propagation + +A failed leaf halts the workflow and its `reason` echoes onto `result.reason`. The failing leaf is reachable directly via `result.origin` / `result.caused_failure` — they point at the originating task without needing to scan the chain. ```ruby RSpec.describe PaymentWorkflow do - it "stops on first failure and includes root cause" do + it "stops on first failure and identifies the failing task" do result = PaymentWorkflow.execute(invalid_card: true) expect(result).to be_failed - expect(result.caused_failure.task).to be_a(ValidateCard) + expect(result.reason).to include("invalid") + expect(result.origin.task).to eq(ValidateCard) + expect(result.caused_failure.task).to eq(ValidateCard) end end ``` +!!! note + + `caused_failure` walks `origin` recursively, so it returns the deepest + leaf even across nested workflows. `threw_failure` returns the immediate + upstream (`origin || self`). For a locally-failing task both helpers + return `self`. See [Result — Chain Analysis](outcomes/result.md#chain-analysis). + ## Testing Callbacks +Callbacks are best verified through their observable side effects. + ```ruby RSpec.describe ProcessBooking do it "notifies guest on success" do @@ -176,21 +221,33 @@ end ## Testing Middlewares +Middlewares run inside Runtime, so test them through a real task lifecycle (see [Middlewares](middlewares.md)). + ```ruby -RSpec.describe "Timeout middleware" do - it "fails when task exceeds time limit" do - result = SlowTask.execute +class TaggingMiddleware + def call(task) + task.context.tagged_at = Time.now + yield + end +end - expect(result).to be_failed - expect(result.cause).to be_a(CMDx::TimeoutError) - expect(result.metadata[:limit]).to eq(3) +RSpec.describe TaggingMiddleware do + it "tags the context before work runs" do + klass = Class.new(CMDx::Task) do + register :middleware, TaggingMiddleware.new + def work; context.work_seen_tag = !context.tagged_at.nil?; end + end + + result = klass.execute + + expect(result.context.work_seen_tag).to be(true) end end ``` ## Direct Instantiation -For fine-grained inspection, instantiate tasks directly: +Instantiate a task directly when you need to inspect its `context` or `errors` before invoking the runtime. ```ruby RSpec.describe CalculateShipping do @@ -198,38 +255,37 @@ RSpec.describe CalculateShipping do task = CalculateShipping.new(weight: 2.5, destination: "CA") expect(task.context.weight).to eq(2.5) - expect(task.result).to be_initialized - end - - it "can be executed manually" do - task = CalculateShipping.new(weight: 2.5, destination: "CA") - task.execute - - expect(task.result).to be_success + expect(task.errors).to be_empty end end ``` +!!! note + + `Task#new` only builds the context and errors registry — it does **not** run the lifecycle. To execute, use `Klass.execute(context_or_hash)`. There is no per-instance `task.execute`. + ## Pattern Matching in Tests -Use Ruby's pattern matching for expressive assertions: +`Result` supports both array and hash deconstruction. ```ruby RSpec.describe BuildApplication do - it "returns expected pattern on success" do + it "deconstructs to [type, task, state, status, reason, metadata, cause, origin]" do result = BuildApplication.execute(version: "1.0") - expect(result.deconstruct).to match(["complete", "success", anything, anything, anything]) + expect(result.deconstruct).to match( + ["Task", BuildApplication, "complete", "success", nil, {}, nil, nil] + ) end - it "matches hash pattern on failure" do + it "matches a hash pattern on failure" do result = BuildApplication.execute(version: nil) case result - in { status: "failed", metadata: { errors: { messages: Hash => msgs } } } - expect(msgs).to have_key(:version) + in { status: "failed", reason: String => reason } + expect(reason).to include("version") else - raise "Expected failed result with validation errors" + raise "Expected failed result" end end end diff --git a/docs/tips_and_tricks.md b/docs/tips_and_tricks.md index 01fe6b855..3cb08e8e8 100644 --- a/docs/tips_and_tricks.md +++ b/docs/tips_and_tricks.md @@ -1,12 +1,12 @@ # Tips and Tricks -Best practices, patterns, and techniques to build maintainable CMDx applications. +Patterns and conventions for building maintainable CMDx applications. ## Project Organization ### Directory Structure -Create a well-organized command structure for maintainable applications: +A predictable layout keeps tasks discoverable as a project grows: ```text /app/ @@ -28,7 +28,7 @@ Create a well-organized command structure for maintainable applications: ### Naming Conventions -Follow consistent naming patterns for clarity and maintainability: +Follow consistent naming patterns for clarity: ```ruby # Verb + Noun @@ -36,7 +36,7 @@ class ExportData < CMDx::Task; end class CompressFile < CMDx::Task; end class ValidateSchema < CMDx::Task; end -# Use present tense verbs for actions +# Use present-tense verbs for actions class GenerateToken < CMDx::Task; end # ✓ Good class GeneratingToken < CMDx::Task; end # ❌ Avoid class TokenGeneration < CMDx::Task; end # ❌ Avoid @@ -44,7 +44,7 @@ class TokenGeneration < CMDx::Task; end # ❌ Avoid ### Story Telling -Break down complex logic into descriptive methods that read like a narrative: +Break complex logic into descriptively named methods so `work` reads like a narrative: ```ruby class ProcessOrder < CMDx::Task @@ -81,23 +81,27 @@ Follow this order for consistent, readable tasks: ```ruby class ExportReport < CMDx::Task - # 1. Register functions - register :middleware, CMDx::Middlewares::Correlate - register :validator, :format, FormatValidator + # 1. Settings, retries, deprecation + settings tags: [:reporting] + retry_on Net::ReadTimeout, limit: 3, jitter: :exponential - # 2. Define callbacks + # 2. Register custom extensions + register :middleware, Telemetry::Middleware.new + register :validator, :phone, PhoneValidator + + # 3. Define callbacks before_execution :find_report on_complete :track_export_metrics, if: ->(task) { Current.tenant.analytics? } - # 3. Declare attributes - attributes :user_id - required :report_id - optional :format_type + # 4. Declare inputs + optional :user_id + required :report_id, coerce: :integer + optional :format_type, coerce: :string, inclusion: { in: %w[pdf csv] } - # 4. Returns contract - returns :exported_at + # 5. Declare outputs (the contract) + output :exported_at, required: true - # 5. Define work method + # 6. Define work def work report.compile! report.export! @@ -108,7 +112,7 @@ class ExportReport < CMDx::Task # TIP: Favor private business logic to reduce the surface of the public API. private - # 5. Build helper functions + # 7. Helpers def find_report @report ||= Report.find(report_id) end @@ -120,38 +124,68 @@ class ExportReport < CMDx::Task end ``` -## Attribute Options +## Sharing Input Options -Use `with_options` to reduce duplication. +Use `with_options` to factor out repeated options across input declarations. !!! note - `with_options` is provided by ActiveSupport and is available automatically in Rails applications. For plain Ruby projects, add `require "active_support/core_ext/object/with_options"` or apply shared options manually. + `with_options` is provided by ActiveSupport and is available automatically in Rails. For plain Ruby projects, add `require "active_support/core_ext/object/with_options"` or apply shared options manually. ```ruby class ConfigureCompany < CMDx::Task - # Apply common options to multiple attributes - with_options(type: :string, presence: true) do - attributes :website, format: { with: URI::DEFAULT_PARSER.make_regexp(%w[http https]) } + with_options(coerce: :string, presence: true) do + optional :website, format: { with: URI::DEFAULT_PARSER.make_regexp(%w[http https]) } required :company_name, :industry optional :description, format: { with: /\A[\w\s\-\.,!?]+\z/ } end - # Nested attributes with shared prefix required :headquarters do - with_options(prefix: :hq_) do - attributes :street, :city, :zip_code, type: :string - required :country, type: :string, inclusion: { in: VALID_COUNTRIES } - optional :region, type: :string + with_options(coerce: :string) do + optional :street, :city, :zip_code + required :country, inclusion: { in: VALID_COUNTRIES } + optional :region end end def work - # Your logic here... + # ... + end +end +``` + +`with_options` works inside nested-input blocks too because the child DSL is evaluated with `instance_eval`. + +## Sharing Behavior via a Base Class + +Pull cross-cutting concerns onto a base task. Subclasses inherit `settings`, `callbacks`, `middlewares`, `coercions`, `validators`, `telemetry`, and `retry_on` automatically. + +```ruby +class ApplicationTask < CMDx::Task + settings tags: [:app] + + retry_on Net::OpenTimeout, Net::ReadTimeout, limit: 2 + + before_execution :ensure_current_tenant! + + private + + def ensure_current_tenant! + fail!("missing tenant") if Current.tenant.nil? + end +end + +class ProcessInvoice < ApplicationTask + required :invoice_id, coerce: :integer + + def work + # Inherits settings, retry_on, and the before_execution callback end end ``` +Inherited registries (callbacks, middlewares, validators, coercions) accumulate — declaring more in a subclass appends to the parent's list. To opt out of an inherited entry, use `deregister` (e.g. `deregister :callback, :before_execution, :ensure_current_tenant!`). `retry_on` and `settings` likewise accumulate via merge: a subclass `retry_on` adds exception classes and overrides individual options (`limit:`, `delay:`, …) without dropping the parent's, and `settings` merges new keys on top. + ## Useful Examples - [Active Job Durability](https://github.com/drexed/cmdx/blob/main/examples/active_job_durability.md) @@ -159,6 +193,7 @@ end - [Active Record Query Tagging](https://github.com/drexed/cmdx/blob/main/examples/active_record_query_tagging.md) - [Active Support Instrumentation](https://github.com/drexed/cmdx/blob/main/examples/active_support_instrumentation.md) - [Flipper Feature Flags](https://github.com/drexed/cmdx/blob/main/examples/flipper_feature_flags.md) +- [OpenAPI Schema Generation](https://github.com/drexed/cmdx/blob/main/examples/openapi_schema_generation.md) - [Paper Trail Whatdunnit](https://github.com/drexed/cmdx/blob/main/examples/paper_trail_whatdunnit.md) - [PubSub Task Chaining](https://github.com/drexed/cmdx/blob/main/examples/pub_sub_task_chaining.md) - [Redis Idempotency](https://github.com/drexed/cmdx/blob/main/examples/redis_idempotency.md) diff --git a/docs/v2-migration.md b/docs/v2-migration.md new file mode 100644 index 000000000..c9c72896e --- /dev/null +++ b/docs/v2-migration.md @@ -0,0 +1,949 @@ +# Upgrading from v1.x to v2.0 + +CMDx 2.0 is a full runtime rewrite. The public DSL — `required`, `optional`, callbacks, middlewares, `retry_on`, `settings`, `Workflow` / `task` — largely survives, but halt semantics, attribute/return declarations, middleware signatures, and most internal classes have changed. + +!!! warning "Not a drop-in upgrade" + + Plan to touch every task class. Halt is now `throw`/`catch` instead of `Result` mutation, attributes became inputs (`type:` → `coerce:`), returns became outputs (with a full pipeline), middleware takes one argument and `yield`s, and the built-in middleware trio (`Correlate`, `Runtime`, `Timeout`) is gone. + +!!! tip "Benchmarks" + + Halts are ~2.5× faster, workflow failures ~3×, allocations down 50–80%. See [`benchmark/RESULTS.md`](https://github.com/drexed/cmdx/blob/main/benchmark/RESULTS.md). + +--- + +## Before You Begin + +1. **Check requirements.** Ruby 3.3+ (MRI, JRuby, or TruffleRuby). See [Getting Started](getting_started.md#requirements). +2. **Pin your current version.** `gem "cmdx", "~> 1.21"` in the `Gemfile` — a quick rollback path if the upgrade stalls. +3. **Baseline the suite.** Run `bundle exec rspec` on v1.x once and save the output; a green suite is your "before" snapshot. +4. **Skim the changelog.** The `[2.0.0]` section of [`CHANGELOG.md`](https://github.com/drexed/cmdx/blob/main/CHANGELOG.md) lists every breaking change by category. +5. **Read this page top-to-bottom.** Each section is a recipe you can apply independently. + +--- + +## TL;DR Cheat Sheet + +| Area | v1.x | v2.0 | +|---|---|---| +| Halt mechanism | mutate `Result` state machine | `catch`/`throw` a frozen `Signal` | +| `Result` mutability | mutable (`initialized → executing → complete`) | read-only; options frozen on construction | +| Lifecycle owner | `CMDx::Executor` | `CMDx::Runtime` | +| Inputs | `attribute` / `attributes` with `type:` | `input` / `inputs` with `coerce:` | +| Outputs | `returns :user, :token` (presence check only) | `output :user, required: true, coerce: ...` (full pipeline) | +| Callbacks | `on_executed`, `on_good`, `on_bad` | drops `on_executed`; renames to `on_ok` / `on_ko` | +| Middleware signature | `call(task, options, &block)` | `call(task) { yield }` | +| Built-in middlewares | `Correlate`, `Runtime`, `Timeout` | removed — register your own | +| Lifecycle observability | middleware-based | `Telemetry` pub/sub with 5 events | +| Workflow parallelism | none / 3rd-party | `tasks ..., strategy: :parallel, pool_size: N` | +| Chain storage | thread-local | fiber-local (parallel-safe) | +| Breakpoints | `task_breakpoints` / `workflow_breakpoints` | removed — use `execute!` for strict mode | +| Loader | Zeitwerk | explicit `require_relative` | +| Pattern matching | n/a | `case result in [_, _, "complete", "success", *]` | +| `result.task` | task **instance** | task **class** | +| `result.chain` | results `Array` | `Chain` object (`Enumerable`) | + +--- + +## Upgrade Workflow + +1. **Bump the gem.** `bundle update cmdx` and run the suite to surface breakage. +2. **Fix configuration.** Drop removed keys (see [Configuration](#configuration)). `rails generate cmdx:install` regenerates the v2 initializer as a reference. +3. **Fix tasks category-by-category.** Inputs → Outputs → Callbacks → Middlewares → Result consumers. The [Automated Migration Prompt](#automated-migration-prompt) mechanizes most of this. +4. **Audit result-handling code** for state-machine assumptions (`result.executing?`, `result.metadata[:x] = ...`, `result.chain_id`, `result.good?` / `bad?`) and any breakpoint / strict-mode configuration. +5. **Move observability** (correlation IDs, runtime metrics, timeouts) to [Telemetry](#telemetry) subscribers or hand-rolled middlewares. +6. **Re-run the suite.** When green, delete dead helpers that papered over v1's rough edges (manual rollbacks, `dry_run:` flags, `SKIP_CMDX_FREEZING` toggles). +7. **Validate.** Run the grep list in [Validating the Migration](#validating-the-migration) to catch stragglers. + +!!! tip + + Coming from < 1.21? Also rename `def call` to `def work` and class-level `.call` / `.call!` to `.execute` / `.execute!`. v2 keeps `.call` / `.call!` as aliases. + +--- + +## Configuration + +The `CMDx::Configuration` surface shrank. Breakpoints, rollback config, freezing, and exception handlers are gone; what remains is registries plus logging/locale. + +### Removed Keys + +| Removed | Replacement | +|---|---| +| `task_breakpoints`, `workflow_breakpoints` | Failure halting is intrinsic. Use `execute!` for strict mode, or gate halts in a middleware. | +| `rollback_on` | `Task#rollback` runs automatically on failure (see [Rollback](#rollback)). | +| `dump_context`, `freeze_results`, `backtrace`, `exception_handler` | Removed. | +| `SKIP_CMDX_FREEZING` env var | Removed. Teardown always freezes `task`, `errors`, and (for the root) `context` and `chain`. | + +### v2 Surface + +```ruby +CMDx.configure do |config| + config.middlewares # CMDx::Middlewares + config.callbacks # CMDx::Callbacks + config.coercions # CMDx::Coercions + config.validators # CMDx::Validators + config.telemetry # CMDx::Telemetry (NEW) + config.default_locale # "en" + config.backtrace_cleaner # ->(bt) { ... } or nil + config.logger # Logger instance + config.log_level # Logger::INFO + config.log_formatter # CMDx::LogFormatters::Line.new +end +``` + +!!! note + + `CMDx.reset_configuration!` is new — call it in test setup/teardown to wipe the global config and invalidate `Task`'s cached registries. + +See [Configuration](configuration.md) for the full surface. + +--- + +## Task Definition + +`def work` is unchanged. v2 raises `ImplementationError` (was `UndefinedMethodError`) if you don't override it. + +### Execution Entry Points + +```ruby +MyTask.execute(name: "x") # unchanged +MyTask.execute!(name: "x") # unchanged +MyTask.call / .call! # still aliases of execute / execute! +``` + +`Task#execute` (the **instance** method) was removed. Use `Runtime.execute(task)` to drive the lifecycle manually. `MyTask.new(...)` still works but is rarely useful outside tests. + +### Removed Instance Accessors + +Read task-level data off the returned `Result` instead. + +| v1 | v2 | +|---|---| +| `MyTask.execute(...).task` → instance | `result.task` → **class** (see [Result Consumers](#result-consumers)) | +| `task.id` | `result.id` | +| `task.result` | `execute` returns the `Result` directly | +| `task.chain` | `result.chain` (a `Chain`, not an Array) | +| `task.dry_run?` | removed — `dry_run` is gone | + +`task.context`, `task.errors`, and `task.logger` still exist on the instance during `work`. + +--- + +## Halts + +`success!` / `skip!` / `fail!` / `throw!` are private instance methods on `Task` that `throw(Signal::TAG, signal)`. Runtime's `catch` intercepts the signal and constructs the result once at the end. + +```ruby +# v1 — mutated result.state, kept running unless you returned +# v2 — throws; unreachable after the call +def work + fail!("invalid email", code: :bad_input) + deliver(context) # v1 could still hit this; v2 NEVER reaches this +end +``` + +Breaking changes: + +- **Halts are terminating.** Code after them in `work` is unreachable. +- `result.fail!` / `result.skip!` are gone — halts live on `Task`, not delegated through `Result`. +- `success!` is new — halt `work` early while staying successful. +- Only `fail!` and `throw!` capture `caller_locations(1)` as the signal backtrace; `success!` and `skip!` do not. `Fault#backtrace` points at your call site, cleaned through `Settings#backtrace_cleaner` when present. +- `throw!(other_result)` is a no-op when `other_result` didn't fail (same as v1; now implemented as a `Signal.echoed` throw). +- Calling any halt method on a frozen task raises `FrozenError`. + +See [Interruptions - Signals](interruptions/signals.md) for the full semantics. + +--- + +## Inputs (was Attributes) + +Rename `attribute` / `attributes` to `input` / `inputs`, and `type:` to `coerce:`. `required` / `optional` aliases are unchanged. + +| v1 | v2 | +|---|---| +| `attribute :email, type: :string, required: true` | `input :email, coerce: :string, required: true` | +| `attributes :name, :role, type: :string` | `inputs :name, :role, coerce: :string` | +| `type: :integer` | `coerce: :integer` | +| `type: [:integer, :float]` | `coerce: %i[integer float]` | +| `type: { date: { strptime: "..." } }` | `coerce: { date: { strptime: "..." } }` | +| `remove_attribute :flag` | `deregister :input, :flag` | +| `MyTask.attributes_schema` | `MyTask.inputs_schema` (plus `MyTask.outputs_schema`, new) | + +`source:` (`:context`, method name, Proc, lambda) and nested-input blocks are unchanged. + +### Removed + +- `Attribute`, `AttributeRegistry`, `AttributeValue`, `Resolver`, `Identifier` classes + +See [Inputs - Definitions](inputs/definitions.md). + +--- + +## Outputs (was Returns) + +`returns` was a presence check. `output` runs through the same required/coerce/validate pipeline as inputs. + +```ruby +# v1 +returns :user, :token + +# v2 +output :user, required: true +output :token, required: true, + coerce: :string, + length: { min: 32 } +``` + +Outputs run **after** `work` returns successfully (skipped if the task halted). A missing required output adds `outputs.missing` to `task.errors`, which Runtime converts into a failed signal. + +### Removed + +| Removed | Replacement | +|---|---| +| `returns :name` | `output :name, required: true` | +| `remove_returns :name` | `deregister :output, :name` | +| `cmdx.returns.missing` locale key | `cmdx.outputs.missing` | + +See [Outputs](outputs.md) for the full pipeline. + +--- + +## Callbacks + +### Event Renames + +| v1 | v2 | +|---|---| +| `before_validation`, `before_execution`, `on_complete`, `on_interrupted`, `on_success`, `on_skipped`, `on_failed` | unchanged | +| `on_executed` | **removed** — use `on_complete` + `on_interrupted`, or `on_ok` / `on_ko` | +| `on_good` | `on_ok` | +| `on_bad` | `on_ko` | + +### Registration + +Every event has an auto-defined DSL method; `register :callback, ...` still works. + +```ruby +class MyTask < CMDx::Task + on_failed :alert_team + on_success ->(task) { Stats.bump(:ok) } + on_success { Stats.bump(:ok) } # block form + register :callback, :on_failed, :alert_team # still supported +end +``` + +Handlers may be a `Symbol` (dispatched via `task.send`), a `Proc` (`instance_exec`'d with the task), or any `#call`-able (invoked with the task). Unknown events and unsupported handlers raise `ArgumentError`. + +### Deregistration + +```ruby +deregister :callback, :on_failed # drops every callback for :on_failed +deregister :callback, :on_failed, :alert_team # drops only this entry (matched by ==) +``` + +See [Callbacks](callbacks.md) for Proc-identity caveats and conditional gates. + +--- + +## Middlewares + +### New Signature + +```ruby +# v1 +class Timing + def call(task, options, &block) + started = monotonic + result = block.call + result.metadata[:ms] = elapsed + result + end +end + +# v2 +class Timing + def call(task) + started = monotonic + yield + ensure + task.context.timing_ms = elapsed + end +end +``` + +Procs and lambdas must capture the next link explicitly — `yield` in a lambda targets the enclosing method: + +```ruby +->(task, &next_link) { next_link.call } +proc { |task, &next_link| next_link.call } +``` + +Differences: + +- **No `options` parameter.** Carry config on the middleware instance. +- **No return-value contract.** Middlewares wrap; Runtime builds the result after the chain unwinds. +- **Must yield.** Skipping `yield` raises `CMDx::MiddlewareError`. The task body never runs, and the error propagates out of both `execute` and `execute!`. +- **Result data isn't visible inside the chain.** Read `task.context` / `task.errors` while wrapping; subscribe to Telemetry's `:task_executed` when you need the finalized `Result`. + +### Registration + +The registry no longer auto-instantiates classes or forwards `**options`. Pass a `#call`-able (class instance, proc, lambda) or a block. + +| v1 | v2 | +|---|---| +| `register :middleware, TelemetryMiddleware` | `register :middleware, TelemetryMiddleware.new` | +| `register :middleware, MonitorMiddleware, service_key: ENV["KEY"]` | `register :middleware, MonitorMiddleware.new(service_key: ENV["KEY"])` | +| `register :middleware, AuditMiddleware, at: 0` | `register :middleware, AuditMiddleware.new, at: 0` | + +### Built-ins Removed + +| Removed | Replacement | +|---|---| +| `CMDx::Middlewares::Correlate` | 5-line proc that sets `context.correlation_id`, or a `:task_started` Telemetry subscriber | +| `CMDx::Middlewares::Runtime` | `result.duration` is built in; `:task_executed` Telemetry for richer payloads | +| `CMDx::Middlewares::Timeout` | wrap `yield` in your own `Timeout.timeout(n)` middleware | + +### Deregistration + +Middleware identity is by-reference — deregister with the exact instance you registered, or by index: + +```ruby +deregister :middleware, audit_instance +deregister :middleware, at: 0 +``` + +See [Middlewares](middlewares.md) for the full surface. + +--- + +## Settings + +`Settings` is a frozen value object. Per-task overrides cover logger, log formatter, log level, backtrace cleaner, and tags — nothing else. Registries live on the `Task` class itself. + +```ruby +# v1 # v2 +settings logger: MyLogger.new, settings logger: MyLogger.new, + tags: %i[critical], log_formatter: CMDx::LogFormatters::JSON.new, + task_breakpoints: %w[failed], # gone log_level: Logger::DEBUG, + freeze_results: false # gone backtrace_cleaner: ->(bt) { bt.reject { |l| l.include?("gems/") } }, + tags: %i[critical] +``` + +`Settings#build(opts)` returns a new instance and does a flat `Hash#merge` — a subclass that overrides `tags:` **replaces** (not concatenates) the parent's. Every getter falls back to `CMDx.configuration` when the key is absent. + +`MyTask.middlewares`, `.callbacks`, `.coercions`, `.validators`, `.telemetry`, `.inputs`, `.outputs` are class-level accessors that lazy-clone from the superclass (or global config) on first read. Subclasses extend — they never replace. + +--- + +## Result Consumers + +### Mutability + +```ruby +result = MyTask.execute(...) + +# v1: result.metadata[:foo] = :bar # allowed +# v2: Result exposes no mutating API. +result.task.frozen? #=> true +result.errors.frozen? #=> true +result.context.frozen? #=> true (root only) +CMDx::Chain.current #=> nil (cleared on root teardown) +``` + +!!! note + + User-supplied `metadata:` hashes (passed to `success!` / `skip!` / `fail!`) are **not** deep-frozen — freeze them yourself before throwing if you need that guarantee. + +### Predicate Renames + +| v1 | v2 | +|---|---| +| `Result::STATES` (4 states) | `Signal::STATES` (2 states: `"complete"`, `"interrupted"`) | +| `result.initialized?`, `result.executing?`, `result.executed?` | **removed** — a `Result` only exists post-finalization | +| `result.complete?`, `result.interrupted?`, `result.success?`, `result.skipped?`, `result.failed?` | unchanged | +| `result.good?` | `result.ok?` | +| `result.bad?` | `result.ko?` | +| `result.chain_id` | `result.chain.id` | +| `result.task` (instance) | `result.task` (**class**) | +| `result.chain` (Array) | `result.chain` (`Chain`, Enumerable) | +| `result.threw_failure?` | `result.thrown_failure?` (semantics flipped: true only when this result re-threw an upstream failure) | + +### New Surface + +```ruby +result.on(:success) { |r| deliver(r.context) } # predicate dispatch + .on(:failed) { |r| alert(r.reason) } +# Accepted keys: :complete :interrupted :success :skipped :failed :ok :ko + +case result # pattern matching +in [_, _, "complete", "success", *] then ok! +in [_, _, _, "failed", reason, *] then alert(reason) +in { task:, status: "failed", cause: } then ... +end + +result.id # uuid_v7 +result.chain # Chain (Enumerable) +result.chain.id # chain's uuid_v7 +result.chain_index # position in chain +result.chain_root? # true when this result is the chain's root +result.duration # milliseconds (Float) +result.retries # integer +result.retried? # bool +result.strict? # produced via execute! +result.deprecated? # task class marked deprecated +result.rolled_back? # rollback ran +result.tags # settings.tags +``` + +### Failure References + +```ruby +result.threw_failure # origin || self (nearest upstream failed, or self when originator) +result.thrown_failure? # true only when this result re-threw an upstream failure +result.caused_failure # walks `origin` to the root-cause leaf +result.caused_failure? # true when this result originated the failure +``` + +`Result#to_h` no longer recursively serializes failure chains. `origin`, `threw_failure`, and `caused_failure` render as `{ task: Class, id: uuid }`, and `to_s` formats them as `<TaskClass uuid>`. + +See [Outcomes - Result](outcomes/result.md) for the full surface. + +--- + +## Workflows + +### Parallel Groups (NEW) + +```ruby +class FanOutWorkflow < CMDx::Task + include CMDx::Workflow + + task LoadInvoice # sequential default + tasks ChargeCard, EmailReceipt, + strategy: :parallel, # NEW + pool_size: 4 # NEW + task FinalizeOrder +end +``` + +- Each parallel worker `deep_dup`s the workflow context, runs its task, then merges its successful child context back into the workflow (on the parent thread, after all workers join). +- All workers share the parent's fiber-local `Chain` — each worker sets `Fiber[Chain::STORAGE_KEY]` on thread entry, and each result is pushed under a `Mutex`. +- After all workers finish, the first **by declaration index** failed result halts the pipeline via `throw!`. Successful contexts merge in index order; failed ones are discarded. + +### Behavioral Changes + +- Defining `#work` on a workflow raises `ImplementationError` — the check fires only for methods defined **on the workflow subclass itself**, so `Workflow#work` (the delegator) is fine. +- `:if` / `:unless` gate the entire group. +- `workflow_breakpoints` is gone — failure always halts the pipeline. To keep going on a skip, branch explicitly in a wrapping task or middleware. + +See [Workflows](workflows.md). + +--- + +## Chain + +```ruby +# v1 # v2 +Thread.current[:cmdx_chain] Fiber[:cmdx_chain] +chain.dry_run? # gone + Chain.current, Chain.current=, Chain.clear # accessors +``` + +`Chain` is fiber-local so parallel workers each see the same underlying chain. `push` and `unshift` are `Mutex`-synchronized. Runtime `unshift`s the root result and `push`es children, so `chain[0]` (and `chain.root`) is always the outermost task regardless of finalization order. + +New in v2: + +- `Chain#root`, `Chain#state`, `Chain#status` — delegate to the root result (`nil` when absent). +- `Chain#last` — most recently appended result. +- `Chain#freeze` — Runtime freezes the chain (and its results array) on root teardown; later mutations raise `FrozenError`. +- `Chain` `include`s `Enumerable`, so `chain.map(&:status)`, `chain.find(&:failed?)`, `chain.first(3)`, `chain.to_a` all work. + +!!! warning "Important" + + `Result#chain` now returns the `Chain` itself, not its results array. Call `chain.id` for the uuid, `chain.to_a` for a plain array, or iterate directly via Enumerable. + +--- + +## Faults & Exceptions + +### Hierarchy + +```text +CMDx::Error = CMDx::Exception (StandardError) +├── CMDx::Fault +├── CMDx::DeprecationError +├── CMDx::DefinitionError (NEW — duplicate input/output accessor) +├── CMDx::ImplementationError (NEW — Task#work unoverridden, or Workflow#work defined) +└── CMDx::MiddlewareError (NEW — middleware didn't yield) +``` + +`CMDx::UndefinedMethodError` is gone. Exception classes are now declared inline in `lib/cmdx.rb` (was `lib/cmdx/exception.rb`). + +### Matcher API + +```ruby +rescue Fault.for?(ProcessOrder, ChargeCard) => fault + Alert.for(fault.task, fault.message) +end + +rescue Fault.matches? { |f| f.result.metadata[:retryable] } => fault + RetryQueue.push(fault) +end +``` + +### Construction + +`Fault#initialize(result)` takes a `Result` (was `(task_class, signal)`). It derives the backtrace from `result.backtrace || result.cause&.backtrace_locations`, then runs it through `task.settings.backtrace_cleaner` when present. `fault.task`, `fault.context`, `fault.chain`, and `fault.result` are all exposed. + +!!! note + + `SkipFault` / `FailFault` (v1) are gone. There's just `Fault` — distinguish via `fault.result.skipped?` / `fault.result.failed?`. + +--- + +## Errors + +Mostly compatible. Messages are stored in a `Set` per key, so duplicate messages on the same key are silently dropped. `Errors` `include`s `Enumerable`, iterating `[key, Set<String>]` pairs (not `Array`). + +New in v2: + +```ruby +errors.added?(:email, "is invalid") +errors.full_messages #=> { email: ["email is invalid"] } +errors.to_hash(true) # full_messages +errors.count # total messages across all keys +errors.each_key { ... } +errors.each_value { ... } +``` + +`errors[:email]` returns `Array<String>` (deduped via the backing Set). + +--- + +## Context + +```ruby +context.merge(other) # accepts Context, Hash, or anything to_h-able +context.retrieve(:foo) { compute } # fetch-or-store +context.delete(:foo) +context.clear +context.deep_dup +context.map { |k, v| ... } # Enumerable +``` + +Dynamic accessors (`context.foo`, `context.foo = 1`, `context.foo?`) are unchanged. The root context is frozen by Runtime teardown; nested subtask contexts stay mutable while their parent runs. `context.dry_run` and the `dry_run: true` constructor flag are gone. + +--- + +## Retries + +Shape unchanged; implementation is now a value object that accumulates across inheritance. + +```ruby +class FlakyTask < CMDx::Task + retry_on Net::ReadTimeout, ConnectionPool::TimeoutError, + limit: 4, + delay: 0.5, + max_delay: 5.0, + jitter: :exponential # :exponential, :half_random, :full_random, + # :bounded_random, Symbol, Proc, or any callable +end + +class ChildTask < FlakyTask + retry_on Errno::ECONNRESET # accumulated; ChildTask retries on all 3 +end +``` + +`Task.retry_on` with no exceptions returns the current (possibly inherited) `Retry`. See [Retries](retries.md). + +--- + +## Deprecation + +v1's `Deprecator` class is replaced by a class-level `deprecation` DSL. + +```ruby +class LegacyImporter < CMDx::Task + deprecation :warn # :log, :warn, :error, Symbol, Proc, or any #call-able + # deprecation :error, if: -> { Rails.env.production? } + # deprecation ->(task) { Sentry.capture_message("deprecated task run: #{task.class}") } + + def work + # ... + end +end +``` + +Runtime invokes the deprecation right before the task body runs, sets `result.deprecated?` to `true`, and emits `:task_deprecated`. With `:error`, it raises `DeprecationError` and the task never runs. See [Deprecation](deprecation.md). + +--- + +## Rollback + +`Task#rollback` is now a first-class lifecycle hook. When `work` fails, Runtime calls `rollback` if defined (after `work`, before result finalization), sets `result.rolled_back?` to `true`, and emits `:task_rolled_back`. + +```ruby +class ChargeCard < CMDx::Task + required :order_id, :amount + + def work + context.charge = Stripe::Charge.create(amount:, source: order.source) + end + + def rollback + Stripe::Refund.create(charge: context.charge.id) if context.charge + end +end +``` + +!!! note + + v1 had no built-in rollback dispatch (`rollback_on` config existed but didn't invoke anything). If you wired rollback manually from a middleware or callback, drop the scaffolding. + +--- + +## Telemetry + +v1's pattern for observing the runtime was to write a middleware. v2 ships a dedicated pub/sub with five events that fire **only when subscribers exist** (zero cost otherwise). + +```ruby +CMDx.configure do |config| + config.telemetry.subscribe(:task_executed) do |event| + StatsD.timing("cmdx.#{event.task_class}", event.payload[:result].duration) + end + + config.telemetry.subscribe(:task_retried) do |event| + Rails.logger.warn("retry #{event.payload[:attempt]} for #{event.task_class}") + end +end +``` + +| Event | Payload | +|---|---| +| `:task_started` | empty | +| `:task_deprecated` | empty | +| `:task_retried` | `{ attempt: Integer }` | +| `:task_rolled_back` | empty | +| `:task_executed` | `{ result: Result }` | + +Every event carries a `Telemetry::Event` with `chain_id`, `chain_root`, `task_type`, `task_class`, `task_id`, `name`, `payload`, `timestamp`. Subscribe per-task via `MyTask.telemetry.subscribe(...)`. See [Configuration - Telemetry](configuration.md#telemetry). + +--- + +## Locale & I18n + +CMDx now ships `CMDx::I18nProxy`, which delegates to the `i18n` gem when loaded and falls back to bundled YAML otherwise. Default locale is `en`; override with `config.default_locale`. + +### Key Renames + +| v1 | v2 | +|---|---| +| `cmdx.attributes.required` ("must be accessible via the %{method} source method") | `cmdx.attributes.required` ("is required") | +| `cmdx.attributes.undefined` | removed | +| `cmdx.coercions.unknown` | removed | +| `cmdx.faults.invalid` | removed | +| `cmdx.faults.unspecified` | `cmdx.reasons.unspecified` | +| `cmdx.returns.missing` | `cmdx.outputs.missing` | +| — | `cmdx.validators.length.nil_value` (added) | +| — | `cmdx.validators.numeric.nil_value` (added) | + +### YAML Diff + +```yaml +# Before (v1) +en: + cmdx: + attributes: + required: "must be accessible via the %{method} source method" + undefined: "..." + coercions: + unknown: "..." + faults: + invalid: "..." + unspecified: "Unspecified" + returns: + missing: "must be set in the context" + +# After (v2) +en: + cmdx: + attributes: + required: "is required" + reasons: + unspecified: "Unspecified" + outputs: + missing: "must be set in the context" + validators: + length: + nil_value: "must have a length" + numeric: + nil_value: "must be numeric" +``` + +See [Internationalization](internationalization.md). + +--- + +## Generators + +`cmdx:install`, `cmdx:task`, and `cmdx:workflow` emit the v2 template shape: + +```ruby +# v1 templates: def call ... end +# v2 templates: +class MyTask < ApplicationTask + def work + # Your logic here... + end +end +``` + +Regenerate `cmdx:install` as a reference when migrating an initializer — it documents the v2 middleware / callback / telemetry / coercion / validator registration shapes. + +--- + +## Removed Modules & Classes + +| Removed | Replacement | +|---|---| +| `CMDx::Executor` | `CMDx::Runtime` | +| `CMDx::Attribute` / `AttributeRegistry` / `AttributeValue` | `CMDx::Input` + `CMDx::Inputs` | +| `CMDx::Resolver` | input resolution on `Input#resolve` | +| `CMDx::Identifier` | `SecureRandom.uuid_v7` | +| `CMDx::Locale` | `CMDx::I18nProxy` | +| `CMDx::Deprecator` | declarative `Task.deprecation` | +| `CMDx::Parallelizer` | `CMDx::Pipeline#run_parallel` (`strategy: :parallel`) | +| `CMDx::CallbackRegistry` | `CMDx::Callbacks` | +| `CMDx::MiddlewareRegistry` | `CMDx::Middlewares` | +| `CMDx::CoercionRegistry` | `CMDx::Coercions` | +| `CMDx::ValidatorRegistry` | `CMDx::Validators` | +| `CMDx::Utils::Call` / `Condition` / `Format` / `Normalize` / `Wrap` | `CMDx::Util` (conditional helpers only); `Array(x)` instead of `Wrap.array(x)` | +| `CMDx::Middlewares::Correlate` / `Runtime` / `Timeout` | see [Built-ins Removed](#built-ins-removed) | +| `CMDx::UndefinedMethodError` | `CMDx::ImplementationError` | +| `CMDx::SkipFault` / `FailFault` | `Fault` + `fault.result.skipped?` / `failed?` | +| Zeitwerk autoloading | explicit `require_relative` in `lib/cmdx.rb` — gem no longer requires `zeitwerk`, `forwardable`, `pathname`, `set`, or `timeout` | +| `CMDx.gem_path` and the module-method surface | gone | + +--- + +## Validating the Migration + +Before you call it done: + +**1. Run the suite.** `bundle exec rspec` must be green. For Rails projects, reset the global config between examples so registry caching on `Task` doesn't leak across tests: + +```ruby +RSpec.configure do |c| + c.before(:each) { CMDx.reset_configuration! } +end +``` + +**2. Grep for v1 symbols.** Any hit indicates missed migration: + +```bash +rg --hidden \ + 'task_breakpoints|workflow_breakpoints|rollback_on|dump_context|freeze_results|SKIP_CMDX_FREEZING|\.good\?|\.bad\?|chain_id[^=]|threw_failure\?|dry_run|attributes_schema|remove_attribute|remove_return|on_executed|on_good|on_bad|cmdx\.returns\.missing|cmdx\.faults\.(invalid|unspecified)|CMDx::Executor|CMDx::Middlewares::(Correlate|Runtime|Timeout)|CMDx::(SkipFault|FailFault|UndefinedMethodError)|register\s+:attribute|attribute\s+:' +``` + +**3. Check one log line.** A successful task logs a v2-shaped record with `chain_id`, `chain_index`, `chain_root`, `type`, `task`, `id`, `state`, `status`, `duration`: + +```text +cmdx: chain_id="0190..." chain_index=0 chain_root=true type="Task" task=MyTask id="0190..." state="complete" status="success" reason=nil metadata={} duration=12.34 ... +``` + +If you see `initialized` or `executing` in the output, something is serializing a v1 result. + +--- + +## Troubleshooting + +| Symptom | Fix | +|---|---| +| `NoMethodError: undefined method 'good?' for Result` | `result.good?` → `result.ok?`, `result.bad?` → `result.ko?` | +| `NoMethodError: undefined method 'chain_id'` | `result.chain_id` → `result.chain.id` | +| `NoMethodError: undefined method 'executed?' / 'executing?' / 'initialized?'` | Predicates removed; use `result.complete? \|\| result.interrupted?` | +| `CMDx::MiddlewareError: middleware did not yield the next_link` | A middleware's `rescue` / `ensure` / early-return path skipped `yield`. Yield on every code path. | +| `CMDx::ImplementationError: cannot define Workflow#work` | A workflow subclass defined `#work`. Delete it and move the body into `task` / `tasks` declarations. | +| `FrozenError: cannot throw signals` | `skip!` / `fail!` / `throw!` called on a frozen task (post-execution). Restructure to halt inside `work`. | +| `Translation missing: cmdx.returns.missing` | Rename locale key to `cmdx.outputs.missing`. Same for `cmdx.faults.unspecified` → `cmdx.reasons.unspecified`. | +| `ArgumentError: middleware must respond to #call` | A middleware class was registered instead of an instance. Pass `MyMiddleware.new(...)`. | +| `undefined method 'metadata=' for Result` | `result.metadata[:x] = ...` writes aren't allowed. Set `task.context.x` **before** the halt instead. | +| `Fault#task` is a class, not an instance | v2 behavior — `fault.result.task` is the class. Read instance-scoped data off `fault.context` / `fault.result.context`. | + +--- + +## Rollback Plan + +If the upgrade stalls: + +1. `git revert` the migration branch. +2. Pin the gem: `gem "cmdx", "~> 1.21"`. +3. Restore any helpers you deleted (manual rollback dispatchers, breakpoint config, `dry_run` branches). + +A handful of patterns are hard to shim under v1 once you've rewritten them — keep them in git history rather than trying to forward-port: + +- Read-only `Result` access patterns (v1 `Result` is mutable, so nothing breaks if you leave guards in). +- `success!` calls (no v1 equivalent — replace with `return` or custom metadata). +- Parallel workflow groups (v1 has no first-class parallel strategy — fall back to running groups sequentially). +- Telemetry subscribers (wrap as v1 middlewares calling the same sinks). + +--- + +## Automated Migration Prompt + +Paste the block below into your agent (Cursor, Claude Code, etc.) with your project open. It's written to be idempotent — running it twice won't double-rewrite already-migrated code. + +````markdown +You are upgrading a Ruby project from CMDx v1.x to v2.0. + +Context: +- Project root: the current working directory. +- Source of truth: `docs/v2-migration.md` in the cmdx gem. When a v2 code + example conflicts with older documentation, the migration doc wins. +- Ruby: MRI 3.3+ (or compatible JRuby/TruffleRuby). +- Scope: every file under `app/`, `lib/`, `spec/`, `test/`, + `config/initializers/`, and any `*.rb` under the project root — unless an + `.migrationignore` file exists, in which case respect it. + +Idempotency rule: +- For every rewrite rule below, check whether the target code already matches + the v2 shape. If it does, skip silently. Never re-apply a rule to code that + already satisfies it. + +Work in passes. After each pass, run `bundle exec rspec` (or the project's +equivalent) and fix failures before continuing. If a failure can't be resolved +by the rules here, stop and surface the file:line so a human can resolve it. + +--- + +## Pass 1 — Inputs (lowest risk) + +- Rename every `attribute` / `attributes` declaration to `input` / `inputs`. + `required` / `optional` are unchanged. +- Rename every `type:` option to `coerce:`. Preserve array forms: + `type: [:integer, :float]` → `coerce: %i[integer float]`. +- Replace `remove_attribute :name` with `deregister :input, :name`. +- Replace `MyTask.attributes_schema` with `MyTask.inputs_schema`. +- Replace `register :attribute, ...` with `required :name, ...` / + `optional :name, ...` / `register :input, :name, ...`. + +## Pass 2 — Outputs + +- Replace `returns :a, :b` with one `output :a, required: true` / + `output :b, required: true` per key. Leave room for future `coerce:` / + validator options. +- Replace `remove_returns :name` with `deregister :output, :name`. + +## Pass 3 — Locale files + +- In every custom YAML under `config/locales/` (or wherever locales live): + - `cmdx.returns.missing` → `cmdx.outputs.missing`. + - `cmdx.faults.unspecified` → `cmdx.reasons.unspecified`. + - Delete `cmdx.attributes.undefined`, `cmdx.coercions.unknown`, + `cmdx.faults.invalid`. + - If you override `cmdx.attributes.required`, update the string — v1's + default was "must be accessible via the %{method} source method"; v2's + default is "is required". Keep your custom override if it still reads + naturally for the new context. + +## Pass 4 — Callbacks + +- Delete every `on_executed` callback; if the user was relying on it, replace + with a pair of `on_complete` + `on_interrupted` callbacks (or `on_ok` / + `on_ko`). +- Rename `on_good` → `on_ok`, `on_bad` → `on_ko`. + +## Pass 5 — Result consumers + +- `result.good?` → `result.ok?`; `result.bad?` → `result.ko?`. +- `result.chain_id` → `result.chain.id`. +- `result.threw_failure?` → `result.thrown_failure?` (semantics flipped — + v2 is true ONLY when this result re-threw an upstream failure). +- Delete any `result.initialized?`, `result.executing?`, or + `result.executed?` calls. For "did this task finish running", use + `result.complete? || result.interrupted?`. +- Delete writes to `result.metadata[...] = ...` — `Result` is read-only. + Move the data onto `task.context` BEFORE the halt. +- `result.task` now returns the task CLASS (v1 returned the instance). Code + that called `result.task.id`, `result.task.context`, etc. needs + `result.id`, `result.context`, and so on. +- `result.chain` now returns the `Chain` object (Enumerable), not an Array. + `result.chain.each`, `result.chain.map`, `result.chain.to_a`, and + `result.chain[0]` all work. `result.chain.first` / `result.chain.last` too. +- Replace `task.id` / `task.result` / `task.chain` (v1 Task instance + accessors) with `result.id` / `result` / `result.chain` off the returned + Result. +- `rescue CMDx::SkipFault` / `rescue CMDx::FailFault` → `rescue CMDx::Fault`, + then branch on `e.result.skipped?` / `e.result.failed?`. + +## Pass 6 — Middlewares + +- Rewrite every middleware `def call(task, options, &block)` to + `def call(task)` and replace `block.call` with `yield`. Move any + `options`-dependent configuration onto the middleware instance via + `initialize`. +- Middlewares must NOT return the result or mutate `result.metadata`. Write + observability data onto `task.context`, or emit it from a Telemetry + subscriber on `:task_executed`. +- In every middleware, ensure `yield` runs on every code path — including + `rescue` and `ensure`. Skipping `yield` raises `CMDx::MiddlewareError`. +- Procs/lambdas used as middleware must declare `&next_link` explicitly and + call `next_link.call` (never `yield` — it targets the enclosing method). +- Delete registrations of `CMDx::Middlewares::Correlate`, `Runtime`, and + `Timeout` — the classes are removed. If the project depended on them, + replace with a short custom middleware or a Telemetry subscriber on + `:task_started` / `:task_executed`. +- Update `register :middleware, SomeClass, foo: 1` to + `register :middleware, SomeClass.new(foo: 1)`. The registry no longer + auto-instantiates classes or forwards `**options`. + +## Pass 7 — Configuration + +- In `CMDx.configure` blocks and per-task `settings(...)` calls, delete: + `task_breakpoints`, `workflow_breakpoints`, `rollback_on`, `dump_context`, + `freeze_results`, `backtrace`, `exception_handler`. +- Delete any `SKIP_CMDX_FREEZING` env-var references. +- Delete `dry_run: true` from execution calls and any `Chain#dry_run?` / + `context.dry_run` reads. + +## Pass 8 — Lifecycle (workflows, rollback) + +- If a task defines a `rollback` method that was invoked manually from a + middleware or callback, delete the manual dispatch — Runtime calls + `rollback` automatically on failure. +- If a workflow class defines `def work`, delete it — workflows raise + `ImplementationError` when `#work` is defined on the subclass. Convert the + body to `task` / `tasks` declarations. + +## Pass 9 — Errors iteration + +- `errors.each` now yields `[key, Set<String>]`, not `[key, Array<String>]`. + Code that expected `Array`-only methods on the value (`.push`, `<<`, + index access) needs `set.to_a` or `errors[key]` (which returns an Array). + +--- + +## Final self-verification + +Run this grep from the project root: + +```bash +rg --hidden \ + 'task_breakpoints|workflow_breakpoints|rollback_on|dump_context|freeze_results|SKIP_CMDX_FREEZING|\.good\?|\.bad\?|chain_id[^=]|threw_failure\?|dry_run|attributes_schema|remove_attribute|remove_return|on_executed|on_good|on_bad|cmdx\.returns\.missing|cmdx\.faults\.(invalid|unspecified)|CMDx::Executor|CMDx::Middlewares::(Correlate|Runtime|Timeout)|CMDx::(SkipFault|FailFault|UndefinedMethodError)|register\s+:attribute|attribute\s+:' +``` + +Every hit is either (a) a string/comment that should be updated, or +(b) unfinished migration. Classify and either fix or report. + +## Exit contract + +Stop when BOTH of these hold: + +1. `bundle exec rspec` exits 0. +2. The final self-verification grep returns no hits (excluding the + migration doc itself, tests that deliberately assert v1→v2 deltas, and + `CHANGELOG.md`). + +If either fails and you can't resolve it from the rules above, stop and +report the failing file:line with a one-line diagnosis. +```` diff --git a/docs/workflows.md b/docs/workflows.md index 42964e816..632d28517 100644 --- a/docs/workflows.md +++ b/docs/workflows.md @@ -1,21 +1,19 @@ # Workflows -Compose multiple tasks into powerful, sequential pipelines. Workflows provide a declarative way to build complex business processes with conditional execution, shared context, and flexible error handling. +Compose multiple tasks into ordered pipelines. A workflow is a `Task` subclass that includes `CMDx::Workflow`; the module supplies a `#work` that delegates to `CMDx::Pipeline`, which runs the declared task groups against a shared context. -Since workflows are Task subclasses, they inherit all Task features: [attributes](attributes/definitions.md), [callbacks](callbacks.md), [middlewares](middlewares.md), [settings](configuration.md#task-configuration), and [returns](returns.md). Use these to validate workflow-level inputs, set up shared state, or track workflow outcomes. +Because workflows are tasks, they inherit every Task feature: [inputs](inputs/definitions.md), [callbacks](callbacks.md), [middlewares](middlewares.md), [settings](configuration.md#settings), [outputs](outputs.md), and [retries](retries.md). Use these to validate workflow-level inputs, set up shared state, or react to workflow outcomes. ```ruby class OnboardingWorkflow < CMDx::Task include CMDx::Workflow - register :middleware, CMDx::Middlewares::Correlate - before_execution :load_user on_failed :notify_admin! - required :user_id, type: :integer + required :user_id, coerce: :integer - returns :onboarded_at + output :onboarded_at task CreateProfile task SetupPreferences @@ -35,15 +33,15 @@ end ## Declarations -Tasks run in declaration order (FIFO), sharing a common context across the pipeline. +Tasks run in declaration order, sharing the workflow's context. !!! warning - Don't define a `work` method in workflows—the module handles execution automatically. Attempting to do so raises a `RuntimeError`. + Don't define `#work` on a workflow — `Workflow#work` delegates to `Pipeline`. Defining your own raises `CMDx::ImplementationError`. ### Task -`task` and `tasks` are aliases—use either interchangeably. +`task` and `tasks` are aliases — use either interchangeably. Each call appends a new group to the pipeline. ```ruby class OnboardingWorkflow < CMDx::Task @@ -56,140 +54,92 @@ class OnboardingWorkflow < CMDx::Task end ``` -### Group - -Group related tasks to share configuration: - -!!! warning "Important" - - Settings and conditionals apply to all tasks in the group. - -```ruby -class ContentModerationWorkflow < CMDx::Task - include CMDx::Workflow +Every entry must be a `Task` subclass; anything else raises `TypeError` at declaration time. - # Screening phase - tasks ScanForProfanity, CheckForSpam, ValidateImages, breakpoints: ["skipped"] +### Group Options - # Review phase - tasks ApplyFilters, ScoreContent, FlagSuspicious +Options apply to the entire group: - # Decision phase - tasks PublishContent, QueueForReview, NotifyModerators -end -``` +| Option | Default | Description | +|---------------|----------------|--------------------------------------------------------| +| `strategy:` | `:sequential` | `:sequential` or `:parallel` | +| `pool_size:` | `tasks.size` | Worker count when `strategy: :parallel` | +| `if:` / `unless:` | — | Skip the entire group when the predicate isn't satisfied | ### Conditionals -Conditionals support multiple syntaxes for flexible execution control: +Conditionals support multiple syntaxes for flexible execution control. They're evaluated against the workflow instance. ```ruby class ContentAccessCheck - def call(task) - task.context.user.can?(:publish_content) + def call(workflow) + workflow.context.user.can?(:publish_content) end end class OnboardingWorkflow < CMDx::Task include CMDx::Workflow - # If and/or Unless - task SendWelcomeEmail, if: :email_configured?, unless: :email_disabled? - - # Proc - task SendWelcomeEmail, if: -> { Rails.env.production? && self.class.name.include?("Premium") } + # Symbols resolve to instance methods on the workflow + task SendWelcomeEmail, if: :email_configured? - # Lambda + # Procs and lambdas are instance_exec'd against the workflow + task SendWelcomeEmail, if: -> { Rails.env.production? } task SendWelcomeEmail, if: proc { context.features_enabled? } - # Class or Module + # Class or instance: must respond to #call(workflow) task SendWelcomeEmail, unless: ContentAccessCheck - - # Instance task SendWelcomeEmail, if: ContentAccessCheck.new - # Conditional applies to all tasks of this declaration group + # The conditional applies to every task in the group tasks SendWelcomeEmail, CreateDashboard, SetupTutorial, if: :email_configured? private def email_configured? - context.user.email_address == true - end - - def email_disabled? - context.user.communication_preference == :disabled + context.user.email_address.matches?(/@mycompany.com/) end end ``` ## Halt Behavior -By default, skipped tasks don't stop the workflow—they're treated as no-ops. Configure breakpoints globally via [`workflow_breakpoints`](configuration.md#breakpoints) or per-task to customize this behavior. - -```ruby -class AnalyticsWorkflow < CMDx::Task - include CMDx::Workflow - - task CollectMetrics # If fails → workflow stops - task FilterOutliers # If skipped → workflow continues - task GenerateDashboard # Only runs if no failures occurred -end -``` - -### Task Configuration +A workflow halts on the **first failed result** in any group. Skipped tasks never halt the pipeline — they're treated as no-ops and the next task runs as normal. -Configure halt behavior for the entire workflow: +When a task fails, the pipeline echoes its `reason`, `state`, and `status` through the workflow via `throw!`, so the workflow's own result is `failed?` with the same `reason`. The propagated signal carries the failed leaf as its `origin`, so `result.origin` / `result.threw_failure` / `result.caused_failure` all point at the originating task without scanning the chain: ```ruby -class SecurityWorkflow < CMDx::Task - include CMDx::Workflow - - # Halt on both failed and skipped results - settings(workflow_breakpoints: ["skipped", "failed"]) - - task PerformSecurityScan - task ValidateSecurityRules -end - -class OptionalTasksWorkflow < CMDx::Task - include CMDx::Workflow - - # Never halt, always continue - settings(breakpoints: []) +result = AnalyticsWorkflow.execute - task TryBackupData - task TryCleanupLogs - task TryOptimizeCache -end +result.failed? #=> true +result.reason #=> "metrics service unreachable" +result.origin.task #=> CollectMetrics +result.caused_failure.task #=> CollectMetrics ``` -### Group Configuration - -Different task groups can have different halt behavior: - ```ruby -class SubscriptionWorkflow < CMDx::Task +class AnalyticsWorkflow < CMDx::Task include CMDx::Workflow - task CreateSubscription, ValidatePayment, breakpoints: ["skipped", "failed"] - - # Never halt, always continue - task SendConfirmationEmail, UpdateBilling, breakpoints: [] + task CollectMetrics # If fails → workflow stops, AnalyticsWorkflow is failed + task FilterOutliers # If skipped → workflow continues + task GenerateDashboard # Only runs if no upstream failure occurred end ``` +To make a "soft" failure non-halting, have the task `skip!` instead of `fail!`. There is no per-group or per-workflow setting to ignore failures. + ## Rollback in Workflows -Each task in a workflow handles its own rollback independently. When a task's status matches the `rollback_on` setting (default: `["failed"]`), that task's `rollback` method is called immediately after its execution — not retroactively for previously completed tasks. +When a task fails, Runtime calls its `#rollback` method (if defined) immediately after `work` returns and *before* the failure is `throw!`n up to the workflow. Concretely, the failed leaf task's lifecycle is: `perform_work` → `perform_rollback` → `on_*` callbacks → result finalization → throw to workflow. Rollback is **per-task**: previously successful tasks in the same workflow are not rolled back automatically. ```ruby class PaymentWorkflow < CMDx::Task include CMDx::Workflow task ReserveInventory # Succeeds → no rollback - task ChargeCard # Fails → ChargeCard.rollback called - task SendConfirmation # Never runs (workflow halts on failure) + task ChargeCard # Fails → ChargeCard#rollback runs + task SendConfirmation # Never runs (workflow halts on failure) end class ChargeCard < CMDx::Task @@ -199,14 +149,14 @@ class ChargeCard < CMDx::Task end def rollback - PaymentGateway.void(context.charge.id) + PaymentGateway.void(context.charge.id) if context.charge end end ``` -!!! warning "Important" +!!! warning "Compensation across tasks" - CMDx does **not** automatically rollback previously successful tasks when a later task fails. If you need to undo `ReserveInventory` when `ChargeCard` fails, handle it in `ChargeCard`'s rollback or use a callback on the workflow itself. + To undo earlier successful tasks when a later one fails, handle it on the workflow itself with an `on_failed` callback. The callback runs after the pipeline halts but before teardown, with full access to `context`. ```ruby class PaymentWorkflow < CMDx::Task @@ -227,7 +177,7 @@ end ## Nested Workflows -Build hierarchical workflows by composing workflows within workflows: +Workflows are tasks, so they nest naturally: ```ruby class EmailPreparationWorkflow < CMDx::Task @@ -252,9 +202,11 @@ class CompleteEmailWorkflow < CMDx::Task end ``` +A nested workflow's failure echoes through its parent the same way a leaf task's failure does, and the chain captures every result for traceability. + ## Parallel Execution -Run tasks concurrently using native Ruby threads for maximum throughput. No external dependencies required. +Run a group concurrently using native Ruby threads. No external dependencies required. ```ruby class SendWelcomeNotifications < CMDx::Task @@ -263,34 +215,37 @@ class SendWelcomeNotifications < CMDx::Task # One thread per task (default) tasks SendWelcomeEmail, SendWelcomeSms, SendWelcomePush, strategy: :parallel - # Fixed thread pool size - tasks SendWelcomeEmail, SendWelcomeSms, SendWelcomePush, strategy: :parallel, pool_size: 2 + # Bounded thread pool + tasks SendWelcomeEmail, SendWelcomeSms, SendWelcomePush, + strategy: :parallel, pool_size: 2 end ``` !!! warning - Each parallel task receives its own context copy, which is merged back after execution. If multiple tasks write to the same key, the last merge wins non-deterministically. Use distinct keys per task to avoid conflicts. + Each parallel task receives its own deep-duplicated `context` copy, which is merged back into the workflow's context after execution. If multiple tasks write to the same key, the last merge wins non-deterministically. Use distinct keys per task to avoid conflicts. If any parallel task fails, the failed result is propagated through `throw!` (the other tasks still complete first; they just don't merge back). ## Task Generator -Generate new CMDx workflow tasks quickly using the built-in generator: +Generate a workflow scaffold: ```bash rails generate cmdx:workflow SendNotifications ``` -This creates a new workflow task file with the basic structure: +Produces: ```ruby # app/tasks/send_notifications.rb -class SendNotifications < CMDx::Task +class SendNotifications < ApplicationTask include CMDx::Workflow - tasks Task1, Task2 + # Docs: https://drexed.github.io/cmdx/workflows end ``` +If `ApplicationTask` isn't defined the generator falls back to `CMDx::Task`. + !!! tip - Use **present tense verbs + pluralized noun** for workflow task names, eg: `SendNotifications`, `DownloadFiles`, `ValidateDocuments` + Use **present-tense verb + pluralized noun** for workflow names: `SendNotifications`, `DownloadFiles`, `ValidateDocuments`. diff --git a/lib/cmdx.rb b/lib/cmdx.rb index 89a4f8bcf..0e81f48b1 100644 --- a/lib/cmdx.rb +++ b/lib/cmdx.rb @@ -2,78 +2,124 @@ require "bigdecimal" require "date" -require "forwardable" require "json" require "logger" -require "pathname" require "securerandom" -require "set" require "time" -require "timeout" require "yaml" -require "zeitwerk" module CMDx - # @rbs EMPTY_ARRAY: Array[untyped] + # Frozen empty array reused as a sentinel return value to avoid per-call + # allocations on hot paths. + # + # @api private EMPTY_ARRAY = [].freeze private_constant :EMPTY_ARRAY - # @rbs EMPTY_HASH: Hash[untyped, untyped] + # Frozen empty hash reused as a sentinel return value to avoid per-call + # allocations on hot paths. + # + # @api private EMPTY_HASH = {}.freeze private_constant :EMPTY_HASH - # @rbs EMPTY_STRING: String + # Shared empty string constant used as a sentinel default. Intentionally + # not frozen so callers may treat it as a mutable seed when needed. + # + # @api private EMPTY_STRING = "" private_constant :EMPTY_STRING - extend self + # Root exception type for the library. Every CMDx-raised exception inherits + # from this class, so `rescue CMDx::Error` (or its alias `CMDx::Exception`) + # catches anything thrown by the framework without trapping unrelated + # `StandardError` descendants. {Fault} is the notable subclass propagated + # by `execute!`. + Error = Exception = Class.new(StandardError) - # Returns the path to the CMDx gem. - # - # @return [Pathname] the path to the CMDx gem - # - # @example - # CMDx.gem_path # => Pathname.new("/path/to/cmdx") - # - # @rbs return: Pathname - def gem_path - @gem_path ||= Pathname.new(__dir__).parent - end + # Raised when a task or workflow attempts to define an input where an + # accessor with the same name already exists. + DefinitionError = Class.new(Error) -end + # Raised by {Deprecation} when a task configured with + # `settings(deprecate: :error)` is executed. Signals that the caller must + # migrate off the deprecated task before continuing. + DeprecationError = Class.new(Error) -# Set up Zeitwerk loader for the CMDx gem -loader = Zeitwerk::Loader.for_gem -loader.inflector.inflect("cmdx" => "CMDx", "json" => "JSON") -loader.ignore("#{__dir__}/cmdx/configuration") -loader.ignore("#{__dir__}/cmdx/exception") -loader.ignore("#{__dir__}/cmdx/fault") -loader.ignore("#{__dir__}/cmdx/railtie") -loader.ignore("#{__dir__}/generators") -loader.ignore("#{__dir__}/locales") -loader.setup + # Raised when a subclass fails to fulfill an abstract contract — most + # commonly when {Task} is invoked without overriding `#work`, or when a + # {Workflow} attempts to define `#work` itself. + ImplementationError = Class.new(Error) -# Pre-load configuration to make module methods available -# This is acceptable since configuration is fundamental to the framework -require_relative "cmdx/configuration" + # Raised by the middleware chain when a registered middleware fails to + # yield to `next_link`, which would otherwise silently skip the task body. + MiddlewareError = Class.new(Error) -# Pre-load exceptions to make them available at the top level -# This ensures CMDx::Error and its descendants are always available -require_relative "cmdx/exception" +end -# Pre-load fault classes to make them available at the top level -# This ensures CMDx::FailFault and CMDx::SkipFault are always available +require_relative "cmdx/version" require_relative "cmdx/fault" +require_relative "cmdx/util" +require_relative "cmdx/i18n_proxy" +require_relative "cmdx/logger_proxy" +require_relative "cmdx/log_formatters/json" +require_relative "cmdx/log_formatters/key_value" +require_relative "cmdx/log_formatters/line" +require_relative "cmdx/log_formatters/logstash" +require_relative "cmdx/log_formatters/raw" +require_relative "cmdx/coercions/array" +require_relative "cmdx/coercions/big_decimal" +require_relative "cmdx/coercions/boolean" +require_relative "cmdx/coercions/complex" +require_relative "cmdx/coercions/date" +require_relative "cmdx/coercions/date_time" +require_relative "cmdx/coercions/float" +require_relative "cmdx/coercions/hash" +require_relative "cmdx/coercions/integer" +require_relative "cmdx/coercions/rational" +require_relative "cmdx/coercions/string" +require_relative "cmdx/coercions/symbol" +require_relative "cmdx/coercions/time" +require_relative "cmdx/coercions/coerce" +require_relative "cmdx/coercions" +require_relative "cmdx/validators/absence" +require_relative "cmdx/validators/exclusion" +require_relative "cmdx/validators/format" +require_relative "cmdx/validators/inclusion" +require_relative "cmdx/validators/length" +require_relative "cmdx/validators/numeric" +require_relative "cmdx/validators/presence" +require_relative "cmdx/validators/validate" +require_relative "cmdx/validators" +require_relative "cmdx/input" +require_relative "cmdx/inputs" +require_relative "cmdx/output" +require_relative "cmdx/outputs" +require_relative "cmdx/callbacks" +require_relative "cmdx/middlewares" +require_relative "cmdx/telemetry" +require_relative "cmdx/settings" +require_relative "cmdx/retry" +require_relative "cmdx/deprecation" +require_relative "cmdx/context" +require_relative "cmdx/chain" +require_relative "cmdx/signal" +require_relative "cmdx/result" +require_relative "cmdx/pipeline" +require_relative "cmdx/runtime" +require_relative "cmdx/errors" +require_relative "cmdx/task" +require_relative "cmdx/workflow" +require_relative "cmdx/configuration" # Conditionally load Rails components if Rails is available if defined?(Rails::Generators) require_relative "generators/cmdx/install_generator" - require_relative "generators/cmdx/locale_generator" require_relative "generators/cmdx/task_generator" require_relative "generators/cmdx/workflow_generator" end -# Load the Railtie last after everything else is required so we don't -# need to load any CMDx components when we use this Railtie. +# Load the Railtie last after everything else is required so +# we don't load any CMDx components when we use this Railtie. require_relative "cmdx/railtie" if defined?(Rails::Railtie) diff --git a/lib/cmdx/.DS_Store b/lib/cmdx/.DS_Store index 4af31a590285627138f91421c9c83106758bc944..a1692371eaed5da3f61136a7fca23f677f948d1a 100644 GIT binary patch delta 88 zcmZoMXfc=|#>CJzu~2NHo}wrt0|NsP3otNbGUPMlG3YTAF(geaRA*$|+{akSw)p}h n6XRxf4t@@xmdytlzcWwf7t!Se={f+!43lkmq<iEMW!!V!0Ep delta 193 zcmZoMXfc=|#>B`mu~2NHo}wr-0|Nsi1A_nqLncEWLoq`ELn=e^#DmKdK@tKCMGU1t z;do?8h7yK+ptv3oCwb=NCnx3PCxO*6Fm?mYdiNg;fFcYGf^hxCKx2!5rX(`t0Ldy4 wZ{j=&E`;ihJ(X;m**W+*fKJ<Z@H_Klei2<BkQqS7F*E@2W*?Cq%o7_}0Gxg?`Tzg` diff --git a/lib/cmdx/attribute.rb b/lib/cmdx/attribute.rb deleted file mode 100644 index 67010afef..000000000 --- a/lib/cmdx/attribute.rb +++ /dev/null @@ -1,440 +0,0 @@ -# frozen_string_literal: true - -module CMDx - # Represents a configurable attribute within a CMDx task. - # Attributes define the data structure and validation rules for task parameters. - # They can be nested to create complex hierarchical data structures. - class Attribute - - # @rbs AFFIX: Proc - AFFIX = proc do |value, &block| - value == true ? block.call : value - end.freeze - private_constant :AFFIX - - # Returns the task instance associated with this attribute. - # - # @return [CMDx::Task] The task instance - # - # @example - # attribute.task.context[:user_id] # => 42 - # - # @rbs @task: Task - attr_accessor :task - - # Returns the name of this attribute. - # - # @return [Symbol] The attribute name - # - # @example - # attribute.name # => :user_id - # - # @rbs @name: Symbol - attr_reader :name - - # Returns the configuration options for this attribute. - # - # @return [Hash{Symbol => Object}] Configuration options hash - # - # @example - # attribute.options # => { required: true, default: 0 } - # - # @rbs @options: Hash[Symbol, untyped] - attr_reader :options - - # Returns the child attributes for nested structures. - # - # @return [Array<Attribute>] Array of child attributes - # - # @example - # attribute.children # => [#<Attribute @name=:street>, #<Attribute @name=:city>] - # - # @rbs @children: Array[Attribute] - attr_reader :children - - # Returns the parent attribute if this is a nested attribute. - # - # @return [Attribute, nil] The parent attribute, or nil if root-level - # - # @example - # attribute.parent # => #<Attribute @name=:address> - # - # @rbs @parent: (Attribute | nil) - attr_reader :parent - - # Returns the expected type(s) for this attribute's value. - # - # @return [Array<Class>] Array of expected type classes - # - # @example - # attribute.types # => [Integer, String] - # - # @rbs @types: Array[Class] - attr_reader :types - - # Returns the description of the attribute. - # - # @return [String] The description of the attribute - # - # @example - # attribute.description # => "The user's name" - # - # @rbs @description: String - attr_reader :description - - # Creates a new attribute with the specified name and configuration. - # - # @param name [Symbol, String] The name of the attribute - # @param options [Hash] Configuration options for the attribute - # @option options [Attribute] :parent The parent attribute for nested structures - # @option options [Boolean] :required Whether the attribute is required (default: false) - # @option options [Array<Class>, Class] :types The expected type(s) for the attribute value - # @option options [String] :description The description of the attribute - # @option options [Symbol, String, Proc] :source The source of the attribute value - # @option options [Symbol, String] :as The method name to use for this attribute - # @option options [Symbol, String, Boolean] :prefix The prefix to add to the method name - # @option options [Symbol, String, Boolean] :suffix The suffix to add to the method name - # @option options [Object] :default The default value for the attribute - # - # @yield [self] Block to configure nested attributes - # - # @example - # Attribute.new(:user_id, required: true, types: [Integer, String]) do - # required :name, types: String - # optional :email, types: String - # end - # - # @rbs ((Symbol | String) name, ?Hash[Symbol, untyped] options) ?{ () -> void } -> void - def initialize(name, options = {}, &) - @parent = options.delete(:parent) - @required = options.delete(:required) || false - @types = Utils::Wrap.array(options.delete(:types) || options.delete(:type)) - @description = options.delete(:description) || options.delete(:desc) - - @name = name.to_sym - @options = options - @children = [] - - instance_eval(&) if block_given? - end - - # Deep-copies children and clears task-dependent memoization so that - # duped attributes can be safely bound to a task without mutating the - # class-level originals. This makes concurrent execution thread-safe. - # - # @param source [Attribute] The attribute being duplicated - # - # @rbs (Attribute source) -> void - def initialize_dup(source) - super - @children = source.children.map(&:dup) - remove_instance_variable(:@source) if defined?(@source) - remove_instance_variable(:@method_name) if defined?(@method_name) - @task = nil - end - - class << self - - # Builds multiple attributes with the same configuration. - # - # @param names [Array<Symbol, String>] The names of the attributes to create - # @param options [Hash] Configuration options for the attributes - # @option options [Object] :* Any attribute configuration option - # - # @yield [self] Block to configure nested attributes - # - # @return [Array<Attribute>] Array of created attributes - # - # @raise [ArgumentError] When no names are provided or :as is used with multiple attributes - # - # @example - # Attribute.build(:first_name, :last_name, required: true, types: String) - # - # @rbs (*untyped names, **untyped options) ?{ () -> void } -> Array[Attribute] - def build(*names, **options, &) - if names.none? - raise ArgumentError, "no attributes given" - elsif (names.size > 1) && options.key?(:as) - raise ArgumentError, "the :as option only supports one attribute per definition" - end - - names.filter_map { |name| new(name, **options, &) } - end - - # Creates optional attributes (not required). - # - # @param names [Array<Symbol, String>] The names of the attributes to create - # @param options [Hash] Configuration options for the attributes - # @option options [Object] :* Any attribute configuration option - # - # @yield [self] Block to configure nested attributes - # - # @return [Array<Attribute>] Array of created optional attributes - # - # @example - # Attribute.optional(:description, :tags, types: String) - # - # @rbs (*untyped names, **untyped options) ?{ () -> void } -> Array[Attribute] - def optional(*names, **options, &) - build(*names, **options.merge(required: false), &) - end - - # Creates required attributes. - # - # @param names [Array<Symbol, String>] The names of the attributes to create - # @param options [Hash] Configuration options for the attributes - # @option options [Object] :* Any attribute configuration option - # - # @yield [self] Block to configure nested attributes - # - # @return [Array<Attribute>] Array of created required attributes - # - # @example - # Attribute.required(:id, :name, types: [Integer, String]) - # - # @rbs (*untyped names, **untyped options) ?{ () -> void } -> Array[Attribute] - def required(*names, **options, &) - build(*names, **options.merge(required: true), &) - end - - end - - # Checks if the attribute is optional. - # - # @return [Boolean] true if the attribute is optional, false otherwise - # - # @example - # attribute.optional? # => true - # - # @rbs () -> bool - def optional? - !required? || !!options[:optional] - end - - # Checks if the attribute is required. - # - # @return [Boolean] true if the attribute is required, false otherwise - # - # @example - # attribute.required? # => true - # - # @rbs () -> bool - def required? - !!@required - end - - # Determines the source of the attribute value. Returns :context - # as a safe fallback when task is not yet set (e.g., schema introspection). - # - # @return [Symbol] The source identifier for the attribute value - # - # @example - # attribute.source # => :context - # - # @rbs () -> untyped - def source - return @source if defined?(@source) - - parent&.method_name || begin - value = options[:source] - - if value.is_a?(Proc) - task ? @source = task.instance_eval(&value) : :context - elsif value.respond_to?(:call) - task ? @source = value.call(task) : :context - else - @source = value || :context - end - end - end - - # Returns the method name for this attribute when it can be resolved - # statically (without a task instance). Returns nil for Proc/callable - # sources whose method name depends on runtime evaluation. - # - # @return [Symbol, nil] The static method name, or nil if dynamic - # - # @example - # attribute.allocation_name # => :user_name - # - # @rbs () -> Symbol? - def allocation_name - return @allocation_name if defined?(@allocation_name) - - @allocation_name = options[:as] || begin - src = options[:source] - source_name = - if parent - parent.allocation_name - elsif !src.is_a?(Proc) && !src.respond_to?(:call) - src || :context - end - - if source_name.is_a?(Symbol) - prefix = AFFIX.call(options[:prefix]) { "#{source_name}_" } - suffix = AFFIX.call(options[:suffix]) { "_#{source_name}" } - :"#{prefix}#{name}#{suffix}" - end - end - end - - # Generates the method name for accessing this attribute. - # - # @return [Symbol] The method name for the attribute - # - # @example - # attribute.method_name # => :user_name - # - # @rbs () -> Symbol - def method_name - return @method_name if defined?(@method_name) - - result = options[:as] || begin - prefix = AFFIX.call(options[:prefix]) { "#{source}_" } - suffix = AFFIX.call(options[:suffix]) { "_#{source}" } - :"#{prefix}#{name}#{suffix}" - end - - # Only memoize if @source is defined to avoid memoizing method - # name when no task is present. - return result unless defined?(@source) - - @method_name = result - end - - # Defines and verifies the entire attribute tree including nested children. - # - # @rbs () -> void - def define_and_verify_tree - define_and_verify - - children.each do |child| - child.task = task - child.define_and_verify_tree - end - end - - # Recursively clears the task reference from this attribute and all children. - # Prevents the class-level attribute from retaining the last-executed task instance. - # - # @rbs () -> void - def clear_task_tree! - @task = nil - children.each(&:clear_task_tree!) - end - - # @return [Hash] A hash representation of the attribute - # - # @example - # attribute.to_h # => { - # name: :user_id, - # method_name: :current_user_id, - # description: "The user's name", - # required: true, - # types: [:integer], - # options: {}, - # children: [] - # } - # - # @rbs () -> Hash[Symbol, untyped] - def to_h - { - name: name, - method_name: method_name, - description: description, - required: required?, - types: types, - options: options.except(:if, :unless), - children: children.map(&:to_h) - } - end - - private - - # Creates nested attributes as children of this attribute. - # - # @param names [Array<Symbol, String>] The names of the child attributes - # @param options [Hash] Configuration options for the child attributes - # @option options [Object] :* Any attribute configuration option - # - # @yield [self] Block to configure the child attributes - # - # @return [Array<Attribute>] Array of created child attributes - # - # @example - # attributes :street, :city, :zip, types: String - # - # @rbs (*untyped names, **untyped options) ?{ () -> void } -> Array[Attribute] - def attributes(*names, **options, &) - attrs = self.class.build(*names, **options.merge(parent: self), &) - children.concat(attrs) - end - alias attribute attributes - - # Creates optional nested attributes. - # - # @param names [Array<Symbol, String>] The names of the optional child attributes - # @param options [Hash] Configuration options for the child attributes - # @option options [Object] :* Any attribute configuration option - # - # @yield [self] Block to configure the child attributes - # - # @return [Array<Attribute>] Array of created optional child attributes - # - # @example - # optional :middle_name, :nickname, types: String - # - # @rbs (*untyped names, **untyped options) ?{ () -> void } -> Array[Attribute] - def optional(*names, **options, &) - attributes(*names, **options.merge(required: false), &) - end - - # Creates required nested attributes. - # - # @param names [Array<Symbol, String>] The names of the required child attributes - # @param options [Hash] Configuration options for the child attributes - # @option options [Object] :* Any attribute configuration option - # - # @yield [self] Block to configure the child attributes - # - # @return [Array<Attribute>] Array of created required child attributes - # - # @example - # required :first_name, :last_name, types: String - # - # @rbs (*untyped names, **untyped options) ?{ () -> void } -> Array[Attribute] - def required(*names, **options, &) - attributes(*names, **options.merge(required: true), &) - end - - # Defines the attribute reader on the task class (once) and - # generates/validates the per-instance value (every execution). - # - # @raise [RuntimeError] When the method name is already defined on the task - # - # @rbs () -> void - def define_and_verify - name_of_method = method_name - - unless task.class.method_defined?(name_of_method) - if task.respond_to?(name_of_method, true) - raise <<~MESSAGE - The method #{name_of_method.inspect} is already defined on the #{task.class.name} task. - This may be due conflicts with one of the task's user defined or internal methods/attributes. - - Use :as, :prefix, and/or :suffix attribute options to avoid conflicts with existing methods. - MESSAGE - end - - task.class.define_method(name_of_method) do - attributes[name_of_method] - end - end - - attribute_value = AttributeValue.new(self) - attribute_value.generate - attribute_value.validate - end - - end -end diff --git a/lib/cmdx/attribute_registry.rb b/lib/cmdx/attribute_registry.rb deleted file mode 100644 index ce7f9425a..000000000 --- a/lib/cmdx/attribute_registry.rb +++ /dev/null @@ -1,185 +0,0 @@ -# frozen_string_literal: true - -module CMDx - # Manages a collection of attributes for task definition and verification. - # The registry provides methods to register, deregister, and process attributes - # in a hierarchical structure, supporting nested attribute definitions. - class AttributeRegistry - - # Returns the collection of registered attributes. - # - # @return [Array<Attribute>] Array of registered attributes - # - # @example - # registry.registry # => [#<Attribute @name=:name>, #<Attribute @name=:email>] - # - # @rbs @registry: Array[Attribute] - attr_reader :registry - alias to_a registry - - # Creates a new attribute registry with an optional initial collection. - # - # @param registry [Array<Attribute>] Initial attributes to register - # - # @return [AttributeRegistry] A new registry instance - # - # @example - # registry = AttributeRegistry.new - # registry = AttributeRegistry.new([attr1, attr2]) - # - # @rbs (?Array[Attribute] registry) -> void - def initialize(registry = []) - @registry = registry - end - - # Creates a duplicate of this registry with copied attributes. - # - # @return [AttributeRegistry] A new registry with duplicated attributes - # - # @example - # new_registry = registry.dup - # - # @rbs () -> AttributeRegistry - def dup - self.class.new(registry.dup) - end - - # Registers one or more attributes to the registry. - # - # @param attributes [Attribute, Array<Attribute>] Attribute(s) to register - # - # @return [AttributeRegistry] Self for method chaining - # - # @example - # registry.register(attribute) - # registry.register([attr1, attr2]) - # - # @rbs (Attribute | Array[Attribute] attributes) -> self - def register(attributes) - @registry.concat(Utils::Wrap.array(attributes)) - self - end - - # Removes attributes from the registry by name. - # Supports hierarchical attribute removal by matching the entire attribute tree. - # - # @param names [Symbol, String, Array<Symbol, String>] Name(s) of attributes to remove - # - # @return [AttributeRegistry] Self for method chaining - # - # @example - # registry.deregister(:name) - # registry.deregister(['name1', 'name2']) - # - # @rbs ((Symbol | String | Array[Symbol | String]) names) -> self - def deregister(names) - Utils::Wrap.array(names).each do |name| - @registry.reject! { |attribute| matches_attribute_tree?(attribute, name.to_sym) } - end - - self - end - - # Eagerly defines attribute reader methods on the task class for all - # attributes whose method names can be statically resolved. Called at - # class definition time so readers are defined once, not per-execution. - # - # @param task_class [Class] The task class to define readers on - # @param attrs [Array<Attribute>] Attributes to process (defaults to all) - # - # @rbs (Class task_class, ?Array[Attribute] attrs) -> void - def define_readers_on!(task_class, attrs = registry) - attrs.each { |attr| define_reader_tree!(task_class, attr) } - end - - # Removes eagerly defined reader methods from the task class for - # attributes about to be deregistered. Must be called before {#deregister}. - # - # @param task_class [Class] The task class to undefine readers on - # @param names [Array<Symbol, String>] Attribute method names being removed - # - # @rbs (Class task_class, (Symbol | String | Array[Symbol | String]) names) -> void - def undefine_readers_on!(task_class, names) - Utils::Wrap.array(names).each do |name| - sym = name.to_sym - - registry.each do |attribute| - next unless matches_attribute_tree?(attribute, sym) - - undefine_reader_tree!(task_class, attribute) - end - end - end - - # Verifies attribute definitions against a task instance. - # Each attribute is duped before binding so the class-level originals - # are never mutated — this eliminates the concurrency hazard of - # shared mutable @task, @source, and @method_name state. - # - # @param task [Task] The task to verify attributes against - # - # @rbs (Task task) -> void - def define_and_verify(task) - registry.each do |attribute| - duplicate = attribute.dup - duplicate.task = task - duplicate.define_and_verify_tree - end - end - - private - - # Recursively defines reader methods for an attribute and its children - # when the method name is statically resolvable. - # - # @param task_class [Class] The task class to define readers on - # @param attribute [Attribute] The attribute to define a reader for - # - # @rbs (Class task_class, Attribute attribute) -> void - def define_reader_tree!(task_class, attribute) - name = attribute.allocation_name - - if name && !task_class.method_defined?(name) - if task_class.private_method_defined?(name) - raise <<~MESSAGE - The method #{name.inspect} is already defined on the #{task_class.name} task. - This may be due conflicts with one of the task's user defined or internal methods/attributes. - - Use :as, :prefix, and/or :suffix attribute options to avoid conflicts with existing methods. - MESSAGE - end - - task_class.define_method(name) { attributes[name] } - end - - attribute.children.each { |child| define_reader_tree!(task_class, child) } - end - - # Recursively removes reader methods for an attribute and its children. - # - # @param task_class [Class] The task class to undefine readers on - # @param attribute [Attribute] The attribute whose readers to remove - # - # @rbs (Class task_class, Attribute attribute) -> void - def undefine_reader_tree!(task_class, attribute) - name = attribute.allocation_name - task_class.remove_method(name) if name && task_class.method_defined?(name, false) - attribute.children.each { |child| undefine_reader_tree!(task_class, child) } - end - - # Recursively checks if an attribute or any of its children match the given name. - # - # @param attribute [Attribute] The attribute to check - # @param name [Symbol] The name to match against - # - # @return [Boolean] True if the attribute or any child matches the name - # - # @rbs (Attribute attribute, Symbol name) -> bool - def matches_attribute_tree?(attribute, name) - return true if attribute.method_name == name - - attribute.children.any? { |child| matches_attribute_tree?(child, name) } - end - - end -end diff --git a/lib/cmdx/attribute_value.rb b/lib/cmdx/attribute_value.rb deleted file mode 100644 index 92032f0f6..000000000 --- a/lib/cmdx/attribute_value.rb +++ /dev/null @@ -1,252 +0,0 @@ -# frozen_string_literal: true - -module CMDx - # Manages the value lifecycle for a single attribute within a task. - # Handles value sourcing, derivation, coercion, and validation through - # a coordinated pipeline that ensures data integrity and type safety. - class AttributeValue - - extend Forwardable - - # Returns the attribute managed by this value handler. - # - # @return [Attribute] The attribute instance - # - # @example - # attr_value.attribute.name # => :user_id - # - # @rbs @attribute: Attribute - attr_reader :attribute - - def_delegators :attribute, :task, :parent, :name, :options, :types, :source, :method_name, :required?, :optional? - def_delegators :task, :attributes, :errors - - # Creates a new attribute value manager for the given attribute. - # - # @param attribute [Attribute] The attribute to manage values for - # - # @example - # attr = Attribute.new(:user_id, required: true) - # attr_value = AttributeValue.new(attr) - # - # @rbs (Attribute attribute) -> void - def initialize(attribute) - @attribute = attribute - end - - # Retrieves the current value for this attribute from the task's attributes. - # - # @return [Object, nil] The current attribute value or nil if not set - # - # @example - # attr_value.value # => "john_doe" - # - # @rbs () -> untyped - def value - attributes[method_name] - end - - # Generates the attribute value through the complete pipeline: - # sourcing, derivation, coercion, and storage. - # - # @return [Object, nil] The generated value or nil if generation failed - # - # @example - # attr_value.generate # => 42 - # - # @rbs () -> untyped - def generate - return value if attributes.key?(method_name) - - sourced_value = source_value - return if errors.for?(method_name) - - derived_value = derive_value(sourced_value) - return if errors.for?(method_name) - - coerced_value = coerce_value(derived_value) - transformed_value = transform_value(coerced_value) - return if errors.for?(method_name) - - attributes[method_name] = transformed_value - end - - # Validates the current attribute value against configured validators. - # - # @raise [ValidationError] When validation fails (handled internally) - # - # @example - # attr_value.validate - # # Validates value against :presence, :format, etc. - # - # @rbs () -> void - def validate - registry = task.class.settings.validators - - options.slice(*registry.keys).each do |type, opts| - registry.validate(type, task, value, opts) - rescue ValidationError => e - errors.add(method_name, e.message) - nil - end - end - - private - - # Retrieves the source value for this attribute from various sources. - # - # @return [Object, nil] The sourced value or nil if unavailable - # - # @raise [NoMethodError] When the source method doesn't exist - # - # @example - # # Sources from task method, proc, or direct value - # source_value # => "raw_value" - # @rbs () -> untyped - def source_value - sourced_value = - case source - when Symbol then task.send(source) - when Proc then task.instance_eval(&source) - else source.respond_to?(:call) ? source.call(task) : source - end - - if required? && (parent.nil? || parent&.required?) && Utils::Condition.evaluate(task, options) - case sourced_value - when Context then sourced_value.key?(name) - when Hash then sourced_value.key?(name.to_s) || sourced_value.key?(name.to_sym) - when Proc then true # HACK: Cannot be determined so assume it will respond - else sourced_value.respond_to?(name, true) - end || errors.add(method_name, Locale.t("cmdx.attributes.required", method: source)) - end - - sourced_value - rescue NoMethodError - errors.add(method_name, Locale.t("cmdx.attributes.undefined", method: source)) - nil - end - - # Retrieves the default value for this attribute if configured. - # - # @return [Object, nil] The default value or nil if not configured - # - # @example - # # Default can be symbol, proc, or direct value - # -> { rand(100) } # => 23 - # - # @rbs () -> untyped - def default_value - default = options[:default] - - if default.is_a?(Symbol) && task.respond_to?(default, true) - task.send(default) - elsif default.is_a?(Proc) - task.instance_eval(&default) - elsif default.respond_to?(:call) - default.call(task) - else - default - end - end - - # Derives the actual value from the source value using various strategies. - # - # @param source_value [Object] The source value to derive from - # - # @return [Object, nil] The derived value or nil if derivation failed - # - # @raise [NoMethodError] When the derivation method doesn't exist - # - # @example - # # Derives from hash key, method call, or proc execution - # context.user_id # => 42 - # - # @rbs (untyped source_value) -> untyped - def derive_value(source_value) - derived_value = - case source_value - when Context then source_value[name] - when Hash - if source_value.key?(name.to_s) - source_value[name.to_s] - elsif source_value.key?(name.to_sym) - source_value[name.to_sym] - end - when Symbol then source_value.send(name) - when Proc then task.instance_exec(name, &source_value) - else - if source_value.respond_to?(:call) - source_value.call(task, name) - elsif source_value.respond_to?(name, true) - source_value.send(name) - end - end - - derived_value.nil? ? default_value : derived_value - rescue NoMethodError - errors.add(method_name, Locale.t("cmdx.attributes.undefined", method: name)) - nil - end - - # Transforms the derived value using the transform option. - # - # @param derived_value [Object] The value to transform - # - # @return [Object, nil] The transformed value or nil if transformation failed - # - # @example - # :downcase # => "hello" - # - # @rbs (untyped derived_value) -> untyped - def transform_value(derived_value) - transform = options[:transform] - - if transform.is_a?(Symbol) && derived_value.respond_to?(transform, true) - derived_value.send(transform) - elsif transform.respond_to?(:call) - transform.call(derived_value) - else - derived_value - end - end - - # Coerces the derived value to the expected type(s) using the coercion registry. - # - # @param transformed_value [Object] The value to coerce - # - # @return [Object, nil] The coerced value or nil if coercion failed - # - # @raise [CoercionError] When coercion fails (handled internally) - # - # @example - # # Coerces "42" to Integer, "true" to Boolean, etc. - # coerce_value("42") # => 42 - # - # @rbs (untyped transformed_value) -> untyped - def coerce_value(transformed_value) - return transformed_value if types.empty? - - registry = task.class.settings.coercions - last_idx = types.size - 1 - - types.find.with_index do |type, i| - break registry.coerce(type, task, transformed_value, options) - rescue CoercionError => e - next if i != last_idx - - unless optional? && transformed_value.nil? - message = - if last_idx.zero? - e.message - else - tl = types.map { |t| Locale.t("cmdx.types.#{t}") }.join(", ") - Locale.t("cmdx.coercions.into_any", types: tl) - end - - errors.add(method_name, message) - end - end - end - - end -end diff --git a/lib/cmdx/callback_registry.rb b/lib/cmdx/callback_registry.rb deleted file mode 100644 index 1724eab5e..000000000 --- a/lib/cmdx/callback_registry.rb +++ /dev/null @@ -1,169 +0,0 @@ -# frozen_string_literal: true - -module CMDx - # Registry for managing callbacks that can be executed at various points during task execution. - # - # Callbacks are organized by type and can be registered with optional conditions and options. - # Each callback type represents a specific execution phase or outcome. - # - # Supports copy-on-write semantics: a duped registry shares the parent's - # data until a write operation triggers materialization. - class CallbackRegistry - - extend Forwardable - - # @rbs TYPES: Array[Symbol] - TYPES = %i[ - before_validation - before_execution - on_complete - on_interrupted - on_executed - on_success - on_skipped - on_failed - on_good - on_bad - ].freeze - - # @rbs TYPES_SET: Set[Symbol] - TYPES_SET = TYPES.to_set.freeze - private_constant :TYPES_SET - - def_delegators :registry, :empty? - - # @param registry [Hash, nil] Initial registry hash, defaults to empty - # - # @rbs (?Hash[Symbol, Set[Array[untyped]]]? registry) -> void - def initialize(registry = nil) - @registry = registry || {} - end - - # Sets up copy-on-write state when duplicated via dup. - # - # @param source [CallbackRegistry] The registry being duplicated - # - # @rbs (CallbackRegistry source) -> void - def initialize_dup(source) - @parent = source - @registry = nil - super - end - - # Returns the internal registry of callbacks organized by type. - # Delegates to the parent registry when not yet materialized. - # - # @return [Hash{Symbol => Set<Array>}] Hash mapping callback types to their registered callables - # - # @example - # registry.registry # => { before_execution: #<Set: [[[:validate], {}]]> } - # - # @rbs () -> Hash[Symbol, Set[Array[untyped]]] - def registry - @registry || @parent.registry - end - alias to_h registry - - # Registers one or more callables for a specific callback type - # - # @param type [Symbol] The callback type from TYPES - # @param callables [Array<#call>] Callable objects to register - # @param options [Hash] Options to pass to the callback - # @param block [Proc] Optional block to register as a callable - # @option options [Hash] :if Condition hash for conditional execution - # @option options [Hash] :unless Inverse condition hash for conditional execution - # - # @return [CallbackRegistry] self for method chaining - # - # @raise [ArgumentError] When type is not a valid callback type - # - # @example Register a method callback - # registry.register(:before_execution, :validate_inputs) - # @example Register a block with conditions - # registry.register(:on_success, if: { status: :completed }) do |task| - # task.log("Success callback executed") - # end - # - # @rbs (Symbol type, *untyped callables, **untyped options) ?{ (Task) -> void } -> self - def register(type, *callables, **options, &block) - materialize! - - callables << block if block_given? - - @registry[type] ||= Set.new - @registry[type] << [callables, options] - self - end - - # Removes one or more callables for a specific callback type - # - # @param type [Symbol] The callback type from TYPES - # @param callables [Array<#call>] Callable objects to remove - # @param options [Hash] Options that were used during registration - # @param block [Proc] Optional block to remove - # @option options [Object] :* Any option key-value pairs - # - # @return [CallbackRegistry] self for method chaining - # - # @example Remove a specific callback - # registry.deregister(:before_execution, :validate_inputs) - # - # @rbs (Symbol type, *untyped callables, **untyped options) ?{ (Task) -> void } -> self - def deregister(type, *callables, **options, &block) - materialize! - - callables << block if block_given? - return self unless @registry[type] - - @registry[type].delete([callables, options]) - @registry.delete(type) if @registry[type].empty? - self - end - - # Invokes all registered callbacks for a given type - # - # @param type [Symbol] The callback type to invoke - # @param task [Task] The task instance to pass to callbacks - # - # @raise [TypeError] When type is not a valid callback type - # - # @example Invoke all before_execution callbacks - # registry.invoke(:before_execution, task) - # - # @rbs (Symbol type, Task task) -> void - def invoke(type, task) - raise TypeError, "unknown callback type #{type.inspect}" unless TYPES_SET.include?(type) - return unless registry[type] - - registry[type].each do |callables, options| - next unless Utils::Condition.evaluate(task, options) - - Utils::Wrap.array(callables).each do |callable| - if callable.is_a?(Symbol) - task.send(callable) - elsif callable.is_a?(Proc) - task.instance_exec(&callable) - elsif callable.respond_to?(:call) - callable.call(task) - else - raise "cannot invoke #{callable}" - end - end - end - end - - private - - # Copies the parent's registry data into this instance, - # severing the copy-on-write link. - # - # @rbs () -> void - def materialize! - return if @registry - - @registry = @parent.registry.transform_values(&:dup) - @parent = nil - end - - end -end diff --git a/lib/cmdx/callbacks.rb b/lib/cmdx/callbacks.rb new file mode 100644 index 000000000..eb7427aac --- /dev/null +++ b/lib/cmdx/callbacks.rb @@ -0,0 +1,127 @@ +# frozen_string_literal: true + +module CMDx + # Registry of lifecycle callbacks invoked by Runtime. Callbacks can be + # method names (Symbols dispatched via `task.send`), blocks/Procs + # (`instance_exec`'d on the task), or arbitrary `#call` objects. + # + # Each registration may carry `:if` / `:unless` gates (Symbol, Proc, or + # any `#call`-able). Gates are evaluated against the task before the + # callback is invoked; non-passing gates skip the callback silently. + class Callbacks + + # Callback event names Runtime dispatches. + EVENTS = Set[ + :before_validation, + :before_execution, + :on_complete, + :on_interrupted, + :on_success, + :on_skipped, + :on_failed, + :on_ok, + :on_ko + ].freeze + + attr_reader :registry + + def initialize + @registry = {} + end + + def initialize_copy(source) + @registry = source.registry.transform_values(&:dup) + end + + # Adds a callback for `event`. + # + # @param event [Symbol] one of {EVENTS} + # @param callable [Symbol, #call, nil] method name or callable; pass either this or a block + # @param options [Hash{Symbol => Object}] + # @option options [Symbol, Proc, #call] :if gate that must evaluate truthy + # @option options [Symbol, Proc, #call] :unless gate that must evaluate falsy + # @yield the callback body + # @return [Callbacks] self for chaining + # @raise [ArgumentError] when both `callable` and a block are given, when the + # callback type is invalid, or when `event` is unknown + def register(event, callable = nil, **options, &block) + callback = callable || block + + if callable && block + raise ArgumentError, "provide either a callable or a block, not both" + elsif !callback.is_a?(Symbol) && !callback.respond_to?(:call) + raise ArgumentError, "callback must be a Symbol or respond to #call" + elsif !EVENTS.include?(event) + raise ArgumentError, "unknown event #{event.inspect}, must be one of #{EVENTS.join(', ')}" + end + + (registry[event] ||= []) << [callback, options.freeze] + self + end + + # Drops callbacks registered for `event`. With no `callable`, removes + # every callback for `event`. With a `callable`, removes only the + # entries whose callback matches `callable` by `==` (works for Symbol + # method names, classes/modules, and any callable held by reference). + # When the last entry for `event` is removed, the key itself is dropped. + # + # @param event [Symbol] one of {EVENTS} + # @param callable [Symbol, #call, nil] optional specific callback to remove + # @return [Callbacks] self for chaining + # @raise [ArgumentError] when `event` is unknown + def deregister(event, callable = nil) + raise ArgumentError, "unknown event #{event.inspect}, must be one of #{EVENTS.join(', ')}" unless EVENTS.include?(event) + + if callable.nil? + registry.delete(event) + elsif (entries = registry[event]) + entries.reject! { |cb, _opts| cb == callable } + registry.delete(event) if entries.empty? + end + + self + end + + # @return [Boolean] + def empty? + registry.empty? + end + + # @return [Integer] number of distinct events with callbacks + def size + registry.size + end + + # @return [Integer] total callbacks across all events + def count + registry.each_value.sum(&:size) + end + + # Fires each callback registered for `event` against `task`. Skips any + # callback whose `:if`/`:unless` gates fail. + # + # @param event [Symbol] + # @param task [Task] + # @return [void] + # @raise [ArgumentError] when a callback is neither a Symbol nor responds to `#call` + def process(event, task) + return unless (callbacks = registry[event]) + + callbacks.each do |callable, options| + next unless Util.satisfied?(options[:if], options[:unless], task) + + case callable + when Symbol + task.send(callable) + when Proc + task.instance_exec(task, &callable) + else + next callable.call(task) if callable.respond_to?(:call) + + raise ArgumentError, "callback must be a Symbol, Proc, or respond to #call" + end + end + end + + end +end diff --git a/lib/cmdx/chain.rb b/lib/cmdx/chain.rb index 9f5016f5e..533fe0eca 100644 --- a/lib/cmdx/chain.rb +++ b/lib/cmdx/chain.rb @@ -1,215 +1,114 @@ # frozen_string_literal: true module CMDx - # Manages a collection of task execution results in a thread and fiber safe manner. - # Chains provide a way to track related task executions and their outcomes - # within the same execution context. + # Ordered collection of {Result}s produced by a top-level task and any nested + # tasks it triggers. A Chain is stored per-fiber so concurrent workflows + # (see Pipeline parallel strategy) each get their own. The root Runtime + # clears the chain on teardown. class Chain - extend Forwardable + include Enumerable - # @rbs CONCURRENCY_KEY: Symbol - CONCURRENCY_KEY = :cmdx_chain - - # Returns the unique identifier for this chain. - # - # @return [String] The chain identifier - # - # @example - # chain.id # => "abc123xyz" - # - # @rbs @id: String - attr_reader :id - - # Returns the collection of execution results in this chain. - # - # @return [Array<Result>] Array of task results - # - # @example - # chain.results # => [#<Result>, #<Result>] - # - # @rbs @results: Array[Result] - attr_reader :results - - def_delegators :results, :first, :last, :size - def_delegators :first, :state, :status, :outcome - - # Creates a new chain with a unique identifier and empty results collection. - # - # @return [Chain] A new chain instance - # - # @rbs () -> void - def initialize(dry_run: false) - @mutex = Mutex.new - @id = Identifier.generate - @results = [] - @dry_run = !!dry_run - end + # Fiber-local storage key used by {.current}/{.current=}/{.clear}. + STORAGE_KEY = :cmdx_chain class << self - # Retrieves the current chain for the current execution context. - # - # @return [Chain, nil] The current chain or nil if none exists - # - # @example - # chain = Chain.current - # if chain - # puts "Current chain: #{chain.id}" - # end - # - # @rbs () -> Chain? + # @return [Chain, nil] the chain active on the current fiber, or nil outside execution def current - thread_or_fiber[CONCURRENCY_KEY] + Fiber[STORAGE_KEY] end - # Sets the current chain for the current execution context. - # - # @param chain [Chain] The chain to set as current - # - # @return [Chain] The set chain - # - # @example - # Chain.current = my_chain - # - # @rbs (Chain chain) -> Chain + # Installs `chain` as the active chain on the current fiber. + # @param chain [Chain, nil] + # @return [Chain, nil] def current=(chain) - thread_or_fiber[CONCURRENCY_KEY] = chain + Fiber[STORAGE_KEY] = chain end - # Clears the current chain for the current execution context. - # - # @return [nil] Always returns nil - # - # @example - # Chain.clear - # - # @rbs () -> nil + # Clears the fiber-local chain reference. + # @return [nil] def clear - thread_or_fiber[CONCURRENCY_KEY] = nil + Fiber[STORAGE_KEY] = nil end - # Builds or extends the current chain by adding a result. - # Creates a new chain if none exists, otherwise appends to the current one. - # - # @param result [Result] The task execution result to add - # - # @return [Chain] The current chain (newly created or existing) - # - # @raise [TypeError] If result is not a CMDx::Result instance - # - # @example - # result = task.execute - # chain = Chain.build(result) - # puts "Chain size: #{chain.size}" - # - # @rbs (Result result) -> Chain - def build(result, dry_run: false) - raise TypeError, "must be a CMDx::Result" unless result.is_a?(Result) - - self.current ||= new(dry_run:) - current.push(result) - current - end + end - private - - # Returns the thread or fiber storage for the current execution context. - # - # @return [Hash] The thread or fiber storage - # - # @rbs () -> Hash - if Fiber.respond_to?(:storage) - def thread_or_fiber = Fiber.storage - else - def thread_or_fiber = Thread.current - end + attr_reader :id, :results + def initialize + @mutex = Mutex.new + @id = SecureRandom.uuid_v7 + @results = [] end - # Thread-safe append of a result to the chain. - # Caches the result's index to avoid repeated O(n) lookups. - # - # @param result [Result] The result to append + # Appends `result` to the chain. Thread-safe to support parallel pipelines. # - # @return [Array<Result>] The updated results array - # - # @rbs (Result result) -> Array[Result] + # @param result [Result] + # @return [Chain] self for chaining def push(result) - @mutex.synchronize do - result.instance_variable_set(:@chain_index, @results.size) - @results << result - end + @mutex.synchronize { @results << result } + self end + alias << push - # Thread-safe lookup of a result's position in the chain. - # - # @param result [Result] The result to find + # Prepends `result` to the chain. Thread-safe to support parallel pipelines. # - # @return [Integer, nil] The zero-based index or nil if not found - # - # @rbs (Result result) -> Integer? + # @param result [Result] + # @return [Chain] self for chaining + def unshift(result) + @mutex.synchronize { @results.unshift(result) } + self + end + + # @param result [Result] + # @return [Integer, nil] zero-based position of `result`, or nil when absent def index(result) - @mutex.synchronize { @results.index(result) } + @results.index(result) end - # Returns whether the chain is running in dry-run mode. - # - # @return [Boolean] Whether the chain is running in dry-run mode - # - # @example - # chain.dry_run? # => true - # - # @rbs () -> bool - def dry_run? - !!@dry_run + # @return [Result, nil] the most recently appended result + def last + @results.last end - # Freezes the chain and its internal results to prevent modifications. - # - # @return [Chain] the frozen chain - # - # @example - # chain.freeze - # chain.results << result # => raises FrozenError - # - # @rbs () -> self - def freeze - results.freeze - super + # @return [Result, nil] the root result, or nil when absent + def root + @results.find(&:chain_root?) end - # Converts the chain to a hash representation. - # - # @option return [String] :id The chain identifier - # @option return [Array<Hash>] :results Array of result hashes - # - # @return [Hash] Hash containing chain id and serialized results - # - # @example - # chain_hash = chain.to_h - # puts chain_hash[:id] - # puts chain_hash[:results].size - # - # @rbs () -> Hash[Symbol, untyped] - def to_h - { - id:, - dry_run: dry_run?, - results: results.map(&:to_h) - } + # @return [String, nil] the state of the root result, or nil when absent + def state + root&.state end - # Converts the chain to a string representation. - # - # @return [String] Formatted string representation of the chain - # - # @example - # puts chain.to_s + # @return [String, nil] the status of the root result, or nil when absent + def status + root&.status + end + + # @return [Boolean] + def empty? + @results.empty? + end + + # @return [Integer] + def size + @results.size + end + + # @yield [Result] each result in insertion order + # @return [Enumerator, Chain] + def each(&) + @results.each(&) + end + + # Freezes the chain and its results. Called by Runtime teardown. # - # @rbs () -> String - def to_s - Utils::Format.to_str(to_h) + # @return [Chain] self + def freeze + @mutex.synchronize { @results.freeze } + super end end diff --git a/lib/cmdx/coercion_registry.rb b/lib/cmdx/coercion_registry.rb deleted file mode 100644 index 8affbf9b3..000000000 --- a/lib/cmdx/coercion_registry.rb +++ /dev/null @@ -1,138 +0,0 @@ -# frozen_string_literal: true - -module CMDx - # Registry for managing type coercion handlers. - # - # Provides a centralized way to register, deregister, and execute type coercions - # for various data types including arrays, numbers, dates, and other primitives. - # - # Supports copy-on-write semantics: a duped registry shares the parent's - # data until a write operation triggers materialization. - class CoercionRegistry - - # Initialize a new coercion registry. - # - # @param registry [Hash{Symbol => Class}, nil] optional initial registry hash - # - # @example - # registry = CoercionRegistry.new - # registry = CoercionRegistry.new(custom: CustomCoercion) - # - # @rbs (?Hash[Symbol, Class]? registry) -> void - def initialize(registry = nil) - @registry = registry || { - array: Coercions::Array, - big_decimal: Coercions::BigDecimal, - boolean: Coercions::Boolean, - complex: Coercions::Complex, - date: Coercions::Date, - datetime: Coercions::DateTime, - float: Coercions::Float, - hash: Coercions::Hash, - integer: Coercions::Integer, - rational: Coercions::Rational, - string: Coercions::String, - time: Coercions::Time - } - end - - # Sets up copy-on-write state when duplicated via dup. - # - # @param source [CoercionRegistry] The registry being duplicated - # - # @rbs (CoercionRegistry source) -> void - def initialize_dup(source) - @parent = source - @registry = nil - super - end - - # Returns the internal registry mapping coercion types to handler classes. - # Delegates to the parent registry when not yet materialized. - # - # @return [Hash{Symbol => Class}] Hash of coercion type names to coercion classes - # - # @example - # registry.registry # => { integer: Coercions::Integer, boolean: Coercions::Boolean } - # - # @rbs () -> Hash[Symbol, Class] - def registry - @registry || @parent.registry - end - alias to_h registry - - # Register a new coercion handler for a type. - # - # @param name [Symbol, String] the type name to register - # @param coercion [Class] the coercion class to handle this type - # - # @return [CoercionRegistry] self for method chaining - # - # @example - # registry.register(:custom_type, CustomCoercion) - # registry.register("another_type", AnotherCoercion) - # - # @rbs ((Symbol | String) name, Class coercion) -> self - def register(name, coercion) - materialize! - - @registry[name.to_sym] = coercion - self - end - - # Remove a coercion handler for a type. - # - # @param name [Symbol, String] the type name to deregister - # - # @return [CoercionRegistry] self for method chaining - # - # @example - # registry.deregister(:custom_type) - # registry.deregister("another_type") - # - # @rbs ((Symbol | String) name) -> self - def deregister(name) - materialize! - - @registry.delete(name.to_sym) - self - end - - # Coerce a value to the specified type using the registered handler. - # - # @param type [Symbol] the type to coerce to - # @param task [Object] the task context for the coercion - # @param value [Object] the value to coerce - # @param options [Hash] additional options for the coercion - # @option options [Object] :* Any coercion option key-value pairs - # - # @return [Object] the coerced value - # - # @raise [TypeError] when the type is not registered - # - # @example - # result = registry.coerce(:integer, task, "42") - # result = registry.coerce(:boolean, task, "true", strict: true) - # - # @rbs (Symbol type, untyped task, untyped value, ?Hash[Symbol, untyped] options) -> untyped - def coerce(type, task, value, options = EMPTY_HASH) - raise TypeError, "unknown coercion type #{type.inspect}" unless registry.key?(type) - - Utils::Call.invoke(task, registry[type], value, options) - end - - private - - # Copies the parent's registry data into this instance, - # severing the copy-on-write link. - # - # @rbs () -> void - def materialize! - return if @registry - - @registry = @parent.registry.dup - @parent = nil - end - - end -end diff --git a/lib/cmdx/coercions.rb b/lib/cmdx/coercions.rb new file mode 100644 index 000000000..14c715136 --- /dev/null +++ b/lib/cmdx/coercions.rb @@ -0,0 +1,164 @@ +# frozen_string_literal: true + +module CMDx + # Registry of named type coercions applied to input/output values. Ships + # with built-ins for `:array`, `:big_decimal`, `:boolean`, `:complex`, + # `:date`, `:date_time`, `:float`, `:hash`, `:integer`, `:rational`, + # `:string`, `:symbol`, `:time`. Coercion handlers return the coerced + # value on success, or a {Failure} carrying an i18n message on failure. + class Coercions + + # Sentinel returned by a coercion when the value can't be converted. + # Runtime records the message as a validation error against the attribute. + Failure = Data.define(:message) + + attr_reader :registry + + def initialize + @registry = { + array: Coercions::Array, + big_decimal: Coercions::BigDecimal, + boolean: Coercions::Boolean, + complex: Coercions::Complex, + date: Coercions::Date, + date_time: Coercions::DateTime, + float: Coercions::Float, + hash: Coercions::Hash, + integer: Coercions::Integer, + rational: Coercions::Rational, + string: Coercions::String, + symbol: Coercions::Symbol, + time: Coercions::Time + } + end + + def initialize_copy(source) + @registry = source.registry.dup + end + + # Registers a named coercion, overwriting any existing entry with the + # same name. + # + # @param name [Symbol] + # @param callable [#call, nil] pass either this or a block + # @yield (see built-in coercion signatures — `call(value, options = {})`) + # @return [Coercions] self for chaining + # @raise [ArgumentError] when both `callable` and a block are given, or + # when the resolved coercion isn't callable + def register(name, callable = nil, &block) + raise ArgumentError, "provide either a callable or a block, not both" if callable && block + + coercion = callable || block + raise ArgumentError, "coercion must respond to #call" unless coercion.respond_to?(:call) + + registry[name.to_sym] = coercion + self + end + + # @param name [Symbol] + # @return [Coercions] self for chaining + def deregister(name) + registry.delete(name.to_sym) + self + end + + # @param name [Symbol] + # @return [#call] the registered coercion + # @raise [ArgumentError] when `name` isn't registered + def lookup(name) + registry[name] || begin + raise ArgumentError, "unknown coercion: #{name}" + end + end + + # Normalizes the `:coerce` declaration on an input/output into a list of + # `[handler, opts]` pairs. Accepts a Symbol, Array, Hash, or any + # callable. + # + # @param options [Hash{Symbol => Object}] declaration options + # @return [Array<Array(Object, Hash)>] pairs of handler + per-handler options + # @raise [ArgumentError] when `:coerce` is an unsupported format + def extract(options) + return EMPTY_ARRAY if options.empty? + + raw = options[:coerce] + return EMPTY_ARRAY if raw.nil? || raw == EMPTY_ARRAY + + case raw + when ::Symbol + [[raw, EMPTY_HASH]] + when ::Array + raw.map { |t| normalize_entry(t) } + when ::Hash + raw.map { |k, v| [k, v == true ? EMPTY_HASH : v] } + else + return [[raw, EMPTY_HASH]] if raw.respond_to?(:call) + + raise ArgumentError, "unsupported type format: #{raw.inspect}" + end + end + + # @return [Boolean] + def empty? + registry.empty? + end + + # @return [Integer] + def size + registry.size + end + + # Applies each coercion rule to `value`. Returns the first successful + # coercion. When every rule fails and more than one was declared (and + # none were inline callables), the aggregated "into_any" message is + # recorded; otherwise the last individual failure is used. + # + # @param task [Task] used for inline `Symbol`/`Proc` handlers and error recording + # @param name [Symbol] attribute name for error reporting + # @param value [Object] value to coerce + # @param rules [Array<Array(Object, Hash)>] from {#extract} + # @return [Object, Failure] coerced value, or `Failure` when every rule failed + def coerce(task, name, value, rules) + return value if rules.empty? + + last_failure = nil + any_inline = false + + rules.each do |handler, opts| + result = + if handler.is_a?(::Symbol) && registry.key?(handler) + lookup(handler).call(value, **opts) + else + any_inline = true + Coercions::Coerce.call(task, value, handler) + end + + return result unless result.is_a?(Failure) + + last_failure = result + end + + if rules.size > 1 && !any_inline + type_names = rules.map { |h, _| I18nProxy.t("cmdx.types.#{h}") }.join(", ") + last_failure = Failure.new(I18nProxy.t("cmdx.coercions.into_any", types: type_names)) + end + + task.errors.add(name, last_failure.message) + last_failure + end + + private + + def normalize_entry(entry) + case entry + when ::Symbol, ::Proc + [entry, EMPTY_HASH] + else + return [entry, EMPTY_HASH] if entry.respond_to?(:call) + + raise ArgumentError, "unsupported coerce entry: #{entry.inspect}" + end + end + + end +end diff --git a/lib/cmdx/coercions/array.rb b/lib/cmdx/coercions/array.rb index 805cc5f65..d192f7989 100644 --- a/lib/cmdx/coercions/array.rb +++ b/lib/cmdx/coercions/array.rb @@ -1,47 +1,32 @@ # frozen_string_literal: true module CMDx - module Coercions - # Converts various input types to Array format - # - # Handles conversion from strings that look like JSON arrays and other - # values that can be wrapped in an array using Ruby's Array() method. + class Coercions + # Coerces to Array. JSON-decodes strings; arrays pass through; objects + # responding to `#to_a` are unwrapped; everything else is wrapped. module Array extend self - # Converts a value to an Array - # - # @param value [Object] The value to convert to an array - # @param options [Hash] Optional configuration parameters (currently unused) - # @option options [Object] :unused Currently no options are used - # - # @return [Array] The converted array value - # - # @raise [CoercionError] If the value cannot be converted to an array - # - # @example Convert a JSON-like string to an array - # Array.call("[1, 2, 3]") # => [1, 2, 3] - # @example Convert other values using Array() - # Array.call("hello") # => ["hello"] - # Array.call(42) # => [42] - # Array.call(nil) # => [] - # @example Handle invalid JSON-like strings - # Array.call("[not json") # => raises CoercionError - # - # @rbs (untyped value, ?Hash[Symbol, untyped] options) -> Array[untyped] + # @param value [Object] + # @param options [Hash{Symbol => Object}] unused + # @return [Array, Coercions::Failure] def call(value, options = EMPTY_HASH) - if value.is_a?(::String) && ( - value.start_with?("[") || - value.strip == "null" - ) - JSON.parse(value) || [] + if value.is_a?(::Array) + value + elsif value.is_a?(::String) + result = JSON.parse(value) + result.is_a?(::Array) ? result : [value] + elsif value.respond_to?(:to_a) + value.to_a else - Utils::Wrap.array(value) + [value] end rescue JSON::ParserError - type = Locale.t("cmdx.types.array") - raise CoercionError, Locale.t("cmdx.coercions.into_an", type:) + [value] + rescue TypeError + type = I18nProxy.t("cmdx.types.array") + Failure.new(I18nProxy.t("cmdx.coercions.into_an", type:)) end end diff --git a/lib/cmdx/coercions/big_decimal.rb b/lib/cmdx/coercions/big_decimal.rb index 2fa28615c..b34559ade 100644 --- a/lib/cmdx/coercions/big_decimal.rb +++ b/lib/cmdx/coercions/big_decimal.rb @@ -1,41 +1,24 @@ # frozen_string_literal: true module CMDx - module Coercions - # Converts various input types to BigDecimal format - # - # Handles conversion from numeric strings, integers, floats, and other - # values that can be converted to BigDecimal using Ruby's BigDecimal() method. + class Coercions + # Coerces to `BigDecimal`. Default precision is 14 digits; override with + # `precision:` on the declaration. module BigDecimal extend self - # @rbs DEFAULT_PRECISION: Integer - DEFAULT_PRECISION = 14 - - # Converts a value to a BigDecimal - # - # @param value [Object] The value to convert to BigDecimal - # @param options [Hash] Optional configuration parameters - # @option options [Integer] :precision The precision to use (defaults to DEFAULT_PRECISION) - # - # @return [BigDecimal] The converted BigDecimal value - # - # @raise [CoercionError] If the value cannot be converted to BigDecimal - # - # @example Convert numeric strings to BigDecimal - # BigDecimal.call("123.45") # => #<BigDecimal:7f8b8c0d8e0f '0.12345E3',9(18)> - # BigDecimal.call("0.001", precision: 6) # => #<BigDecimal:7f8b8c0d8e0f '0.1E-2',9(18)> - # @example Convert other numeric types - # BigDecimal.call(42) # => #<BigDecimal:7f8b8c0d8e0f '0.42E2',9(18)> - # BigDecimal.call(3.14159) # => #<BigDecimal:7f8b8c0d8e0f '0.314159E1',9(18)> - # - # @rbs (untyped value, ?Hash[Symbol, untyped] options) -> BigDecimal + # @param value [Object] + # @param options [Hash{Symbol => Object}] + # @option options [Integer] :precision (14) + # @return [BigDecimal, Coercions::Failure] def call(value, options = EMPTY_HASH) - BigDecimal(value, options[:precision] || DEFAULT_PRECISION) + return value if value.is_a?(BigDecimal) + + BigDecimal(value, options[:precision] || 14) rescue ArgumentError, TypeError - type = Locale.t("cmdx.types.big_decimal") - raise CoercionError, Locale.t("cmdx.coercions.into_a", type:) + type = I18nProxy.t("cmdx.types.big_decimal") + Failure.new(I18nProxy.t("cmdx.coercions.into_a", type:)) end end diff --git a/lib/cmdx/coercions/boolean.rb b/lib/cmdx/coercions/boolean.rb index 9901b2af7..4ce87f0b3 100644 --- a/lib/cmdx/coercions/boolean.rb +++ b/lib/cmdx/coercions/boolean.rb @@ -1,56 +1,35 @@ # frozen_string_literal: true module CMDx - module Coercions - # Converts various input types to Boolean format - # - # Handles conversion from strings, numbers, and other values to boolean - # using predefined truthy and falsey patterns. + class Coercions + # Coerces to Boolean by matching the string form against the {TRUTHY} + # and {FALSEY} sets (case- and whitespace-insensitive). Anything else + # (including `nil`) fails. module Boolean extend self - # @rbs FALSEY: Regexp - FALSEY = /\A(false|f|no|n|0)\z/i - - # @rbs TRUTHY: Regexp - TRUTHY = /\A(true|t|yes|y|1)\z/i - - # Converts a value to a Boolean - # - # @param value [Object] The value to convert to boolean - # @param options [Hash] Optional configuration parameters (currently unused) - # @option options [Object] :unused Currently no options are used - # - # @return [Boolean] The converted boolean value - # - # @raise [CoercionError] If the value cannot be converted to boolean - # - # @example Convert truthy strings to true - # Boolean.call("true") # => true - # Boolean.call("yes") # => true - # Boolean.call("1") # => true - # @example Convert falsey strings to false - # Boolean.call("false") # => false - # Boolean.call("no") # => false - # Boolean.call("0") # => false - # Boolean.call(nil) # => false - # Boolean.call("") # => false - # @example Handle case-insensitive input - # Boolean.call("TRUE") # => true - # Boolean.call("False") # => false - # @example Handle edge cases - # Boolean.call("abc") # => raises CoercionError - # - # @rbs (untyped value, ?Hash[Symbol, untyped] options) -> bool + TRUTHY = Set["true", "yes", "on", "y", "1", "t"].freeze + FALSEY = Set["false", "no", "off", "n", "0", "f"].freeze + + # @param value [Object] + # @param options [Hash{Symbol => Object}] unused + # @return [Boolean, Coercions::Failure] def call(value, options = EMPTY_HASH) - case value.to_s - when FALSEY, EMPTY_STRING then false - when TRUTHY then true - else - type = Locale.t("cmdx.types.boolean") - raise CoercionError, Locale.t("cmdx.coercions.into_a", type:) - end + return coercion_failure if value.nil? + + str = value.to_s.strip.downcase + return true if TRUTHY.include?(str) + return false if FALSEY.include?(str) + + coercion_failure + end + + private + + def coercion_failure + type = I18nProxy.t("cmdx.types.boolean") + Failure.new(I18nProxy.t("cmdx.coercions.into_a", type:)) end end diff --git a/lib/cmdx/coercions/coerce.rb b/lib/cmdx/coercions/coerce.rb new file mode 100644 index 000000000..1232100d3 --- /dev/null +++ b/lib/cmdx/coercions/coerce.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module CMDx + class Coercions + # Invokes an inline `:coerce` handler (Symbol method name, Proc, or + # anything with `#call`). Used by {Coercions#coerce} for non-built-in + # rules. + module Coerce + + extend self + + # @param task [Task] receiver for Symbol/Proc handlers, also passed to callable handlers + # @param value [Object] + # @param handler [Symbol, Proc, #call] + # @return [Object] the handler's return value + # @raise [ArgumentError] when `handler` isn't a supported type + def call(task, value, handler) + case handler + when ::Symbol + task.send(handler, value) + when ::Proc + task.instance_exec(value, &handler) + else + return handler.call(value, task) if handler.respond_to?(:call) + + raise ArgumentError, "coerce handler must be a Symbol, Proc, or respond to #call" + end + end + + end + end +end diff --git a/lib/cmdx/coercions/complex.rb b/lib/cmdx/coercions/complex.rb index ddceafa36..38c14ee45 100644 --- a/lib/cmdx/coercions/complex.rb +++ b/lib/cmdx/coercions/complex.rb @@ -1,39 +1,24 @@ # frozen_string_literal: true module CMDx - module Coercions - # Converts various input types to Complex number format - # - # Handles conversion from numeric strings, integers, floats, and other - # values that can be converted to Complex using Ruby's Complex() method. + class Coercions + # Coerces to `Complex`. Supply `imaginary:` to provide the imaginary + # component when `value` is a real-only input. module Complex extend self - # Converts a value to a Complex number - # - # @param value [Object] The value to convert to Complex - # @param options [Hash] Optional configuration parameters (currently unused) - # @option options [Object] :* Any configuration option (unused) - # - # @return [Complex] The converted Complex number value - # - # @raise [CoercionError] If the value cannot be converted to Complex - # - # @example Convert numeric strings to Complex - # Complex.call("3+4i") # => (3+4i) - # Complex.call("2.5") # => (2.5+0i) - # @example Convert other numeric types - # Complex.call(5) # => (5+0i) - # Complex.call(3.14) # => (3.14+0i) - # Complex.call(Complex(1, 2)) # => (1+2i) - # - # @rbs (untyped value, ?Hash[Symbol, untyped] options) -> Complex + # @param value [Object] + # @param options [Hash{Symbol => Object}] + # @option options [Numeric] :imaginary (0) + # @return [Complex, Coercions::Failure] def call(value, options = EMPTY_HASH) - Complex(value) + return value if value.is_a?(::Complex) + + Complex(value, options[:imaginary] || 0) rescue ArgumentError, TypeError - type = Locale.t("cmdx.types.complex") - raise CoercionError, Locale.t("cmdx.coercions.into_a", type:) + type = I18nProxy.t("cmdx.types.complex") + Failure.new(I18nProxy.t("cmdx.coercions.into_a", type:)) end end diff --git a/lib/cmdx/coercions/date.rb b/lib/cmdx/coercions/date.rb index 691bee6f9..0a2536c2c 100644 --- a/lib/cmdx/coercions/date.rb +++ b/lib/cmdx/coercions/date.rb @@ -1,45 +1,41 @@ # frozen_string_literal: true module CMDx - module Coercions - # Converts various input types to Date format - # - # Handles conversion from strings, Date objects, DateTime objects, Time objects, - # and other date-like values to Date objects using Ruby's built-in parsing - # capabilities and optional custom format parsing. + class Coercions + # Coerces to `Date`. Pass `strptime:` to parse via a specific format; + # otherwise `Date.parse` is used for strings, and `#to_date` for any + # other responding object. module Date extend self - # Converts a value to a Date object - # - # @param value [Object] The value to convert to a Date - # @param options [Hash] Optional configuration parameters - # @option options [String] :strptime Custom date format string for parsing - # - # @return [Date] The converted Date object - # - # @raise [CoercionError] If the value cannot be converted to a Date - # - # @example Convert string to Date using default parsing - # Date.call("2023-12-25") # => #<Date: 2023-12-25> - # Date.call("Dec 25, 2023") # => #<Date: 2023-12-25> - # @example Convert string using custom format - # Date.call("25/12/2023", strptime: "%d/%m/%Y") # => #<Date: 2023-12-25> - # Date.call("12-25-2023", strptime: "%m-%d-%Y") # => #<Date: 2023-12-25> - # @example Return existing Date objects unchanged - # Date.call(Date.new(2023, 12, 25)) # => #<Date: 2023-12-25> - # Date.call(DateTime.new(2023, 12, 25)) # => #<Date: 2023-12-25> - # - # @rbs (untyped value, ?Hash[Symbol, untyped] options) -> Date + # @param value [Object] + # @param options [Hash{Symbol => Object}] + # @option options [String] :strptime format string for `Date.strptime` + # @return [Date, Coercions::Failure] def call(value, options = EMPTY_HASH) - return value.to_date if value.respond_to?(:to_date) - return ::Date.strptime(value, options[:strptime]) if options[:strptime] + if value.is_a?(::Date) + value + elsif value.is_a?(::String) + if (strptime = options[:strptime]) + ::Date.strptime(value, strptime) + else + ::Date.parse(value) + end + elsif value.respond_to?(:to_date) + value.to_date + else + coercion_failure + end + rescue ArgumentError, TypeError, ::Date::Error + coercion_failure + end + + private - ::Date.parse(value) - rescue TypeError, ::Date::Error - type = Locale.t("cmdx.types.date") - raise CoercionError, Locale.t("cmdx.coercions.into_a", type:) + def coercion_failure + type = I18nProxy.t("cmdx.types.date") + Failure.new(I18nProxy.t("cmdx.coercions.into_a", type:)) end end diff --git a/lib/cmdx/coercions/date_time.rb b/lib/cmdx/coercions/date_time.rb index 4211a5c13..d2bb412da 100644 --- a/lib/cmdx/coercions/date_time.rb +++ b/lib/cmdx/coercions/date_time.rb @@ -1,45 +1,41 @@ # frozen_string_literal: true module CMDx - module Coercions - # Converts various input types to DateTime format - # - # Handles conversion from date strings, Date objects, Time objects, and other - # values that can be converted to DateTime using Ruby's DateTime.parse method - # or custom strptime formats. + class Coercions + # Coerces to `DateTime`. Pass `strptime:` to parse via a specific format; + # otherwise `DateTime.parse` is used for strings, and `#to_datetime` for + # any other responding object. module DateTime extend self - # Converts a value to a DateTime - # - # @param value [Object] The value to convert to DateTime - # @param options [Hash] Optional configuration parameters - # @option options [String] :strptime Custom date format string for parsing - # - # @return [DateTime] The converted DateTime value - # - # @raise [CoercionError] If the value cannot be converted to DateTime - # - # @example Convert date strings to DateTime - # DateTime.call("2023-12-25") # => #<DateTime: 2023-12-25T00:00:00+00:00> - # DateTime.call("Dec 25, 2023") # => #<DateTime: 2023-12-25T00:00:00+00:00> - # @example Convert with custom strptime format - # DateTime.call("25/12/2023", strptime: "%d/%m/%Y") - # # => #<DateTime: 2023-12-25T00:00:00+00:00> - # @example Convert existing date objects - # DateTime.call(Date.new(2023, 12, 25)) # => #<DateTime: 2023-12-25T00:00:00+00:00> - # DateTime.call(Time.new(2023, 12, 25)) # => #<DateTime: 2023-12-25T00:00:00+00:00> - # - # @rbs (untyped value, ?Hash[Symbol, untyped] options) -> DateTime + # @param value [Object] + # @param options [Hash{Symbol => Object}] + # @option options [String] :strptime format string for `DateTime.strptime` + # @return [DateTime, Coercions::Failure] def call(value, options = EMPTY_HASH) - return value.to_datetime if value.respond_to?(:to_datetime) - return ::DateTime.strptime(value, options[:strptime]) if options[:strptime] + if value.is_a?(::DateTime) + value + elsif value.is_a?(::String) + if (strptime = options[:strptime]) + ::DateTime.strptime(value, strptime) + else + ::DateTime.parse(value) + end + elsif value.respond_to?(:to_datetime) + value.to_datetime + else + coercion_failure + end + rescue ArgumentError, TypeError, ::Date::Error + coercion_failure + end + + private - ::DateTime.parse(value) - rescue TypeError, ::Date::Error - type = Locale.t("cmdx.types.date_time") - raise CoercionError, Locale.t("cmdx.coercions.into_a", type:) + def coercion_failure + type = I18nProxy.t("cmdx.types.date_time") + Failure.new(I18nProxy.t("cmdx.coercions.into_a", type:)) end end diff --git a/lib/cmdx/coercions/float.rb b/lib/cmdx/coercions/float.rb index d2cda10db..5ad4d4427 100644 --- a/lib/cmdx/coercions/float.rb +++ b/lib/cmdx/coercions/float.rb @@ -1,42 +1,20 @@ # frozen_string_literal: true module CMDx - module Coercions - # Converts various input types to Float format - # - # Handles conversion from numeric strings, integers, and other numeric types - # that can be converted to floats using Ruby's Float() method. + class Coercions + # Coerces to Float via `Kernel#Float` (strict parsing; no silent zero). module Float extend self - # Converts a value to a Float - # - # @param value [Object] The value to convert to a float - # @param options [Hash] Optional configuration parameters (currently unused) - # @option options [Object] :unused Currently no options are used - # - # @return [Float] The converted float value - # - # @raise [CoercionError] If the value cannot be converted to a float - # - # @example Convert numeric strings to float - # Float.call("123") # => 123.0 - # Float.call("123.456") # => 123.456 - # Float.call("-42.5") # => -42.5 - # Float.call("1.23e4") # => 12300.0 - # @example Convert numeric types to float - # Float.call(42) # => 42.0 - # Float.call(BigDecimal("123.456")) # => 123.456 - # Float.call(Rational(3, 4)) # => 0.75 - # Float.call(Complex(5.0, 0)) # => 5.0 - # - # @rbs (untyped value, ?Hash[Symbol, untyped] options) -> Float + # @param value [Object] + # @param options [Hash{Symbol => Object}] unused + # @return [Float, Coercions::Failure] def call(value, options = EMPTY_HASH) Float(value) rescue ArgumentError, RangeError, TypeError - type = Locale.t("cmdx.types.float") - raise CoercionError, Locale.t("cmdx.coercions.into_a", type:) + type = I18nProxy.t("cmdx.types.float") + Failure.new(I18nProxy.t("cmdx.coercions.into_a", type:)) end end diff --git a/lib/cmdx/coercions/hash.rb b/lib/cmdx/coercions/hash.rb index baa553928..a65b1dec3 100644 --- a/lib/cmdx/coercions/hash.rb +++ b/lib/cmdx/coercions/hash.rb @@ -1,67 +1,40 @@ # frozen_string_literal: true module CMDx - module Coercions - # Coerces various input types into Hash objects - # - # Supports conversion from: - # - Nil values (converted to empty Hash) - # - Hash objects (returned as-is) - # - Array objects (converted using Hash[*array]) - # - JSON strings starting with "{" (parsed into Hash) - # - JSON strings that are "null" (parsed into empty Hash) - # - Other types raise CoercionError + class Coercions + # Coerces to Hash. `nil` becomes `{}`; strings are JSON-decoded (and + # must decode to a Hash); `#to_hash`/`#to_h` are used as fallbacks. module Hash extend self - # Coerces a value into a Hash - # - # @param value [Object] The value to coerce - # @param options [Hash] Additional options (currently unused) - # @option options [Symbol] :strict Whether to enforce strict conversion - # - # @return [Hash] The coerced hash value - # - # @raise [CoercionError] When the value cannot be coerced to a Hash - # - # @example Coerce from existing Hash - # Hash.call({a: 1, b: 2}) # => {a: 1, b: 2} - # @example Coerce from Array - # Hash.call([:a, 1, :b, 2]) # => {a: 1, b: 2} - # @example Coerce from JSON string - # Hash.call('{"key": "value"}') # => {"key" => "value"} - # - # @rbs (untyped value, ?Hash[Symbol, untyped] options) -> Hash[untyped, untyped] + # @param value [Object] + # @param options [Hash{Symbol => Object}] unused + # @return [Hash, Coercions::Failure] def call(value, options = EMPTY_HASH) if value.nil? {} elsif value.is_a?(::Hash) value - elsif value.is_a?(::Array) - ::Hash[*value] - elsif value.is_a?(::String) && ( - value.start_with?("{") || - value.strip == "null" - ) - JSON.parse(value) || {} + elsif value.is_a?(::String) + result = JSON.parse(value) + result.is_a?(::Hash) ? result : coercion_failure + elsif value.respond_to?(:to_hash) + value.to_hash elsif value.respond_to?(:to_h) value.to_h else - raise_coercion_error! + coercion_failure end rescue ArgumentError, TypeError, JSON::ParserError - raise_coercion_error! + coercion_failure end private - # Raises a CoercionError with localized message - # - # @raise [CoercionError] Always raised with localized error message - def raise_coercion_error! - type = Locale.t("cmdx.types.hash") - raise CoercionError, Locale.t("cmdx.coercions.into_a", type:) + def coercion_failure + type = I18nProxy.t("cmdx.types.hash") + Failure.new(I18nProxy.t("cmdx.coercions.into_a", type:)) end end diff --git a/lib/cmdx/coercions/integer.rb b/lib/cmdx/coercions/integer.rb index 287fc4851..b25266849 100644 --- a/lib/cmdx/coercions/integer.rb +++ b/lib/cmdx/coercions/integer.rb @@ -1,45 +1,20 @@ # frozen_string_literal: true module CMDx - module Coercions - # Converts various input types to Integer format - # - # Handles conversion from strings, numbers, and other values to integers - # using Ruby's Integer() method. Raises CoercionError for values that - # cannot be converted to integers. + class Coercions + # Coerces to Integer via `Kernel#Integer` (strict; rejects floats-as-strings). module Integer extend self - # Converts a value to an Integer - # - # @param value [Object] The value to convert to an integer - # @param options [Hash] Optional configuration parameters (currently unused) - # @option options [Object] :unused Currently no options are used - # - # @return [Integer] The converted integer value - # - # @raise [CoercionError] If the value cannot be converted to an integer - # - # @example Convert numeric strings to integers - # Integer.call("42") # => 42 - # Integer.call("-123") # => -123 - # Integer.call("0") # => 0 - # @example Convert numeric types to integers - # Integer.call(42.0) # => 42 - # Integer.call(3.14) # => 3 - # Integer.call(0.0) # => 0 - # @example Handle edge cases - # Integer.call("") # => raises CoercionError - # Integer.call(nil) # => raises CoercionError - # Integer.call("abc") # => raises CoercionError - # - # @rbs (untyped value, ?Hash[Symbol, untyped] options) -> Integer + # @param value [Object] + # @param options [Hash{Symbol => Object}] unused + # @return [Integer, Coercions::Failure] def call(value, options = EMPTY_HASH) Integer(value) rescue ArgumentError, FloatDomainError, RangeError, TypeError - type = Locale.t("cmdx.types.integer") - raise CoercionError, Locale.t("cmdx.coercions.into_an", type:) + type = I18nProxy.t("cmdx.types.integer") + Failure.new(I18nProxy.t("cmdx.coercions.into_an", type:)) end end diff --git a/lib/cmdx/coercions/rational.rb b/lib/cmdx/coercions/rational.rb index 42256d106..dfcaaeefc 100644 --- a/lib/cmdx/coercions/rational.rb +++ b/lib/cmdx/coercions/rational.rb @@ -1,45 +1,24 @@ # frozen_string_literal: true module CMDx - module Coercions - # Converts various input types to Rational format - # - # Handles conversion from strings, numbers, and other values to rational - # numbers using Ruby's Rational() method. Raises CoercionError for values - # that cannot be converted to rational numbers. + class Coercions + # Coerces to `Rational`. Supply `denominator:` to build a rational from + # a numerator and a custom denominator. module Rational extend self - # Converts a value to a Rational - # - # @param value [Object] The value to convert to a rational number - # @param options [Hash] Optional configuration parameters (currently unused) - # @option options [Object] :unused Currently no options are used - # - # @return [Rational] The converted rational number - # - # @raise [CoercionError] If the value cannot be converted to a rational number - # - # @example Convert numeric strings to rational numbers - # Rational.call("3/4") # => (3/4) - # Rational.call("2.5") # => (5/2) - # Rational.call("0") # => (0/1) - # @example Convert numeric types to rational numbers - # Rational.call(3.14) # => (157/50) - # Rational.call(2) # => (2/1) - # Rational.call(0.5) # => (1/2) - # @example Handle edge cases - # Rational.call("") # => (0/1) - # Rational.call(nil) # => (0/1) - # Rational.call(0) # => (0/1) - # - # @rbs (untyped value, ?Hash[Symbol, untyped] options) -> Rational + # @param value [Object] + # @param options [Hash{Symbol => Object}] + # @option options [Numeric] :denominator (1) + # @return [Rational, Coercions::Failure] def call(value, options = EMPTY_HASH) - Rational(value) + return value if value.is_a?(::Rational) + + Rational(value, options[:denominator] || 1) rescue ArgumentError, FloatDomainError, RangeError, TypeError, ZeroDivisionError - type = Locale.t("cmdx.types.rational") - raise CoercionError, Locale.t("cmdx.coercions.into_a", type:) + type = I18nProxy.t("cmdx.types.rational") + Failure.new(I18nProxy.t("cmdx.coercions.into_a", type:)) end end diff --git a/lib/cmdx/coercions/string.rb b/lib/cmdx/coercions/string.rb index 44b015668..24edf30a5 100644 --- a/lib/cmdx/coercions/string.rb +++ b/lib/cmdx/coercions/string.rb @@ -1,34 +1,15 @@ # frozen_string_literal: true module CMDx - module Coercions - # Coerces values to String type using Ruby's built-in String() method. - # - # This coercion handles various input types by converting them to their - # string representation. It's a simple wrapper around Ruby's String() - # method for consistency with the CMDx coercion interface. + class Coercions + # Coerces to String via `Kernel#String`. Never fails for normal objects. module String extend self - # Coerces a value to String type. - # - # @param value [Object] The value to coerce to a string - # @param options [Hash] Optional configuration parameters (unused in this coercion) - # @option options [Object] :* Any configuration option (unused) - # - # @return [String] The coerced string value - # - # @raise [TypeError] If the value cannot be converted to a string - # - # @example Basic string coercion - # String.call("hello") # => "hello" - # String.call(42) # => "42" - # String.call([1, 2, 3]) # => "[1, 2, 3]" - # String.call(nil) # => "" - # String.call(true) # => "true" - # - # @rbs (untyped value, ?Hash[Symbol, untyped] options) -> String + # @param value [Object] + # @param options [Hash{Symbol => Object}] unused + # @return [String] def call(value, options = EMPTY_HASH) String(value) end diff --git a/lib/cmdx/coercions/symbol.rb b/lib/cmdx/coercions/symbol.rb index 7fbdd3b40..43bfd63dd 100644 --- a/lib/cmdx/coercions/symbol.rb +++ b/lib/cmdx/coercions/symbol.rb @@ -1,38 +1,23 @@ # frozen_string_literal: true module CMDx - module Coercions - # Coerces values to Symbol type using Ruby's to_sym method. - # - # This coercion handles various input types by converting them to symbols. - # It provides error handling for values that cannot be converted to symbols - # and raises appropriate CMDx coercion errors with localized messages. + class Coercions + # Coerces to Symbol via `#to_s.to_sym`. Fails only when `value` has no + # `#to_s` (i.e. `BasicObject` instances). module Symbol extend self - # Coerces a value to Symbol type. - # - # @param value [Object] The value to coerce to a symbol - # @param options [Hash] Optional configuration parameters (unused in this coercion) - # @option options [Object] :* Any configuration option (unused) - # - # @return [Symbol] The coerced symbol value - # - # @raise [CoercionError] If the value cannot be converted to a symbol - # - # @example Basic symbol coercion - # Symbol.call("hello") # => :hello - # Symbol.call("user_id") # => :user_id - # Symbol.call("") # => :"" - # Symbol.call(:existing) # => :existing - # - # @rbs (untyped value, ?Hash[Symbol, untyped] options) -> Symbol + # @param value [Object] + # @param options [Hash{Symbol => Object}] unused + # @return [Symbol, Coercions::Failure] def call(value, options = EMPTY_HASH) - value.to_sym + return value if value.is_a?(::Symbol) + + value.to_s.to_sym rescue NoMethodError - type = Locale.t("cmdx.types.symbol") - raise CoercionError, Locale.t("cmdx.coercions.into_a", type:) + type = I18nProxy.t("cmdx.types.symbol") + Failure.new(I18nProxy.t("cmdx.coercions.into_a", type:)) end end diff --git a/lib/cmdx/coercions/time.rb b/lib/cmdx/coercions/time.rb index 888580eed..b05619ce5 100644 --- a/lib/cmdx/coercions/time.rb +++ b/lib/cmdx/coercions/time.rb @@ -1,47 +1,43 @@ # frozen_string_literal: true module CMDx - module Coercions - # Converts various input types to Time format - # - # Handles conversion from strings, dates, and other time-like objects to Time - # using Ruby's built-in time parsing methods. Supports custom strptime formats - # and raises CoercionError for values that cannot be converted to Time. + class Coercions + # Coerces to `Time`. Strings use `Time.parse` (or `strptime` when + # supplied); Numerics are treated as epoch seconds; objects responding + # to `#to_time` are unwrapped. module Time extend self - # Converts a value to a Time object - # - # @param value [Object] The value to convert to a Time object - # @param options [Hash] Optional configuration parameters - # @option options [String] :strptime Custom strptime format string for parsing - # - # @return [Time] The converted Time object - # - # @raise [CoercionError] If the value cannot be converted to a Time object - # - # @example Convert time-like objects - # Time.call(Time.now) # => Time object (unchanged) - # Time.call(DateTime.now) # => Time object (converted) - # Time.call(Date.today) # => Time object (converted) - # @example Convert strings with default parsing - # Time.call("2023-12-25 10:30:00") # => Time object - # Time.call("2023-12-25") # => Time object - # Time.call("10:30:00") # => Time object - # @example Convert strings with custom format - # Time.call("25/12/2023", strptime: "%d/%m/%Y") # => Time object - # Time.call("12-25-2023", strptime: "%m-%d-%Y") # => Time object - # - # @rbs (untyped value, ?Hash[Symbol, untyped] options) -> Time + # @param value [Object] + # @param options [Hash{Symbol => Object}] + # @option options [String] :strptime format string for `Time.strptime` + # @return [Time, Coercions::Failure] def call(value, options = EMPTY_HASH) - return value.to_time if value.respond_to?(:to_time) - return ::Time.strptime(value, options[:strptime]) if options[:strptime] - - ::Time.parse(value) + if value.is_a?(::Time) + value + elsif value.is_a?(::String) + if (strptime = options[:strptime]) + ::Time.strptime(value, strptime) + else + ::Time.parse(value) + end + elsif value.is_a?(::Numeric) + ::Time.at(value) + elsif value.respond_to?(:to_time) + value.to_time + else + coercion_failure + end rescue ArgumentError, TypeError - type = Locale.t("cmdx.types.time") - raise CoercionError, Locale.t("cmdx.coercions.into_a", type:) + coercion_failure + end + + private + + def coercion_failure + type = I18nProxy.t("cmdx.types.time") + Failure.new(I18nProxy.t("cmdx.coercions.into_a", type:)) end end diff --git a/lib/cmdx/configuration.rb b/lib/cmdx/configuration.rb index 293538a91..3fe60bda4 100644 --- a/lib/cmdx/configuration.rb +++ b/lib/cmdx/configuration.rb @@ -2,260 +2,52 @@ module CMDx - # Configuration class that manages global settings for CMDx including middlewares, - # callbacks, coercions, validators, breakpoints, backtraces, and logging. + # Global defaults used by every task unless the task overrides via + # `Task.settings`/register DSL. A fresh `Task` subclass inherits the current + # configuration's registries (via `#dup`) at the time its accessor is first + # called, so changes to configuration only apply to tasks that haven't + # cached their copy yet. class Configuration - # @rbs DEFAULT_BREAKPOINTS: Array[String] - DEFAULT_BREAKPOINTS = %w[failed].freeze + attr_accessor :middlewares, :callbacks, :coercions, :validators, :telemetry, + :default_locale, :backtrace_cleaner, :logger, :log_level, :log_formatter - # @rbs DEFAULT_ROLLPOINTS: Array[String] - DEFAULT_ROLLPOINTS = %w[failed].freeze - - # Returns the middleware registry for task execution. - # - # @return [MiddlewareRegistry] The middleware registry - # - # @example - # config.middlewares.register(CustomMiddleware) - # - # @rbs @middlewares: MiddlewareRegistry - attr_accessor :middlewares - - # Returns the callback registry for task lifecycle hooks. - # - # @return [CallbackRegistry] The callback registry - # - # @example - # config.callbacks.register(:before_execution, :log_start) - # - # @rbs @callbacks: CallbackRegistry - attr_accessor :callbacks - - # Returns the coercion registry for type conversions. - # - # @return [CoercionRegistry] The coercion registry - # - # @example - # config.coercions.register(:custom, CustomCoercion) - # - # @rbs @coercions: CoercionRegistry - attr_accessor :coercions - - # Returns the validator registry for attribute validation. - # - # @return [ValidatorRegistry] The validator registry - # - # @example - # config.validators.register(:email, EmailValidator) - # - # @rbs @validators: ValidatorRegistry - attr_accessor :validators - - # Returns the breakpoint statuses for task execution interruption. - # - # @return [Array<String>] Array of status names that trigger breakpoints - # - # @example - # config.task_breakpoints = ["failed", "skipped"] - # - # @rbs @task_breakpoints: Array[String] - attr_accessor :task_breakpoints - - # Returns the breakpoint statuses for workflow execution interruption. - # - # @return [Array<String>] Array of status names that trigger breakpoints - # - # @example - # config.workflow_breakpoints = ["failed", "skipped"] - # - # @rbs @task_breakpoints: Array[String] - # @rbs @workflow_breakpoints: Array[String] - attr_accessor :workflow_breakpoints - - # Returns the logger instance for CMDx operations. - # - # @return [Logger] The logger instance - # - # @example - # config.logger.level = Logger::DEBUG - # - # @rbs @logger: Logger - attr_accessor :logger - - # Returns whether to log backtraces for failed tasks. - # - # @return [Boolean] true if backtraces should be logged - # - # @example - # config.backtrace = true - # - # @rbs @backtrace: bool - attr_accessor :backtrace - - # Returns the proc used to clean backtraces before logging. - # - # @return [Proc, nil] The backtrace cleaner proc, or nil if not set - # - # @example - # config.backtrace_cleaner = ->(bt) { bt.first(5) } - # - # @rbs @backtrace_cleaner: (Proc | nil) - attr_accessor :backtrace_cleaner - - # Returns the proc called when exceptions occur during execution. - # - # @return [Proc, nil] The exception handler proc, or nil if not set - # - # @example - # config.exception_handler = ->(task, error) { Sentry.capture_exception(error) } - # - # @rbs @exception_handler: (Proc | nil) - attr_accessor :exception_handler - - # Returns the statuses that trigger a task execution rollback. - # - # @return [Array<String>] Array of status names that trigger rollback - # - # @example - # config.rollback_on = ["failed", "skipped"] - # - # @rbs @rollback_on: Array[String] - attr_accessor :rollback_on - - # Returns whether to include context data in hash representation output. - # - # @return [Boolean] true if context should be included (default: false) - # - # @example - # config.dump_context = true - # - # @rbs @dump_context: bool - attr_accessor :dump_context - - # Returns whether to freeze task results after execution. - # Set to false in test environments to allow stubbing on result objects. - # - # @return [Boolean] true if results should be frozen (default: true) - # - # @example - # config.freeze_results = false - # - # @rbs @freeze_results: bool - attr_accessor :freeze_results - - # Returns the default locale used for built-in translation lookups. - # Must match the basename of a YAML file in lib/locales/ (e.g. "en", "es", "ja"). - # - # @return [String] The locale identifier (default: "en") - # - # @example - # config.default_locale = "es" - # - # @rbs @locale: String - attr_accessor :default_locale - - # Initializes a new Configuration instance with default values. - # - # Creates new registry instances for middlewares, callbacks, coercions, and - # validators. Sets default breakpoints and configures a basic logger. - # - # @return [Configuration] a new Configuration instance - # - # @example - # config = Configuration.new - # config.middlewares.class # => MiddlewareRegistry - # config.task_breakpoints # => ["failed"] - # - # @rbs () -> void def initialize - @middlewares = MiddlewareRegistry.new - @callbacks = CallbackRegistry.new - @coercions = CoercionRegistry.new - @validators = ValidatorRegistry.new - - @task_breakpoints = DEFAULT_BREAKPOINTS - @workflow_breakpoints = DEFAULT_BREAKPOINTS - @rollback_on = DEFAULT_ROLLPOINTS - @dump_context = false - @freeze_results = true - @default_locale = "en" + @middlewares = Middlewares.new + @callbacks = Callbacks.new + @coercions = Coercions.new + @validators = Validators.new + @telemetry = Telemetry.new - @backtrace = false + @default_locale = "en" @backtrace_cleaner = nil - @exception_handler = nil - @logger = Logger.new( + @log_formatter = LogFormatters::Line.new + @log_level = Logger::INFO + @logger = Logger.new( $stdout, progname: "cmdx", - formatter: LogFormatters::Line.new, - level: Logger::INFO + formatter: @log_formatter, + level: @log_level ) end - # Converts the configuration to a hash representation. - # - # @return [Hash{Symbol => Object}] hash containing all configuration values - # - # @example - # config = Configuration.new - # config.to_h - # # => { middlewares: #<MiddlewareRegistry>, callbacks: #<CallbackRegistry>, ... } - # - # @rbs () -> Hash[Symbol, untyped] - def to_h - { - middlewares: @middlewares, - callbacks: @callbacks, - coercions: @coercions, - validators: @validators, - task_breakpoints: @task_breakpoints, - workflow_breakpoints: @workflow_breakpoints, - rollback_on: @rollback_on, - freeze_results: @freeze_results, - default_locale: @default_locale, - backtrace: @backtrace, - backtrace_cleaner: @backtrace_cleaner, - exception_handler: @exception_handler, - dump_context: @dump_context, - logger: @logger - } - end - end extend self - # Returns the global configuration instance, creating it if it doesn't exist. - # - # @return [Configuration] the global configuration instance - # - # @example - # config = CMDx.configuration - # config.middlewares # => #<MiddlewareRegistry> - # - # @rbs () -> Configuration + # @return [Configuration] the lazily-initialized global configuration def configuration return @configuration if @configuration @configuration ||= Configuration.new end - # Configures CMDx using a block that receives the configuration instance. + # Yields the global configuration for mutation. # - # @yield [Configuration] the configuration instance to configure - # - # @return [Configuration] the configured configuration instance - # - # @raise [ArgumentError] when no block is provided - # - # @example - # CMDx.configure do |config| - # config.task_breakpoints = ["failed", "skipped"] - # config.logger.level = Logger::DEBUG - # end - # - # @rbs () { (Configuration) -> void } -> Configuration + # @yield [Configuration] + # @return [Configuration] + # @raise [ArgumentError] when no block is given def configure raise ArgumentError, "block required" unless block_given? @@ -264,17 +56,20 @@ def configure config end - # Resets the global configuration to a new instance with default values. + # Replaces the global configuration with a fresh instance and invalidates + # the cached registries on `Task` so new lookups rebuild from the new config. + # Intended for test setup/teardown. # - # @return [Configuration] the new configuration instance - # - # @example - # CMDx.reset_configuration! - # # Configuration is now reset to defaults - # - # @rbs () -> Configuration + # @return [void] def reset_configuration! @configuration = Configuration.new + return unless defined?(Task) + + Task.instance_variable_set(:@middlewares, nil) + Task.instance_variable_set(:@callbacks, nil) + Task.instance_variable_set(:@coercions, nil) + Task.instance_variable_set(:@validators, nil) + Task.instance_variable_set(:@telemetry, nil) end end diff --git a/lib/cmdx/context.rb b/lib/cmdx/context.rb index 32e7adf4c..8c988eda6 100644 --- a/lib/cmdx/context.rb +++ b/lib/cmdx/context.rb @@ -1,309 +1,254 @@ # frozen_string_literal: true module CMDx - # A hash-like context object that provides a flexible way to store and access - # key-value pairs during task execution. Keys are automatically converted to - # symbols for consistency. - # - # The Context class extends Forwardable to delegate common hash methods and - # provides additional convenience methods for working with context data. + # Shared data object passed through task execution. Wraps a symbol-keyed + # hash; supports `ctx.foo`/`ctx.foo = 1`/`ctx.foo?` dynamic accessors via + # {#method_missing}. Runtime freezes the root context during teardown so + # nested subtasks can't mutate the outer task's state after completion. class Context - extend Forwardable - - # Returns the internal hash storing context data. - # - # @return [Hash{Symbol => Object}] The internal hash table - # - # @example - # context.table # => { name: "John", age: 30 } - # - # @rbs @table: Hash[Symbol, untyped] - attr_reader :table - alias to_h table + include Enumerable + + class << self + + # Normalizes `context` into a Context instance. Passes through an + # unfrozen Context unchanged (so nested tasks share state); unwraps + # anything with `#context` (e.g. a Task); wraps hashes/hash-likes into + # a new Context with symbolized keys. + # + # @param context [Context, #context, Hash, #to_h, #to_hash] + # @return [Context] + # @raise [ArgumentError] when `context` doesn't respond to `#to_h`/`#to_hash` + def build(context = EMPTY_HASH) + if context.is_a?(self) && !context.frozen? + context + elsif context.respond_to?(:context) + build(context.context) + else + new(context) + end + end - def_delegators :table, :keys, :values, :each, :each_key, :each_value, :map + end - # Creates a new Context instance from the given arguments. - # - # @param args [Hash, Object] arguments to initialize the context with - # @option args [Object] :key the key-value pairs to store in the context - # - # @return [Context] a new Context instance - # - # @raise [ArgumentError] when args doesn't respond to `to_h` or `to_hash` - # - # @example - # context = Context.new(name: "John", age: 30) - # context[:name] # => "John" - # - # @rbs (untyped args) -> void - def initialize(args = {}) + # @param context [Hash, #to_h, #to_hash] source hash, keys are symbolized + # @raise [ArgumentError] when `context` doesn't respond to `#to_h`/`#to_hash` + def initialize(context = EMPTY_HASH) @table = - if args.respond_to?(:to_hash) - args.to_hash - elsif args.respond_to?(:to_h) - args.to_h + if context.respond_to?(:to_hash) + context.to_hash + elsif context.respond_to?(:to_h) + context.to_h else raise ArgumentError, "must respond to `to_h` or `to_hash`" end.transform_keys(&:to_sym) end - # Builds a Context instance, reusing existing unfrozen contexts when possible. - # - # @param context [Context, Object] the context to build from - # @option context [Object] :key the key-value pairs to store in the context + # Stores `value` under `key`, symbolizing the key. Overwrites any + # existing entry. # - # @return [Context] a Context instance, either new or reused - # - # @example - # existing = Context.new(name: "John") - # built = Context.build(existing) # reuses existing context - # built.object_id == existing.object_id # => true - # - # @rbs (untyped context) -> Context - def self.build(context = {}) - if context.is_a?(self) && !context.frozen? - context - elsif context.respond_to?(:context) - build(context.context) - else - new(context) - end + # @param key [Symbol, String] + # @param value [Object] + # @return [Object] the stored value + def store(key, value) + @table[key.to_sym] = value end + alias []= store - # Retrieves a value from the context by key. + # Merges another context/hash-like into this one in place. Keys from + # `context` win on conflict. # - # @param key [String, Symbol] the key to retrieve - # - # @return [Object, nil] the value associated with the key, or nil if not found - # - # @example - # context = Context.new(name: "John") - # context[:name] # => "John" - # context["name"] # => "John" (automatically converted to symbol) - # - # @rbs ((String | Symbol) key) -> untyped - def [](key) - table[key.to_sym] + # @param context [Context, Hash, #to_h, #to_hash] + # @return [Context] self for chaining + def merge(context = EMPTY_HASH) + other = self.class.build(context) + @table.merge!(other.to_h) + self end - # Stores a key-value pair in the context. - # - # @param key [String, Symbol] the key to store - # @param value [Object] the value to store - # - # @return [Object] the stored value - # - # @example - # context = Context.new - # context.store(:name, "John") - # context[:name] # => "John" - # - # @rbs ((String | Symbol) key, untyped value) -> untyped - def store(key, value) - table[key.to_sym] = value + # @param key [Symbol, String] + # @return [Object, nil] + def [](key) + @table[key.to_sym] end - alias []= store - # Fetches a value from the context by key, with optional default value. + # Hash-like fetch. Supports a default value, default block, or raises + # `KeyError` just like `Hash#fetch`. # - # @param key [String, Symbol] the key to fetch - # - # @yield [key] a block to compute the default value - # - # @return [Object] the value associated with the key, or the default/default block result - # - # @example - # context = Context.new(name: "John") - # context.fetch(:name) # => "John" - # context.fetch(:age, 25) # => 25 - # context.fetch(:city) { |key| "Unknown #{key}" } # => "Unknown city" - # - # @rbs ((String | Symbol) key, *untyped) ?{ ((String | Symbol)) -> untyped } -> untyped + # @param key [Symbol, String] + # @return [Object] def fetch(key, ...) - table.fetch(key.to_sym, ...) + @table.fetch(key.to_sym, ...) end - # Fetches a value from the context by key, or stores and returns a default value if not found. - # - # @param key [String, Symbol] the key to fetch or store - # @param value [Object] the default value to store if key is not found - # - # @yield [key] a block to compute the default value to store - # - # @return [Object] the existing value if key is found, otherwise the stored default value - # - # @example - # context = Context.new(name: "John") - # context.fetch_or_store(:name, "Default") # => "John" (existing value) - # context.fetch_or_store(:age, 25) # => 25 (stored and returned) - # context.fetch_or_store(:city) { |key| "Unknown #{key}" } # => "Unknown city" (stored and returned) + # @param key [Symbol, String] top-level key (symbolized) + # @param keys [Array<Object>] nested keys passed through untouched + # @return [Object, nil] + def dig(key, *keys) + @table.dig(key.to_sym, *keys) + end + + # Fetch-or-store. Returns the existing value, or stores and returns the + # default (from block if given, else `value`). # - # @rbs ((String | Symbol) key, ?untyped value) ?{ () -> untyped } -> untyped - def fetch_or_store(key, value = nil) - table.fetch(key.to_sym) do - table[key.to_sym] = block_given? ? yield : value + # @param key [Symbol, String] + # @param value [Object] fallback when no block is given + # @yield [] invoked only when `key` is absent + # @yieldreturn [Object] value to store + # @return [Object] + def retrieve(key, value = nil) + nk = key.to_sym + + @table.fetch(nk) do + @table[nk] = block_given? ? yield : value end end - # Merges the given arguments into the current context, modifying it in place. - # - # @param args [Hash, Object] arguments to merge into the context - # @option args [Object] :key the key-value pairs to merge - # - # @return [Context] self for method chaining - # - # @example - # context = Context.new(name: "John") - # context.merge!(age: 30, city: "NYC") - # context.to_h # => {name: "John", age: 30, city: "NYC"} - # - # @rbs (?untyped args) -> self - def merge!(args = EMPTY_HASH) - table.merge!(args.to_h.transform_keys(&:to_sym)) - self + # @param key [Symbol, String] + # @return [Boolean] + def key?(key) + @table.key?(key.to_sym) end - alias merge merge! - # Deletes a key-value pair from the context. - # - # @param key [String, Symbol] the key to delete - # - # @yield [key] a block to handle the case when key is not found - # - # @return [Object, nil] the deleted value, or the block result if key not found - # - # @example - # context = Context.new(name: "John", age: 30) - # context.delete!(:age) # => 30 - # context.delete!(:city) { |key| "Key #{key} not found" } # => "Key city not found" - # - # @rbs ((String | Symbol) key) ?{ ((String | Symbol)) -> untyped } -> untyped - def delete!(key, &) - table.delete(key.to_sym, &) + # @return [Array<Symbol>] + def keys + @table.keys end - alias delete delete! - # Clears all key-value pairs from the context. - # - # @return [Context] self for method chaining - # - # @example - # context = Context.new(name: "John") - # context.clear! - # context.to_h # => {} + # @return [Array<Object>] + def values + @table.values + end + + # @return [Boolean] + def empty? + @table.empty? + end + + # @return [Integer] + def size + @table.size + end + + # @yield [key, value] + # @return [Context, Enumerator] + def each(&) + @table.each(&) + end + + # @yield [Symbol] + # @return [Context, Enumerator] + def each_key(&) + @table.each_key(&) + end + + # @yield [Object] + # @return [Context, Enumerator] + def each_value(&) + @table.each_value(&) + end + + # @param key [Symbol, String] + # @yield [Symbol] optional default block, receives the symbolized key + # @return [Object, nil] removed value + def delete(key, &) + @table.delete(key.to_sym, &) + end + + # Removes every entry. # - # @rbs () -> self - def clear! - table.clear + # @return [Context] self + def clear + @table.clear self end - alias clear clear! - # Compares this context with another object for equality. + # Equal when `other` is a Context with the same underlying hash. # - # @param other [Object] the object to compare with - # - # @return [Boolean] true if other is a Context with the same data - # - # @example - # context1 = Context.new(name: "John") - # context2 = Context.new(name: "John") - # context1 == context2 # => true - # - # @rbs (untyped other) -> bool + # @param other [Object] + # @return [Boolean] def eql?(other) - other.is_a?(self.class) && (table == other.to_h) + other.is_a?(self.class) && (to_h == other.to_h) end alias == eql? - # Checks if the context contains a specific key. - # - # @param key [String, Symbol] the key to check - # - # @return [Boolean] true if the key exists in the context - # - # @example - # context = Context.new(name: "John") - # context.key?(:name) # => true - # context.key?(:age) # => false - # - # @rbs ((String | Symbol) key) -> bool - def key?(key) - table.key?(key.to_sym) + # @return [Integer] + def hash + @table.hash end - # Digs into nested structures using the given keys. - # - # @param key [String, Symbol] the first key to dig with - # @param keys [Array<String, Symbol>] additional keys for deeper digging - # - # @return [Object, nil] the value found by digging, or nil if not found - # - # @example - # context = Context.new(user: {profile: {name: "John"}}) - # context.dig(:user, :profile, :name) # => "John" - # context.dig(:user, :profile, :age) # => nil - # - # @rbs ((String | Symbol) key, *(String | Symbol) keys) -> untyped - def dig(key, *keys) - table.dig(key.to_sym, *keys) + # @return [Hash{Symbol => Object}] the underlying table (not a copy) + def to_h + @table end - # Converts the context to a string representation. - # - # @return [String] a formatted string representation of the context data + # @return [String] space-separated `key=value.inspect` pairs + def to_s + @table.map { |k, v| "#{k}=#{v.inspect}" }.join(" ") + end + + # Returns a deep copy. Non-mutable scalars are shared; Hashes/Arrays are + # recursively duplicated; other objects fall back to `#dup` (and then + # to the original on `StandardError`). # - # @example - # context = Context.new(name: "John", age: 30) - # context.to_s # => "name: John, age: 30" + # @return [Context] + def deep_dup + ctx = self.class.allocate + ctx.instance_variable_set(:@table, compute_deep_dup(@table)) + ctx + end + + # Freezes the context and its backing hash. Runtime calls this on the + # root task's context during teardown. # - # @rbs () -> String - def to_s - Utils::Format.to_str(to_h) + # @return [Context] self + def freeze + @table.freeze + super end private - # Handles method calls that don't match defined methods. - # Supports assignment-style calls (e.g., `name=`) and key access. - # - # @param method_name [Symbol] the method name that was called - # @param args [Array<Object>] arguments passed to the method - # @param _kwargs [Hash] keyword arguments (unused) - # @option _kwargs [Object] :* Any keyword arguments (unused) + # Provides dynamic read/write/predicate access to context keys. # - # @yield [Object] optional block + # - `ctx.name` — reads `@table[name]`, `nil` when absent. + # - `ctx.name = val` — stores `val` under `:name`. + # - `ctx.name?` — truthy check for `@table[:name]`. # - # @return [Object] the result of the method call - # - # @rbs (Symbol method_name, *untyped args, **untyped _kwargs) ?{ () -> untyped } -> untyped + # @api private def method_missing(method_name, *args, **_kwargs, &) if method_name.end_with?("=") - store(method_name.name.chop, args.first) + @table[method_name[..-2].to_sym] = args.first + elsif method_name.end_with?("?") + !!@table[method_name[..-2].to_sym] else - table[method_name] + @table[method_name] end end - # Checks if the object responds to a given method. - # Supports both getter access for existing keys and setter methods. - # - # @param method_name [Symbol] the method name to check - # @param include_private [Boolean] whether to include private methods - # - # @return [Boolean] true if the method can be called - # - # @example - # context = Context.new(name: "John") - # context.respond_to?(:name) # => true - # context.respond_to?(:name=) # => true - # context.respond_to?(:age) # => false - # - # @rbs (Symbol method_name, ?bool include_private) -> bool def respond_to_missing?(method_name, include_private = false) - key?(method_name) || method_name.end_with?("=") || super + @table.key?(method_name) || method_name.end_with?("=", "?") || super + end + + def deep_dup_hash(h) + h.each_with_object({}) { |(k, v), acc| acc[k] = deep_dup_value(v) } + end + + def compute_deep_dup(value) + case value + when Numeric, Symbol, TrueClass, FalseClass, NilClass + value + when Hash + value.each_with_object({}) { |(k, v), acc| acc[k] = compute_deep_dup(v) } + when Array + value.map { |e| compute_deep_dup(e) } + else + begin + value.dup + rescue StandardError + value + end + end end end diff --git a/lib/cmdx/deprecation.rb b/lib/cmdx/deprecation.rb new file mode 100644 index 000000000..6da4ee5ae --- /dev/null +++ b/lib/cmdx/deprecation.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +module CMDx + # Declared via `Task.deprecation`. Runs before a task's lifecycle to warn, + # log, raise, or delegate when a task class has been marked deprecated. + # Supports conditional `:if` / `:unless` gating via {Util.satisfied?}. + class Deprecation + + # @param value [:log, :warn, :error, Symbol, Proc, #call, nil] action to take; + # `nil` disables + # @param options [Hash{Symbol => Object}] + # @option options [Symbol, Proc, #call] :if + # @option options [Symbol, Proc, #call] :unless + def initialize(value, options = EMPTY_HASH) + @value = value + @options = options.freeze + end + + # Runs the configured deprecation action, yielding first so Runtime can + # mark the result as deprecated for telemetry. + # + # @param task [Task] + # @yield invoked immediately before the action runs, only when conditions pass + # @return [void] + # @raise [DeprecationError] when `value` is `:error` + # @raise [ArgumentError] when `value` is an unsupported type + def execute(task) + return if @value.nil? + return unless Util.satisfied?(@options[:if], @options[:unless], task) + + yield + + case @value + when :log + task.logger.warn { "DEPRECATED: #{task.class} - migrate to a replacement or discontinue use" } + when :warn + Kernel.warn("[#{task.class}] DEPRECATED: migrate to a replacement or discontinue use") + when :error + raise DeprecationError, "#{task.class} usage prohibited" + when Symbol + task.send(@value) + when Proc + task.instance_exec(task, &@value) + else + return @value.call(task) if @value.respond_to?(:call) + + raise ArgumentError, "deprecation must be a Symbol, Proc, or respond to #call" + end + end + + end +end diff --git a/lib/cmdx/deprecator.rb b/lib/cmdx/deprecator.rb deleted file mode 100644 index ba1fac7d1..000000000 --- a/lib/cmdx/deprecator.rb +++ /dev/null @@ -1,77 +0,0 @@ -# frozen_string_literal: true - -module CMDx - # Handles deprecation warnings and restrictions for tasks. - # - # The Deprecator module provides functionality to restrict usage of deprecated - # tasks based on configuration settings. It supports different deprecation - # behaviors including warnings, logging, and errors. - module Deprecator - - extend self - - # @rbs RAISE_REGEXP: Regexp - RAISE_REGEXP = /\Araise\z/ - private_constant :RAISE_REGEXP - - # @rbs LOG_REGEXP: Regexp - LOG_REGEXP = /\Alog\z/ - private_constant :LOG_REGEXP - - # @rbs WARN_REGEXP: Regexp - WARN_REGEXP = /\Awarn\z/ - private_constant :WARN_REGEXP - - # @rbs EVAL: Proc - EVAL = proc do |target, callable| - case callable - when NilClass, FalseClass, TrueClass then !!callable - when RAISE_REGEXP, LOG_REGEXP, WARN_REGEXP then callable - when Symbol then target.send(callable) - when Proc then target.instance_eval(&callable) - else - raise "cannot evaluate #{callable.inspect}" unless callable.respond_to?(:call) - - callable.call(target) - end - end.freeze - private_constant :EVAL - - # Restricts task usage based on deprecation settings. - # - # @param task [Object] The task object to check for deprecation - # @option task.class.settings.deprecate [Symbol, Proc, String, Boolean] - # The deprecation configuration for the task - # @option task.class.settings.deprecate :raise Raises DeprecationError - # @option task.class.settings.deprecate :log Logs deprecation warning - # @option task.class.settings.deprecate :warn Outputs warning to stderr - # @option task.class.settings.deprecate true Raises DeprecationError - # @option task.class.settings.deprecate false No action taken - # @option task.class.settings.deprecate nil No action taken - # - # @raise [DeprecationError] When deprecation type is :raise or true - # @raise [RuntimeError] When deprecation type is unknown - # - # @example - # class MyTask - # settings(deprecate: :warn) - # end - # - # MyTask.new # => [MyTask] DEPRECATED: migrate to a replacement or discontinue use - # - # @rbs (Task task) -> void - def restrict(task) - setting = task.class.settings.deprecate - return unless setting - - case type = EVAL.call(task, setting) - when NilClass, FalseClass then nil # Do nothing - when TrueClass, RAISE_REGEXP then raise DeprecationError, "#{task.class.name} usage prohibited" - when LOG_REGEXP then task.logger.warn { "DEPRECATED: migrate to a replacement or discontinue use" } - when WARN_REGEXP then warn("[#{task.class.name}] DEPRECATED: migrate to a replacement or discontinue use", category: :deprecated) - else raise "unknown deprecation type #{type.inspect}" - end - end - - end -end diff --git a/lib/cmdx/errors.rb b/lib/cmdx/errors.rb index eb907d72b..a4335a2f0 100644 --- a/lib/cmdx/errors.rb +++ b/lib/cmdx/errors.rb @@ -1,117 +1,130 @@ # frozen_string_literal: true module CMDx - # Collection of validation and execution errors organized by attribute. - # Provides methods to add, query, and format error messages for different - # attributes in a task or workflow execution. + # Per-task container of validation / coercion / output errors. Each key maps + # to a deduplicating Set of messages. A non-empty Errors forces Runtime to + # throw a failed signal (`signal_errors!`). Frozen on teardown by Runtime. class Errors - extend Forwardable + include Enumerable - # Returns the internal hash of error messages by attribute. - # - # @return [Hash{Symbol => Set<String>}] Hash mapping attribute names to error message sets - # - # @example - # errors.messages # => { email: #<Set: ["must be valid", "is required"]> } - # - # @rbs @messages: Hash[Symbol, Set[String]] attr_reader :messages - def_delegators :messages, :any?, :clear, :empty?, :size - - # Initialize a new error collection. - # - # @rbs () -> void def initialize @messages = {} end - # Add an error message for a specific attribute. - # - # @param attribute [Symbol] The attribute name associated with the error - # @param message [String] The error message to add + # Adds `message` under `key`. Duplicate messages are silently dropped. # - # @example - # errors = CMDx::Errors.new - # errors.add(:email, "must be valid format") - # errors.add(:email, "cannot be blank") - # - # @rbs (Symbol attribute, String message) -> void - def add(attribute, message) - return if message.empty? + # @param key [Symbol] + # @param message [String] + # @return [Set<String>] the set of messages now stored under `key` + def add(key, message) + (messages[key] ||= Set.new) << message + end + alias []= add - messages[attribute] ||= Set.new - messages[attribute] << message + # @param key [Symbol] + # @return [Array<String>] messages for `key`, or a frozen empty array + def [](key) + messages[key]&.to_a || EMPTY_ARRAY end - # Check if there are any errors for a specific attribute. - # - # @param attribute [Symbol] The attribute name to check for errors - # - # @return [Boolean] true if the attribute has errors, false otherwise - # - # @example - # errors.for?(:email) # => true - # errors.for?(:name) # => false - # - # @rbs (Symbol attribute) -> bool - def for?(attribute) - set = messages[attribute] - !set.nil? && !set.empty? + # @param key [Symbol] + # @param message [String] + # @return [Boolean] true when `message` is recorded under `key` + def added?(key, message) + !!messages[key]&.include?(message) end - # Convert errors to a hash format with arrays of full messages. - # - # @return [Hash{Symbol => Array<String>}] Hash with attribute keys and message arrays - # - # @example - # errors.full_messages # => { email: ["email must be valid format", "email cannot be blank"] } - # - # @rbs () -> Hash[Symbol, Array[String]] + # @param key [Symbol] + # @return [Boolean] + def key?(key) + messages.key?(key) + end + alias for? key? + + # @return [Array<Symbol>] keys with at least one message + def keys + messages.keys + end + + # @return [Boolean] + def empty? + messages.empty? + end + + # @return [Integer] number of keyed entries + def size + messages.size + end + + # @return [Integer] total messages across all keys + def count + messages.each_value.sum(&:size) + end + + # @yield [key, set] each `[key, Set<String>]` pair + # @return [Errors, Enumerator] + def each(&) + messages.each(&) + end + + # @yield [Symbol] + # @return [Errors, Enumerator] + def each_key(&) + messages.each_key(&) + end + + # @yield [Set<String>] + # @return [Errors, Enumerator] + def each_value(&) + messages.each_value(&) + end + + # @param key [Symbol] + # @return [Set<String>, nil] the removed set, or nil when absent + def delete(key) + messages.delete(key) + end + + # @return [Hash{Symbol => Set<String>}] empties the container + def clear + messages.clear + end + + # @return [Hash{Symbol => Array<String>}] messages prefixed with their key + # (e.g. `{ name: ["name is required"] }`) def full_messages - messages.each_with_object({}) do |(attribute, messages), hash| - hash[attribute] = messages.map { |message| "#{attribute} #{message}" } + messages.each_with_object({}) do |(key, set), hash| + hash[key] = set.map { |message| "#{key} #{message}" } end end - # Convert errors to a hash format with arrays of messages. - # - # @return [Hash{Symbol => Array<String>}] Hash with attribute keys and message arrays - # - # @example - # errors.to_h # => { email: ["must be valid format", "cannot be blank"] } - # - # @rbs () -> Hash[Symbol, Array[String]] + # @return [Hash{Symbol => Array<String>}] raw messages as arrays def to_h messages.transform_values(&:to_a) end - # Convert errors to a hash format with optional full messages. - # - # @param full [Boolean] Whether to include full messages with attribute names - # @return [Hash{Symbol => Array<String>}] Hash with attribute keys and message arrays - # - # @example - # errors.to_hash # => { email: ["must be valid format", "cannot be blank"] } - # errors.to_hash(true) # => { email: ["email must be valid format", "email cannot be blank"] } - # - # @rbs (?bool full) -> Hash[Symbol, Array[String]] + # @param full [Boolean] when true return {#full_messages}, otherwise {#to_h} + # @return [Hash{Symbol => Array<String>}] def to_hash(full = false) full ? full_messages : to_h end - # Convert errors to a human-readable string format. - # - # @return [String] Formatted error messages joined with periods - # - # @example - # errors.to_s # => "email must be valid format. email cannot be blank" - # - # @rbs () -> String + # @return [String] all full messages joined with `". "`, suitable as a + # fail reason def to_s full_messages.values.flatten.join(". ") end + # Freezes the container and every message set. Called by Runtime teardown. + # + # @return [Errors] self + def freeze + messages.each_value(&:freeze).freeze + super + end + end end diff --git a/lib/cmdx/exception.rb b/lib/cmdx/exception.rb deleted file mode 100644 index 9d7151840..000000000 --- a/lib/cmdx/exception.rb +++ /dev/null @@ -1,46 +0,0 @@ -# frozen_string_literal: true - -module CMDx - - # Base exception class for all CMDx-related errors. - # - # This serves as the root exception class for all errors raised by the CMDx - # framework. It inherits from StandardError and provides a common base for - # handling CMDx-specific exceptions. - Exception = Error = Class.new(StandardError) - - # Raised when attribute coercion fails during task execution. - # - # This error occurs when a attribute value cannot be converted to the expected - # type using the registered coercion handlers. It indicates that the provided - # value is incompatible with the attribute's defined type. - CoercionError = Class.new(Error) - - # Raised when a deprecated task is used. - # - # This error occurs when a deprecated task is called. It indicates that the - # task is no longer supported and should be replaced with a newer alternative. - DeprecationError = Class.new(Error) - - # Raised when an abstract method is called without being implemented. - # - # This error occurs when a subclass fails to implement required abstract - # methods such as 'task' in tasks. It indicates incomplete implementation - # of required functionality. - UndefinedMethodError = Class.new(Error) - - # Error raised when task execution exceeds the configured timeout limit. - # - # This error occurs when a task takes longer to execute than the specified - # time limit. Timeout errors are raised by Ruby's Timeout module and are - # caught by the middleware to properly fail the task with timeout information. - TimeoutError = Class.new(Interrupt) - - # Raised when attribute validation fails during task execution. - # - # This error occurs when a attribute value doesn't meet the validation criteria - # defined by the validator. It indicates that the provided value violates - # business rules or data integrity constraints. - ValidationError = Class.new(Error) - -end diff --git a/lib/cmdx/executor.rb b/lib/cmdx/executor.rb deleted file mode 100644 index 727d7d032..000000000 --- a/lib/cmdx/executor.rb +++ /dev/null @@ -1,379 +0,0 @@ -# frozen_string_literal: true - -module CMDx - # Executes CMDx tasks with middleware support, error handling, and lifecycle management. - # - # The Executor class is responsible for orchestrating task execution, including - # pre-execution validation, execution with middleware, post-execution callbacks, - # and proper error handling for different types of failures. - class Executor - - extend Forwardable - - # @rbs STATE_CALLBACKS: Hash[String, Symbol] - STATE_CALLBACKS = Result::STATES.to_h { |s| [s, :"on_#{s}"] }.freeze - private_constant :STATE_CALLBACKS - - # @rbs STATUS_CALLBACKS: Hash[String, Symbol] - STATUS_CALLBACKS = Result::STATUSES.to_h { |s| [s, :"on_#{s}"] }.freeze - private_constant :STATUS_CALLBACKS - - # Returns the task being executed. - # - # @return [Task] The task instance - # - # @example - # executor.task.id # => "abc123" - # - # @rbs @task: Task - attr_reader :task - - def_delegators :task, :result, :resolver - - # @param task [CMDx::Task] The task to execute - # - # @return [CMDx::Executor] A new executor instance - # - # @example - # executor = CMDx::Executor.new(my_task) - # - # @rbs (Task task) -> void - def initialize(task) - @task = task - end - - # Executes a task with optional exception raising. - # - # @param task [CMDx::Task] The task to execute - # @param raise [Boolean] Whether to raise exceptions (default: false) - # - # @return [CMDx::Result] The execution result - # - # @raise [StandardError] When raise is true and execution fails - # - # @example - # CMDx::Executor.execute(my_task) - # CMDx::Executor.execute(my_task, raise: true) - # - # @rbs (Task task, raise: bool) -> Result - def self.execute(task, raise: false) - instance = new(task) - raise ? instance.execute! : instance.execute - end - - # Executes the task with graceful error handling. - # - # @return [CMDx::Result] The execution result - # - # @example - # executor = CMDx::Executor.new(my_task) - # result = executor.execute - # - # @rbs () -> Result - def execute - task.class.settings.middlewares.call!(task) do - pre_execution! unless @pre_execution - execution! - verify_context_returns! - rescue UndefinedMethodError => e - raise_exception(e) - rescue Fault => e - resolver.throw!(e.result, halt: false, cause: e) - rescue StandardError => e - retry if retry_execution?(e) - resolver.fail!(Utils::Normalize.exception(e), halt: false, cause: e, source: :exception) - task.class.settings.exception_handler&.call(task, e) - ensure - resolver.executed! - post_execution! - end - - verify_middleware_yield! - finalize_execution! - end - - # Executes the task with exception raising on failure. - # - # @return [CMDx::Result] The execution result - # - # @raise [StandardError] When execution fails - # - # @example - # executor = CMDx::Executor.new(my_task) - # result = executor.execute! - # - # @rbs () -> Result - def execute! - task.class.settings.middlewares.call!(task) do - pre_execution! unless @pre_execution - execution! - verify_context_returns! - rescue UndefinedMethodError => e - raise_exception(e) - rescue Fault => e - resolver.throw!(e.result, halt: false, cause: e) - - if halt_execution?(e) - raise_exception(e) - else - resolver.executed! - post_execution! - end - rescue StandardError => e - retry if retry_execution?(e) - resolver.fail!(Utils::Normalize.exception(e), halt: false, cause: e, source: :exception) - raise_exception(e) - else - resolver.executed! - post_execution! - end - - verify_middleware_yield! - finalize_execution! - end - - protected - - # Determines if execution should halt based on breakpoint configuration. - # Returns false when the result was created with +strict: false+. - # - # @param exception [Exception] The exception that occurred - # - # @return [Boolean] Whether execution should halt - # - # @rbs (Exception exception) -> bool - def halt_execution?(exception) - return false unless exception.result.strict? - - @halt_statuses ||= Utils::Normalize.statuses( - task.class.settings.breakpoints || - task.class.settings.task_breakpoints - ).freeze - - @halt_statuses.include?(exception.result.status) - end - - # Determines if execution should be retried based on retry configuration. - # - # @param exception [Exception] The exception that occurred - # - # @return [Boolean] Whether execution should be retried - # - # @rbs (Exception exception) -> bool - def retry_execution?(exception) - @retry ||= Retry.new(task) - - return false unless @retry.available? && @retry.remaining? - return false unless @retry.exception?(exception) - - result.retries += 1 - - task.logger.warn do - reason = Utils::Normalize.exception(exception) - task.to_h.merge!(reason:, remaining_retries: @retry.remaining) - end - - task.errors.clear - - wait = @retry.wait - sleep(wait) if wait.positive? - - true - end - - # Raises an exception and clears the chain. - # - # @param exception [Exception] The exception to raise - # - # @raise [Exception] The provided exception - # - # @rbs (Exception exception) -> void - def raise_exception(exception) - Chain.clear - - raise(exception) - end - - # Invokes callbacks of a specific type for the task. - # - # @param type [Symbol] The type of callback to invoke - # - # @return [void] - # - # @example - # invoke_callbacks(:before_execution) - # - # @rbs (Symbol type) -> void - def invoke_callbacks(type) - task.class.settings.callbacks.invoke(type, task) - end - - private - - # Performs pre-execution tasks including validation and attribute verification. - # - # @rbs () -> void - def pre_execution! - @pre_execution = true - - invoke_callbacks(:before_validation) - - task.class.settings.attributes.define_and_verify(task) - return if task.errors.empty? - - resolver.fail!( - Locale.t("cmdx.faults.invalid"), - source: :validation, - errors: { - full_message: task.errors.to_s, - messages: task.errors.to_h - } - ) - end - - # Executes the main task logic. - # Wraps task.work in catch(:cmdx_halt) so that success! can halt early. - # - # @rbs () -> void - def execution! - invoke_callbacks(:before_execution) - - resolver.executing! - catch(:cmdx_halt) { task.work } - end - - # Verifies that all declared returns are present in the context after execution. - # - # @rbs () -> void - def verify_context_returns! - return unless result.success? - - returns = Utils::Wrap.array(task.class.settings.returns) - missing = returns.reject { |name| task.context.key?(name) } - return if missing.empty? - - missing.each { |name| task.errors.add(name, Locale.t("cmdx.returns.missing")) } - - resolver.fail!( - Locale.t("cmdx.faults.invalid"), - source: :context, - errors: { - full_message: task.errors.to_s, - messages: task.errors.to_h - } - ) - end - - # Performs post-execution tasks including callback invocation. - # - # @rbs () -> void - def post_execution! - return if task.class.settings.callbacks.empty? - - invoke_callbacks(STATE_CALLBACKS[result.state]) - invoke_callbacks(:on_executed) if result.executed? - - invoke_callbacks(STATUS_CALLBACKS[result.status]) - invoke_callbacks(:on_good) if result.good? - invoke_callbacks(:on_bad) if result.bad? - end - - # Detects if middleware swallowed the block without yielding. - # When this happens the result is still in the initialized state. - # - # @rbs () -> void - def verify_middleware_yield! - return unless result.initialized? - - resolver.fail!( - Locale.t("cmdx.faults.invalid"), - halt: false, - source: :middleware - ) - resolver.executed! - end - - # Finalizes execution by freezing the task, logging results, and rolling back work. - # - # @rbs () -> void - def finalize_execution! - log_execution! - log_backtrace! if task.class.settings.backtrace - - rollback_execution! - freeze_execution! - clear_chain! - end - - # Logs the execution result at the configured log level. - # - # @rbs () -> void - def log_execution! - task.logger.info { result.to_h } - end - - # Logs the backtrace of the exception if the task failed. - # - # @rbs () -> void - def log_backtrace! - return unless result.failed? - - exception = result.caused_failure&.cause - return if exception.nil? || exception.is_a?(Fault) - - task.logger.error do - Utils::Normalize.exception(exception) << "\n" << - if (cleaner = task.class.settings.backtrace_cleaner) - cleaner.call(exception.backtrace).join("\n\t") - else - exception.full_message(highlight: false) - end - end - end - - # Freezes the task and its associated objects to prevent modifications. - # - # @rbs () -> void - def freeze_execution! - # Stubbing on frozen objects is not allowed in most test environments. - return unless CMDx.configuration.freeze_results - - task.freeze - resolver.freeze - result.freeze - - # Freezing the context and chain can only be done once the outer-most - # task has completed. - return unless result.index.zero? - - task.context.freeze - task.chain.freeze - end - - # Clears the chain if the task is the outermost (top-level) task - # and the current thread's chain is the same instance this task belongs to. - # - # @rbs () -> void - def clear_chain! - return unless result.index.zero? - return unless Chain.current.equal?(task.chain) - - Chain.clear - end - - # Rolls back the work of a task. - # - # @rbs () -> void - def rollback_execution! - return if result.rolled_back? - return unless task.respond_to?(:rollback) - - @rollback_statuses ||= Utils::Normalize.statuses(task.class.settings.rollback_on).freeze - return unless @rollback_statuses.include?(result.status) - - result.rolled_back = true - task.rollback - end - - end -end diff --git a/lib/cmdx/fault.rb b/lib/cmdx/fault.rb index d43191754..24de617b6 100644 --- a/lib/cmdx/fault.rb +++ b/lib/cmdx/fault.rb @@ -1,109 +1,92 @@ # frozen_string_literal: true module CMDx - - # Base fault class for handling task execution failures and interruptions. + # Exception raised by `execute!` (strict mode) when a task fails. Carries + # the originating {Result} (deepest in any propagation chain) and exposes + # `task`, `signal`, `context`, and `chain` as delegators. The backtrace is + # cleaned through the configured `backtrace_cleaner` when present. # - # Faults represent error conditions that occur during task execution, providing - # a structured way to handle and categorize different types of failures. - # Each fault contains a reference to the result object that caused the fault. + # Use {.for?} or {.matches?} to build matcher subclasses suitable for + # `rescue` clauses. class Fault < Error - extend Forwardable - - # Returns the result that caused this fault. - # - # @return [Result] The result instance - # - # @example - # fault.result.reason # => "Validation failed" - # - # @rbs @result: Result - attr_reader :result - - def_delegators :result, :task, :context, :chain - - # Initialize a new fault with the given result. - # - # @param result [Result] the result object that caused this fault - # - # @raise [ArgumentError] if result is nil or invalid - # - # @example - # fault = Fault.new(task_result) - # fault.result.reason # => "Task validation failed" - # - # @rbs (Result result) -> void - def initialize(result) - @result = result - - super(result.reason) - end - class << self - # Create a fault class that matches specific task types. - # - # @param tasks [Array<Class>] array of task classes to match against + # Returns a matcher subclass that matches Faults whose `task` is (or + # inherits from) any of the given task classes. Suitable for use in + # `rescue`. # - # @return [Class] a new fault class that matches the specified tasks + # @param tasks [Array<Class>] one or more Task classes + # @return [Class<Fault>] anonymous matcher subclass + # @raise [ArgumentError] when no tasks are given # # @example - # Fault.for?(UserTask, AdminUserTask) - # # => true if fault.task is a UserTask or AdminUserTask - # - # @rbs (*Class tasks) -> Class + # rescue Fault.for?(ProcessOrder, ChargeCard) => fault + # Alert.for_fault(fault) + # end def for?(*tasks) - temp_fault = Class.new(self) do - def self.===(other) - other.is_a?(superclass) && @tasks.any? { |task| other.task.is_a?(task) } - end - end + tasks = tasks.flatten + raise ArgumentError, "at least one task required" if tasks.empty? - temp_fault.tap { |c| c.instance_variable_set(:@tasks, tasks) } + matcher do |other| + tasks.any? { |task| other.task <= task } + end end - # Create a fault class that matches based on a custom block. - # - # @param block [Proc] block that determines if a fault matches + # Returns a matcher subclass whose `===` runs `block` against the fault. # - # @return [Class] a new fault class that matches based on the block - # - # @raise [ArgumentError] if no block is provided - # - # @example - # Fault.matches? { |fault| fault.result.metadata[:critical] } - # # => true if fault has critical metadata - # - # @rbs () { (Fault) -> bool } -> Class + # @yieldparam fault [Fault] + # @yieldreturn [Boolean] + # @return [Class<Fault>] anonymous matcher subclass + # @raise [ArgumentError] when no block is given def matches?(&block) - raise ArgumentError, "block required" unless block_given? + raise ArgumentError, "block required" unless block - temp_fault = Class.new(self) do - def self.===(other) - other.is_a?(superclass) && @block.call(other) + matcher(&block) + end + + private + + def matcher(&) + fault_class = self + Class.new(fault_class) do + define_singleton_method(:===) do |other| + fault_class === other && yield(other) end end - - temp_fault.tap { |c| c.instance_variable_set(:@block, block) } end end - end + attr_reader :result - # Fault raised when a task is intentionally skipped during execution. - # - # This fault occurs when a task determines it should not execute based on - # its current context or conditions. Skipped tasks are not considered failures - # but rather intentional bypasses of task execution logic. - SkipFault = Class.new(Fault) + # @param result [Result] the failed result this Fault represents + def initialize(result) + @result = result - # Fault raised when a task execution fails due to errors or validation failures. - # - # This fault occurs when a task encounters an error condition, validation failure, - # or any other condition that prevents successful completion. Failed tasks indicate - # that the intended operation could not be completed successfully. - FailFault = Class.new(Fault) + super(result.reason || I18nProxy.t("cmdx.reasons.unspecified")) + if (frames = result.backtrace || result.cause&.backtrace_locations) + frames = frames.map(&:to_s) + frames = task.settings.backtrace_cleaner&.call(frames) || frames + set_backtrace(frames) + end + end + + # @return [Class<Task>] the failing task class + def task + @result.task + end + + # @return [Context] the failed task's context + def context + @result.context + end + + # @return [Chain] the chain the failed result belongs to + def chain + @result.chain + end + + end end diff --git a/lib/cmdx/i18n_proxy.rb b/lib/cmdx/i18n_proxy.rb new file mode 100644 index 000000000..efa991f70 --- /dev/null +++ b/lib/cmdx/i18n_proxy.rb @@ -0,0 +1,82 @@ +# frozen_string_literal: true + +module CMDx + # Translation façade used internally for coercion, validator, and output + # error messages. Delegates to `I18n.translate` when the `i18n` gem is + # available; otherwise loads CMDx's bundled YAML locale file and performs + # percent-interpolation on the string itself. Results are memoized. + class I18nProxy + + class << self + + # @param key [String, Symbol] dot-separated translation key + # @param options [Hash{Symbol => Object}] interpolation values (e.g. `type:`) + # @return [String, Object] the translated string (or the raw default value) + def translate(key, **options) + @proxy ||= new + @proxy.translate(key, **options) + end + alias t translate + + # @return [Array<String>] directories searched (in order) for bundled locale YAMLs + def locale_paths + @locale_paths ||= [File.expand_path("../locales", __dir__)] + end + + # Register an additional directory containing locale YAML files. Later + # registrations take precedence over earlier ones (the most recently + # registered path's values win during deep merge). Resets the memoized + # proxy so subsequent lookups see the new path. + # + # @param path [String] absolute path to a directory of `<locale>.yml` files + # @return [Array<String>] the updated locale paths + def register(path) + locale_paths.push(path) unless locale_paths.include?(path) + @proxy = nil + locale_paths + end + + end + + # @param key [String, Symbol] dot-separated translation key + # @param options [Hash{Symbol => Object}] interpolation values + # @return [String, Object] the translated/interpolated message + def translate(key, **options) + return ::I18n.translate(key, **options) if defined?(::I18n) && ::I18n.respond_to?(:translate) + + options[:default] ||= translation_default(key) + + case message = options.delete(:default) + when String + message % options + when NilClass + "Translation missing: #{key}" + else + message + end + end + alias t translate + + private + + def translation_default(key) + default_locale = CMDx.configuration.default_locale || "en" + translation_key = "#{default_locale}.#{key}" + + @defaults ||= {} + return @defaults[translation_key] if @defaults.key?(translation_key) + + @translations ||= {} + @translations[default_locale] ||= begin + file = "#{default_locale}.yml" + paths = self.class.locale_paths.map { |dir| File.join(dir, file) }.select { |p| File.exist?(p) } + raise LoadError, "unable to load #{default_locale} translations" if paths.empty? + + paths.reduce({}) { |hash, path| hash.merge(YAML.safe_load_file(path)) }.freeze + end + + @defaults[translation_key] = @translations[default_locale].dig(*translation_key.split(".")) + end + + end +end diff --git a/lib/cmdx/identifier.rb b/lib/cmdx/identifier.rb deleted file mode 100644 index 671f5e4d3..000000000 --- a/lib/cmdx/identifier.rb +++ /dev/null @@ -1,30 +0,0 @@ -# frozen_string_literal: true - -module CMDx - # Generates unique identifiers for tasks, workflows, and other CMDx components. - # - # The Identifier module provides a consistent way to generate unique identifiers - # across the CMDx system, with fallback support for different Ruby versions. - module Identifier - - extend self - - # Generates a unique identifier string. - # - # @return [String] A unique identifier string (UUID v7 if available, otherwise UUID v4) - # - # @raise [StandardError] If SecureRandom is unavailable or fails to generate an identifier - # - # @example Generate a unique identifier - # CMDx::Identifier.generate - # # => "01890b2c-1234-5678-9abc-def123456789" - # - # @rbs () -> String - if SecureRandom.respond_to?(:uuid_v7) - def generate = SecureRandom.uuid_v7 - else - def generate = SecureRandom.uuid - end - - end -end diff --git a/lib/cmdx/input.rb b/lib/cmdx/input.rb new file mode 100644 index 000000000..b8fa0c39e --- /dev/null +++ b/lib/cmdx/input.rb @@ -0,0 +1,259 @@ +# frozen_string_literal: true + +module CMDx + # A single declared task input. Holds declaration options (`:source`, + # `:default`, `:required`, `:coerce`, validators, `:transform`, etc.) and + # owns the resolution pipeline that produces the value the task will read + # through the generated accessor. + class Input + + attr_reader :name, :children + + # @param name [Symbol, String] input key (symbolized) + # @param children [Array<Input>] nested child inputs resolved from this one's value + # @param options [Hash{Symbol => Object}] declaration options + # @option options [String] :description (also accepts `:desc`) + # @option options [Symbol] :as overrides the accessor name + # @option options [Boolean, String] :prefix prefix for the accessor name + # @option options [Boolean, String] :suffix suffix for the accessor name + # @option options [Symbol, Proc, #call] :source (`:context`) where to fetch from + # @option options [Object, Symbol, Proc, #call] :default + # @option options [Symbol, Proc, #call] :transform mutator applied after coercion + # @option options [Symbol, Proc, #call] :if + # @option options [Symbol, Proc, #call] :unless + # @option options [Boolean] :required + def initialize(name, children: EMPTY_ARRAY, **options) + @name = name.to_sym + @children = children.freeze + @options = options.freeze + end + + # @return [String, nil] + def description + @options[:description] || @options[:desc] + end + + # @return [Symbol, nil] + def as + @options[:as] + end + + # @return [Boolean, String, nil] + def prefix + @options[:prefix] + end + + # @return [Boolean, String, nil] + def suffix + @options[:suffix] + end + + # @return [Symbol, Proc, #call] + def source + @options[:source] || :context + end + + # @return [Object, Symbol, Proc, #call, nil] + def default + @options[:default] + end + + # @return [Symbol, Proc, #call, nil] + def transform + @options[:transform] + end + + # @return [Symbol, Proc, #call, nil] + def condition_if + @options[:if] + end + + # @return [Symbol, Proc, #call, nil] + def condition_unless + @options[:unless] + end + + # @return [Boolean] + def required + @options.fetch(:required, false) + end + + # Computed accessor/reader method name. Uses `:as` when provided, + # otherwise combines `:prefix`, `name`, and `:suffix` around the source. + # + # @return [Symbol] + def accessor_name + return as if as + + @accessor_name ||= begin + prefix_str = + case prefix + when true + "#{source}_" + when ::String + prefix + end + suffix_str = + case suffix + when true + "_#{source}" + when ::String + suffix + end + + :"#{prefix_str}#{name}#{suffix_str}" + end + end + + # @return [Symbol] backing ivar used by the generated reader method + def ivar_name + @ivar_name ||= :"@_input_#{accessor_name}" + end + + # Evaluates required-ness against `task`, respecting `:if`/`:unless`. + # When called without a task, returns the static `:required` flag. + # + # @param task [Task, nil] + # @return [Boolean] + def required?(task = nil) + return false unless required + return true if task.nil? + return false unless Util.satisfied?(condition_if, condition_unless, task) + + true + end + + # Fetches + coerces + transforms + validates the value from its + # configured `:source` on `task`. Missing-but-required inputs add a + # validation error to `task.errors`. + # + # @param task [Task] + # @return [Object, nil, Coercions::Failure] the resolved value + def resolve(task) + value, key_provided = resolve_with_key(task) + run_pipeline(value, key_provided, task) + end + + # Same as {#resolve} but fetches the value from `parent_value` (used for + # nested child inputs) instead of the declared `:source`. + # + # @param parent_value [#[], #key?, Object] the parent input's resolved value + # @param task [Task] + # @return [Object, nil, Coercions::Failure] + def resolve_from_parent(parent_value, task) + value, key_provided = resolve_from_parent_with_key(parent_value) + run_pipeline(value, key_provided, task) + end + + # @return [Hash{Symbol => Object}] serialized schema used by `inputs_schema` + def to_h + { + name: accessor_name, + description:, + required: required?, + options: @options, + children: children.map(&:to_h) + } + end + + private + + def run_pipeline(value, key_provided, task) + if required?(task) && !key_provided + task.errors.add(accessor_name, I18nProxy.t("cmdx.attributes.required")) + return + end + + value = apply_default(task) if value.nil? + return if value.nil? + + @coercions ||= task.class.coercions.extract(@options) + value = task.class.coercions.coerce(task, accessor_name, value, @coercions) + return if value.is_a?(Coercions::Failure) + + value = apply_transform(value, task) if transform + @validators ||= task.class.validators.extract(@options) + task.class.validators.validate(task, accessor_name, value, @validators) + + value + end + + def resolve_with_key(task) + case source + when :context + [task.context[name], task.context.key?(name)] + when Symbol + obj = task.send(source) + return [nil, false] if obj.nil? + + fetch_by_name(obj) + when Proc + [task.instance_exec(&source), true] + else + return [source.call(task), true] if source.respond_to?(:call) + + raise ArgumentError, "source must be a Symbol, Proc, or respond to #call" + end + end + + def resolve_from_parent_with_key(parent_value) + return [nil, false] unless parent_value.respond_to?(:[]) + + fetch_by_name(parent_value) + end + + def fetch_by_name(obj) + if obj.respond_to?(:key?) + if obj.key?(name) + [obj[name], true] + elsif obj.key?(name_str = name.to_s) + [obj[name_str], true] + else + [nil, false] + end + elsif obj.respond_to?(:[]) + # Without #key? we cannot distinguish "key absent" from "value is nil", + # so an explicit nil is treated as not provided (triggers default/required). + value = obj[name] + [value, !value.nil?] + elsif obj.respond_to?(name) + [obj.send(name), true] + else + [nil, false] + end + end + + def apply_default(task) + return if default.nil? + + case default + when Symbol + task.send(default) + when Proc + task.instance_exec(&default) + else + return default unless default.respond_to?(:call) + + default.call(task) + end + end + + def apply_transform(value, task) + case transform + when Symbol + if value.respond_to?(transform) + value.send(transform) + else + task.send(transform, value) + end + when Proc + task.instance_exec(value, &transform) + else + return transform.call(value, task) if transform.respond_to?(:call) + + raise ArgumentError, "transform must be a Symbol, Proc, or respond to #call" + end + end + + end +end diff --git a/lib/cmdx/inputs.rb b/lib/cmdx/inputs.rb new file mode 100644 index 000000000..420269aa8 --- /dev/null +++ b/lib/cmdx/inputs.rb @@ -0,0 +1,145 @@ +# frozen_string_literal: true + +module CMDx + # Registry of declared task inputs. Each registration creates an {Input} and + # defines a reader method on the task class. {#resolve} walks every input + # (and nested children) to populate the task's instance variables before `work`. + class Inputs + + attr_reader :registry + + def initialize + @registry = {} + end + + def initialize_copy(source) + @registry = source.registry.dup + end + + # Declares one or more inputs and defines accessor readers on `klass`. + # A block nests child inputs under each declared name (see {ChildBuilder}). + # + # @param klass [Class] the task class to define readers on + # @param names [Array<Symbol>] input names + # @param options [Hash{Symbol => Object}] passed to {Input#initialize} + # @yield block evaluated in a {ChildBuilder} for nested inputs + # @return [Inputs] self for chaining + def register(klass, *names, **options, &block) + children = block ? ChildBuilder.build(&block) : EMPTY_ARRAY + + names.each do |name| + input = Input.new(name, children:, **options) + registry[input.name] = input + klass.send(:define_input_reader, input) + end + + self + end + + # Removes inputs and their accessor readers from `klass`. + # + # @param klass [Class] + # @param names [Array<Symbol>] + # @return [Inputs] self for chaining + def deregister(klass, *names) + names.each do |name| + input = registry.delete(name.to_sym) + klass.send(:undefine_input_reader, input) + end + + self + end + + # @return [Boolean] + def empty? + registry.empty? + end + + # @return [Integer] + def size + registry.size + end + + # Resolves every input (with children) for `task`, setting each input's + # computed value into its backing ivar so the generated readers return it. + # + # @param task [Task] + # @return [void] + def resolve(task) + registry.each_value do |input| + value = input.resolve(task) + task.instance_variable_set(input.ivar_name, value) + resolve_children(input, value, task) + end + end + + private + + def resolve_children(input, parent_value, task) + return if input.children.empty? || parent_value.nil? + + input.children.each do |child| + child_value = child.resolve_from_parent(parent_value, task) + task.instance_variable_set(child.ivar_name, child_value) + resolve_children(child, child_value, task) + end + end + + # DSL receiver for the block passed to {Inputs#register}. Builds a frozen + # list of child {Input}s. Supports arbitrary nesting: every DSL method + # accepts its own block. + class ChildBuilder + + class << self + + # @yield (see Inputs#register) + # @return [Array<Input>] frozen list of built children + def build(&) + builder = new + builder.instance_eval(&) + builder.children.freeze + end + + end + + attr_reader :children + + def initialize + @children = [] + end + + # @param names [Array<Symbol>] + # @param options [Hash{Symbol => Object}] + # @return [Array<Input>] + def inputs(*names, **options, &) + build(*names, **options, &) + end + alias input inputs + + # Declares optional child inputs (equivalent to `inputs ..., required: false`). + # @param names [Array<Symbol>] + # @param options [Hash{Symbol => Object}] + # @return [Array<Input>] + def optional(*names, **options, &) + build(*names, required: false, **options, &) + end + + # Declares required child inputs (equivalent to `inputs ..., required: true`). + # @param names [Array<Symbol>] + # @param options [Hash{Symbol => Object}] + # @return [Array<Input>] + def required(*names, **options, &) + build(*names, required: true, **options, &) + end + + private + + def build(*names, **options, &block) + nested = block ? self.class.build(&block) : EMPTY_ARRAY + names.map { |name| children << Input.new(name, children: nested, **options) } + end + + end + + end +end diff --git a/lib/cmdx/locale.rb b/lib/cmdx/locale.rb deleted file mode 100644 index 5d1bf3847..000000000 --- a/lib/cmdx/locale.rb +++ /dev/null @@ -1,78 +0,0 @@ -# frozen_string_literal: true - -module CMDx - # Provides internationalization and localization support for CMDx. - # Handles translation lookups with fallback to the configured locale's - # default messages when I18n gem is not available. - module Locale - - extend self - - # Translates a key to the current locale with optional interpolation. - # Falls back to the configured locale's YAML file translations if - # I18n gem is unavailable. - # - # @param key [String, Symbol] The translation key (supports dot notation) - # @param options [Hash] Translation options - # @option options [String] :default Fallback message if translation missing - # @option options [String] :locale Target locale (when I18n available) - # @option options [Hash] :scope Translation scope (when I18n available) - # @option options [Object] :* Any other options passed to I18n.t or string interpolation - # - # @return [String] The translated message - # - # @raise [ArgumentError] When interpolation fails due to missing keys - # - # @example Basic translation - # Locale.translate("errors.invalid_input") - # # => "Invalid input provided" - # @example With interpolation - # Locale.translate("welcome.message", name: "John") - # # => "Welcome, John!" - # @example With fallback - # Locale.translate("missing.key", default: "Custom fallback message") - # # => "Custom fallback message" - # - # @rbs ((String | Symbol) key, **untyped options) -> String - def translate(key, **options) - options[:default] ||= translation_default(key) - return ::I18n.t(key, **options) if defined?(::I18n) - - case message = options.delete(:default) - when String then message % options - when NilClass then "Translation missing: #{key}" - else message - end - end - - # @see #translate - alias t translate - - private - - # Resolves and caches the default translation for a key by digging - # into the configured locale's YAML translations. - # - # @param key [String, Symbol] The translation key - # - # @return [String, nil] The resolved translation or nil - # - # @rbs ((String | Symbol) key) -> String? - def translation_default(key) - tkey = "#{CMDx.configuration.default_locale}.#{key}" - - @translation_defaults ||= {} - return @translation_defaults[tkey] if @translation_defaults.key?(tkey) - - @default_translations ||= begin - path = CMDx.gem_path.join("lib/locales/#{CMDx.configuration.default_locale}.yml") - raise ArgumentError, "locale file not found: #{path}" unless path.exist? - - YAML.load_file(path).freeze - end - - @translation_defaults[tkey] = @default_translations.dig(*tkey.split(".")) - end - - end -end diff --git a/lib/cmdx/log_formatters/json.rb b/lib/cmdx/log_formatters/json.rb index 3a4b60672..4777732c8 100644 --- a/lib/cmdx/log_formatters/json.rb +++ b/lib/cmdx/log_formatters/json.rb @@ -2,34 +2,23 @@ module CMDx module LogFormatters - # Formats log messages as JSON for structured logging - # - # This formatter converts log entries into JSON format with standardized fields - # including severity, timestamp, program name, process ID, and formatted message. - # The output is suitable for log aggregation systems and structured analysis. + # `Logger` formatter that emits one JSON object per line with `severity`, + # ISO8601 `timestamp`, `progname`, `pid`, and `message` (rendered via + # `#to_h` when available — Result instances serialize themselves). class JSON - # Formats a log entry as a JSON string - # - # @param severity [String] The log level (e.g., "INFO", "ERROR", "DEBUG") - # @param time [Time] The timestamp when the log entry was created - # @param progname [String, nil] The program name or identifier - # @param message [Object] The log message content - # - # @return [String] A JSON-formatted log entry with a trailing newline - # - # @example Basic usage - # logger_formatter.call("INFO", Time.now, "MyApp", "User logged in") - # # => '{"severity":"INFO","timestamp":"2024-01-15T10:30:45.123456Z","progname":"MyApp","pid":12345,"message":"User logged in"}\n' - # - # @rbs (String severity, Time time, String? progname, String message) -> String + # @param severity [String] Logger severity name + # @param time [Time] + # @param progname [String, nil] + # @param message [Object] anything `Logger` was handed + # @return [String] JSON line terminated by `"\n"` def call(severity, time, progname, message) hash = { severity:, timestamp: time.utc.iso8601(6), progname:, pid: Process.pid, - message: Utils::Format.to_log(message) + message: message.respond_to?(:to_h) ? message.to_h : message } ::JSON.dump(hash) << "\n" diff --git a/lib/cmdx/log_formatters/key_value.rb b/lib/cmdx/log_formatters/key_value.rb index 87e0f4087..8dc4557a2 100644 --- a/lib/cmdx/log_formatters/key_value.rb +++ b/lib/cmdx/log_formatters/key_value.rb @@ -2,37 +2,26 @@ module CMDx module LogFormatters - # Formats log messages as key-value pairs for structured logging - # - # This formatter converts log entries into key-value format with standardized fields - # including severity, timestamp, program name, process ID, and formatted message. - # The output is suitable for log parsing tools and human-readable structured logs. + # `Logger` formatter that emits `key=value.inspect` pairs on a single + # line. Hash-like messages (including Result) are flattened into the + # top-level `message` field via `#to_h`. class KeyValue - # Formats a log entry as a key-value string - # - # @param severity [String] The log level (e.g., "INFO", "ERROR", "DEBUG") - # @param time [Time] The timestamp when the log entry was created - # @param progname [String, nil] The program name or identifier - # @param message [Object] The log message content - # - # @return [String] A key-value formatted log entry with a trailing newline - # - # @example Basic usage - # logger_formatter.call("INFO", Time.now, "MyApp", "User logged in") - # # => "severity=INFO timestamp=2024-01-15T10:30:45.123456Z progname=MyApp pid=12345 message=User logged in\n" - # - # @rbs (String severity, Time time, String? progname, String message) -> String + # @param severity [String] Logger severity name + # @param time [Time] + # @param progname [String, nil] + # @param message [Object] + # @return [String] single-line key=value line terminated by `"\n"` def call(severity, time, progname, message) hash = { severity:, timestamp: time.utc.iso8601(6), progname:, pid: Process.pid, - message: Utils::Format.to_log(message) + message: message.respond_to?(:to_h) ? message.to_h : message } - Utils::Format.to_str(hash) << "\n" + hash.map { |k, v| "#{k}=#{v.inspect}" }.join(" ") << "\n" end end diff --git a/lib/cmdx/log_formatters/line.rb b/lib/cmdx/log_formatters/line.rb index a58b32ad4..fd93cec9e 100644 --- a/lib/cmdx/log_formatters/line.rb +++ b/lib/cmdx/log_formatters/line.rb @@ -2,27 +2,15 @@ module CMDx module LogFormatters - # Formats log messages as single-line text for human-readable logging - # - # This formatter converts log entries into a compact single-line format with - # severity abbreviation, ISO8601 timestamp, process ID, and formatted message. - # The output is optimized for human readability and traditional log file formats. + # Default formatter. Emits a human-readable single-line log entry that + # mirrors Ruby's built-in `Logger::Formatter` style. class Line - # Formats a log entry as a single-line string - # - # @param severity [String] The log level (e.g., "INFO", "ERROR", "DEBUG") - # @param time [Time] The timestamp when the log entry was created - # @param progname [String, nil] The program name or identifier - # @param message [Object] The log message content - # - # @return [String] A single-line formatted log entry with a trailing newline - # - # @example Basic usage - # logger_formatter.call("INFO", Time.now, "MyApp", "User logged in") - # # => "I, [2024-01-15T10:30:45.123456Z #12345] INFO -- MyApp: User logged in\n" - # - # @rbs (String severity, Time time, String? progname, String message) -> String + # @param severity [String] Logger severity name + # @param time [Time] + # @param progname [String, nil] + # @param message [Object] + # @return [String] formatted line terminated by `"\n"` def call(severity, time, progname, message) "#{severity[0]}, [#{time.utc.iso8601(6)} ##{Process.pid}] #{severity} -- #{progname}: #{message}\n" end diff --git a/lib/cmdx/log_formatters/logstash.rb b/lib/cmdx/log_formatters/logstash.rb index 4df2b9082..2e9118ee7 100644 --- a/lib/cmdx/log_formatters/logstash.rb +++ b/lib/cmdx/log_formatters/logstash.rb @@ -2,34 +2,21 @@ module CMDx module LogFormatters - # Formats log messages as Logstash-compatible JSON for structured logging - # - # This formatter converts log entries into Logstash-compatible JSON format with - # standardized fields including @version, @timestamp, severity, program name, - # process ID, and formatted message. The output follows Logstash event format - # specifications for seamless integration with ELK stack and similar systems. + # `Logger` formatter that produces one JSON line per entry in the shape + # expected by Logstash (`@version` + `@timestamp`). class Logstash - # Formats a log entry as a Logstash-compatible JSON string - # - # @param severity [String] The log level (e.g., "INFO", "ERROR", "DEBUG") - # @param time [Time] The timestamp when the log entry was created - # @param progname [String, nil] The program name or identifier - # @param message [Object] The log message content - # - # @return [String] A Logstash-compatible JSON-formatted log entry with a trailing newline - # - # @example Basic usage - # logger_formatter.call("INFO", Time.now, "MyApp", "User logged in") - # # => '{"severity":"INFO","progname":"MyApp","pid":12345,"message":"User logged in","@version":"1","@timestamp":"2024-01-15T10:30:45.123456Z"}\n' - # - # @rbs (String severity, Time time, String? progname, String message) -> String + # @param severity [String] Logger severity name + # @param time [Time] + # @param progname [String, nil] + # @param message [Object] + # @return [String] JSON line terminated by `"\n"` def call(severity, time, progname, message) hash = { severity:, progname:, pid: Process.pid, - message: Utils::Format.to_log(message), + message: message.respond_to?(:to_h) ? message.to_h : message, "@version" => "1", "@timestamp" => time.utc.iso8601(6) } diff --git a/lib/cmdx/log_formatters/raw.rb b/lib/cmdx/log_formatters/raw.rb index 57a43fcdc..47dca766d 100644 --- a/lib/cmdx/log_formatters/raw.rb +++ b/lib/cmdx/log_formatters/raw.rb @@ -2,28 +2,16 @@ module CMDx module LogFormatters - # Formats log messages as raw text without additional formatting - # - # This formatter outputs log messages in their original form with minimal - # processing, adding only a trailing newline. It's useful for scenarios - # where you want to preserve the exact message content without metadata - # or structured formatting. + # Passthrough formatter that writes only the message (terminated with + # `"\n"`). Useful when surrounding infrastructure already supplies + # severity and timestamp. class Raw - # Formats a log entry as raw text - # - # @param severity [String] The log level (e.g., "INFO", "ERROR", "DEBUG") - # @param time [Time] The timestamp when the log entry was created - # @param progname [String, nil] The program name or identifier - # @param message [Object] The log message content - # - # @return [String] The raw message with a trailing newline - # - # @example Basic usage - # logger_formatter.call("INFO", Time.now, "MyApp", "User logged in") - # # => "User logged in\n" - # - # @rbs (String severity, Time time, String? progname, String message) -> String + # @param severity [String] ignored + # @param time [Time] ignored + # @param progname [String, nil] ignored + # @param message [Object] + # @return [String] `"#{message}\n"` def call(severity, time, progname, message) "#{message}\n" end diff --git a/lib/cmdx/logger_proxy.rb b/lib/cmdx/logger_proxy.rb new file mode 100644 index 000000000..611e9fe78 --- /dev/null +++ b/lib/cmdx/logger_proxy.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module CMDx + # Returns a logger tailored to a task's settings. If the task overrides + # `log_level` or `log_formatter`, the base logger is `dup`'d so those + # overrides don't leak into sibling tasks sharing the same global logger. + module LoggerProxy + + extend self + + # @param task [Task] + # @return [Logger] a logger configured with the task's level/formatter + def logger(task) + settings = task.class.settings + logger = settings.logger + level = settings.log_level + formatter = settings.log_formatter + + change_level = level && level != logger.level + change_formatter = formatter && formatter != logger.formatter + return logger unless change_level || change_formatter + + logger = logger.dup + logger.level = level if change_level + logger.formatter = formatter if change_formatter + logger + end + + end +end diff --git a/lib/cmdx/middleware_registry.rb b/lib/cmdx/middleware_registry.rb deleted file mode 100644 index a1d5070dc..000000000 --- a/lib/cmdx/middleware_registry.rb +++ /dev/null @@ -1,148 +0,0 @@ -# frozen_string_literal: true - -module CMDx - # Registry for managing middleware components in a task execution chain. - # - # The MiddlewareRegistry maintains an ordered list of middleware components - # that can be inserted, removed, and executed in sequence. Each middleware - # can be configured with specific options and is executed in the order - # they were registered. - # - # Supports copy-on-write semantics: a duped registry shares the parent's - # data until a write operation triggers materialization. - class MiddlewareRegistry - - # Initialize a new middleware registry. - # - # @param registry [Array, nil] Initial array of middleware entries - # - # @example - # registry = MiddlewareRegistry.new - # registry = MiddlewareRegistry.new([[MyMiddleware, {option: 'value'}]]) - # - # @rbs (?Array[Array[untyped]]? registry) -> void - def initialize(registry = nil) - @registry = registry || [] - end - - # Sets up copy-on-write state when duplicated via dup. - # - # @param source [MiddlewareRegistry] The registry being duplicated - # - # @rbs (MiddlewareRegistry source) -> void - def initialize_dup(source) - @parent = source - @registry = nil - super - end - - # Returns the ordered collection of middleware entries. - # Delegates to the parent registry when not yet materialized. - # - # @return [Array<Array>] Array of middleware-options pairs - # - # @example - # registry.registry # => [[LoggingMiddleware, {level: :debug}], [AuthMiddleware, {}]] - # - # @rbs () -> Array[Array[untyped]] - def registry - @registry || @parent.registry - end - alias to_a registry - - # Register a middleware component in the registry. - # - # @param middleware [Object] The middleware object to register - # @param at [Integer] Position to insert the middleware (default: -1, end of list) - # @param options [Hash] Configuration options for the middleware - # @option options [Symbol] :key Configuration key for the middleware - # @option options [Object] :value Configuration value for the middleware - # - # @return [MiddlewareRegistry] Returns self for method chaining - # - # @example - # registry.register(LoggingMiddleware, at: 0, log_level: :debug) - # registry.register(AuthMiddleware, at: -1, timeout: 30) - # - # @rbs (untyped middleware, ?at: Integer, **untyped options) -> self - def register(middleware, at: -1, **options) - materialize! - - @registry.insert(at, [middleware, options]) - self - end - - # Remove a middleware component from the registry. - # - # @param middleware [Object] The middleware object to remove - # - # @return [MiddlewareRegistry] Returns self for method chaining - # - # @example - # registry.deregister(LoggingMiddleware) - # - # @rbs (untyped middleware) -> self - def deregister(middleware) - materialize! - - @registry.reject! { |mw, _opts| mw == middleware } - self - end - - # Execute the middleware chain for a given task. - # - # @param task [Object] The task object to process through middleware - # - # @yield [task] Block to execute after all middleware processing - # @yieldparam task [Object] The processed task object - # - # @return [Object] Result of the block execution - # - # @raise [ArgumentError] When no block is provided - # - # @example - # result = registry.call!(my_task) do |processed_task| - # processed_task.execute - # end - # - # @rbs (untyped task) { (untyped) -> untyped } -> untyped - def call!(task, &) - raise ArgumentError, "block required" unless block_given? - - recursively_call_middleware(0, task, &) - end - - private - - # Copies the parent's registry data into this instance, - # severing the copy-on-write link. - # - # @rbs () -> void - def materialize! - return if @registry - - @registry = @parent.registry.map(&:dup) - @parent = nil - end - - # Recursively execute middleware in the chain. - # - # @param index [Integer] Current middleware index in the chain - # @param task [Object] The task object being processed - # @param block [Proc] Block to execute after middleware processing - # - # @yield [task] Block to execute after middleware processing - # @yieldparam task [Object] The processed task object - # - # @return [Object] Result of the block execution or next middleware call - # - # @rbs (Integer index, untyped task) { (untyped) -> untyped } -> untyped - def recursively_call_middleware(index, task, &block) - return yield(task) if index >= registry.size - - middleware, options = registry[index] - middleware.call(task, **options) { recursively_call_middleware(index + 1, task, &block) } - end - - end -end diff --git a/lib/cmdx/middlewares.rb b/lib/cmdx/middlewares.rb new file mode 100644 index 000000000..0a1c54815 --- /dev/null +++ b/lib/cmdx/middlewares.rb @@ -0,0 +1,112 @@ +# frozen_string_literal: true + +module CMDx + # Ordered list of middlewares wrapping a task's lifecycle. Each middleware + # is a callable with the signature `call(task) { next_link.call }`; Runtime + # builds a nested chain and requires each middleware to yield to the next. + class Middlewares + + attr_reader :registry + + def initialize + @registry = [] + end + + def initialize_copy(source) + @registry = source.registry.dup + end + + # Inserts a middleware. With no `:at`, appends. With `:at`, inserts at + # the given (clamped) index — supports negative indexing. + # + # @param callable [#call, nil] provide either this or a block + # @param at [Integer, nil] insertion index + # @yield the middleware body, receiving `(task)` and `next_link` via block + # @return [Middlewares] self for chaining + # @raise [ArgumentError] when both or neither of `callable`/block are given, + # when the callable doesn't respond to `#call`, or when `:at` isn't an Integer + def register(callable = nil, at: nil, &block) + middleware = callable || block + + if callable && block + raise ArgumentError, "provide either a callable or a block, not both" + elsif !middleware.respond_to?(:call) + raise ArgumentError, "middleware must respond to #call" + elsif !at.nil? && !at.is_a?(Integer) + raise ArgumentError, "at must be an Integer" + end + + if at.nil? + registry << middleware + else + at = [at.clamp(-registry.size - 1, registry.size), registry.size].min + registry.insert(at, middleware) + end + + self + end + + # Removes a middleware by reference or by index. + # + # @param middleware [#call, nil] the exact middleware to remove + # @param at [Integer, nil] index to remove + # @return [Middlewares] self for chaining + # @raise [ArgumentError] when neither or both of `middleware`/`:at` are given, + # or when `:at` isn't an Integer + def deregister(middleware = nil, at: nil) + if at.nil? && middleware.nil? + raise ArgumentError, "provide either a middleware or an at: index" + elsif !at.nil? && !middleware.nil? + raise ArgumentError, "provide either a middleware or an at: index, not both" + elsif !at.nil? && !at.is_a?(Integer) + raise ArgumentError, "at must be an Integer" + end + + if at.nil? + registry.delete(middleware) + else + registry.delete_at(at) + end + + self + end + + # @return [Boolean] + def empty? + registry.empty? + end + + # @return [Integer] + def size + registry.size + end + + # Walks the middleware chain around `task`'s lifecycle. The final link + # yields to `block`, which is expected to run the actual lifecycle. + # + # @param task [Task] + # @yield the innermost link — the task's lifecycle body + # @return [void] + # @raise [MiddlewareError] when a middleware forgets to yield to `next_link`, + # which would otherwise silently skip the task + def process(task) + processed = false + count = registry.size + + chain = lambda do |i| + if i == count + processed = true + yield + else + registry[i].call(task) { chain.call(i + 1) } + end + end + chain.call(0) + + processed || begin + raise MiddlewareError, "middleware did not yield the next_link" + end + end + + end +end diff --git a/lib/cmdx/middlewares/correlate.rb b/lib/cmdx/middlewares/correlate.rb deleted file mode 100644 index aa9fcdf0f..000000000 --- a/lib/cmdx/middlewares/correlate.rb +++ /dev/null @@ -1,140 +0,0 @@ -# frozen_string_literal: true - -module CMDx - module Middlewares - # Middleware for correlating task executions with unique identifiers. - # - # The Correlate middleware provides thread and fiber safe correlation ID management - # for tracking task execution flows across different operations. It automatically - # generates correlation IDs when none are provided and stores them in task result - # metadata for traceability. - module Correlate - - extend self - - # @rbs CONCURRENCY_KEY: Symbol - CONCURRENCY_KEY = :cmdx_correlate - - # Retrieves the current correlation ID from local storage. - # - # @return [String, nil] The current correlation ID or nil if not set - # - # @example Get current correlation ID - # Correlate.id # => "550e8400-e29b-41d4-a716-446655440000" - # - # @rbs () -> String? - def id - thread_or_fiber[CONCURRENCY_KEY] - end - - # Sets the correlation ID in local storage. - # - # @param id [String] The correlation ID to set - # @return [String] The set correlation ID - # - # @example Set correlation ID - # Correlate.id = "abc-123-def" - # - # @rbs (String id) -> String - def id=(id) - thread_or_fiber[CONCURRENCY_KEY] = id - end - - # Clears the current correlation ID from local storage. - # - # @return [nil] Always returns nil - # - # @example Clear correlation ID - # Correlate.clear - # - # @rbs () -> nil - def clear - thread_or_fiber[CONCURRENCY_KEY] = nil - end - - # Temporarily uses a new correlation ID for the duration of a block. - # Restores the previous ID after the block completes, even if an error occurs. - # - # @param new_id [String] The correlation ID to use temporarily - # @yield The block to execute with the new correlation ID - # @return [Object] The result of the yielded block - # - # @example Use temporary correlation ID - # Correlate.use("temp-id") do - # # Operations here use "temp-id" - # perform_operation - # end - # # Previous ID is restored - # - # @rbs (String new_id) { () -> untyped } -> untyped - def use(new_id) - old_id = id - self.id = new_id - yield - ensure - self.id = old_id - end - - # Middleware entry point that applies correlation ID logic to task execution. - # - # Evaluates the condition from options and applies correlation ID handling - # if enabled. Generates or retrieves correlation IDs based on the :id option - # and stores them in task result metadata. - # - # @param task [Task] The task being executed - # @param options [Hash] Configuration options for correlation - # @option options [Symbol, Proc, Object, nil] :id The correlation ID source - # @option options [Symbol, Proc, Object, nil] :if Condition to enable correlation - # @option options [Symbol, Proc, Object, nil] :unless Condition to disable correlation - # - # @yield The task execution block - # - # @return [Object] The result of task execution - # - # @example Basic usage with automatic ID generation - # Correlate.call(task, &block) - # @example Use custom correlation ID - # Correlate.call(task, id: "custom-123", &block) - # @example Use task method for ID - # Correlate.call(task, id: :correlation_id, &block) - # @example Use proc for dynamic ID generation - # Correlate.call(task, id: -> { "dynamic-#{Time.now.to_i}" }, &block) - # @example Conditional correlation - # Correlate.call(task, if: :enable_correlation, &block) - # - # @rbs (Task task, **untyped options) { () -> untyped } -> untyped - def call(task, **options, &) - return yield unless Utils::Condition.evaluate(task, options) - - correlation_id = task.result.metadata[:correlation_id] ||= - id || - case callable = options[:id] - when Symbol then task.send(callable) - when Proc then task.instance_eval(&callable) - else - if callable.respond_to?(:call) - callable.call(task) - else - callable || id || Identifier.generate - end - end - - use(correlation_id, &) - end - - private - - # Returns the thread or fiber storage for the current execution context. - # - # @return [Hash] The thread or fiber storage - # - # @rbs () -> Hash - if Fiber.respond_to?(:storage) - def thread_or_fiber = Fiber.storage - else - def thread_or_fiber = Thread.current - end - - end - end -end diff --git a/lib/cmdx/middlewares/runtime.rb b/lib/cmdx/middlewares/runtime.rb deleted file mode 100644 index 09d102ed6..000000000 --- a/lib/cmdx/middlewares/runtime.rb +++ /dev/null @@ -1,77 +0,0 @@ -# frozen_string_literal: true - -module CMDx - module Middlewares - # Middleware for measuring task execution runtime. - # - # The Runtime middleware provides performance monitoring by measuring - # the execution time of tasks using monotonic clock for accuracy. - # It stores runtime measurements in task result metadata for analysis. - module Runtime - - extend self - - # Middleware entry point that measures task execution runtime and - # task execution start and end times. - # - # Evaluates the condition from options and measures execution time - # if enabled. Uses monotonic clock for precise timing measurements - # and stores the result in task metadata. - # - # @param task [Task] The task being executed - # @param options [Hash] Configuration options for runtime measurement - # @option options [Symbol, Proc, Object, nil] :if Condition to enable runtime measurement - # @option options [Symbol, Proc, Object, nil] :unless Condition to disable runtime measurement - # - # @yield The task execution block - # - # @return [Object] The result of task execution - # - # @example Basic usage with automatic runtime measurement - # Runtime.call(task, &block) - # @example Conditional runtime measurement - # Runtime.call(task, if: :enable_profiling, &block) - # @example Disable runtime measurement - # Runtime.call(task, unless: :skip_profiling, &block) - # - # @rbs (Task task, **untyped options) { () -> untyped } -> untyped - def call(task, **options) - unow = utc_time - mnow = monotonic_time - return yield unless Utils::Condition.evaluate(task, options) - - result = yield - task.result.metadata.merge!( - runtime: monotonic_time - mnow, - ended_at: utc_time, - started_at: unow - ) - result - end - - private - - # Gets the current monotonic time in milliseconds. - # - # Uses Process.clock_gettime with CLOCK_MONOTONIC for consistent - # timing measurements that are not affected by system clock changes. - # - # @return [Integer] Current monotonic time in milliseconds - # - # @rbs () -> Integer - def monotonic_time - Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond) - end - - # Gets the current UTC time in ISO 8601 format. - # - # @return [String] Current UTC time in ISO 8601 format - # - # @rbs () -> String - def utc_time - Time.now.utc.iso8601 - end - - end - end -end diff --git a/lib/cmdx/middlewares/timeout.rb b/lib/cmdx/middlewares/timeout.rb deleted file mode 100644 index a7dad82b2..000000000 --- a/lib/cmdx/middlewares/timeout.rb +++ /dev/null @@ -1,76 +0,0 @@ -# frozen_string_literal: true - -module CMDx - module Middlewares - # Middleware for enforcing execution time limits on tasks. - # - # The Timeout middleware provides execution time control by wrapping - # task execution with Ruby's Timeout module. It automatically fails - # tasks that exceed the configured time limit and provides detailed - # error information including the exceeded limit. - module Timeout - - extend self - - # Default timeout limit in seconds when none is specified. - # - # @rbs DEFAULT_LIMIT: Integer - DEFAULT_LIMIT = 3 - - # Middleware entry point that enforces execution time limits. - # - # Evaluates the condition from options and applies timeout control - # if enabled. Supports various timeout limit configurations including - # numeric values, task method calls, and dynamic proc evaluation. - # - # @param task [Task] The task being executed - # @param options [Hash] Configuration options for timeout control - # @option options [Numeric, Symbol, Proc, Object] :seconds The timeout limit source - # @option options [Symbol, Proc, Object, nil] :if Condition to enable timeout control - # @option options [Symbol, Proc, Object, nil] :unless Condition to disable timeout control - # - # @yield The task execution block - # - # @return [Object] The result of task execution - # - # @raise [TimeoutError] When execution exceeds the configured limit - # - # @example Basic usage with default 3 second timeout - # Timeout.call(task, &block) - # @example Custom timeout limit in seconds - # Timeout.call(task, seconds: 10, &block) - # @example Use task method for timeout limit - # Timeout.call(task, seconds: :timeout_limit, &block) - # @example Use proc for dynamic timeout calculation - # Timeout.call(task, seconds: -> { calculate_timeout }, &block) - # @example Conditional timeout control - # Timeout.call(task, if: :enable_timeout, &block) - # - # @rbs (Task task, **untyped options) { () -> untyped } -> untyped - def call(task, **options, &) - return yield unless Utils::Condition.evaluate(task, options) - - limit = - case callable = options[:seconds] - when Numeric then callable - when Symbol then task.send(callable) - when Proc then task.instance_eval(&callable) - else callable.respond_to?(:call) ? callable.call(task) : DEFAULT_LIMIT - end - - limit = Float(limit) - return yield unless limit.positive? - - ::Timeout.timeout(limit, TimeoutError, "execution exceeded #{limit} seconds", &) - rescue TimeoutError => e - task.resolver.fail!( - Utils::Normalize.exception(e), - cause: e, - source: :timeout, - limit: - ) - end - - end - end -end diff --git a/lib/cmdx/output.rb b/lib/cmdx/output.rb new file mode 100644 index 000000000..22b8a421e --- /dev/null +++ b/lib/cmdx/output.rb @@ -0,0 +1,151 @@ +# frozen_string_literal: true + +module CMDx + # A single declared output. Runtime calls {#verify} after `work` to enforce + # `:required`, apply `:default`, run `:coerce` types, apply `:transform`, + # and run validators against the value the task wrote to `task.context[name]`. + class Output + + attr_reader :name + + # @param name [Symbol, String] output key (symbolized) + # @param options [Hash{Symbol => Object}] declaration options + # @option options [String] :description (also accepts `:desc`) + # @option options [Boolean] :required + # @option options [Symbol, Proc, #call] :if + # @option options [Symbol, Proc, #call] :unless + # @option options [Symbol, Array, Hash, Proc] :coerce + # @option options [Object, Symbol, Proc, #call] :default + # @option options [Symbol, Proc, #call] :transform mutator applied after coercion + def initialize(name, **options) + @name = name.to_sym + @options = options.freeze + end + + # @return [String, nil] + def description + @options[:description] || @options[:desc] + end + + # @return [Object, Symbol, Proc, #call, nil] + def default + @options[:default] + end + + # @return [Symbol, Proc, #call, nil] + def transform + @options[:transform] + end + + # @return [Symbol, Proc, #call, nil] + def condition_if + @options[:if] + end + + # @return [Symbol, Proc, #call, nil] + def condition_unless + @options[:unless] + end + + # @return [Boolean] + def required + @options.fetch(:required, false) + end + + # Evaluates required-ness against `task`, respecting `:if`/`:unless`. + # When called without a task, returns the static `:required` flag. + # + # @param task [Task, nil] + # @return [Boolean] + def required?(task = nil) + return false unless required + return true if task.nil? + return false unless Util.satisfied?(condition_if, condition_unless, task) + + true + end + + # @return [Hash{Symbol => Object}] serialized schema for `outputs_schema` + def to_h + { + name:, + description:, + required: required?, + options: @options + } + end + + # Enforces the output contract against `task.context[name]` after `work` runs. + # + # Steps, in order: + # 1. Reads the value from `task.context` and falls back to `:default` when nil. + # 2. Adds a `cmdx.outputs.missing` error when required (respecting `:if`/`:unless`) + # and neither the key nor a default supplied a value. + # 3. Short-circuits when the key was never written and no default exists. + # 4. Runs `:coerce` types; aborts silently on {Coercions::Failure} (the coercion + # layer records its own error). + # 5. Applies `:transform` to the coerced value. + # 6. Runs validators, then writes the final value back to `task.context[name]`. + # + # @param task [Task] the running task whose context is inspected and mutated + # @return [void] + def verify(task) + key_provided = task.context.key?(name) + value = task.context[name] + value = apply_default(task) if value.nil? + + if required?(task) && !key_provided && value.nil? + task.errors.add(name, I18nProxy.t("cmdx.outputs.missing")) + return + end + + return if !key_provided && value.nil? + + coercions = task.class.coercions.extract(@options) + value = task.class.coercions.coerce(task, name, value, coercions) + return if value.is_a?(Coercions::Failure) + + value = apply_transform(value, task) if transform + + validators = task.class.validators.extract(@options) + task.class.validators.validate(task, name, value, validators) + + task.context[name] = value + end + + private + + def apply_default(task) + return if default.nil? + + case default + when Symbol + task.send(default) + when Proc + task.instance_exec(&default) + else + return default unless default.respond_to?(:call) + + default.call(task) + end + end + + def apply_transform(value, task) + case transform + when Symbol + if value.respond_to?(transform) + value.send(transform) + else + task.send(transform, value) + end + when Proc + task.instance_exec(value, &transform) + else + return transform.call(value, task) if transform.respond_to?(:call) + + raise ArgumentError, "transform must be a Symbol, Proc, or respond to #call" + end + end + + end +end diff --git a/lib/cmdx/outputs.rb b/lib/cmdx/outputs.rb new file mode 100644 index 000000000..b4a8ef543 --- /dev/null +++ b/lib/cmdx/outputs.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +module CMDx + # Registry of declared task outputs. Runtime verifies each output after + # `work` completes: presence, coercion, and validation run against values + # the task wrote to context. + class Outputs + + attr_reader :registry + + def initialize + @registry = {} + end + + def initialize_copy(source) + @registry = source.registry.dup + end + + # Declares one or more output keys. All share the same `options`. + # + # @param keys [Array<Symbol>] + # @param options [Hash{Symbol => Object}] passed through to {Output#initialize} + # @return [Outputs] self for chaining + def register(*keys, **options) + keys.each do |key| + output = Output.new(key, **options) + registry[output.name] = output + end + + self + end + + # @param keys [Array<Symbol>] + # @return [Outputs] self for chaining + def deregister(*keys) + keys.each { |key| registry.delete(key.to_sym) } + self + end + + # @return [Boolean] + def empty? + registry.empty? + end + + # @return [Integer] + def size + registry.size + end + + # Verifies every declared output against `task.context`. Adds any failures + # to `task.errors`. + # + # @param task [Task] + # @return [void] + def verify(task) + registry.each_value { |output| output.verify(task) } + end + + end +end diff --git a/lib/cmdx/parallelizer.rb b/lib/cmdx/parallelizer.rb deleted file mode 100644 index ea51f275f..000000000 --- a/lib/cmdx/parallelizer.rb +++ /dev/null @@ -1,100 +0,0 @@ -# frozen_string_literal: true - -module CMDx - # Bounded thread pool that processes items concurrently. - # - # Distributes work across a fixed number of threads using a queue, - # collecting results in submission order. - class Parallelizer - - # Returns the items to process. - # - # @return [Array] the items to process - # - # @example - # parallelizer.items # => [1, 2, 3] - # - # @rbs @items: Array[untyped] - attr_reader :items - - # Returns the number of threads in the pool. - # - # @return [Integer] the thread pool size - # - # @example - # parallelizer.pool_size # => 4 - # - # @rbs @pool_size: Integer - attr_reader :pool_size - - # Creates a new Parallelizer instance. - # - # @param items [Array] the items to process concurrently - # @param pool_size [Integer] number of threads (defaults to item count) - # - # @return [Parallelizer] a new parallelizer instance - # - # @example - # Parallelizer.new([1, 2, 3], pool_size: 2) - # - # @rbs (Array[untyped] items, ?pool_size: Integer) -> void - def initialize(items, pool_size: nil) - @items = items - @pool_size = Integer(pool_size || items.size) - end - - # Processes items concurrently and returns results in submission order. - # - # @param items [Array] the items to process concurrently - # @param pool_size [Integer] number of threads (defaults to item count) - # - # @yield [item] block called for each item in a worker thread - # @yieldparam item [Object] an item from the items array - # @yieldreturn [Object] the result for this item - # - # @return [Array] results in the same order as input items - # - # @example - # Parallelizer.call([1, 2, 3], pool_size: 2) { |n| n * 10 } - # # => [10, 20, 30] - # - # @rbs [T, R] (Array[T] items, ?pool_size: Integer) { (T) -> R } -> Array[R] - def self.call(items, pool_size: nil, &block) - new(items, pool_size:).call(&block) - end - - # Distributes items across the thread pool and returns results - # in submission order. - # - # @yield [item] block called for each item in a worker thread - # @yieldparam item [Object] an item from the items array - # @yieldreturn [Object] the result for this item - # - # @return [Array] results in the same order as input items - # - # @example - # Parallelizer.new(%w[a b c]).call { |s| s.upcase } - # # => ["A", "B", "C"] - # - # @rbs [T, R] () { (T) -> R } -> Array[R] - def call(&block) - results = Array.new(items.size) - queue = Queue.new - - items.each_with_index { |item, i| queue << [item, i] } - pool_size.times { queue << nil } - - Array.new(pool_size) do - Thread.new do - while (entry = queue.pop) - item, index = entry - results[index] = block.call(item) # rubocop:disable Performance/RedundantBlockCall - end - end - end.each(&:join) - - results - end - - end -end diff --git a/lib/cmdx/pipeline.rb b/lib/cmdx/pipeline.rb index 40eeb6741..4c67aecca 100644 --- a/lib/cmdx/pipeline.rb +++ b/lib/cmdx/pipeline.rb @@ -1,168 +1,101 @@ # frozen_string_literal: true module CMDx - # Executes workflows by processing task groups with conditional logic and breakpoint handling. - # The Pipeline class manages the execution flow of workflow tasks, evaluating conditions - # and handling breakpoints that can interrupt execution at specific task statuses. + # Runs a Workflow's declared task groups. Each group selects a strategy + # (`:sequential` by default, or `:parallel`). A group failure halts the + # pipeline by echoing the failed result's signal through `throw!`, which + # bubbles up through Runtime as the workflow's own failure. + # + # @see Workflow class Pipeline - # @rbs SEQUENTIAL_REGEXP: Regexp - SEQUENTIAL_REGEXP = /\Asequential\z/ - private_constant :SEQUENTIAL_REGEXP + class << self - # @rbs PARALLEL_REGEXP: Regexp - PARALLEL_REGEXP = /\Aparallel\z/ - private_constant :PARALLEL_REGEXP + # @param workflow [Task & Workflow] + # @return [void] + def execute(workflow) + new(workflow).execute + end - # Returns the workflow being executed by this pipeline. - # - # @return [Workflow] The workflow instance - # - # @example - # pipeline.workflow.context[:status] # => "processing" - # - # @rbs @workflow: Workflow - attr_reader :workflow + end - # @param workflow [Workflow] The workflow to execute - # - # @return [Pipeline] A new pipeline instance - # - # @example - # pipeline = Pipeline.new(my_workflow) - # - # @rbs (Workflow workflow) -> void + # @param workflow [Task & Workflow] def initialize(workflow) @workflow = workflow end - # Executes a workflow using a new pipeline instance. - # - # @param workflow [Workflow] The workflow to execute + # Iterates every group in the workflow's pipeline, respecting + # `:if`/`:unless` and the `:strategy` key. Any group that produces a + # failed result halts execution by throwing through the workflow. # # @return [void] - # - # @example - # Pipeline.execute(my_workflow) - # - # @rbs (Workflow workflow) -> void - def self.execute(workflow) - new(workflow).execute - end - - # Executes the workflow by processing all task groups in sequence. - # Each group is evaluated against its conditions, and breakpoints are checked - # after each task execution to determine if workflow should continue or halt. - # - # @return [void] - # - # @example - # pipeline = Pipeline.new(my_workflow) - # pipeline.execute - # - # @rbs () -> void + # @raise [ArgumentError] for an empty task group or unknown strategy def execute - default_breakpoints = Utils::Normalize.statuses( - workflow.class.settings.breakpoints || - workflow.class.settings.workflow_breakpoints - ) + @workflow.class.pipeline.each do |group| + raise ArgumentError, "no tasks in group" if group.tasks.empty? - workflow.class.pipeline.each do |group| - next unless Utils::Condition.evaluate(workflow, group.options) + next unless Util.satisfied?(group.options[:if], group.options[:unless], @workflow) - breakpoints = - if group.options.key?(:breakpoints) - Utils::Normalize.statuses(group.options[:breakpoints]) + halt = + case strategy = group.options[:strategy] + when :sequential, NilClass + run_sequential(group) + when :parallel + run_parallel(group) else - default_breakpoints + raise ArgumentError, "invalid strategy: #{strategy.inspect}" end - execute_group_tasks(group, breakpoints) + @workflow.send(:throw!, halt) if halt end end private - # Executes a group of tasks using the specified execution strategy. - # - # @param group [CMDx::Group] The task group to execute - # @param breakpoints [Array<Symbol>] Status values that trigger execution breaks - # @option group.options [Symbol, String] :strategy Execution strategy (:sequential, :parallel, or nil for default) - # - # @return [void] - # - # @example - # execute_group_tasks(group, ["failed", "skipped"]) - # - # @rbs (untyped group, Array[String] breakpoints) -> void - def execute_group_tasks(group, breakpoints) - case strategy = group.options[:strategy] - when NilClass, SEQUENTIAL_REGEXP then execute_tasks_in_sequence(group, breakpoints) - when PARALLEL_REGEXP then execute_tasks_in_parallel(group, breakpoints) - else raise "unknown execution strategy #{strategy.inspect}" - end - end - - # Executes tasks sequentially within a group, checking breakpoints after each task. - # If a task result status matches a breakpoint, the workflow is interrupted. - # - # @param group [ExecutionGroup] The group of tasks to execute - # @param breakpoints [Array<String>] Breakpoint statuses that trigger workflow interruption - # - # @return [void] - # - # @raise [HaltError] When a task result status matches a breakpoint - # - # @example - # execute_tasks_in_sequence(group, ["failed", "skipped"]) - # - # @rbs (untyped group, Array[String] breakpoints) -> void - def execute_tasks_in_sequence(group, breakpoints) + def run_sequential(group) group.tasks.each do |task| - task_result = task.execute(workflow.context) - next unless breakpoints.include?(task_result.status) - - workflow.throw!(task_result) + result = task.execute(@workflow.context) + return result if result.failed? end + + nil end - # Each task receives a snapshot of the workflow context to prevent - # unsynchronized concurrent writes to a shared Hash. Snapshots are - # merged back into the workflow context after all tasks complete. - # - # @param group [CMDx::Group] The task group to execute in parallel - # @param breakpoints [Array<String>] Status values that trigger execution breaks - # @option group.options [Integer] :pool_size Number of concurrent threads (defaults to task count) - # - # @return [void] - # - # @raise [Fault] When a task result status matches a breakpoint - # - # @example - # execute_tasks_in_parallel(group, ["failed"]) - # - # @rbs (untyped group, Array[String] breakpoints) -> void - def execute_tasks_in_parallel(group, breakpoints) - contexts = group.tasks.map { Context.new(workflow.context.to_h) } - ctx_pairs = group.tasks.zip(contexts) - pool_size = group.options.fetch(:pool_size, ctx_pairs.size) - - results = Parallelizer.call(ctx_pairs, pool_size:) do |task, context| - Chain.current = workflow.chain - task.execute(context) + def run_parallel(group) + tasks = group.tasks + chain = Chain.current + size = group.options[:pool_size] || tasks.size + queue = Queue.new + results = Array.new(tasks.size) + mutex = Mutex.new + + tasks.each_with_index { |tc, i| queue << [tc, i] } + size.times { queue << nil } + + workers = Array.new(size) do + Thread.new do + Fiber[Chain::STORAGE_KEY] = chain + while (entry = queue.pop) + task_class, index = entry + ctx_copy = @workflow.context.deep_dup + result = task_class.execute(ctx_copy) + mutex.synchronize { results[index] = result } + end + end end - contexts.each { |ctx| workflow.context.merge!(ctx) } + workers.each(&:join) - faulted = results.select { |r| breakpoints.include?(r.status) } - return if faulted.empty? + failed = nil + results.each do |result| + if result.failed? + failed ||= result + else + @workflow.context.merge(result.context) + end + end - workflow.public_send( - :"#{faulted.last.status}!", - Locale.t("cmdx.reasons.unspecified"), - source: :parallel, - faults: faulted.map(&:to_h) - ) + failed end end diff --git a/lib/cmdx/railtie.rb b/lib/cmdx/railtie.rb index 8b4d03fd4..e37e53587 100644 --- a/lib/cmdx/railtie.rb +++ b/lib/cmdx/railtie.rb @@ -1,47 +1,21 @@ # frozen_string_literal: true module CMDx - # Rails integration class that automatically configures CMDx when the Rails - # application initializes. Handles locale configuration and I18n setup. + # Rails integration. Loaded only when `Rails::Railtie` is defined. Wires the + # app's `I18n.load_path` so CMDx locale files for each available locale are + # available, and points the CMDx logger and backtrace cleaner at Rails. class Railtie < Rails::Railtie railtie_name :cmdx - # Configures CMDx locales during Rails application initialization. - # - # Iterates through available locales from the Rails configuration and loads - # corresponding CMDx locale files. Reloads the I18n system to ensure - # all locales are properly registered. - # - # @param app [Rails::Application] the Rails application instance - # - # @raise [LoadError] if locale files cannot be loaded - # - # @example - # # This initializer runs automatically when Rails starts - # # It will load locales like en.yml, es.yml, fr.yml if they exist - # # in the CMDx gem's locales directory - # - # @rbs (untyped app) -> void - initializer("cmdx.configure_locales") do |app| - Utils::Wrap.array(app.config.i18n.available_locales).each do |locale| - path = CMDx.gem_path.join("lib/locales/#{locale}.yml") - next unless File.file?(path) + initializer("cmdx.configure_rails") do |app| + available_locales = app.config.i18n.available_locales.join(",") + locale_path = File.expand_path("../locales/{#{available_locales}}.yml", __dir__) + I18n.load_path += Dir[locale_path] - ::I18n.load_path << path - end - - ::I18n.reload! - end - - # Configures the backtrace cleaner for CMDx in a Rails environment. - # - # Sets the backtrace cleaner to the Rails backtrace cleaner. - # - # @rbs () -> void - initializer("cmdx.backtrace_cleaner") do - CMDx.configuration.backtrace_cleaner = lambda do |backtrace| - Rails.backtrace_cleaner.clean(backtrace) + CMDx.configure do |config| + config.logger = Rails.logger + config.backtrace_cleaner = ->(bt) { Rails.backtrace_cleaner.clean(bt) } end end diff --git a/lib/cmdx/resolver.rb b/lib/cmdx/resolver.rb deleted file mode 100644 index d201eea3b..000000000 --- a/lib/cmdx/resolver.rb +++ /dev/null @@ -1,263 +0,0 @@ -# frozen_string_literal: true - -module CMDx - # Manages state transitions for a {CMDx::Result}, handling success annotation, - # skipping, failure, throwing, and halting with fault raising. - # - # The Resolver owns the +strict+ flag and all transition logic. It writes to the - # Result via +instance_variable_set+ so that Result remains a pure data object. - # - # @example Accessed through the task - # task.resolver.fail!("Validation failed", cause: validation_error) - # task.resolver.skip!("Already processed", halt: false) - class Resolver - - extend Forwardable - - # Returns the result being resolved. - # - # @return [CMDx::Result] The result instance - # - # @rbs @result: Result - attr_reader :result - - def_delegators :result, :task, :success?, :skipped?, :failed?, - :initialized?, :executing?, :complete?, :interrupted? - - # @param result [CMDx::Result] The result to manage transitions for - # - # @return [CMDx::Resolver] A new resolver instance - # - # @raise [TypeError] When result is not a CMDx::Result instance - # - # @example - # resolver = CMDx::Resolver.new(result) - # - # @rbs (Result) -> void - def initialize(result) - raise TypeError, "must be a CMDx::Result" unless result.is_a?(Result) - - @result = result - end - - # Transitions the result to the appropriate final state based on status. - # Delegates to {#complete!} when successful, {#interrupt!} otherwise. - # - # @return [void] - # - # @example - # resolver.executed! - # - # @rbs () -> void - def executed! - success? ? complete! : interrupt! - end - - # Transitions the result state from initialized to executing. - # - # @raise [RuntimeError] When attempting to transition from invalid state - # - # @example - # resolver.executing! - # - # @rbs () -> void - def executing! - return if executing? - - raise "can only transition to #{Result::EXECUTING} from #{Result::INITIALIZED}" unless initialized? - - assign(state: Result::EXECUTING) - end - - # Transitions the result state from executing to complete. - # - # @raise [RuntimeError] When attempting to transition from invalid state - # - # @example - # resolver.complete! - # - # @rbs () -> void - def complete! - return if complete? - - raise "can only transition to #{Result::COMPLETE} from #{Result::EXECUTING}" unless executing? - - assign(state: Result::COMPLETE) - end - - # Transitions the result state to interrupted. - # - # @raise [RuntimeError] When attempting to transition from invalid state - # - # @example - # resolver.interrupt! - # - # @rbs () -> void - def interrupt! - return if interrupted? - - raise "cannot transition to #{Result::INTERRUPTED} from #{Result::COMPLETE}" if complete? - - assign(state: Result::INTERRUPTED) - end - - # Sets a reason and optional metadata on a successful result without - # changing its state or status. Useful for annotating why a task succeeded. - # When halt is true, uses throw/catch to exit the work method early. - # - # @param reason [String, nil] Reason or note for the success - # @param halt [Boolean] Whether to halt execution after success - # @param metadata [Hash] Additional metadata about the success - # @option metadata [Object] :* Any key-value pairs for additional metadata - # - # @raise [RuntimeError] When status is not success - # - # @example - # resolver.success!("Created 42 records") - # resolver.success!("Imported", halt: false, rows: 100) - # - # @rbs (?String? reason, halt: bool, **untyped metadata) -> void - def success!(reason = nil, halt: true, **metadata) - raise "can only be used while #{Result::SUCCESS}" unless success? - - assign( - reason:, - metadata: - ) - - throw(:cmdx_halt) if halt - end - - # @param reason [String, nil] Reason for skipping the task - # @param halt [Boolean] Whether to halt execution after skipping - # @param cause [Exception, nil] Exception that caused the skip - # @param strict [Boolean] Whether this skip is strict (default: true). - # @param metadata [Hash] Additional metadata about the skip - # @option metadata [Object] :* Any key-value pairs for additional metadata - # - # @raise [RuntimeError] When attempting to skip from invalid status - # - # @example - # resolver.skip!("Dependencies not met", cause: dependency_error) - # resolver.skip!("Already processed", halt: false) - # resolver.skip!("Optional step", strict: false) - # - # @rbs (?String? reason, halt: bool, cause: Exception?, strict: bool, **untyped metadata) -> void - def skip!(reason = nil, halt: true, cause: nil, strict: true, **metadata) - return if skipped? - - raise "can only transition to #{Result::SKIPPED} from #{Result::SUCCESS}" unless success? - - assign( - state: Result::INTERRUPTED, - status: Result::SKIPPED, - reason: reason || Locale.t("cmdx.reasons.unspecified"), - cause:, - strict:, - metadata: - ) - - halt! if halt - end - - # @param reason [String, nil] Reason for task failure - # @param halt [Boolean] Whether to halt execution after failure - # @param cause [Exception, nil] Exception that caused the failure - # @param strict [Boolean] Whether this failure is strict (default: true). - # @param metadata [Hash] Additional metadata about the failure - # @option metadata [Object] :* Any key-value pairs for additional metadata - # - # @raise [RuntimeError] When attempting to fail from invalid status - # - # @example - # resolver.fail!("Validation failed", cause: validation_error) - # resolver.fail!("Network timeout", halt: false, timeout: 30) - # resolver.fail!("Soft failure", strict: false) - # - # @rbs (?String? reason, halt: bool, cause: Exception?, strict: bool, **untyped metadata) -> void - def fail!(reason = nil, halt: true, cause: nil, strict: true, **metadata) - return if failed? - - raise "can only transition to #{Result::FAILED} from #{Result::SUCCESS}" unless success? - - assign( - state: Result::INTERRUPTED, - status: Result::FAILED, - reason: reason || Locale.t("cmdx.reasons.unspecified"), - cause:, - strict:, - metadata: - ) - - halt! if halt - end - - # @raise [SkipFault] When task was skipped - # @raise [FailFault] When task failed - # - # @example - # resolver.halt! - # - # @rbs () -> void - def halt! - return if success? - - klass = skipped? ? SkipFault : FailFault - fault = klass.new(result) - - frames = caller_locations(3..-1) - - unless frames.empty? - frames = frames.map(&:to_s) - - if (cleaner = task.class.settings.backtrace_cleaner) - cleaner.call(frames) - end - - fault.set_backtrace(frames) - end - - raise(fault) - end - - # @param other [CMDx::Result] Result to throw from current result - # @param halt [Boolean] Whether to halt execution after throwing - # @param cause [Exception, nil] Exception that caused the throw - # @param metadata [Hash] Additional metadata to merge - # @option metadata [Object] :* Any key-value pairs for additional metadata - # - # @raise [TypeError] When other is not a CMDx::Result instance - # - # @example - # other_result = OtherTask.execute - # resolver.throw!(other_result, cause: upstream_error) - # - # @rbs (Result other, halt: bool, cause: Exception?, **untyped metadata) -> void - def throw!(other, halt: true, cause: nil, **metadata) - raise TypeError, "must be a CMDx::Result" unless other.is_a?(Result) - - assign( - state: other.state, - status: other.status, - reason: other.reason, - cause: cause || other.cause, - strict: other.strict?, - metadata: other.metadata.merge(metadata) - ) - - halt! if halt - end - - private - - # Writes attributes to the result via instance_variable_set. - # - # @param attrs [Hash] Attributes to assign to the result - # - # @rbs (**untyped attrs) -> void - def assign(**attrs) - attrs.each { |key, value| @result.instance_variable_set(:"@#{key}", value) } - end - - end -end diff --git a/lib/cmdx/result.rb b/lib/cmdx/result.rb index d46141660..61699c537 100644 --- a/lib/cmdx/result.rb +++ b/lib/cmdx/result.rb @@ -1,443 +1,359 @@ # frozen_string_literal: true module CMDx - # Represents the execution result of a CMDx task, tracking state transitions, - # status changes, and providing methods for handling different outcomes. + # Frozen outcome of a task execution. Provides read-only access to the + # task's signal (state/status/reason/metadata/cause), the chain it belongs + # to, its context, and lifecycle metadata (retries, duration, rollback, + # deprecated). Constructed by Runtime at the end of `execute`. # - # The Result class manages the lifecycle of task execution from initialization - # through completion or interruption, offering a fluent interface for status - # checking and conditional handling. + # @see Runtime#finalize_result + # @see Signal class Result - extend Forwardable - - # @rbs STATES: Array[String] - STATES = [ - INITIALIZED = "initialized", # Initial state before execution - EXECUTING = "executing", # Currently executing task logic - COMPLETE = "complete", # Successfully completed execution - INTERRUPTED = "interrupted" # Execution was halted due to failure - ].freeze - - # @rbs STATUSES: Array[String] - STATUSES = [ - SUCCESS = "success", # Task completed successfully - SKIPPED = "skipped", # Task was skipped intentionally - FAILED = "failed" # Task failed due to error or validation - ].freeze - - # @rbs STRIP_FAILURE: Proc - STRIP_FAILURE = proc do |hash, result, key| - unless result.public_send(:"#{key}?") - # Strip caused/threw failures since its the same info as the log line - hash[key] = result.public_send(key).to_h.except(:caused_failure, :threw_failure) - end - end.freeze - private_constant :STRIP_FAILURE + EVENTS = Set[ + *Signal::STATES, + *Signal::STATUSES, + :ok, + :ko + ].map!(&:to_sym).freeze + private_constant :EVENTS + + attr_reader :chain + + # @param chain [Chain] the chain this result belongs to + # @param task [Task] the executed task instance + # @param signal [Signal] the final signal from the task's lifecycle + # @param options [Hash{Symbol => Object}] frozen execution metadata + # @option options [String] :id + # @option options [Boolean] :strict + # @option options [Boolean] :deprecated + # @option options [Boolean] :rolled_back + # @option options [Integer] :retries + # @option options [Float] :duration milliseconds + def initialize(chain, task, signal, **options) + @chain = chain + @task = task + @signal = signal + @options = options.freeze + end - # @rbs FAILURE_KEY_REGEX: Regexp - FAILURE_KEY_REGEX = /_failure\z/ - private_constant :FAILURE_KEY_REGEX + # @return [String] uuid_v7 identifier for this execution + def id + @options[:id] + end - # Returns the task instance associated with this result. - # - # @return [CMDx::Task] The task instance - # - # @example - # result.task.id # => "users/create" - # - # @rbs @task: Task - attr_reader :task + # @return [Class<Task>] the task class that ran + def task + @task.class + end - # Returns the current execution state of the result. - # - # @return [String] One of: "initialized", "executing", "complete", "interrupted" - # - # @example - # result.state # => "complete" - # - # @rbs @state: String - attr_reader :state + # @return [String] `"Task"` or `"Workflow"` + def type + task.type + end - # Returns the execution status of the result. - # - # @return [String] One of: "success", "skipped", "failed" - # - # @example - # result.status # => "success" - # - # @rbs @status: String - attr_reader :status + # @return [Integer, nil] this result's position in the chain + def chain_index + @chain.index(self) + end - # Returns additional metadata about the result. - # - # @return [Hash{Symbol => Object}] Metadata hash - # - # @example - # result.metadata # => { duration: 1.5, code: 200, message: "Success" } - # - # @rbs @metadata: Hash[Symbol, untyped] - attr_reader :metadata + # @return [Boolean] true when this result is the root of the chain + def chain_root? + !!@options[:root] + end - # Returns the reason for interruption (skip or failure). - # - # @return [String, nil] The reason message, or nil if not interrupted - # - # @example - # result.reason # => "Validation failed" - # - # @rbs @reason: (String | nil) - attr_reader :reason + # @return [Context] frozen after the root task's teardown + def context + @task.context + end + alias ctx context - # Returns the exception that caused the interruption. - # - # @return [Exception, nil] The causing exception, or nil if not interrupted - # - # @example - # result.cause # => #<StandardError: Connection timeout> - # - # @rbs @cause: (Exception | nil) - attr_reader :cause + # @return [Errors] frozen by Runtime teardown + def errors + @task.errors + end - # Returns whether this result transitions are strict. - # When false, {CMDx::Executor#halt_execution?} returns false - # regardless of the task's breakpoint settings. - # - # @return [Boolean] Whether the result transitions are strict - # - # @example - # result.strict? # => true - # - # @rbs @strict: bool - attr_reader :strict + # @return [String] one of {Signal::STATES} + def state + @signal.state + end - # Returns the number of retries attempted. - # - # @return [Integer] The number of retries attempted - # - # @example - # result.retries # => 2 - # - # @rbs @retries: Integer - attr_accessor :retries + # @return [Boolean] + def complete? + @signal.complete? + end - # Returns whether the result has been rolled back. - # - # @return [Boolean] Whether the result has been rolled back - # - # @example - # result.rolled_back? # => true - # - # @rbs @rolled_back: bool - attr_accessor :rolled_back + # @return [Boolean] + def interrupted? + @signal.interrupted? + end - def_delegators :task, :context, :chain, :errors, :dry_run? - alias ctx context + # @return [String] one of {Signal::STATUSES} + def status + @signal.status + end - # @param task [CMDx::Task] The task instance this result represents - # - # @return [CMDx::Result] A new result instance for the task - # - # @raise [TypeError] When task is not a CMDx::Task instance - # - # @example - # result = CMDx::Result.new(my_task) - # result.state # => "initialized" - # - # @rbs (Task) -> void - def initialize(task) - raise TypeError, "must be a CMDx::Task" unless task.is_a?(CMDx::Task) - - @task = task - @state = INITIALIZED - @status = SUCCESS - @metadata = {} - @reason = nil - @cause = nil - @strict = true - @retries = 0 - @rolled_back = false - end - - STATES.each do |s| - # @return [Boolean] Whether the result is in the specified state - # - # @example - # result.initialized? # => true - # result.executing? # => false - # - # @rbs () -> bool - define_method(:"#{s}?") { state == s } - end - - # @return [Boolean] Whether the task has been executed (complete or interrupted) - # - # @example - # result.executed? # => true if complete? || interrupted? - # - # @rbs () -> bool - def executed? - complete? || interrupted? + # @return [Boolean] + def success? + @signal.success? end - STATUSES.each do |s| - # @return [Boolean] Whether the result has the specified status - # - # @example - # result.success? # => true - # result.failed? # => false - # - # @rbs () -> bool - define_method(:"#{s}?") { status == s } + # @return [Boolean] + def skipped? + @signal.skipped? end - # @return [Boolean] Whether the task execution was successful (not failed) - # - # @example - # result.good? # => true if !failed? - # - # @rbs () -> bool - def good? - !failed? + # @return [Boolean] + def failed? + @signal.failed? end - alias ok? good? - # @return [Boolean] Whether the task execution was unsuccessful (not success) - # - # @example - # result.bad? # => true if !success? - # - # @rbs () -> bool - def bad? - !success? + # @return [Boolean] + def ok? + @signal.ok? end - # @yield [self] Executes the block if task status or state matches - # - # @return [self] Returns self for method chaining + # @return [Boolean] + def ko? + @signal.ko? + end + + # Dispatches the block when any of `keys` matches a truthy predicate on + # this result. Returns `self` for chaining. # - # @raise [ArgumentError] When no block is provided + # @param keys [Array<Symbol, String>] any of the predicate bases: + # `complete`, `interrupted`, `success`, `skipped`, `failed`, `ok`, `ko` + # @yieldparam result [Result] this result + # @return [Result] self for chaining + # @raise [ArgumentError] when no block is given or a key is unknown # # @example - # result.on(:bad) { |r| puts "Task had issues: #{r.reason}" } - # result.on(:success, :complete) { |r| puts "Task completed successfully" } - # - # @rbs () { (Result) -> void } -> self - def on(*states_or_statuses, &) + # result + # .on(:success) { |r| deliver(r.context) } + # .on(:failed) { |r| alert(r.reason) } + def on(*keys) raise ArgumentError, "block required" unless block_given? - yield(self) if states_or_statuses.any? { |s| public_send(:"#{s}?") } + yield(self) if keys.any? do |k| + unless EVENTS.include?(k.to_sym) + raise ArgumentError, + "unknown event #{k.inspect}, must be one of #{EVENTS.join(', ')}" + end + + public_send(:"#{k}?") + end + self end - # @return [CMDx::Result, nil] The result that caused this failure, or nil + # @return [String, nil] + def reason + @signal.reason + end + + # @return [Hash{Symbol => Object}] frozen empty hash when none provided + def metadata + @signal.metadata + end + + # The upstream failed result this one was echoed from (via `Task#throw!` + # or a rescued {Fault} inside `work`). `nil` when this is a locally + # originated failure or the result didn't fail. # - # @example - # cause = result.caused_failure - # puts "Caused by: #{cause.task.id}" if cause + # @return [Result, nil] + def origin + @signal.origin + end + + # @return [Exception, nil] + def cause + @signal.cause + end + + # The originating failed result at the bottom of the propagation chain. + # Walks `origin` recursively. `self` when this result is the originator; + # `nil` when not failed. # - # @rbs () -> Result? + # @return [Result, nil] def caused_failure return unless failed? - chain.results.reverse_each.find(&:failed?) + @caused_failure ||= origin ? origin.caused_failure : self end - # @return [Boolean] Whether this result caused the failure - # - # @example - # if result.caused_failure? - # puts "This task caused the failure" - # end - # - # @rbs () -> bool + # @return [Boolean] true when this result originated the failure chain def caused_failure? - return false unless failed? - - caused_failure == self + failed? && origin.nil? end - # @return [CMDx::Result, nil] The result that threw this failure, or nil + # The nearest upstream failed result. `self` when this result is the + # originator; `nil` when not failed. # - # @example - # thrown = result.threw_failure - # puts "Thrown by: #{thrown.task.id}" if thrown - # - # @rbs () -> Result? + # @return [Result, nil] def threw_failure return unless failed? - current = index - last_failed = nil - - chain.results.each do |r| - next unless r.failed? - - return r if r.index > current - - last_failed = r - end + origin || self + end - last_failed + # @return [Boolean] true when this result re-threw an upstream failure + def thrown_failure? + failed? && !origin.nil? end - # @return [Boolean] Whether this result threw the failure + # The backtrace captured by `fail!` / `throw!` for Fault propagation. + # `nil` when this result is not a failure or the failure didn't capture + # a backtrace. # - # @example - # if result.threw_failure? - # puts "This task threw the failure" - # end - # - # @rbs () -> bool - def threw_failure? - return false unless failed? + # @return [Array<String>, nil] + def backtrace + @signal.backtrace + end - threw_failure == self + # @return [Integer] + def retries + @options[:retries] || 0 end - # @return [Boolean] Whether this result is a thrown failure - # - # @example - # if result.thrown_failure? - # puts "This failure was thrown from another task" - # end - # - # @rbs () -> bool - def thrown_failure? - failed? && !caused_failure? + # @return [Boolean] + def retried? + retries.positive? end - # @return [Boolean] Whether the result transitions are strict - # - # @example - # result.strict? # => true - # - # @rbs () -> bool + # @return [Boolean] true when produced via `execute!` def strict? - !!@strict + !!@options[:strict] end - # @return [Boolean] Whether the result has been retried - # - # @example - # result.retried? # => true - # - # @rbs () -> bool - def retried? - retries.positive? + # @return [Boolean] true when the task class is marked deprecated + def deprecated? + !!@options[:deprecated] end - # @return [Boolean] Whether the result has been rolled back - # - # @example - # result.rolled_back? # => true - # - # @rbs () -> bool + # @return [Boolean] true when a failing task's `rollback` ran def rolled_back? - !!@rolled_back + !!@options[:rolled_back] end - # @return [Integer] Index of this result in the chain - # - # @example - # position = result.index - # puts "Task #{position + 1} of #{chain.results.count}" - # - # @rbs () -> Integer - def index - @chain_index || chain.index(self) + # @return [Float, nil] lifecycle duration in milliseconds + def duration + @options[:duration] end - # @return [String] The outcome of the task execution - # - # @example - # result.outcome # => "success" or "interrupted" - # - # @rbs () -> String - def outcome - initialized? || thrown_failure? ? state : status + # @return [Array<Symbol, String>] + def tags + task.settings.tags end - # @return [Hash] Hash representation of the result - # - # @example - # result.to_h - # # => {state: "complete", status: "success", outcome: "success", reason: "Unspecified", metadata: {}} - # - # @rbs () -> Hash[Symbol, untyped] + # @return [Hash{Symbol => Object}] memoized serialization. Includes + # `:cause`, `:origin`, `:threw_failure`, `:caused_failure`, `:rolled_back` + # on failure. def to_h - task.to_h.merge!( + @to_h ||= { + chain_id: chain.id, + chain_index:, + chain_root: chain_root?, + type:, + task:, + id:, + context:, state:, status:, - outcome:, reason:, - metadata: - ).tap do |hash| - if interrupted? + metadata:, + strict: strict?, + deprecated: deprecated?, + retried: retried?, + retries:, + duration:, + tags: + }.tap do |hash| + if failed? hash[:cause] = cause + hash[:origin] = hash_for_failure(:origin) + hash[:threw_failure] = hash_for_failure(:threw_failure) + hash[:caused_failure] = hash_for_failure(:caused_failure) hash[:rolled_back] = rolled_back? end - - if failed? - STRIP_FAILURE.call(hash, self, :threw_failure) - STRIP_FAILURE.call(hash, self, :caused_failure) - end end end - # @return [String] String representation of the result - # - # @example - # result.to_s # => "task_id=my_task state=complete status=success" - # @example With failure - # result.to_s # => "task_id=my_task state=complete status=failed threw_failure=<[1] MyTask: my_task>" - # - # @rbs () -> String + # @return [String] space-separated `key=value.inspect` pairs; failure + # references render as `<TaskClass uuid>`. def to_s - Utils::Format.to_str(to_h) do |key, value| - if FAILURE_KEY_REGEX.match?(key) - "#{key}=<[#{value[:index]}] #{value[:class]}: #{value[:id]}>" - else - "#{key}=#{value.inspect}" + @to_s ||= begin + buf = String.new(capacity: 256) + + to_h.each_with_object(buf) do |(k, v), buf| + buf << " " unless buf.empty? + + ks = k.name + + if v.nil? + buf << ks << "=nil" + elsif ks == "origin" || ks.end_with?("_failure") + buf << ks << "=<" << v[:task].to_s << " " << v[:id] << ">" + else + buf << ks << "=" << v.inspect + end end end end - # @return [Array] Array containing state, status, reason, cause, and metadata - # - # @example - # state, status = result.deconstruct - # puts "State: #{state}, Status: #{status}" + # Pattern-matching support for `case result in [...]`. # - # @rbs (*untyped) -> Array[untyped] - def deconstruct(*) - [state, status, reason, cause, metadata] - end - - # @return [Hash] Hash with key-value pairs for pattern matching + # @return [Array(String, Class<Task>, String, String, String, Hash, Exception)] + # `[type, task, state, status, reason, metadata, cause, origin]` # # @example - # case result.deconstruct_keys - # in {state: "complete", good: true} - # puts "Task completed successfully" - # in {bad: true} - # puts "Task had issues" + # case result + # in [_, _, "complete", "success", *] then handle_success(result) + # in [_, _, _, "failed", reason, *] then handle_failure(reason) # end + def deconstruct + [ + type, + task, + state, + status, + reason, + metadata, + cause, + origin + ] + end + + # Pattern-matching support for `case result in {...}`. # - # @rbs (*untyped) -> Hash[Symbol, untyped] + # @param keys [Array<Symbol>, nil] restrict the returned hash to these keys + # @return [Hash{Symbol => Object}] def deconstruct_keys(*) { + chain_root: chain_root?, + type:, + task:, state:, status:, reason:, - cause:, metadata:, - outcome:, - executed: executed?, - good: good?, - bad: bad? + cause:, + origin:, + strict: strict?, + deprecated: deprecated?, + retries:, + rolled_back: rolled_back?, + duration: } end + private + + def hash_for_failure(key) + r = public_send(key) + return if r.nil? + + { task: r.task, id: r.id } + end + end end diff --git a/lib/cmdx/retry.rb b/lib/cmdx/retry.rb index 4f9019794..872c7388c 100644 --- a/lib/cmdx/retry.rb +++ b/lib/cmdx/retry.rb @@ -1,165 +1,117 @@ # frozen_string_literal: true module CMDx - # Manages retry logic and state for task execution. - # - # The Retry class tracks retry availability, attempt counts, and - # remaining retries for a given task. It also resolves exception - # matching and computes wait times using configurable jitter strategies. + # Configurable retry-on-exception wrapper around a task's `work`. Supports + # exception list, attempt `:limit`, base `:delay`, `:max_delay` cap, and + # `:jitter` strategy (symbol, proc, or a configured block). Declared via + # `Task.retry_on` and accumulates across inheritance. class Retry - # Returns the task instance associated with this retry. - # - # @return [Task] the task being retried - # - # @example - # retry_instance.task # => #<CreateUser ...> - # - # @rbs @task: Task - attr_reader :task + attr_reader :exceptions - # Creates a new Retry instance for the given task. - # - # @param task [Task] the task to manage retries for - # - # @return [Retry] a new Retry instance - # - # @example - # retry_instance = Retry.new(task) - # - # @rbs (Task task) -> void - def initialize(task) - @task = task + # @param exceptions [Array<Class>] exceptions to retry on + # @param options [Hash{Symbol => Object}] + # @option options [Integer] :limit (3) maximum retry attempts + # @option options [Float] :delay (0.5) base delay in seconds between attempts + # @option options [Float] :max_delay clamp for computed delays + # @option options [Symbol, Proc, #call] :jitter built-in strategy (`:exponential`, + # `:half_random`, `:full_random`, `:bounded_random`) or custom + # @yield [attempt, delay] optional custom jitter block, used when `:jitter` isn't set + def initialize(exceptions, options = EMPTY_HASH, &block) + @exceptions = exceptions.flatten + @options = options.freeze + @block = block end - # Returns the total number of retries configured for the task. - # - # @return [Integer] the configured retry count - # - # @example - # retry_instance.available # => 3 - # - # @rbs () -> Integer - def available - Integer(task.class.settings.retries || 0) - end + # Returns a new Retry layering `new_exceptions` and `new_options` onto the + # current one. Used for inheritance so subclasses extend rather than + # replace. + # + # @param new_exceptions [Array<Class>] + # @param new_options [Hash{Symbol => Object}] + # @yield [attempt, delay] optional replacement jitter block + # @return [Retry] + def build(new_exceptions, new_options, &block) + return self if new_exceptions.empty? - # Checks if the task has any retries configured. - # - # @return [Boolean] true if retries are configured - # - # @example - # retry_instance.available? # => true - # - # @rbs () -> bool - def available? - available.positive? - end + merged_exceptions = exceptions | new_exceptions.flatten + merged_options = @options.merge(new_options) - # Returns the number of retry attempts already made. - # - # @return [Integer] the current retry attempt count - # - # @example - # retry_instance.attempts # => 1 - # - # @rbs () -> Integer - def attempts - Integer(task.result.retries || 0) + self.class.new(merged_exceptions, merged_options, &block || @block) end - # Checks if the task has been retried at least once. - # - # @return [Boolean] true if at least one retry has occurred - # - # @example - # retry_instance.retried? # => true - # - # @rbs () -> bool - def retried? - attempts.positive? + # @return [Integer] + def limit + @options[:limit] || 3 end - # Returns the number of retries still available. - # - # @return [Integer] the remaining retry count - # - # @example - # retry_instance.remaining # => 2 - # - # @rbs () -> Integer - def remaining - available - attempts + # @return [Float] base delay in seconds + def delay + @options[:delay] || 0.5 end - # Checks if there are retries still available. - # - # @return [Boolean] true if remaining retries exist - # - # @example - # retry_instance.remaining? # => true - # - # @rbs () -> bool - def remaining? - remaining.positive? + # @return [Float, nil] upper bound for computed delays + def max_delay + @options[:max_delay] end - # Returns the list of exception classes eligible for retry. - # - # @return [Array<Class>] exception classes that trigger a retry - # - # @example - # retry_instance.exceptions # => [StandardError, CMDx::TimeoutError] - # - # @rbs () -> Array[Class] - def exceptions - @exceptions ||= Utils::Wrap.array( - task.class.settings.retry_on || - [StandardError, CMDx::TimeoutError] - ) + # @return [Symbol, Proc, #call, nil] jitter strategy or the block given to {#initialize} + def jitter + @options[:jitter] || @block end - # Checks if the given exception matches any configured retry exception. - # - # @param exception [Exception] the exception to check + # Sleeps `attempt`'s jittered/bounded delay. No-op when the base delay is zero. # - # @return [Boolean] true if the exception qualifies for retry - # - # @example - # retry_instance.exception?(RuntimeError.new("fail")) # => true - # - # @rbs (Exception exception) -> bool - def exception?(exception) - exceptions.any? { |e| exception.class <= e } + # @param attempt [Integer] zero-based retry attempt number + # @param task [Task, nil] used as receiver for Symbol/Proc jitter strategies + # @return [void] + def wait(attempt, task = nil) + return unless delay.positive? + + d = + case jitter + when :exponential + delay * (2**attempt) + when :half_random + (delay / 2.0) + (rand * delay / 2.0) + when :full_random + rand * delay + when :bounded_random + delay + (rand * delay) + when Symbol + task.send(jitter, attempt, delay) + when Proc + task.instance_exec(attempt, delay, &jitter) + else + if jitter.respond_to?(:call) + jitter.call(attempt, delay) + else + delay + end + end + + d = d.clamp(0, max_delay) if max_delay + Kernel.sleep(d) if d.positive? end - # Computes the wait time before the next retry attempt. - # - # Supports multiple jitter strategies: a Symbol calls a task method, - # a Proc is evaluated in the task instance context, a callable object - # receives the task and attempts, and a Numeric is multiplied by the - # attempt count. - # - # @return [Float] the wait duration in seconds - # - # @example With numeric jitter (0.5 * attempts) - # retry_instance.wait # => 1.0 - # @example With symbol jitter referencing a task method - # retry_instance.wait # => 2.5 - # - # @rbs () -> Float - def wait - jitter = task.class.settings.retry_jitter - - if jitter.is_a?(Symbol) - task.send(jitter, attempts) - elsif jitter.is_a?(Proc) - task.instance_exec(attempts, &jitter) - elsif jitter.respond_to?(:call) - jitter.call(task, attempts) - else - jitter.to_f * attempts - end.to_f + # Executes the block up to `limit + 1` times. Re-raises the last + # exception when attempts are exhausted. + # + # @param task [Task, nil] passed to {#wait} so jitter strategies can use it + # @yieldparam attempt [Integer] zero-based attempt index + # @yieldreturn [Object] the block's successful return value + # @return [Object] the block's successful return value + # @raise [Exception] the last caught exception once retries exhaust + def process(task = nil, &) + return yield(0) if exceptions.empty? || !limit.positive? + + (limit + 1).times do |attempt| + return yield(attempt) + rescue *exceptions => e + raise(e) if attempt >= limit + + wait(attempt, task) + end end end diff --git a/lib/cmdx/runtime.rb b/lib/cmdx/runtime.rb new file mode 100644 index 000000000..a66bec043 --- /dev/null +++ b/lib/cmdx/runtime.rb @@ -0,0 +1,226 @@ +# frozen_string_literal: true + +module CMDx + # Orchestrates a task's full lifecycle: chain acquisition, middlewares, + # telemetry, deprecation, callbacks, input resolution, `work` (wrapped in + # retry), output verification, rollback on failure, result finalization, + # and teardown (freeze + chain clear). + # + # Signal propagation: Runtime wraps `work` in `catch(Signal::TAG)` so + # `success!` / `skip!` / `fail!` / `throw!` break out cleanly. Raised + # Faults are converted to echoed signals (carrying the upstream failed + # result as `:origin`); other `StandardError`s become failed signals with + # the exception as `:cause`. `execute!` (strict mode) re-raises on failure + # after the result is finalized, raising a {Fault} built from the deepest + # originating result so `fault.task` points at the leaf that failed. + # + # @note Always used via the class method; never new Runtime manually. + # @see Task.execute + # @see Task.execute! + class Runtime + + class << self + + # @param task [Task] + # @param strict [Boolean] when true, re-raise on failure (`execute!` semantics) + # @return [Result] the finalized, frozen result + # @raise [Fault, StandardError] only when `strict: true` and the task failed + def execute(task, strict: false) + new(task, strict:).execute + end + + end + + # @param task [Task] + # @param strict [Boolean] + def initialize(task, strict: false) + @task = task + @strict = strict + @id = SecureRandom.uuid_v7 + end + + # Runs the full lifecycle. Teardown runs in `ensure`, guaranteeing the + # task's context/errors get frozen and the fiber chain is cleared even + # when strict mode re-raises. + # + # @return [Result] + # @raise [Fault, StandardError] under strict mode on failure + def execute + acquire_chain + + run_middlewares do + emit_telemetry(:task_started) + run_deprecation + run_lifecycle + finalize_result + raise_signal! if @strict + end + + @result + ensure + run_teardown + end + + private + + def acquire_chain + @root = Chain.current.nil? + Chain.current = Chain.new if @root + end + + def run_middlewares(&) + middlewares = @task.class.middlewares + return yield if middlewares.empty? + + middlewares.process(@task, &) + end + + def run_deprecation + deprecation = @task.class.deprecation + return unless deprecation + + deprecation.execute(@task) do + @deprecated = true + emit_telemetry(:task_deprecated) + end + end + + def run_lifecycle + measure_duration do + run_callbacks(:before_execution) + run_callbacks(:before_validation) + perform_work + perform_rollback if @signal.failed? + run_callbacks(:"on_#{@signal.state}") + run_callbacks(:"on_#{@signal.status}") + run_callbacks(@signal.ok? ? :on_ok : :on_ko) + end + end + + def raise_signal! + return unless @result.failed? + + cause = @signal.cause + raise cause if cause && !cause.is_a?(Fault) + + raise Fault, @result.caused_failure + end + + def finalize_result + @result = Result.new( + Chain.current, + @task, + @signal, + root: @root, + id: @id, + strict: @strict, + deprecated: @deprecated, + rolled_back: @rolled_back, + retries: @retries, + duration: @duration + ).tap do |result| + @root ? Chain.current.unshift(result) : Chain.current.push(result) + emit_telemetry(:task_executed, result:) + @task.logger.info { result } + end + end + + def run_teardown + @task.context.freeze if @root + @task.errors.freeze + @task.freeze + return unless @root + + Chain.current.freeze + Chain.clear + end + + def measure_duration + start = Process.clock_gettime(Process::CLOCK_MONOTONIC, :float_millisecond) + yield + ensure + @duration = Process.clock_gettime(Process::CLOCK_MONOTONIC, :float_millisecond) - start + end + + def run_callbacks(event) + callbacks = @task.class.callbacks + return if callbacks.empty? + + callbacks.process(event, @task) + end + + def perform_work + @signal = catch(Signal::TAG) do + resolve_inputs! + retry_execution { @task.work } + verify_outputs! + Signal.success + rescue Fault => e + Signal.echoed(e.result, cause: e) + rescue Error => e + raise(e) + rescue StandardError => e + Signal.failed("[#{e.class}] #{e.message}", cause: e) + end + end + + def perform_rollback + return unless @task.respond_to?(:rollback) + + @rolled_back = true + emit_telemetry(:task_rolled_back) + @task.rollback + end + + def resolve_inputs! + inputs = @task.class.inputs + return if inputs.empty? + + inputs.resolve(@task) + signal_errors! + end + + def retry_execution + @task.class.retry_on.process(@task) do |attempt| + @retries = attempt + emit_telemetry(:task_retried, attempt:) if attempt.positive? + yield + end + + signal_errors! + end + + def verify_outputs! + outputs = @task.class.outputs + return if outputs.empty? + + outputs.verify(@task) + signal_errors! + end + + def signal_errors! + return if @task.errors.empty? + + throw(Signal::TAG, Signal.failed(@task.errors.to_s)) + end + + def emit_telemetry(name, payload = EMPTY_HASH) + telemetry = @task.class.telemetry + return unless telemetry.subscribed?(name) + + event = Telemetry::Event.new( + chain_id: Chain.current.id, + chain_root: @root, + task_type: @task.class.type, + task_class: @task.class, + task_id: @id, + name:, + payload:, + timestamp: Time.now.utc + ) + + telemetry.emit(name, event) + end + + end +end diff --git a/lib/cmdx/settings.rb b/lib/cmdx/settings.rb index 920c20c6d..25a76ac08 100644 --- a/lib/cmdx/settings.rb +++ b/lib/cmdx/settings.rb @@ -1,225 +1,63 @@ # frozen_string_literal: true module CMDx - # Value object encapsulating all per-task configuration. Registries are - # deep-duped on inheritance; scalar settings delegate to a parent Settings - # or to the global Configuration rather than eagerly copying values. + # Per-task configuration overrides. Options are frozen on construction; + # {#build} returns a new instance rather than mutating. Every getter falls + # back to {CMDx.configuration} when the option wasn't set on the task. class Settings - class << self - - private - - # Defines a reader that delegates to the parent Settings chain, - # falling through to Configuration when no parent exists. - # - # @param names [Array<Symbol>] Setting names to define - # - # @rbs (*Symbol names) -> void - def delegate_to_configuration(*names) - names.each do |name| - ivar = :"@#{name}" - - attr_writer(name) + # @param options [Hash{Symbol => Object}] task-specific overrides + # @option options [Logger] :logger + # @option options [#call] :log_formatter + # @option options [Integer] :log_level + # @option options [#call] :backtrace_cleaner + # @option options [Array<Symbol, String>] :tags + def initialize(options = EMPTY_HASH) + @options = options.freeze + end - define_method(name) do - return instance_variable_get(ivar) if instance_variable_defined?(ivar) + # Returns a new Settings with `new_options` merged on top. Returns `self` + # unchanged when `new_options` is empty (used by Task inheritance). + # + # @param new_options [Hash{Symbol => Object}] overrides to layer on top + # @return [Settings] merged instance (or `self` when no changes) + def build(new_options) + return self if new_options.empty? - value = @parent ? @parent.public_send(name) : CMDx.configuration.public_send(name) - instance_variable_set(ivar, value) + self.class.new(@options.merge(new_options)) + end - value - end - end + # @return [Logger] task-level logger or the global configuration's logger + def logger + @options.fetch(:logger) do + CMDx.configuration.logger end + end - # Defines a reader that delegates to the parent Settings only. - # Returns nil when the chain is exhausted. - # - # @param names [Array<Symbol>] Setting names to define - # @param with_fallback [Boolean] Whether to fall back to Configuration - # - # @rbs (*Symbol names, with_fallback: bool) -> void - def delegate_to_parent(*names, with_fallback: false) - names.each do |name| - ivar = :"@#{name}" - - attr_writer(name) - - define_method(name) do - return instance_variable_get(ivar) if instance_variable_defined?(ivar) - - value = @parent&.public_send(name) - value ||= CMDx.configuration.public_send(name) if with_fallback - instance_variable_set(ivar, value) - - value - end - end + # @return [#call] Logger formatter used when logging task results + def log_formatter + @options.fetch(:log_formatter) do + CMDx.configuration.log_formatter end - end - # Returns the attribute registry for task parameters. - # - # @return [AttributeRegistry] The attribute registry - # - # @rbs @attributes: AttributeRegistry - attr_accessor :attributes - - # Returns the callback registry for task lifecycle hooks. - # - # @return [CallbackRegistry] The callback registry - # - # @rbs @callbacks: CallbackRegistry - attr_accessor :callbacks - - # Returns the coercion registry for type conversions. - # - # @return [CoercionRegistry] The coercion registry - # - # @rbs @coercions: CoercionRegistry - attr_accessor :coercions - - # Returns the middleware registry for task execution. - # - # @return [MiddlewareRegistry] The middleware registry - # - # @rbs @middlewares: MiddlewareRegistry - attr_accessor :middlewares - - # Returns the validator registry for attribute validation. - # - # @return [ValidatorRegistry] The validator registry - # - # @rbs @validators: ValidatorRegistry - attr_accessor :validators - - # Returns the expected return keys after execution. - # - # @return [Array<Symbol>] Expected return keys after execution - # - # @rbs @returns: Array[Symbol] - attr_accessor :returns - - # Returns the tags for task categorization. - # - # @return [Array<Symbol>] Tags for categorization - # - # @rbs @tags: Array[Symbol] - attr_accessor :tags - - # @!attribute [rw] backtrace - # @return [Boolean] true if backtraces should be logged - delegate_to_configuration :backtrace - - # @!attribute [rw] dump_context - # @return [Boolean] true if context should be included in Task#to_h - delegate_to_configuration :dump_context - - # @!attribute [rw] rollback_on - # @return [Array<String>] Statuses that trigger rollback - delegate_to_configuration :rollback_on - - # @!attribute [rw] task_breakpoints - # @return [Array<String>] Default task breakpoint statuses - delegate_to_configuration :task_breakpoints - - # @!attribute [rw] workflow_breakpoints - # @return [Array<String>] Default workflow breakpoint statuses - delegate_to_configuration :workflow_breakpoints - - # @!attribute [rw] backtrace_cleaner - # @return [Proc, nil] The backtrace cleaner proc - delegate_to_parent :backtrace_cleaner, with_fallback: true - - # @!attribute [rw] breakpoints - # @return [Array<String>, nil] Per-task breakpoints override - delegate_to_parent :breakpoints - - # @!attribute [rw] deprecate - # @return [Symbol, Proc, Boolean, nil] Deprecation behavior - delegate_to_parent :deprecate - - # @!attribute [rw] exception_handler - # @return [Proc, nil] The exception handler proc - delegate_to_parent :exception_handler, with_fallback: true - - # @!attribute [rw] logger - # @return [Logger] The logger instance - delegate_to_parent :logger, with_fallback: true - - # @!attribute [rw] log_formatter - # @return [Proc, nil] Per-task log formatter override - delegate_to_parent :log_formatter - - # @!attribute [rw] log_level - # @return [Integer, nil] Per-task log level override - delegate_to_parent :log_level - - # @!attribute [rw] retries - # @return [Integer, nil] Number of retries on failure - delegate_to_parent :retries - - # @!attribute [rw] retry_jitter - # @return [Numeric, Symbol, Proc, nil] Jitter between retries - delegate_to_parent :retry_jitter - - # @!attribute [rw] retry_on - # @return [Array<Class>, Class, nil] Exception classes to retry on - delegate_to_parent :retry_on - - # Creates a new Settings instance, inheriting registries from a parent - # Settings or the global Configuration. Scalar settings are resolved - # lazily via delegation rather than eagerly copied. - # - # @param parent [Settings, nil] Parent settings to inherit from - # @param overrides [Hash] Field values to override after inheritance - # - # @example - # Settings.new(parent: ParentTask.settings, deprecate: true) - # - # @rbs (?parent: Settings?, **untyped overrides) -> void - def initialize(parent: nil, **overrides) - @parent = parent - - init_registries - init_collections - - overrides.each { |key, value| public_send(:"#{key}=", value) } + # @return [Integer] `Logger` severity level + def log_level + @options.fetch(:log_level) do + CMDx.configuration.log_level + end end - private - - # Dups registries from the parent Settings or global Configuration - # so each task class gets its own mutable copy. - # - # @rbs () -> void - def init_registries - if @parent - @middlewares = @parent.middlewares.dup - @callbacks = @parent.callbacks.dup - @coercions = @parent.coercions.dup - @validators = @parent.validators.dup - @attributes = @parent.attributes.dup - else - config = CMDx.configuration - - @middlewares = config.middlewares.dup - @callbacks = config.callbacks.dup - @coercions = config.coercions.dup - @validators = config.validators.dup - @attributes = AttributeRegistry.new + # @return [#call, nil] callable that cleans fault backtrace frames + def backtrace_cleaner + @options.fetch(:backtrace_cleaner) do + CMDx.configuration.backtrace_cleaner end end - # Initializes array-valued settings that need their own copy - # to avoid cross-class mutation. - # - # @rbs () -> void - def init_collections - @returns = @parent&.returns&.dup || EMPTY_ARRAY - @tags = @parent&.tags&.dup || EMPTY_ARRAY + # @return [Array<Symbol, String>] task tags, surfaced on result hashes + def tags + @options[:tags] || EMPTY_ARRAY end end diff --git a/lib/cmdx/signal.rb b/lib/cmdx/signal.rb new file mode 100644 index 000000000..7242f9ec3 --- /dev/null +++ b/lib/cmdx/signal.rb @@ -0,0 +1,158 @@ +# frozen_string_literal: true + +module CMDx + # Internal halt token thrown by `success!`, `skip!`, `fail!`, and `throw!` + # from inside a Task's `work`. Runtime catches `Signal::TAG` and converts the + # payload into a {Result}. Not meant to be raised directly by user code. + # + # @see Runtime#perform_work + # @see Task#success! + # @see Task#fail! + class Signal + + # `catch`/`throw` tag used by Runtime to intercept signal payloads. + TAG = :cmdx_signal + + # All valid execution lifecycle states. + STATES = [ + COMPLETE = "complete", + INTERRUPTED = "interrupted" + ].freeze + # All valid outcome statuses. + STATUSES = [ + SUCCESS = "success", + SKIPPED = "skipped", + FAILED = "failed" + ].freeze + + class << self + + # Builds (or returns a memoized singleton of) a successful signal. + # + # @param reason [String, nil] optional human-readable reason + # @param options [Hash{Symbol => Object}] optional `:metadata`, `:cause`, `:backtrace` + # @return [Signal] frozen success singleton when no args, otherwise a new instance + def success(reason = nil, **options) + return Success if reason.nil? && options.empty? + + new(COMPLETE, SUCCESS, **options, reason:) + end + + # @param reason [String, nil] optional human-readable reason + # @param options [Hash{Symbol => Object}] optional `:metadata`, `:cause`, `:backtrace` + # @return [Signal] frozen skipped singleton when no args, otherwise a new instance + def skipped(reason = nil, **options) + return Skipped if reason.nil? && options.empty? + + new(INTERRUPTED, SKIPPED, **options, reason:) + end + + # @param reason [String, nil] optional human-readable reason + # @param options [Hash{Symbol => Object}] optional `:metadata`, `:cause`, `:backtrace` + # @return [Signal] frozen failed singleton when no args, otherwise a new instance + def failed(reason = nil, **options) + return Failed if reason.nil? && options.empty? + + new(INTERRUPTED, FAILED, **options, reason:) + end + + # Mirrors another Signal/Result's state + status with fresh options. + # Used by Runtime to propagate a nested `Fault`'s outcome. + # + # @param other [Signal, Result] source to mirror state/status/reason from + # @param options [Hash{Symbol => Object}] overrides: `:metadata`, `:cause`, + # `:backtrace`, `:origin` + # @return [Signal] new instance mirroring `other` + # @raise [ArgumentError] when `other` is neither a Signal nor a Result + def echoed(other, **options) + raise ArgumentError, "must be a Result or Signal" unless other.is_a?(Result) || other.is_a?(Signal) + + options[:origin] = other if other.is_a?(Result) && !options.key?(:origin) + new(other.state, other.status, **options, reason: other.reason) + end + + end + + attr_reader :state, :status + + # @param state [String] one of {STATES} + # @param status [String] one of {STATUSES} + # @param options [Hash{Symbol => Object}] frozen metadata payload + # @option options [String] :reason + # @option options [Hash] :metadata + # @option options [Exception] :cause + # @option options [Result] :origin + # @option options [Array<Thread::Backtrace::Location>] :backtrace + def initialize(state, status, **options) + @state = state + @status = status + @options = options.freeze + end + + Success = new(COMPLETE, SUCCESS) + Skipped = new(INTERRUPTED, SKIPPED) + Failed = new(INTERRUPTED, FAILED) + + # @return [Boolean] true when the task ran to completion without interruption + def complete? + state == COMPLETE + end + + # @return [Boolean] true when skip/fail interrupted the task + def interrupted? + state == INTERRUPTED + end + + # @return [Boolean] + def success? + status == SUCCESS + end + + # @return [Boolean] + def skipped? + status == SKIPPED + end + + # @return [Boolean] + def failed? + status == FAILED + end + + # @return [Boolean] true for success or skipped (anything but failed) + def ok? + !failed? + end + + # @return [Boolean] true for skipped or failed (anything but success) + def ko? + !success? + end + + # @return [String, nil] human-readable explanation supplied by the caller + def reason + @options[:reason] + end + + # @return [Hash{Symbol => Object}] frozen-empty hash when none was provided + def metadata + @options[:metadata] || EMPTY_HASH + end + + # @return [Exception, nil] underlying exception when a rescue produced this signal + def cause + @options[:cause] + end + + # @return [Result, nil] upstream result this signal was echoed from, when any + def origin + @options[:origin] + end + + # @return [Array<Thread::Backtrace::Location>, nil] caller locations captured + # by `fail!` / `throw!` for Fault backtraces + def backtrace + @options[:backtrace] + end + + end +end diff --git a/lib/cmdx/task.rb b/lib/cmdx/task.rb index 0dde605db..4726b187e 100644 --- a/lib/cmdx/task.rb +++ b/lib/cmdx/task.rb @@ -1,429 +1,367 @@ # frozen_string_literal: true module CMDx - # Represents a task that can be executed within the CMDx framework. - # Tasks define attributes, callbacks, and execution logic that can be - # chained together to form workflows. + # Base class for all units of work. Subclasses override `#work` and + # declare their contract via `required`, `optional`, `output`, `callbacks`, + # `retry_on`, `deprecation`, and `settings`. Invoked via {.execute} (safe) + # or {.execute!} (strict, raises on failure). + # + # Inheritance: every registry accessor (middlewares, callbacks, coercions, + # validators, telemetry, inputs, outputs) lazily clones from the + # superclass's registry (or the global configuration at the top of the + # hierarchy), so subclasses extend rather than replace. + # + # @see Runtime + # @see Workflow class Task - extend Forwardable - - # Returns the hash of processed attribute values for this task. - # - # @return [Hash{Symbol => Object}] Hash of attribute names to their values - # - # @example - # task.attributes # => { user_id: 42, user_name: "John" } - # - # @rbs @attributes: Hash[Symbol, untyped] - attr_reader :attributes + class << self - # Returns the collection of validation and execution errors. - # - # @return [Errors] The errors collection - # - # @example - # task.errors.to_h # => { email: ["must be valid"] } - # - # @rbs @errors: Errors - attr_reader :errors + # Declares exceptions to retry on. Builds on the superclass's `Retry`. + # Passing no exceptions returns the current (possibly inherited) Retry. + # + # @param exceptions [Array<Class>] + # @param options [Hash{Symbol => Object}] see {Retry#initialize} + # @yield [attempt, delay] optional custom jitter block + # @return [Retry] + def retry_on(*exceptions, **options, &) + @retry_on ||= + if superclass.respond_to?(:retry_on) + superclass.retry_on.build(exceptions, options, &) + else + Retry.new(exceptions, options, &) + end - # Returns the unique identifier for this task instance. - # - # @return [String] The task identifier - # - # @example - # task.id # => "abc123xyz" - # - # @rbs @id: String - attr_reader :id + return @retry_on if exceptions.empty? - # Returns the execution context for this task. - # - # @return [Context] The context instance - # - # @example - # task.context[:user_id] # => 42 - # - # @rbs @context: Context - attr_reader :context - alias ctx context + @retry_on = @retry_on.build(exceptions, options, &) + end - # Returns the execution result for this task. - # - # @return [Result] The result instance - # - # @example - # task.result.status # => "success" - # - # @rbs @result: Result - attr_reader :result - alias res result + # Reads or extends this class's {Settings}. Inherits from the superclass. + # + # @param options [Hash{Symbol => Object}] merged onto the current settings + # @return [Settings] + def settings(options = EMPTY_HASH) + @settings ||= + if superclass.respond_to?(:settings) + superclass.settings.build(options) + else + Settings.new(options) + end - # Returns the execution chain containing all task results. - # - # @return [Chain] The chain instance - # - # @example - # task.chain.results.size # => 3 - # - # @rbs @chain: Chain - attr_reader :chain + return @settings if options.empty? - # Returns the resolver for managing result state transitions. - # - # @return [Resolver] The resolver instance - # - # @example - # task.resolver.fail!("Validation failed") - # - # @rbs @resolver: Resolver - attr_reader :resolver + @settings = @settings.build(options) + end - def_delegators :resolver, :success!, :skip!, :fail!, :throw! - def_delegators :chain, :dry_run? + # @return [Middlewares] cloned from superclass/configuration on first call + def middlewares + @middlewares ||= + if superclass.respond_to?(:middlewares) + superclass.middlewares.dup + else + CMDx.configuration.middlewares.dup + end + end - # @param context [Hash, Context, nil] The initial context for the task - # - # @return [Task] A new task instance - # - # @raise [DeprecationError] If the task class is deprecated - # - # @example - # task = MyTask.new - # task = MyTask.new(name: "example", priority: :high) - # task = MyTask.new(Context.build(name: "example")) - # - # @rbs (untyped context) -> void - def initialize(context = nil) - Deprecator.restrict(self) + # @return [Callbacks] cloned from superclass/configuration on first call + def callbacks + @callbacks ||= + if superclass.respond_to?(:callbacks) + superclass.callbacks.dup + else + CMDx.configuration.callbacks.dup + end + end - @id = Identifier.generate - @context = Context.build(context) - @errors = Errors.new - @result = Result.new(self) - @resolver = Resolver.new(@result) - @chain = Chain.build(@result, dry_run: @context.delete(:dry_run)) + Callbacks::EVENTS.each do |event| + define_method(event) do |callable = nil, **options, &block| + register(:callback, event, callable, **options, &block) + end + end - @attributes = {} - end + # @return [Telemetry] cloned from superclass/configuration on first call + def telemetry + @telemetry ||= + if superclass.respond_to?(:telemetry) + superclass.telemetry.dup + else + CMDx.configuration.telemetry.dup + end + end - class << self + # @return [Coercions] cloned from superclass/configuration on first call + def coercions + @coercions ||= + if superclass.respond_to?(:coercions) + superclass.coercions.dup + else + CMDx.configuration.coercions.dup + end + end - # Returns the cached task type string for this class. - # - # @return [String] "Workflow" or "Task" - # - # @rbs () -> String - def type - @type ||= include?(Workflow) ? "Workflow" : "Task" + # @return [Validators] cloned from superclass/configuration on first call + def validators + @validators ||= + if superclass.respond_to?(:validators) + superclass.validators.dup + else + CMDx.configuration.validators.dup + end end - # Returns (and lazily creates) the task-level Settings object. - # On first access, inherits from the superclass settings or - # the global Configuration. Optional overrides are applied once. - # - # @param overrides [Hash] Configuration overrides applied on first access - # @option overrides [Object] :* Any configuration override key-value pairs + # Dispatches to the appropriate registry's `register` method. # - # @return [Settings] The settings instance for this task class - # - # @example - # class MyTask < Task - # settings deprecate: true, tags: [:experimental] - # end - # - # @rbs (**untyped overrides) -> Settings - def settings(**overrides) - @settings ||= begin - parent = superclass.settings if superclass.respond_to?(:settings) - Settings.new(parent:, **overrides) + # @param type [:middleware, :callback, :coercion, :validator, :input, :output] + # @return [Object] the registry's self + # @raise [ArgumentError] when `type` is unknown + def register(type, ...) + case type + when :middleware + middlewares.register(...) + when :callback + callbacks.register(...) + when :coercion + coercions.register(...) + when :validator + validators.register(...) + when :input + inputs.register(self, ...) + when :output + outputs.register(...) + else raise ArgumentError, "unknown registry type: #{type.inspect}" end end - # @param type [Symbol] The type of registry to register with - # @param object [Object] The object to register + # Dispatches to the appropriate registry's `deregister` method. # - # @raise [RuntimeError] If the registry type is unknown - # - # @example - # register(:attribute, MyAttribute.new) - # register(:callback, :before, -> { puts "before" }) - # - # @rbs (Symbol type, untyped object, *untyped) -> void - def register(type, object, ...) + # @param type [:middleware, :callback, :coercion, :validator, :input, :output] + # @return [Object] the registry's self + # @raise [ArgumentError] when `type` is unknown + def deregister(type, ...) case type - when :attribute - settings.attributes.register(object) - settings.attributes.define_readers_on!(self, Utils::Wrap.array(object)) - when :callback then settings.callbacks.register(object, ...) - when :middleware then settings.middlewares.register(object, ...) - when :validator then settings.validators.register(object, ...) - when :coercion then settings.coercions.register(object, ...) - else raise "unknown registry type #{type.inspect}" + when :middleware + middlewares.deregister(...) + when :callback + callbacks.deregister(...) + when :coercion + coercions.deregister(...) + when :validator + validators.deregister(...) + when :input + inputs.deregister(self, ...) + when :output + outputs.deregister(...) + else raise ArgumentError, "unknown registry type: #{type.inspect}" end end - # @param type [Symbol] The type of registry to deregister from - # @param object [Object] The object to deregister - # - # @raise [RuntimeError] If the registry type is unknown - # - # @example - # deregister(:attribute, :name) - # deregister(:callback, :before, MyCallback) - # - # @rbs (Symbol type, untyped object, *untyped) -> void - def deregister(type, object, ...) - case type - when :attribute - settings.attributes.undefine_readers_on!(self, object) - settings.attributes.deregister(object) - when :callback then settings.callbacks.deregister(object, ...) - when :middleware then settings.middlewares.deregister(object, ...) - when :validator then settings.validators.deregister(object, ...) - when :coercion then settings.coercions.deregister(object, ...) - else raise "unknown registry type #{type.inspect}" + # Reads, sets, or inherits the task class's {Deprecation}. With a + # `value` or block, replaces any current deprecation. Otherwise returns + # the locally defined one, or the superclass's. + # + # @param value [:log, :warn, :error, Symbol, Proc, #call, nil] + # @param options [Hash{Symbol => Object}] `:if`/`:unless` conditions + # @yield optional block used as the deprecation callable + # @return [Deprecation, nil] + def deprecation(value = nil, **options, &block) + if value || block + @deprecation = Deprecation.new(value || block, options) + elsif defined?(@deprecation) + @deprecation + elsif superclass.respond_to?(:deprecation) + superclass.deprecation end end - # @example - # attributes :name, :email - # attributes :age, type: Integer, default: 18 + # Reads, or declares more, inputs. With no names, returns the registry; + # with names, registers them and defines accessors. # - # @rbs (*untyped) -> void - def attributes(...) - register(:attribute, Attribute.build(...)) + # @param names [Array<Symbol>] + # @param options [Hash{Symbol => Object}] see {Input#initialize} + # @yield nested-input DSL block (see {Inputs::ChildBuilder}) + # @return [Inputs] + def inputs(*names, **options, &) + @inputs ||= + if superclass.respond_to?(:inputs) + superclass.inputs.dup + else + Inputs.new + end + + return @inputs if names.empty? + + @inputs.register(self, *names, **options, &) end - alias attribute attributes + alias input inputs - # @example - # optional :description, :notes - # optional :priority, type: Symbol, default: :normal - # - # @rbs (*untyped) -> void - def optional(...) - register(:attribute, Attribute.optional(...)) + # Declares optional inputs (shorthand for `inputs ..., required: false`). + def optional(*names, **options, &) + register(:input, *names, required: false, **options, &) end - # @example - # required :name, :email - # required :age, type: Integer, min: 0 - # - # @rbs (*untyped) -> void - def required(...) - register(:attribute, Attribute.required(...)) + # Declares required inputs (shorthand for `inputs ..., required: true`). + def required(*names, **options, &) + register(:input, *names, required: true, **options, &) end - # @param names [Array<Symbol>] Names of attributes to remove - # - # @example - # remove_attributes :old_field, :deprecated_field - # - # @rbs (*Symbol names) -> void - def remove_attributes(*names) - deregister(:attribute, names) + # @return [Hash{Symbol => Hash}] serialized input definitions + def inputs_schema + inputs.registry.transform_values(&:to_h) end - alias remove_attribute remove_attributes - # Declares expected context returns that must be set after task execution. - # If any declared return is missing from the context after {#work} completes - # successfully, the task will fail with a validation error. - # - # @param names [Array<Symbol, String>] Names of expected return keys in the context - # - # @example - # returns :user, :token + # Reads, or declares more, outputs. With no keys, returns the registry. # - # @rbs (*untyped names) -> void - def returns(*names) - settings.returns |= names.map(&:to_sym) + # @param keys [Array<Symbol>] + # @param options [Hash{Symbol => Object}] see {Output#initialize} + # @return [Outputs] + def outputs(*keys, **options) + @outputs ||= + if superclass.respond_to?(:outputs) + superclass.outputs.dup + else + Outputs.new + end + + return @outputs if keys.empty? + + @outputs.register(*keys, **options) end + alias output outputs - # Removes declared returns from the task. - # - # @param names [Array<Symbol>] Names of returns to remove - # - # @example - # remove_returns :old_return - # - # @rbs (*Symbol names) -> void - def remove_returns(*names) - settings.returns -= names.map(&:to_sym) + # @return [Hash{Symbol => Hash}] serialized output definitions + def outputs_schema + outputs.registry.transform_values(&:to_h) end - alias remove_return remove_returns - # @return [Hash] Hash of attribute names to their configurations - # - # @example - # MyTask.attributes_schema #=> { - # user_id: { name: :user_id, method_name: :user_id, required: true, types: [:integer], options: {}, children: [] }, - # email: { name: :email, method_name: :email, required: false, types: [:string], options: { default: nil }, children: [] }, - # profile: { name: :profile, method_name: :profile, required: false, types: [:hash], options: {}, children: [ - # { name: :bio, method_name: :bio, required: false, types: [:string], options: {}, children: [] }, - # { name: :name, method_name: :name, required: true, types: [:string], options: {}, children: [] } - # ] } - # } + # @return [String] `"Workflow"` when the class includes {Workflow}, else `"Task"` + def type + @type ||= include?(Workflow) ? "Workflow" : "Task" + end + + # Executes the task. Never raises on failure; inspect the returned + # {Result} instead. # - # @rbs () -> Hash[Symbol, Hash[Symbol, untyped]] - def attributes_schema - Utils::Wrap.array(settings.attributes).to_h do |attr| - [attr.method_name, attr.to_h] - end + # @param context [Hash, Context, #context, #to_h] + # @yieldparam result [Result] + # @return [Result, Object] the yielded block's value when a block is given + def execute(context = EMPTY_HASH) + result = Runtime.execute(new(context), strict: false) + block_given? ? yield(result) : result end + alias call execute + + # Strict execution. Raises {Fault} (or the underlying exception) on + # failure; otherwise identical to {.execute}. + # + # @param context [Hash, Context, #context, #to_h] + # @yieldparam result [Result] + # @return [Result, Object] + # @raise [Fault, StandardError] on task failure + def execute!(context = EMPTY_HASH) + result = Runtime.execute(new(context), strict: true) + block_given? ? yield(result) : result + end + alias call! execute! + + private + + def define_input_reader(input) + accessor = input.accessor_name - CallbackRegistry::TYPES.each do |callback| - # @param callables [Array] Callable objects to register as callbacks - # @param options [Hash] Options for the callback registration - # @option options [Symbol] :priority Priority of the callback - # @option options [Boolean] :async Whether the callback should run asynchronously - # @param block [Proc] Block to register as a callback - # - # @example - # before { puts "before execution" } - # after :cleanup, priority: :high - # around ->(task) { task.logger.info("starting") } - # - # @rbs (*untyped callables, **untyped options) ?{ () -> void } -> void - define_method(callback) do |*callables, **options, &block| - register(:callback, callback, *callables, **options, &block) + if method_defined?(accessor) || private_method_defined?(accessor) + raise DefinitionError, + "cannot define input #{accessor.inspect}: ##{accessor} is already defined on #{self}" end - end - # @param args [Array] Arguments to pass to the task constructor - # @param kwargs [Hash] Keyword arguments to pass to the task constructor - # @option kwargs [Object] :* Any key-value pairs to pass to the task constructor - # - # @return [Result] The execution result - # - # @example - # result = MyTask.execute(name: "example") - # - # @rbs (*untyped args, dry_run: bool, **untyped kwargs) ?{ (Result) -> void } -> Result - def execute(*args, **kwargs) - task = new(*args, **kwargs) - task.execute(raise: false) - block_given? ? yield(task.result) : task.result + define_method(accessor) { instance_variable_get(input.ivar_name) } + input.children.each { |child| define_input_reader(child) } end - # @param args [Array] Arguments to pass to the task constructor - # @param kwargs [Hash] Keyword arguments to pass to the task constructor - # @option kwargs [Object] :* Any key-value pairs to pass to the task constructor - # - # @return [Result] The execution result - # - # @raise [ExecutionError] If the task execution fails - # - # @example - # result = MyTask.execute!(name: "example") - # - # @rbs (*untyped args, dry_run: bool, **untyped kwargs) ?{ (Result) -> void } -> Result - def execute!(*args, **kwargs) - task = new(*args, **kwargs) - task.execute(raise: true) - block_given? ? yield(task.result) : task.result + def undefine_input_reader(input) + accessor = input.accessor_name + undef_method(accessor) if method_defined?(accessor) + input.children.each { |child| undefine_input_reader(child) } end end - # @param raise [Boolean] Whether to raise exceptions on failure - # - # @return [Result] The execution result - # - # @example - # result = task.execute - # result = task.execute(raise: true) - # - # @rbs (raise: bool) ?{ (Result) -> void } -> Result - def execute(raise: false) - Executor.execute(self, raise:) - block_given? ? yield(result) : result + attr_reader :context, :errors + alias ctx context + + # @param context [Hash, Context, #context, #to_h] + def initialize(context = EMPTY_HASH) + @context = Context.build(context) + @errors = Errors.new end - # @raise [UndefinedMethodError] Always raised as this method must be overridden - # - # @example - # class MyTask < Task - # def work - # # Custom work logic here - # puts "Performing work..." - # end - # end + # @return [Logger] a logger tailored to this task's settings + def logger + @logger ||= LoggerProxy.logger(self) + end + + # The task's core logic. Subclasses must override. # - # @rbs () -> void + # @abstract + # @return [void] + # @raise [ImplementationError] when the subclass doesn't override def work - raise UndefinedMethodError, "undefined method #{self.class.name}#work" + raise ImplementationError, "undefined method #{self.class}#work" end - # Returns a logger for this task. When a custom log_level or - # log_formatter is configured, the shared logger is duplicated - # so the original instance is never mutated. - # - # @return [Logger] The logger instance for this task - # - # @example - # logger.info "Starting task execution" - # logger.error "Task failed", error: exception + private + + # Signals a successful halt. # - # @rbs () -> Logger - def logger - @logger ||= begin - settings = self.class.settings - log_instance = settings.logger || CMDx.configuration.logger - - if settings.log_level || settings.log_formatter - log_instance = log_instance.dup - log_instance.level = settings.log_level if settings.log_level - log_instance.formatter = settings.log_formatter if settings.log_formatter - end + # @param reason [String, nil] + # @param metadata [Hash{Symbol => Object}] + # @return [void] throws `Signal::TAG`; never returns + # @raise [FrozenError] when the task has already been frozen (post-execution) + # @note Must be called from inside `work` (inside Runtime's `catch(:cmdx_signal)`). + def success!(reason = nil, **metadata) + raise FrozenError, "cannot throw signals" if frozen? - log_instance - end + throw(Signal::TAG, Signal.success(reason, metadata:)) end - # @option return [Integer] :index The result index - # @option return [String] :chain_id The chain identifier - # @option return [String] :type The task type ("Task" or "Workflow") - # @option return [Array<Symbol>] :tags The task tags - # @option return [String] :class The task class name - # @option return [String] :id The task identifier - # @option return [Hash] :context The task context (when dump_context is true) - # - # @return [Hash] A hash representation of the task + # Signals a skip (interrupted + skipped). # - # @example - # task_hash = task.to_h - # puts "Task type: #{task_hash[:type]}" - # puts "Task tags: #{task_hash[:tags].join(', ')}" - # - # @rbs () -> Hash[Symbol, untyped] - def to_h - { - index: result.index, - chain_id: chain.id, - type: self.class.type, - class: self.class.name, - id:, - dry_run: dry_run?, - tags: self.class.settings.tags - }.tap do |hash| - if self.class.settings.dump_context - # Large context can make dumps and logs very noisy, - # so only include it if explicitly enabled - hash[:context] = context.to_h - end - end + # @param reason [String, nil] + # @param metadata [Hash{Symbol => Object}] + # @return [void] throws `Signal::TAG`; never returns + # @raise [FrozenError] + def skip!(reason = nil, **metadata) + raise FrozenError, "cannot throw signals" if frozen? + + throw(Signal::TAG, Signal.skipped(reason, metadata:)) end - # @return [String] A string representation of the task + # Signals a failure. Captures current call frames as the signal + # backtrace for Fault propagation. # - # @example - # puts task.to_s - # # Output: "Task[MyTask] tags: [:important] id: abc123" + # @param reason [String, nil] + # @param metadata [Hash{Symbol => Object}] + # @return [void] throws `Signal::TAG`; never returns + # @raise [FrozenError] + def fail!(reason = nil, **metadata) + raise FrozenError, "cannot throw signals" if frozen? + + throw(Signal::TAG, Signal.failed(reason, metadata:, backtrace: caller_locations(1))) + end + + # Re-throws a failed peer Result's signal through this task. No-op when + # `other` didn't fail. # - # @rbs () -> String - def to_s - Utils::Format.to_str(to_h) + # @param other [Result] + # @param metadata [Hash{Symbol => Object}] + # @return [void] + # @raise [FrozenError] + def throw!(other, **metadata) + raise FrozenError, "cannot throw signals" if frozen? + + return unless other.failed? + + throw(Signal::TAG, Signal.echoed(other, metadata:, backtrace: caller_locations(1))) end end diff --git a/lib/cmdx/telemetry.rb b/lib/cmdx/telemetry.rb new file mode 100644 index 000000000..4de481828 --- /dev/null +++ b/lib/cmdx/telemetry.rb @@ -0,0 +1,105 @@ +# frozen_string_literal: true + +module CMDx + # Pub/sub for runtime lifecycle events (see {EVENTS}). Subscribers are + # callables receiving an {Event} data object. Runtime emits events only when + # subscribers are registered so telemetry has zero cost when unused. + class Telemetry + + # Immutable event payload passed to subscribers. + Event = Data.define(:chain_id, :chain_root, :task_type, :task_class, :task_id, :name, :payload, :timestamp) + + # Lifecycle event names Runtime emits. + EVENTS = %i[ + task_started + task_deprecated + task_retried + task_rolled_back + task_executed + ].freeze + + attr_reader :registry + + def initialize + @registry = {} + end + + def initialize_copy(source) + @registry = source.registry.transform_values(&:dup) + end + + # Registers a subscriber for `event`. + # + # @param event [Symbol] one of {EVENTS} + # @param callable [#call, nil] subscriber callable; pass either this or a block + # @yieldparam event [Event] + # @return [Telemetry] self for chaining + # @raise [ArgumentError] when both `callable` and a block are provided, when + # the subscriber isn't callable, or when `event` is unknown + def subscribe(event, callable = nil, &block) + subscriber = callable || block + + if callable && block + raise ArgumentError, "provide either a callable or a block, not both" + elsif !subscriber.respond_to?(:call) + raise ArgumentError, "subscriber must respond to #call" + elsif !EVENTS.include?(event) + raise ArgumentError, "unknown event #{event.inspect}, must be one of #{EVENTS.join(', ')}" + end + + (registry[event] ||= []) << subscriber + self + end + + # Removes a previously registered subscriber. Drops the event entry + # entirely when no subscribers remain. + # + # @param event [Symbol] one of {EVENTS} + # @param callable [#call] the subscriber to remove + # @return [Telemetry] self for chaining + # @raise [ArgumentError] when `event` is unknown + def unsubscribe(event, callable) + raise ArgumentError, "unknown event #{event.inspect}, must be one of #{EVENTS.join(', ')}" unless EVENTS.include?(event) + + return self unless subscribed?(event) + + registry[event].delete(callable) + registry.delete(event) if registry[event].empty? + self + end + + # @param event [Symbol] + # @return [Boolean] true when at least one subscriber exists for `event` + def subscribed?(event) + registry.key?(event) + end + + # @return [Boolean] + def empty? + registry.empty? + end + + # @return [Integer] number of subscribed events + def size + registry.size + end + + # @return [Integer] total subscribers across all events + def count + registry.each_value.sum(&:size) + end + + # Dispatches `payload` to every subscriber of `event`. No-op when there + # are no subscribers. + # + # @param event [Symbol] + # @param payload [Event] + # @return [void] + def emit(event, payload) + return unless (subscribers = registry[event]) + + subscribers.each { |s| s.call(payload) } + end + + end +end diff --git a/lib/cmdx/util.rb b/lib/cmdx/util.rb new file mode 100644 index 000000000..1238aba7c --- /dev/null +++ b/lib/cmdx/util.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +module CMDx + # Shared helpers for resolving `:if` / `:unless` conditional options across + # tasks, callbacks, inputs, outputs, validators, and deprecations. Normalizes + # booleans, symbols (method names), procs, and call-ables into a truth value. + module Util + + extend self + + # Evaluates a condition against `receiver`, dispatching by type. + # + # @param condition [Boolean, nil, Symbol, Proc, #call] condition to evaluate + # @param receiver [Object] object the condition runs against (usually a Task) + # @param args [Array<Object>] extra arguments forwarded to the condition + # @return [Boolean, Object] truthiness result (Procs `instance_exec` on receiver) + # @raise [ArgumentError] when the condition is not a supported type + def evaluate(condition, receiver, *args) + case condition + when FalseClass, NilClass + false + when TrueClass + true + when Symbol + receiver.send(condition, *args) + when Proc + receiver.instance_exec(*args, &condition) + else + return condition.call(receiver, *args) if condition.respond_to?(:call) + + raise ArgumentError, "condition must be a Symbol, Proc, or respond to #call" + end + end + + # Evaluates an `:if`-style condition. `nil` is treated as "always true". + # + # @param condition [Boolean, nil, Symbol, Proc, #call] gate to check + # @param receiver [Object] object the condition runs against + # @param args [Array<Object>] extra arguments forwarded to the condition + # @return [Boolean] true when `condition` is nil or evaluates truthy + def if?(condition, receiver, *args) + return true if condition.nil? + + evaluate(condition, receiver, *args) + end + + # Evaluates an `:unless`-style condition. `nil` is treated as "always true". + # + # @param condition [Boolean, nil, Symbol, Proc, #call] gate to check + # @param receiver [Object] object the condition runs against + # @param args [Array<Object>] extra arguments forwarded to the condition + # @return [Boolean] true when `condition` is nil or evaluates falsy + def unless?(condition, receiver, *args) + return true if condition.nil? + + !evaluate(condition, receiver, *args) + end + + # Combines `:if` and `:unless` gates. Used across the framework to decide + # whether a conditional feature (callback, retry, validator, etc.) should run. + # + # @param condition_if [Boolean, nil, Symbol, Proc, #call] `:if` gate + # @param condition_unless [Boolean, nil, Symbol, Proc, #call] `:unless` gate + # @param receiver [Object] object the conditions run against + # @param args [Array<Object>] extra arguments forwarded to both conditions + # @return [Boolean] true only when both gates pass + def satisfied?(condition_if, condition_unless, receiver, *args) + if?(condition_if, receiver, *args) && + unless?(condition_unless, receiver, *args) + end + + end +end diff --git a/lib/cmdx/utils/call.rb b/lib/cmdx/utils/call.rb deleted file mode 100644 index 7a53b6d06..000000000 --- a/lib/cmdx/utils/call.rb +++ /dev/null @@ -1,53 +0,0 @@ -# frozen_string_literal: true - -module CMDx - module Utils - # Utility module for invoking callable objects with different invocation strategies. - # - # This module provides a unified interface for calling methods, procs, and other - # callable objects on target objects, handling the appropriate invocation method - # based on the callable type. - module Call - - extend self - - # Invokes a callable object on the target with the given arguments. - # - # @param target [Object] The target object to invoke the callable on - # @param callable [Symbol, Proc, #call] The callable to invoke - # @param args [Array] Positional arguments to pass to the callable - # @param kwargs [Hash] Keyword arguments to pass to the callable - # @option kwargs [Object] :* Any keyword arguments to pass to the callable - # - # @yield [Object] Block to pass to the callable - # - # @return [Object] The result of invoking the callable - # - # @raise [RuntimeError] When the callable cannot be invoked - # - # @example Invoking a method by symbol - # Call.invoke(user, :name) - # Call.invoke(user, :update, { name: 'John' }) - # @example Invoking a proc - # proc = ->(name) { "Hello #{name}" } - # Call.invoke(user, proc, 'John') - # @example Invoking a callable object - # callable = MyCallable.new - # Call.invoke(user, callable, 'data') - # - # @rbs (untyped target, (Symbol | Proc | untyped) callable, *untyped args, **untyped kwargs) ?{ () -> untyped } -> untyped - def invoke(target, callable, *args, **kwargs, &) - if callable.is_a?(Symbol) - target.send(callable, *args, **kwargs, &) - elsif callable.is_a?(Proc) - target.instance_exec(*args, **kwargs, &callable) - elsif callable.respond_to?(:call) - callable.call(*args, **kwargs, &) - else - raise "cannot invoke #{callable}" - end - end - - end - end -end diff --git a/lib/cmdx/utils/condition.rb b/lib/cmdx/utils/condition.rb deleted file mode 100644 index bf052e65b..000000000 --- a/lib/cmdx/utils/condition.rb +++ /dev/null @@ -1,71 +0,0 @@ -# frozen_string_literal: true - -module CMDx - module Utils - # Provides conditional evaluation utilities for CMDx tasks and workflows. - # - # This module handles conditional logic evaluation with support for `if` and `unless` - # conditions using various callable types including symbols, procs, and objects - # responding to `call`. - module Condition - - extend self - - # @rbs EVAL: Proc - EVAL = proc do |target, callable, *args, **kwargs, &block| - case callable - when NilClass, FalseClass, TrueClass then !!callable - when Symbol then target.send(callable, *args, **kwargs, &block) - when Proc then target.instance_exec(*args, **kwargs, &callable) - else - raise "cannot evaluate #{callable.inspect}" unless callable.respond_to?(:call) - - callable.call(*args, **kwargs, &block) - end - end.freeze - private_constant :EVAL - - # Evaluates conditional logic based on provided options. - # - # Supports both `if` and `unless` conditions, with `unless` taking precedence - # when both are specified. Returns true if no conditions are provided. - # - # @param target [Object] The target object to evaluate conditions against - # @param options [Hash] Conditional options hash - # @option options [Object] :if Condition that must be true for evaluation to succeed - # @option options [Object] :unless Condition that must be false for evaluation to succeed - # - # @return [Boolean] true if conditions are met, false otherwise - # - # @raise [RuntimeError] When a callable cannot be evaluated - # - # @example Basic if condition - # Condition.evaluate(user, if: :active?) - # # => true if user.active? returns true - # @example Unless condition - # Condition.evaluate(user, unless: :blocked?) - # # => true if user.blocked? returns false - # @example Combined conditions - # Condition.evaluate(user, if: :verified?, unless: :suspended?) - # # => true if user.verified? is true AND user.suspended? is false - # @example With arguments and block - # Condition.evaluate(user, if: ->(u) { u.has_permission?(:admin) }, :admin) - # # => true if the proc returns true when called with user and :admin - # - # @rbs (untyped target, Hash[Symbol, untyped] options, *untyped) ?{ () -> untyped } -> bool - def evaluate(target, options, ...) - case options - in if: if_cond, unless: unless_cond - EVAL.call(target, if_cond, ...) && !EVAL.call(target, unless_cond, ...) - in if: if_cond - EVAL.call(target, if_cond, ...) - in unless: unless_cond - !EVAL.call(target, unless_cond, ...) - else - true - end - end - - end - end -end diff --git a/lib/cmdx/utils/format.rb b/lib/cmdx/utils/format.rb deleted file mode 100644 index 4b092c1f6..000000000 --- a/lib/cmdx/utils/format.rb +++ /dev/null @@ -1,82 +0,0 @@ -# frozen_string_literal: true - -module CMDx - module Utils - # Utility module for formatting data structures into log-friendly strings - # and converting messages to appropriate formats for logging - module Format - - extend self - - # @rbs FORMATTER: Proc - FORMATTER = proc do |key, value| - "#{key}=#{value.inspect}" - end.freeze - private_constant :FORMATTER - - # Converts a message to a format suitable for logging - # - # @param message [Object] The message to format - # - # @return [Hash, Object] Returns a hash if the message responds to to_h and is a CMDx object, otherwise returns the original message - # - # @example Hash like objects - # Format.to_log({user_id: 123, action: "login"}) - # # => {user_id: 123, action: "login"} - # @example Simple message - # Format.to_log("simple message") - # # => "simple message" - # @example CMDx object - # Format.to_log(CMDx::Task.new(name: "task1")) - # # => {name: "task1"} - # - # @rbs (untyped message) -> untyped - def to_log(message) - if message.respond_to?(:to_h) && cmdx_based_object?(message.class) - message.to_h - else - message - end - end - - # Converts a hash to a formatted string using a custom formatter - # - # @param hash [Hash] The hash to convert to string - # @param block [Proc, nil] Optional custom formatter block - # @option block [String] :key The hash key - # @option block [Object] :value The hash value - # - # @return [String] Space-separated formatted key-value pairs - # - # @example Default formatter - # Format.to_str({user_id: 123, status: "active"}) - # # => "user_id=123 status=\"active\"" - # @example Custom formatter - # Format.to_str({count: 5, total: 100}) { |k, v| "#{k}:#{v}" } - # # => "count:5 total:100" - # - # @rbs (Hash[untyped, untyped] hash) ?{ (untyped, untyped) -> String } -> String - def to_str(hash, &block) - block ||= FORMATTER - hash.map(&block).join(" ") - end - - private - - # Checks if a class belongs to the CMDx namespace, caching per class. - # - # @param klass [Class] The class to check - # - # @return [Boolean] true if the class is in the CMDx namespace - # - # @rbs (Class klass) -> bool - def cmdx_based_object?(klass) - @cmdx_classes ||= {} - return @cmdx_classes[klass] if @cmdx_classes.key?(klass) - - @cmdx_classes[klass] = klass.ancestors.any? { |a| a.name&.start_with?("CMDx::") } - end - - end - end -end diff --git a/lib/cmdx/utils/normalize.rb b/lib/cmdx/utils/normalize.rb deleted file mode 100644 index 6163719bd..000000000 --- a/lib/cmdx/utils/normalize.rb +++ /dev/null @@ -1,52 +0,0 @@ -# frozen_string_literal: true - -module CMDx - module Utils - # Provides normalization utilities for a variety of objects - # into consistent formats. - module Normalize - - extend self - - # Normalizes an exception into a string representation. - # - # @param exception [Exception] The exception to normalize - # - # @return [String] The normalized exception string - # - # @example From exception - # Normalize.exception(StandardError.new("test")) - # # => "[StandardError] test" - # - # @rbs (Exception exception) -> String - def exception(exception) - "[#{exception.class}] #{exception.message}" - end - - # Normalizes an object into an array of unique status strings. - # - # @param object [Object] The object to normalize into status strings - # - # @return [Array<String>] Unique status strings - # - # @example From array of symbols - # Normalize.statuses([:success, :pending, :success]) - # # => ["success", "pending"] - # @example From single value - # Normalize.statuses(:success) - # # => ["success"] - # @example From nil - # Normalize.statuses(nil) - # # => [] - # - # @rbs (untyped object) -> Array[String] - def statuses(object) - ary = Wrap.array(object) - return EMPTY_ARRAY if ary.empty? - - ary.map(&:to_s).uniq - end - - end - end -end diff --git a/lib/cmdx/utils/wrap.rb b/lib/cmdx/utils/wrap.rb deleted file mode 100644 index 1ad151e47..000000000 --- a/lib/cmdx/utils/wrap.rb +++ /dev/null @@ -1,38 +0,0 @@ -# frozen_string_literal: true - -module CMDx - module Utils - # Provides array wrapping utilities for normalizing input values - # into consistent array structures. - module Wrap - - extend self - - # Wraps an object in an array if it is not already an array. - # - # @param object [Object] The object to wrap in an array - # - # @return [Array] The wrapped array - # - # @example Already an array - # Wrap.array([1, 2, 3]) - # # => [1, 2, 3] - # @example Single value - # Wrap.array(1) - # # => [1] - # @example Nil value - # Wrap.array(nil) - # # => [] - # - # @rbs (untyped object) -> Array[untyped] - def array(object) - case object - when Array then object - when NilClass then EMPTY_ARRAY - else Array(object) - end - end - - end - end -end diff --git a/lib/cmdx/validator_registry.rb b/lib/cmdx/validator_registry.rb deleted file mode 100644 index 8b5381532..000000000 --- a/lib/cmdx/validator_registry.rb +++ /dev/null @@ -1,143 +0,0 @@ -# frozen_string_literal: true - -module CMDx - # Registry for managing validation rules and their corresponding validator classes. - # Provides methods to register, deregister, and execute validators against task values. - # - # Supports copy-on-write semantics: a duped registry shares the parent's - # data until a write operation triggers materialization. - class ValidatorRegistry - - extend Forwardable - - def_delegators :registry, :keys - - # Initialize a new validator registry with default validators. - # - # @param registry [Hash, nil] Optional hash mapping validator names to validator classes - # - # @return [ValidatorRegistry] A new validator registry instance - # - # @rbs (?Hash[Symbol, Class]? registry) -> void - def initialize(registry = nil) - @registry = registry || { - absence: Validators::Absence, - exclusion: Validators::Exclusion, - format: Validators::Format, - inclusion: Validators::Inclusion, - length: Validators::Length, - numeric: Validators::Numeric, - presence: Validators::Presence - } - end - - # Sets up copy-on-write state when duplicated via dup. - # - # @param source [ValidatorRegistry] The registry being duplicated - # - # @rbs (ValidatorRegistry source) -> void - def initialize_dup(source) - @parent = source - @registry = nil - super - end - - # Returns the internal registry mapping validator types to classes. - # Delegates to the parent registry when not yet materialized. - # - # @return [Hash{Symbol => Class}] Hash of validator type names to validator classes - # - # @example - # registry.registry # => { presence: Validators::Presence, format: Validators::Format } - # - # @rbs () -> Hash[Symbol, Class] - def registry - @registry || @parent.registry - end - alias to_h registry - - # Register a new validator class with the given name. - # - # @param name [String, Symbol] The name to register the validator under - # @param validator [Class] The validator class to register - # - # @return [ValidatorRegistry] Returns self for method chaining - # - # @example - # registry.register(:custom, CustomValidator) - # registry.register("email", EmailValidator) - # - # @rbs ((String | Symbol) name, Class validator) -> self - def register(name, validator) - materialize! - - @registry[name.to_sym] = validator - self - end - - # Remove a validator from the registry by name. - # - # @param name [String, Symbol] The name of the validator to remove - # - # @return [ValidatorRegistry] Returns self for method chaining - # - # @example - # registry.deregister(:format) - # registry.deregister("presence") - # - # @rbs ((String | Symbol) name) -> self - def deregister(name) - materialize! - - @registry.delete(name.to_sym) - self - end - - # Validate a value using the specified validator type and options. - # - # @param type [Symbol] The type of validator to use - # @param task [Task] The task context for validation - # @param value [Object] The value to validate - # @param options [Hash, Object] Validation options or condition - # @option options [Boolean] :allow_nil Whether to allow nil values - # - # @raise [TypeError] When the validator type is not registered - # - # @example - # registry.validate(:presence, task, user.name, presence: true) - # registry.validate(:length, task, password, { min: 8, allow_nil: false }) - # - # @rbs (Symbol type, Task task, untyped value, untyped options) -> untyped - def validate(type, task, value, options = EMPTY_HASH) - raise TypeError, "unknown validator type #{type.inspect}" unless registry.key?(type) - - match = - if options.is_a?(Hash) - case options - in allow_nil: then !(allow_nil && value.nil?) - else Utils::Condition.evaluate(task, options, value) - end - else - options - end - - return unless match - - Utils::Call.invoke(task, registry[type], value, options) - end - - private - - # Copies the parent's registry data into this instance, - # severing the copy-on-write link. - # - # @rbs () -> void - def materialize! - return if @registry - - @registry = @parent.registry.dup - @parent = nil - end - - end -end diff --git a/lib/cmdx/validators.rb b/lib/cmdx/validators.rb new file mode 100644 index 000000000..48a4f357a --- /dev/null +++ b/lib/cmdx/validators.rb @@ -0,0 +1,144 @@ +# frozen_string_literal: true + +module CMDx + # Registry of named validators applied to resolved input/output values. + # Ships with built-ins for `:absence`, `:exclusion`, `:format`, + # `:inclusion`, `:length`, `:numeric`, `:presence`. Validators return a + # {Failure} on invalid input (recorded on `task.errors`) or `nil` on + # success. The `:validate` key supports inline callables. + class Validators + + # Sentinel returned by a validator to signal invalid input. Runtime + # records its `message` on the task's errors. + Failure = Data.define(:message) + + attr_reader :registry + + def initialize + @registry = { + absence: Validators::Absence, + exclusion: Validators::Exclusion, + format: Validators::Format, + inclusion: Validators::Inclusion, + length: Validators::Length, + numeric: Validators::Numeric, + presence: Validators::Presence + } + end + + def initialize_copy(source) + @registry = source.registry.dup + end + + # Registers a named validator, overwriting any existing entry. + # + # @param name [Symbol] + # @param callable [#call, nil] pass either this or a block + # @yield validator body — `call(value, options = {})` + # @return [Validators] self for chaining + # @raise [ArgumentError] when both `callable` and a block are given, or + # when the resolved validator isn't callable + def register(name, callable = nil, &block) + raise ArgumentError, "provide either a callable or a block, not both" if callable && block + + validator = callable || block + raise ArgumentError, "validator must respond to #call" unless validator.respond_to?(:call) + + registry[name.to_sym] = validator + self + end + + # @param name [Symbol] + # @return [Validators] self for chaining + def deregister(name) + registry.delete(name.to_sym) + self + end + + # @param name [Symbol] + # @return [#call] + # @raise [ArgumentError] when `name` isn't registered + def lookup(name) + registry[name] || begin + raise ArgumentError, "unknown validator: #{name}" + end + end + + # Picks registered-validator keys out of a declaration's options and + # appends `:validate` (inline callable(s)) when present. + # + # @param options [Hash{Symbol => Object}] declaration options + # @return [Hash{Symbol => Object}] validator rules to run + def extract(options) + return EMPTY_HASH if options.empty? + + rules = options.slice(*registry.keys) + rules = rules.merge(validate: options[:validate]) if options.key?(:validate) + rules + end + + # @return [Boolean] + def empty? + registry.empty? + end + + # @return [Integer] + def size + registry.size + end + + # Runs every rule against `value`, recording a failure message on + # `task.errors` under `name` for each failure. Respects `:allow_nil` + # and `:if`/`:unless` per-rule. + # + # @param task [Task] + # @param name [Symbol] attribute name for error reporting + # @param value [Object] value being validated + # @param rules [Hash{Symbol => Object}] from {#extract} + # @return [void] + def validate(task, name, value, rules) + return if rules.empty? + + rules.each do |type, raw_options| + if type == :validate + Array(raw_options).each do |handler| + result = Validators::Validate.call(task, value, handler) + task.errors.add(name, result.message) if result.is_a?(Failure) + end + next + end + + options = normalize_options(raw_options) + next if options.nil? + + next if options[:allow_nil] && value.nil? + next unless Util.satisfied?(options[:if], options[:unless], task, value) + + result = lookup(type).call(value, options) + next unless result.is_a?(Failure) + + task.errors.add(name, result.message) + end + end + + private + + def normalize_options(raw_options) + case raw_options + when FalseClass, NilClass + nil + when TrueClass + EMPTY_HASH + when Hash + raw_options + when Array + { in: raw_options } + when Regexp + { with: raw_options } + else + raise ArgumentError, "unsupported validator option format: #{raw_options.inspect}" + end + end + + end +end diff --git a/lib/cmdx/validators/absence.rb b/lib/cmdx/validators/absence.rb index d46799005..314de5d04 100644 --- a/lib/cmdx/validators/absence.rb +++ b/lib/cmdx/validators/absence.rb @@ -1,47 +1,19 @@ # frozen_string_literal: true module CMDx - module Validators - # Validates that a value is absent or empty - # - # This validator ensures that the given value is nil, empty, or consists only of whitespace. - # It handles different value types appropriately: - # - Strings: checks for absence of non-whitespace characters - # - Collections: checks for empty collections - # - Other objects: checks for nil values + class Validators + # Validates that a value is blank: `nil`, whitespace-only string, or + # empty collection. module Absence extend self - # Validates that a value is absent or empty - # - # @param value [Object] The value to validate for absence - # @param options [Hash] Validation configuration options - # @option options [String] :message Custom error message - # - # @return [nil] Returns nil if validation passes - # - # @raise [ValidationError] When the value is present, not empty, or contains non-whitespace characters - # - # @example Validate string absence - # Absence.call("") - # # => nil (validation passes) - # @example Validate non-empty string - # Absence.call("hello") - # # => raises ValidationError - # @example Validate array absence - # Absence.call([]) - # # => nil (validation passes) - # @example Validate non-empty array - # Absence.call([1, 2, 3]) - # # => raises ValidationError - # @example Validate with custom message - # Absence.call("hello", message: "Value must be empty") - # # => raises ValidationError with custom message - # - # @rbs (untyped value, ?Hash[Symbol, untyped] options) -> nil + # @param value [Object] + # @param options [Hash{Symbol => Object}] + # @option options [String] :message override for the failure message + # @return [Validators::Failure, nil] def call(value, options = EMPTY_HASH) - match = + present = if value.is_a?(String) /\S/.match?(value) elsif value.respond_to?(:empty?) @@ -50,10 +22,9 @@ def call(value, options = EMPTY_HASH) !value.nil? end - return unless match + return unless present - message = options[:message] if options.is_a?(Hash) - raise ValidationError, message || Locale.t("cmdx.validators.absence") + Failure.new(options[:message] || I18nProxy.t("cmdx.validators.absence")) end end diff --git a/lib/cmdx/validators/exclusion.rb b/lib/cmdx/validators/exclusion.rb index b49b27462..085c9e14a 100644 --- a/lib/cmdx/validators/exclusion.rb +++ b/lib/cmdx/validators/exclusion.rb @@ -1,79 +1,48 @@ # frozen_string_literal: true module CMDx - module Validators - # Validates that a value is not included in a specified set or range - # - # This validator ensures that the given value is excluded from a collection - # of forbidden values or falls outside a specified range. It supports both - # discrete value lists and range-based exclusions. + class Validators + # Inverse of {Inclusion}: the value must not be within the given + # enumerable or `Range`. module Exclusion extend self - # Validates that a value is excluded from the specified options - # - # @param value [Object] The value to validate for exclusion - # @param options [Hash] Validation configuration options - # @option options [Array, Range] :in The collection of forbidden values or range - # @option options [Array, Range] :within Alias for :in option - # @option options [String] :message Custom error message template - # @option options [String] :of_message Custom message for discrete value exclusions - # @option options [String] :in_message Custom message for range-based exclusions - # @option options [String] :within_message Custom message for range-based exclusions - # - # @raise [ValidationError] When the value is found in the forbidden collection - # - # @example Exclude specific values - # Exclusion.call("admin", in: ["admin", "root", "superuser"]) - # # => raises ValidationError if value is "admin" - # @example Exclude values within a range - # Exclusion.call(5, in: 1..10) - # # => raises ValidationError if value is 5 (within 1..10) - # @example Exclude with custom message - # Exclusion.call("test", in: ["test", "demo"], message: "value %{values} is forbidden") - # - # @rbs (untyped value, Hash[Symbol, untyped] options) -> nil + # @param value [Object] + # @param options [Hash{Symbol => Object}] + # @option options [Range, Array, Set, Enumerable] :in disallowed values + # @option options [Range, Array, Set, Enumerable] :within alias for `:in` + # @option options [String] :message global failure-message override + # @option options [String] :of_message override for enumerable failures + # @option options [String] :in_message, :within_message overrides for range failures + # @return [Validators::Failure, nil] + # @raise [ArgumentError] when neither `:in` nor `:within` is given def call(value, options = EMPTY_HASH) values = options[:in] || options[:within] + raise ArgumentError, "exclusion validator requires :in or :within option" if values.nil? if values.is_a?(Range) - raise_within_validation_error!(values.begin, values.end, options) if values.cover?(value) - elsif Utils::Wrap.array(values).any? { |v| v === value } - raise_of_validation_error!(values, options) + within_failure(values.begin, values.end, options) if values.cover?(value) + elsif Array(values).any? { |v| v === value } + of_failure(values, options) end end private - # Raises validation error for discrete value exclusions - # - # @param values [Array] The forbidden values that caused the error - # @param options [Hash] Validation options containing custom messages - # @option options [Object] :* Any validation option key-value pairs - # - # @raise [ValidationError] With appropriate error message - def raise_of_validation_error!(values, options) - values = values.map(&:inspect).join(", ") unless values.nil? + def of_failure(values, options) + values = values.map(&:inspect).join(", ") message = options[:of_message] || options[:message] message %= { values: } unless message.nil? - raise ValidationError, message || Locale.t("cmdx.validators.exclusion.of", values:) + Failure.new(message || I18nProxy.t("cmdx.validators.exclusion.of", values:)) end - # Raises validation error for range-based exclusions - # - # @param min [Object] The minimum value of the forbidden range - # @param max [Object] The maximum value of the forbidden range - # @param options [Hash] Validation options containing custom messages - # @option options [Object] :* Any validation option key-value pairs - # - # @raise [ValidationError] With appropriate error message - def raise_within_validation_error!(min, max, options) + def within_failure(min, max, options) message = options[:in_message] || options[:within_message] || options[:message] message %= { min:, max: } unless message.nil? - raise ValidationError, message || Locale.t("cmdx.validators.exclusion.within", min:, max:) + Failure.new(message || I18nProxy.t("cmdx.validators.exclusion.within", min:, max:)) end end diff --git a/lib/cmdx/validators/format.rb b/lib/cmdx/validators/format.rb index ee3de1f2b..992c61e2d 100644 --- a/lib/cmdx/validators/format.rb +++ b/lib/cmdx/validators/format.rb @@ -1,66 +1,36 @@ # frozen_string_literal: true module CMDx - module Validators - # Validates that a value matches a specified format pattern - # - # This validator ensures that the given value conforms to a specific format - # using regular expressions. It supports both direct regex matching and - # conditional matching with inclusion/exclusion patterns. + class Validators + # Validates that a value matches a `:with` regex and/or does not match a + # `:without` regex. Both may be combined; at least one is required. module Format extend self - # Validates that a value matches the specified format pattern - # - # @param value [Object] The value to validate for format compliance - # @param options [Hash, Regexp] Validation configuration options or direct regex pattern - # @option options [Regexp] :with Required pattern that the value must match - # @option options [Regexp] :without Pattern that the value must not match - # @option options [String] :message Custom error message - # - # @return [nil] Returns nil if validation passes - # - # @raise [ValidationError] When the value doesn't match the required format - # - # @example Direct regex validation - # Format.call("user@example.com", /\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i) - # # => nil (validation passes) - # @example Validate with required pattern - # Format.call("ABC123", with: /\A[A-Z]{3}\d{3}\z/) - # # => nil (validation passes) - # @example Validate with exclusion pattern - # Format.call("hello", without: /\d/) - # # => nil (validation passes - no digits) - # @example Validate with both patterns - # Format.call("test123", with: /\A\w+\z/, without: /\A\d+\z/) - # # => nil (validation passes - alphanumeric but not all digits) - # @example Validate with custom message - # Format.call("invalid", with: /\A\d+\z/, message: "Must contain only digits") - # # => raises ValidationError with custom message - # - # @rbs (untyped value, (Hash[Symbol, untyped] | Regexp) options) -> nil + # @param value [String, nil] + # @param options [Hash{Symbol => Object}] + # @option options [Regexp] :with must match + # @option options [Regexp] :without must not match + # @option options [String] :message override for the failure message + # @return [Validators::Failure, nil] + # @raise [ArgumentError] when neither `:with` nor `:without` is given def call(value, options = EMPTY_HASH) match = - if options.is_a?(Regexp) - value&.match?(options) + case options + in with:, without: + value&.match?(with) && !value&.match?(without) + in with: + value&.match?(with) + in without: + !value&.match?(without) else - case options - in with:, without: - value&.match?(with) && !value&.match?(without) - in with: - value&.match?(with) - in without: - !value&.match?(without) - else - false - end + raise ArgumentError, "format validator requires :with and/or :without option" end return if match - message = options[:message] if options.is_a?(Hash) - raise ValidationError, message || Locale.t("cmdx.validators.format") + Failure.new(options[:message] || I18nProxy.t("cmdx.validators.format")) end end diff --git a/lib/cmdx/validators/inclusion.rb b/lib/cmdx/validators/inclusion.rb index 82a860282..b70376e0a 100644 --- a/lib/cmdx/validators/inclusion.rb +++ b/lib/cmdx/validators/inclusion.rb @@ -1,81 +1,48 @@ # frozen_string_literal: true module CMDx - module Validators - # Validates that a value is included in a specified set or range - # - # This validator ensures that the given value is present within a collection - # of allowed values or falls within a specified range. It supports both - # discrete value lists and range-based validations. + class Validators + # Validates that a value is within an enumerable or `Range`. Range uses + # `#cover?`; other enumerables use `===` (so regex/class matchers work). module Inclusion extend self - # Validates that a value is included in the specified options - # - # @param value [Object] The value to validate for inclusion - # @param options [Hash] Validation configuration options - # @option options [Array, Range] :in The collection of allowed values or range - # @option options [Array, Range] :within Alias for :in option - # @option options [String] :message Custom error message template - # @option options [String] :of_message Custom message for discrete value inclusions - # @option options [String] :in_message Custom message for range-based inclusions - # @option options [String] :within_message Custom message for range-based inclusions - # - # @return [nil] Returns nil if validation passes - # - # @raise [ValidationError] When the value is not found in the allowed collection - # - # @example Include specific values - # Inclusion.call("admin", in: ["admin", "user", "guest"]) - # # => nil (validation passes) - # @example Include values within a range - # Inclusion.call(5, in: 1..10) - # # => nil (validation passes - 5 is within 1..10) - # @example Include with custom message - # Inclusion.call("test", in: ["admin", "user"], message: "must be one of: %{values}") - # - # @rbs (untyped value, Hash[Symbol, untyped] options) -> nil + # @param value [Object] + # @param options [Hash{Symbol => Object}] + # @option options [Range, Array, Set, Enumerable] :in allowed values + # @option options [Range, Array, Set, Enumerable] :within alias for `:in` + # @option options [String] :message global failure-message override + # @option options [String] :of_message override for enumerable failures + # @option options [String] :in_message, :within_message overrides for range failures + # @return [Validators::Failure, nil] + # @raise [ArgumentError] when neither `:in` nor `:within` is given def call(value, options = EMPTY_HASH) values = options[:in] || options[:within] + raise ArgumentError, "inclusion validator requires :in or :within option" if values.nil? if values.is_a?(Range) - raise_within_validation_error!(values.begin, values.end, options) unless values.cover?(value) - elsif Utils::Wrap.array(values).none? { |v| v === value } - raise_of_validation_error!(values, options) + within_failure(values.begin, values.end, options) unless values.cover?(value) + elsif Array(values).none? { |v| v === value } + of_failure(values, options) end end private - # Raises validation error for discrete value inclusions - # - # @param values [Array] The allowed values that caused the error - # @param options [Hash] Validation options containing custom messages - # @option options [Object] :* Any validation option key-value pairs - # - # @raise [ValidationError] With appropriate error message - def raise_of_validation_error!(values, options) - values = values.map(&:inspect).join(", ") unless values.nil? + def of_failure(values, options) + values = values.map(&:inspect).join(", ") message = options[:of_message] || options[:message] message %= { values: } unless message.nil? - raise ValidationError, message || Locale.t("cmdx.validators.inclusion.of", values:) + Failure.new(message || I18nProxy.t("cmdx.validators.inclusion.of", values:)) end - # Raises validation error for range-based inclusions - # - # @param min [Object] The minimum value of the allowed range - # @param max [Object] The maximum value of the allowed range - # @param options [Hash] Validation options containing custom messages - # @option options [Object] :* Any validation option key-value pairs - # - # @raise [ValidationError] With appropriate error message - def raise_within_validation_error!(min, max, options) + def within_failure(min, max, options) message = options[:in_message] || options[:within_message] || options[:message] message %= { min:, max: } unless message.nil? - raise ValidationError, message || Locale.t("cmdx.validators.inclusion.within", min:, max:) + Failure.new(message || I18nProxy.t("cmdx.validators.inclusion.within", min:, max:)) end end diff --git a/lib/cmdx/validators/length.rb b/lib/cmdx/validators/length.rb index 04fa18ee5..4c1413c27 100644 --- a/lib/cmdx/validators/length.rb +++ b/lib/cmdx/validators/length.rb @@ -1,80 +1,67 @@ # frozen_string_literal: true module CMDx - module Validators - # Validates the length of a value against various constraints. - # - # This validator supports multiple length validation strategies including exact length, - # minimum/maximum bounds, and range-based validation. It can be used to ensure - # values meet specific length requirements for strings, arrays, and other - # enumerable objects. + class Validators + # Validates the `#length` of `value` against one of: `:within` / + # `:not_within` / `:in` / `:not_in` (Range), `:min` + `:max`, + # `:gt` / `:lt` (strict comparison), or `:is` / `:is_not` (exact + # match). `:gte`, `:lte`, `:eq`, `:not_eq` are accepted as aliases + # of `:min`, `:max`, `:is`, `:is_not` respectively (with matching + # `_message` overrides). Values without `#length` fail with the + # `:nil_message` override or a default. module Length extend self - # Validates a value's length against specified constraints. - # - # @param value [String, Array, Hash, Object] The value to validate (must respond to #length) - # @param options [Hash] Validation options - # @option options [Range] :within Range that the length must fall within (inclusive) - # @option options [Range] :not_within Range that the length must not fall within - # @option options [Range] :in Alias for :within - # @option options [Range] :not_in Range that the length must not fall within - # @option options [Integer] :min Minimum allowed length - # @option options [Integer] :max Maximum allowed length - # @option options [Integer] :is Exact required length - # @option options [Integer] :is_not Length that is not allowed - # @option options [String] :message Custom error message for all validations - # @option options [String] :within_message Custom message for within/range validations - # @option options [String] :in_message Custom message for :in validation - # @option options [String] :not_within_message Custom message for not_within validation - # @option options [String] :not_in_message Custom message for not_in validation - # @option options [String] :min_message Custom message for minimum length validation - # @option options [String] :max_message Custom message for maximum length validation - # @option options [String] :is_message Custom message for exact length validation - # @option options [String] :is_not_message Custom message for is_not validation - # - # @return [nil] Returns nil if validation passes - # - # @raise [ValidationError] When validation fails - # @raise [ArgumentError] When unknown validation options are provided - # - # @example Exact length validation - # Length.call("hello", is: 5) - # # => nil (validation passes) - # @example Range-based validation - # Length.call("test", within: 3..6) - # # => nil (validation passes - length 4 is within range) - # @example Min/max validation - # Length.call("username", min: 3, max: 20) - # # => nil (validation passes - length 8 is between 3 and 20) - # @example Exclusion validation - # Length.call("short", not_in: 1..3) - # # => nil (validation passes - length 5 is not in excluded range) - # - # @rbs (untyped value, Hash[Symbol, untyped] options) -> nil + ALIASES = { + gte: :min, + lte: :max, + eq: :is, + not_eq: :is_not, + gte_message: :min_message, + lte_message: :max_message, + eq_message: :is_message, + not_eq_message: :is_not_message + }.freeze + private_constant :ALIASES + + # @param value [#length, nil] + # @param options [Hash{Symbol => Object}] see module summary + # @option options [String] :message global failure-message override + # @option options [String] :nil_message override when `value` lacks `#length` + # @option options [String] :within_message, :in_message, :not_within_message, + # :not_in_message, :min_message, :max_message, :gt_message, :lt_message, + # :is_message, :is_not_message + # @return [Validators::Failure, nil] + # @raise [ArgumentError] when no recognized length option is given def call(value, options = EMPTY_HASH) - length = value&.length + return nil_failure(options) unless value.respond_to?(:length) - case options + length = value.length + + case options = options.transform_keys(ALIASES) in within: - raise_within_validation_error!(within.begin, within.end, options) unless within&.cover?(length) + within_failure(within.begin, within.end, options) unless within.cover?(length) in not_within: - raise_not_within_validation_error!(not_within.begin, not_within.end, options) if not_within&.cover?(length) + not_within_failure(not_within.begin, not_within.end, options) if not_within.cover?(length) in in: xin - raise_within_validation_error!(xin.begin, xin.end, options) unless xin&.cover?(length) + within_failure(xin.begin, xin.end, options) unless xin.cover?(length) in not_in: - raise_not_within_validation_error!(not_in.begin, not_in.end, options) if not_in&.cover?(length) + not_within_failure(not_in.begin, not_in.end, options) if not_in.cover?(length) in min:, max: - raise_within_validation_error!(min, max, options) unless length&.between?(min, max) + within_failure(min, max, options) unless length.between?(min, max) in min: - raise_min_validation_error!(min, options) unless !length.nil? && (min <= length) + min_failure(min, options) unless min <= length in max: - raise_max_validation_error!(max, options) unless !length.nil? && (length <= max) + max_failure(max, options) unless length <= max + in gt: + gt_failure(gt, options) unless gt < length + in lt: + lt_failure(lt, options) unless length < lt in is: - raise_is_validation_error!(is, options) unless !length.nil? && (length == is) + is_failure(is, options) unless length == is in is_not: - raise_is_not_validation_error!(is_not, options) if !length.nil? && (length == is_not) + is_not_failure(is_not, options) if length == is_not else raise ArgumentError, "unknown length validator options given" end @@ -82,102 +69,65 @@ def call(value, options = EMPTY_HASH) private - # Raises validation error for within/range validations. - # - # @param min [Integer] Minimum length value - # @param max [Integer] Maximum length value - # @param options [Hash] Validation options containing custom messages - # @option options [Object] :* Any validation option key-value pairs - # - # @raise [ValidationError] Always raised with appropriate message - # - # @rbs (Integer min, Integer max, Hash[Symbol, untyped] options) -> void - def raise_within_validation_error!(min, max, options) + def nil_failure(options) + message = options[:nil_message] || options[:message] + Failure.new(message || I18nProxy.t("cmdx.validators.length.nil_value")) + end + + def within_failure(min, max, options) message = options[:within_message] || options[:in_message] || options[:message] message %= { min:, max: } unless message.nil? - raise ValidationError, message || Locale.t("cmdx.validators.length.within", min:, max:) + Failure.new(message || I18nProxy.t("cmdx.validators.length.within", min:, max:)) end - # Raises validation error for not_within validations. - # - # @param min [Integer] Minimum length value - # @param max [Integer] Maximum length value - # @param options [Hash] Validation options containing custom messages - # @option options [Object] :* Any validation option key-value pairs - # - # @raise [ValidationError] Always raised with appropriate message - # - # @rbs (Integer min, Integer max, Hash[Symbol, untyped] options) -> void - def raise_not_within_validation_error!(min, max, options) + def not_within_failure(min, max, options) message = options[:not_within_message] || options[:not_in_message] || options[:message] message %= { min:, max: } unless message.nil? - raise ValidationError, message || Locale.t("cmdx.validators.length.not_within", min:, max:) + Failure.new(message || I18nProxy.t("cmdx.validators.length.not_within", min:, max:)) end - # Raises validation error for minimum length validation. - # - # @param min [Integer] Minimum required length - # @param options [Hash] Validation options containing custom messages - # @option options [Object] :* Any validation option key-value pairs - # - # @raise [ValidationError] Always raised with appropriate message - # - # @rbs (Integer min, Hash[Symbol, untyped] options) -> void - def raise_min_validation_error!(min, options) + def min_failure(min, options) message = options[:min_message] || options[:message] message %= { min: } unless message.nil? - raise ValidationError, message || Locale.t("cmdx.validators.length.min", min:) + Failure.new(message || I18nProxy.t("cmdx.validators.length.min", min:)) end - # Raises validation error for maximum length validation. - # - # @param max [Integer] Maximum allowed length - # @param options [Hash] Validation options containing custom messages - # @option options [Object] :* Any validation option key-value pairs - # - # @raise [ValidationError] Always raised with appropriate message - # - # @rbs (Integer max, Hash[Symbol, untyped] options) -> void - def raise_max_validation_error!(max, options) + def max_failure(max, options) message = options[:max_message] || options[:message] message %= { max: } unless message.nil? - raise ValidationError, message || Locale.t("cmdx.validators.length.max", max:) + Failure.new(message || I18nProxy.t("cmdx.validators.length.max", max:)) + end + + def gt_failure(gt, options) + message = options[:gt_message] || options[:message] + message %= { gt: } unless message.nil? + + Failure.new(message || I18nProxy.t("cmdx.validators.length.gt", gt:)) + end + + def lt_failure(lt, options) + message = options[:lt_message] || options[:message] + message %= { lt: } unless message.nil? + + Failure.new(message || I18nProxy.t("cmdx.validators.length.lt", lt:)) end - # Raises validation error for exact length validation. - # - # @param is [Integer] Required exact length - # @param options [Hash] Validation options containing custom messages - # @option options [Object] :* Any validation option key-value pairs - # - # @raise [ValidationError] Always raised with appropriate message - # - # @rbs (Integer is, Hash[Symbol, untyped] options) -> void - def raise_is_validation_error!(is, options) + def is_failure(is, options) # rubocop:disable Naming/PredicatePrefix message = options[:is_message] || options[:message] message %= { is: } unless message.nil? - raise ValidationError, message || Locale.t("cmdx.validators.length.is", is:) + Failure.new(message || I18nProxy.t("cmdx.validators.length.is", is:)) end - # Raises validation error for is_not length validation. - # - # @param is_not [Integer] Length that is not allowed - # @param options [Hash] Validation options containing custom messages - # @option options [Object] :* Any validation option key-value pairs - # - # @raise [ValidationError] Always raised with appropriate message - # - # @rbs (Integer is_not, Hash[Symbol, untyped] options) -> void - def raise_is_not_validation_error!(is_not, options) + def is_not_failure(is_not, options) # rubocop:disable Naming/PredicatePrefix message = options[:is_not_message] || options[:message] message %= { is_not: } unless message.nil? - raise ValidationError, message || Locale.t("cmdx.validators.length.is_not", is_not:) + Failure.new(message || I18nProxy.t("cmdx.validators.length.is_not", is_not:)) end end diff --git a/lib/cmdx/validators/numeric.rb b/lib/cmdx/validators/numeric.rb index d20e9e852..ec1757d41 100644 --- a/lib/cmdx/validators/numeric.rb +++ b/lib/cmdx/validators/numeric.rb @@ -1,75 +1,64 @@ # frozen_string_literal: true module CMDx - module Validators - # Validates numeric values against various constraints and ranges - # - # This validator ensures that numeric values meet specified criteria such as - # minimum/maximum bounds, exact matches, or range inclusions. It supports - # both inclusive and exclusive range validations with customizable error messages. + class Validators + # Validates a numeric `value` against one of: `:within` / `:not_within` + # / `:in` / `:not_in` (Range), `:min` + `:max`, `:gt` / `:lt` (strict + # comparison), or `:is` / `:is_not` (exact match). `:gte`, `:lte`, + # `:eq`, `:not_eq` are accepted as aliases of `:min`, `:max`, `:is`, + # `:is_not` respectively (with matching `_message` overrides). `nil` + # fails with `:nil_message` override or default. module Numeric extend self - # Validates a numeric value against the specified options - # - # @param value [Numeric] The numeric value to validate - # @param options [Hash] Validation configuration options - # @option options [Range] :within Range that the value must fall within (inclusive) - # @option options [Range] :not_within Range that the value must not fall within - # @option options [Range] :in Alias for :within option - # @option options [Range] :not_in Alias for :not_within option - # @option options [Numeric] :min Minimum allowed value (inclusive) - # @option options [Numeric] :max Maximum allowed value (inclusive) - # @option options [Numeric] :is Exact value that must match - # @option options [Numeric] :is_not Value that must not match - # @option options [String] :message Custom error message template - # @option options [String] :within_message Custom message for range validations - # @option options [String] :not_within_message Custom message for exclusion validations - # @option options [String] :min_message Custom message for minimum validation - # @option options [String] :max_message Custom message for maximum validation - # @option options [String] :is_message Custom message for exact match validation - # @option options [String] :is_not_message Custom message for exclusion validation - # - # @return [nil] Returns nil if validation passes - # - # @raise [ValidationError] When the value fails validation - # @raise [ArgumentError] When unknown validator options are provided - # - # @example Validate value within a range - # Numeric.call(5, within: 1..10) - # # => nil (validation passes) - # @example Validate minimum and maximum bounds - # Numeric.call(15, min: 10, max: 20) - # # => nil (validation passes) - # @example Validate exact value match - # Numeric.call(42, is: 42) - # # => nil (validation passes) - # @example Validate value exclusion - # Numeric.call(5, not_in: 1..10) - # # => nil (validation passes - 5 is not in 1..10) - # - # @rbs (Numeric value, Hash[Symbol, untyped] options) -> nil + ALIASES = { + gte: :min, + lte: :max, + eq: :is, + not_eq: :is_not, + gte_message: :min_message, + lte_message: :max_message, + eq_message: :is_message, + not_eq_message: :is_not_message + }.freeze + private_constant :ALIASES + + # @param value [Numeric, nil] + # @param options [Hash{Symbol => Object}] see module summary + # @option options [String] :message global failure-message override + # @option options [String] :nil_message override when `value` is nil + # @option options [String] :within_message, :in_message, :not_within_message, + # :not_in_message, :min_message, :max_message, :gt_message, :lt_message, + # :is_message, :is_not_message + # @return [Validators::Failure, nil] + # @raise [ArgumentError] when no recognized numeric option is given def call(value, options = EMPTY_HASH) - case options + return nil_failure(options) if value.nil? + + case options = options.transform_keys(ALIASES) in within: - raise_within_validation_error!(within.begin, within.end, options) unless within&.cover?(value) + within_failure(within.begin, within.end, options) unless within.cover?(value) in not_within: - raise_not_within_validation_error!(not_within.begin, not_within.end, options) if not_within&.cover?(value) + not_within_failure(not_within.begin, not_within.end, options) if not_within.cover?(value) in in: xin - raise_within_validation_error!(xin.begin, xin.end, options) unless xin&.cover?(value) + within_failure(xin.begin, xin.end, options) unless xin.cover?(value) in not_in: - raise_not_within_validation_error!(not_in.begin, not_in.end, options) if not_in&.cover?(value) + not_within_failure(not_in.begin, not_in.end, options) if not_in.cover?(value) in min:, max: - raise_within_validation_error!(min, max, options) unless value&.between?(min, max) + within_failure(min, max, options) unless value.between?(min, max) in min: - raise_min_validation_error!(min, options) unless !value.nil? && (min <= value) + min_failure(min, options) unless min <= value in max: - raise_max_validation_error!(max, options) unless !value.nil? && (value <= max) + max_failure(max, options) unless value <= max + in gt: + gt_failure(gt, options) unless gt < value + in lt: + lt_failure(lt, options) unless value < lt in is: - raise_is_validation_error!(is, options) unless !value.nil? && (value == is) + is_failure(is, options) unless value == is in is_not: - raise_is_not_validation_error!(is_not, options) if !value.nil? && (value == is_not) + is_not_failure(is_not, options) if value == is_not else raise ArgumentError, "unknown numeric validator options given" end @@ -77,102 +66,65 @@ def call(value, options = EMPTY_HASH) private - # Raises validation error for range inclusion validation - # - # @param min [Numeric] The minimum value of the allowed range - # @param max [Numeric] The maximum value of the allowed range - # @param options [Hash] Validation options containing custom messages - # @option options [Object] :* Any validation option key-value pairs - # - # @raise [ValidationError] With appropriate error message - # - # @rbs (Numeric min, Numeric max, Hash[Symbol, untyped] options) -> void - def raise_within_validation_error!(min, max, options) + def nil_failure(options) + message = options[:nil_message] || options[:message] + Failure.new(message || I18nProxy.t("cmdx.validators.numeric.nil_value")) + end + + def within_failure(min, max, options) message = options[:within_message] || options[:in_message] || options[:message] message %= { min:, max: } unless message.nil? - raise ValidationError, message || Locale.t("cmdx.validators.numeric.within", min:, max:) + Failure.new(message || I18nProxy.t("cmdx.validators.numeric.within", min:, max:)) end - # Raises validation error for range exclusion validation - # - # @param min [Numeric] The minimum value of the excluded range - # @param max [Numeric] The maximum value of the excluded range - # @param options [Hash] Validation options containing custom messages - # @option options [Object] :* Any validation option key-value pairs - # - # @raise [ValidationError] With appropriate error message - # - # @rbs (Numeric min, Numeric max, Hash[Symbol, untyped] options) -> void - def raise_not_within_validation_error!(min, max, options) + def not_within_failure(min, max, options) message = options[:not_within_message] || options[:not_in_message] || options[:message] message %= { min:, max: } unless message.nil? - raise ValidationError, message || Locale.t("cmdx.validators.numeric.not_within", min:, max:) + Failure.new(message || I18nProxy.t("cmdx.validators.numeric.not_within", min:, max:)) end - # Raises validation error for minimum value validation - # - # @param min [Numeric] The minimum allowed value - # @param options [Hash] Validation options containing custom messages - # @option options [Object] :* Any validation option key-value pairs - # - # @raise [ValidationError] With appropriate error message - # - # @rbs (Numeric min, Hash[Symbol, untyped] options) -> void - def raise_min_validation_error!(min, options) + def min_failure(min, options) message = options[:min_message] || options[:message] message %= { min: } unless message.nil? - raise ValidationError, message || Locale.t("cmdx.validators.numeric.min", min:) + Failure.new(message || I18nProxy.t("cmdx.validators.numeric.min", min:)) end - # Raises validation error for maximum value validation - # - # @param max [Numeric] The maximum allowed value - # @param options [Hash] Validation options containing custom messages - # @option options [Object] :* Any validation option key-value pairs - # - # @raise [ValidationError] With appropriate error message - # - # @rbs (Numeric max, Hash[Symbol, untyped] options) -> void - def raise_max_validation_error!(max, options) + def max_failure(max, options) message = options[:max_message] || options[:message] message %= { max: } unless message.nil? - raise ValidationError, message || Locale.t("cmdx.validators.numeric.max", max:) + Failure.new(message || I18nProxy.t("cmdx.validators.numeric.max", max:)) + end + + def gt_failure(gt, options) + message = options[:gt_message] || options[:message] + message %= { gt: } unless message.nil? + + Failure.new(message || I18nProxy.t("cmdx.validators.numeric.gt", gt:)) + end + + def lt_failure(lt, options) + message = options[:lt_message] || options[:message] + message %= { lt: } unless message.nil? + + Failure.new(message || I18nProxy.t("cmdx.validators.numeric.lt", lt:)) end - # Raises validation error for exact value match validation - # - # @param is [Numeric] The exact value that was expected - # @param options [Hash] Validation options containing custom messages - # @option options [Object] :* Any validation option key-value pairs - # - # @raise [ValidationError] With appropriate error message - # - # @rbs (Numeric is, Hash[Symbol, untyped] options) -> void - def raise_is_validation_error!(is, options) + def is_failure(is, options) # rubocop:disable Naming/PredicatePrefix message = options[:is_message] || options[:message] message %= { is: } unless message.nil? - raise ValidationError, message || Locale.t("cmdx.validators.numeric.is", is:) + Failure.new(message || I18nProxy.t("cmdx.validators.numeric.is", is:)) end - # Raises validation error for value exclusion validation - # - # @param is_not [Numeric] The value that was not allowed - # @param options [Hash] Validation options containing custom messages - # @option options [Object] :* Any validation option key-value pairs - # - # @raise [ValidationError] With appropriate error message - # - # @rbs (Numeric is_not, Hash[Symbol, untyped] options) -> void - def raise_is_not_validation_error!(is_not, options) + def is_not_failure(is_not, options) # rubocop:disable Naming/PredicatePrefix message = options[:is_not_message] || options[:message] message %= { is_not: } unless message.nil? - raise ValidationError, message || Locale.t("cmdx.validators.numeric.is_not", is_not:) + Failure.new(message || I18nProxy.t("cmdx.validators.numeric.is_not", is_not:)) end end diff --git a/lib/cmdx/validators/presence.rb b/lib/cmdx/validators/presence.rb index de51ab27b..f6cfb229d 100644 --- a/lib/cmdx/validators/presence.rb +++ b/lib/cmdx/validators/presence.rb @@ -1,47 +1,19 @@ # frozen_string_literal: true module CMDx - module Validators - # Validates that a value is present and not empty - # - # This validator ensures that the given value exists and contains meaningful content. - # It handles different value types appropriately: - # - Strings: checks for non-whitespace characters - # - Collections: checks for non-empty collections - # - Other objects: checks for non-nil values + class Validators + # Validates that a value is present: non-`nil`, non-empty, and (for + # strings) not whitespace-only. module Presence extend self - # Validates that a value is present and not empty - # - # @param value [Object] The value to validate for presence - # @param options [Hash] Validation configuration options - # @option options [String] :message Custom error message - # - # @return [nil] Returns nil if validation passes - # - # @raise [ValidationError] When the value is empty, nil, or contains only whitespace - # - # @example Validate string presence - # Presence.call("hello world") - # # => nil (validation passes) - # @example Validate empty string - # Presence.call(" ") - # # => raises ValidationError - # @example Validate array presence - # Presence.call([1, 2, 3]) - # # => nil (validation passes) - # @example Validate empty array - # Presence.call([]) - # # => raises ValidationError - # @example Validate with custom message - # Presence.call(nil, message: "Value cannot be blank") - # # => raises ValidationError with custom message - # - # @rbs (untyped value, ?Hash[Symbol, untyped] options) -> nil + # @param value [Object] + # @param options [Hash{Symbol => Object}] + # @option options [String] :message override for the failure message + # @return [Validators::Failure, nil] def call(value, options = EMPTY_HASH) - match = + present = if value.is_a?(String) /\S/.match?(value) elsif value.respond_to?(:empty?) @@ -50,10 +22,9 @@ def call(value, options = EMPTY_HASH) !value.nil? end - return if match + return if present - message = options[:message] if options.is_a?(Hash) - raise ValidationError, message || Locale.t("cmdx.validators.presence") + Failure.new(options[:message] || I18nProxy.t("cmdx.validators.presence")) end end diff --git a/lib/cmdx/validators/validate.rb b/lib/cmdx/validators/validate.rb new file mode 100644 index 000000000..8184e4a2c --- /dev/null +++ b/lib/cmdx/validators/validate.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module CMDx + class Validators + # Invokes an inline `:validate` handler. Used by {Validators#validate} + # for each handler passed under the `:validate` option. + module Validate + + extend self + + # @param task [Task] receiver for Symbol/Proc handlers, also passed to callable handlers + # @param value [Object] + # @param handler [Symbol, Proc, #call] + # @return [Validators::Failure, nil, Object] handler's return value + # @raise [ArgumentError] when `handler` isn't a supported type + def call(task, value, handler) + case handler + when Symbol + task.send(handler, value) + when Proc + task.instance_exec(value, &handler) + else + return handler.call(value, task) if handler.respond_to?(:call) + + raise ArgumentError, "validate handler must be a Symbol, Proc, or respond to #call" + end + end + + end + end +end diff --git a/lib/cmdx/version.rb b/lib/cmdx/version.rb index b07955686..437b03963 100644 --- a/lib/cmdx/version.rb +++ b/lib/cmdx/version.rb @@ -2,9 +2,7 @@ module CMDx - # @return [String] the version of the CMDx gem - # - # @rbs return: String - VERSION = "1.21.0" + # Semantic version string for the CMDx library. + VERSION = "2.0.0" end diff --git a/lib/cmdx/workflow.rb b/lib/cmdx/workflow.rb index 5a006e4a9..98752112a 100644 --- a/lib/cmdx/workflow.rb +++ b/lib/cmdx/workflow.rb @@ -1,132 +1,78 @@ # frozen_string_literal: true module CMDx - # Provides workflow execution capabilities by organizing tasks into execution groups. - # Workflows allow you to define sequences of tasks that can be executed conditionally - # with breakpoint handling and context management. + # Mixin that turns a {Task} subclass into a workflow: a pipeline of + # ordered task groups run sequentially or in parallel. Defining `#work` + # on a workflow is forbidden — `#work` is auto-generated to delegate to + # {Pipeline}. Subclasses inherit the parent's pipeline (via dup). + # + # @see Pipeline module Workflow module ClassMethods - # Prevents redefinition of the work method to maintain workflow integrity. - # - # @param method_name [Symbol] The name of the method being added - # - # @raise [RuntimeError] If attempting to redefine the work method - # - # @example - # class MyWorkflow - # include CMDx::Workflow - # # This would raise an error: - # # def work; end - # end - # - # @rbs (Symbol method_name) -> void - def method_added(method_name) - raise "cannot redefine #{name}##{method_name} method" if method_name == :work - + # @api private + def inherited(subclass) super + subclass.instance_variable_set(:@pipeline, pipeline.dup) end - # Returns the collection of execution groups for this workflow. - # - # @return [Array<ExecutionGroup>] Array of execution groups - # - # @example - # class MyWorkflow - # include CMDx::Workflow - # task Task1 - # task Task2 - # puts pipeline.size # => 2 - # end - # - # @rbs () -> Array[ExecutionGroup] + # @return [Array<ExecutionGroup>] declared groups, in order def pipeline @pipeline ||= [] end - # Adds multiple tasks to the workflow with optional configuration. - # - # @param tasks [Array<Class>] Array of task classes to add - # @param options [Hash] Configuration options for the task execution - # @option options [Hash] :breakpoints Breakpoints that trigger workflow interruption - # @option options [Hash] :conditions Conditional logic for task execution - # - # @raise [TypeError] If any task is not a CMDx::Task subclass - # - # @example - # class MyWorkflow - # include CMDx::Workflow - # tasks ValidateTask, ProcessTask, NotifyTask, breakpoints: [:failure, :halt] - # end + # Declares a task group. With no tasks, returns the pipeline. Tasks + # must be `Task` subclasses. # - # @rbs (*untyped tasks, **untyped options) -> void + # @param tasks [Array<Class<Task>>] + # @param options [Hash{Symbol => Object}] + # @option options [:sequential, :parallel] :strategy (:sequential) + # @option options [Integer] :pool_size parallel worker count + # @option options [Symbol, Proc, #call] :if + # @option options [Symbol, Proc, #call] :unless + # @return [Array<ExecutionGroup>] the full pipeline + # @raise [TypeError] when any element isn't a `Task` subclass def tasks(*tasks, **options) + return pipeline if tasks.empty? + pipeline << ExecutionGroup.new( - tasks.map do |task| - next task if task.is_a?(Class) && (task <= Task) + tasks: + tasks.map do |task| + next task if task.is_a?(Class) && (task <= Task) - raise TypeError, "must be a CMDx::Task" - end, - options + raise TypeError, "#{task.inspect} is not a Task" + end, + options: ) end alias task tasks - # Returns all tasks in the pipeline. - # - # @return [Array<Class>] Array of task classes - # - # @example - # class MyWorkflow - # include CMDx::Workflow - # task Task1 - # task Task2 - # puts subtasks.size # => 2 - # end + private + + # Forbids user-defined `work` on workflows; `Workflow#work` delegates + # to {Pipeline}. # - # @rbs () -> Array[Class] - def subtasks - pipeline.flat_map(&:tasks) + # @raise [ImplementationError] when a workflow defines `work` + def method_added(method_name) + return super unless method_name == :work + + raise ImplementationError, "cannot define #{name}##{method_name} in a workflow" end end - # Represents a group of tasks with shared execution options. - # @attr tasks [Array<Class>] Array of task classes in this group - # @attr options [Hash] Configuration options for the group - ExecutionGroup = Struct.new(:tasks, :options) + # Immutable declaration of a task group. + ExecutionGroup = Data.define(:tasks, :options) - # Extends the including class with workflow capabilities. - # - # @param base [Class] The class including this module - # - # @example - # class MyWorkflow - # include CMDx::Workflow - # # Now has access to task, tasks, and work methods - # end - # - # @rbs (Class base) -> void + # @api private def self.included(base) base.extend(ClassMethods) end - # Executes the workflow by processing all tasks in the pipeline. - # This method delegates execution to the Pipeline class which handles - # the processing of tasks with proper error handling and context management. - # - # @example - # class MyWorkflow - # include CMDx::Workflow - # task ValidateTask - # task ProcessTask - # end - # - # workflow = MyWorkflow.new - # result = workflow.work + # Runs the workflow's pipeline. Not meant to be overridden. # - # @rbs () -> void + # @return [void] def work Pipeline.execute(self) end diff --git a/lib/generators/cmdx/install_generator.rb b/lib/generators/cmdx/install_generator.rb index 5a0c8a714..f1855c002 100644 --- a/lib/generators/cmdx/install_generator.rb +++ b/lib/generators/cmdx/install_generator.rb @@ -1,32 +1,12 @@ # frozen_string_literal: true module Cmdx - # Generates CMDx initializer file for Rails applications - # - # This generator creates a configuration initializer that sets up global - # CMDx settings for the Rails application. It copies a pre-configured - # initializer template to the standard Rails initializers directory. class InstallGenerator < Rails::Generators::Base source_root File.expand_path("templates", __dir__) desc "Creates CMDx initializer with global configuration settings" - # Copies the CMDx initializer template to the Rails application - # - # Creates a new initializer file at `config/initializers/cmdx.rb` containing - # the default CMDx configuration settings. This allows applications to - # customize global CMDx behavior through the standard Rails configuration - # pattern. - # - # @return [void] - # - # @example Basic usage - # rails generate cmdx:install - # - # @example Custom initializer location - # generator.copy_initializer_file - # # => Creates config/initializers/cmdx.rb def copy_initializer_file copy_file("install.rb", "config/initializers/cmdx.rb") end diff --git a/lib/generators/cmdx/locale_generator.rb b/lib/generators/cmdx/locale_generator.rb deleted file mode 100644 index 3db52087f..000000000 --- a/lib/generators/cmdx/locale_generator.rb +++ /dev/null @@ -1,39 +0,0 @@ -# frozen_string_literal: true - -module Cmdx - # Generates CMDx locale files for Rails applications - # - # Rails generator that copies CMDx locale files into the application's - # config/locales directory. This allows applications to customize and extend - # the default CMDx locale files. - class LocaleGenerator < Rails::Generators::Base - - source_root File.expand_path("../../locales", __dir__) - - desc "Copies the locale with the given ISO 639 code" - - argument :locale, type: :string, default: "en", banner: "locale: en, es, fr, etc" - - # Copies the locale template to the Rails application - # - # Copies the specified locale file from the gem's locales directory to the - # application's config/locales directory. If the locale file doesn't exist - # in the gem, the generator will fail gracefully. - # - # @return [void] - # - # @example Copy default (English) locale file - # generator = Cmdx::LocaleGenerator.new(["en"]) - # generator.copy_locale_files - # # => Creates config/locales/en.yml - # - # @example Copy Spanish locale file - # generator = Cmdx::LocaleGenerator.new(["es"]) - # generator.copy_locale_files - # # => Creates config/locales/es.yml - def copy_locale_files - copy_file("#{locale}.yml", "config/locales/#{locale}.yml") - end - - end -end diff --git a/lib/generators/cmdx/task_generator.rb b/lib/generators/cmdx/task_generator.rb index 3cb908ff7..c6fae255a 100644 --- a/lib/generators/cmdx/task_generator.rb +++ b/lib/generators/cmdx/task_generator.rb @@ -1,32 +1,12 @@ # frozen_string_literal: true module Cmdx - # Generates CMDx task files for Rails applications - # - # This generator creates task classes that inherit from either ApplicationTask - # (if defined) or CMDx::Task. It generates the task file in the standard - # Rails tasks directory structure. class TaskGenerator < Rails::Generators::NamedBase source_root File.expand_path("templates", __dir__) desc "Creates a task with the given NAME" - # Copies the task template to the Rails application - # - # Creates a new task file at `app/tasks/[class_path]/[file_name].rb` using - # the task template. The file is placed in the standard Rails tasks directory - # structure, maintaining proper namespacing if the task is nested. - # - # @return [void] - # - # @example Basic usage - # rails generate cmdx:task UserRegistration - # # => Creates app/tasks/user_registration.rb - # - # @example Nested task - # rails generate cmdx:task Admin::UserManagement - # # => Creates app/tasks/admin/user_management.rb def copy_files path = File.join("app/tasks", class_path, "#{file_name}.rb") template("task.rb.tt", path) @@ -34,19 +14,6 @@ def copy_files private - # Determines the appropriate parent class name for the generated task - # - # Attempts to use ApplicationTask if it exists in the application, otherwise - # falls back to CMDx::Task. This allows applications to define their own - # base task class while maintaining compatibility. - # - # @return [Class] The parent class for the generated task - # - # @example - # parent_class_name # => ApplicationTask - # - # @example Fallback behavior - # parent_class_name # => CMDx::Task def parent_class_name ApplicationTask rescue NameError diff --git a/lib/generators/cmdx/templates/install.rb b/lib/generators/cmdx/templates/install.rb index 9ebadffa8..8477d9225 100644 --- a/lib/generators/cmdx/templates/install.rb +++ b/lib/generators/cmdx/templates/install.rb @@ -1,65 +1,81 @@ # frozen_string_literal: true CMDx.configure do |config| - # Task breakpoint configuration - controls when execute! raises faults - # See https://github.com/drexed/cmdx/blob/main/docs/outcomes/statuses.md for more details + # =========================================================================== + # Locale + # =========================================================================== + # Fallback locale for built-in messages (validation, coercion, etc.) when + # the I18n gem is not present. With I18n loaded, CMDx follows `I18n.locale`. # - # Available statuses: "success", "skipped", "failed" - # If set to an empty array, task will never halt - config.task_breakpoints = %w[failed] + # config.default_locale = "en" - # Workflow breakpoint configuration - controls when workflows stop execution - # When a task returns these statuses, subsequent workflow tasks won't execute - # See https://github.com/drexed/cmdx/blob/main/docs/workflows.md for more details + # =========================================================================== + # Logging + # =========================================================================== + # In Rails, the Railtie already wires `config.logger = Rails.logger` and a + # backtrace cleaner — override here only if you need something different. # - # Available statuses: "success", "skipped", "failed" - # If set to an empty array, workflow will never halt - config.workflow_breakpoints = %w[failed] - - # Logger configuration - choose from multiple formatters - # See https://github.com/drexed/cmdx/blob/main/docs/logging.md for more details + # Formatters: Line (default), Json, KeyValue, Logstash, Raw # - # Available formatters: - # - CMDx::LogFormatters::Json - # - CMDx::LogFormatters::KeyValue - # - CMDx::LogFormatters::Line - # - CMDx::LogFormatters::Logstash - # - CMDx::LogFormatters::Raw - config.logger = Logger.new( - $stdout, - progname: "cmdx", - formatter: CMDx::LogFormatters::Line.new, - level: Logger::INFO - ) + # config.logger = Logger.new($stdout, progname: "cmdx") + # config.log_level = Logger::INFO + # config.log_formatter = CMDx::LogFormatters::Line.new + # config.backtrace_cleaner = ->(bt) { Rails.backtrace_cleaner.clean(bt) } - # Rollback configuration - controls which statuses trigger task rollback - # See https://github.com/drexed/cmdx/blob/main/docs/outcomes/statuses.md for more details + # =========================================================================== + # Middlewares + # =========================================================================== + # Wrap every task's execution. Must respond to `call(task) { ... }`. # - # Available statuses: "success", "skipped", "failed" - # If set to an empty array, task will never rollback - config.rollback_on = %w[failed] - - # Default locale configuration - used for built-in translation lookups - # Must match the basename of a YAML file in lib/locales/ (e.g. "en", "es", "ja") - # config.default_locale = "en" + # Example — run each task under the current user's locale: + # + # config.middlewares.register(proc do |task, &next_link| + # locale = task.context.current_user&.locale || I18n.default_locale + # I18n.with_locale(locale) { next_link.call } + # end) - # Backtrace configuration - controls whether to log backtraces on faults and exceptions - # https://github.com/drexed/cmdx/blob/main/docs/configuration.md#backtraces - # config.backtrace = false - # config.backtrace_cleaner = nil + # =========================================================================== + # Callbacks + # =========================================================================== + # Events: + # :before_validation, :before_execution, + # :on_complete, :on_interrupted, + # :on_success, :on_skipped, :on_failed, + # :on_ok, :on_ko + # + # config.callbacks.register(:on_failed, proc do |task| + # Rails.logger.error("[cmdx] #{task.class.name} failed: #{task.result.metadata[:reason]}") + # end) - # Exception handler configuration - called when non-fault exceptions are raised - # https://github.com/drexed/cmdx/blob/main/docs/configuration.md#exception-handlers - # config.exception_handler = nil + # =========================================================================== + # Telemetry + # =========================================================================== + # Events: + # :task_started, :task_deprecated, :task_retried, + # :task_rolled_back, :task_executed + # + # config.telemetry.subscribe(:task_executed, proc do |event| + # StatsD.timing("cmdx.#{event.name}", event.payload[:runtime]) + # end) - # Dump context configuration - include context data in hash representation output - # https://github.com/drexed/cmdx/blob/main/docs/configuration.md#dump-context - # config.dump_context = false + # =========================================================================== + # Coercions + # =========================================================================== + # Register custom type coercions. Callable receives `(value, **options)`. + # + # config.coercions.register(:currency, proc do |value, **| + # BigDecimal(value.to_s.gsub(/[^\d.-]/, "")) + # end) - # Additional global configurations - automatically applied to all tasks + # =========================================================================== + # Validators + # =========================================================================== + # Register custom validators. Callable receives `(value, options)` and + # returns a `CMDx::Validators::Failure.new(message)` on failure. # - # Middlewares - https://github.com/drexed/cmdx/blob/main/docs/middlewares.md - # Callbacks - https://github.com/drexed/cmdx/blob/main/docs/callbacks.md - # Coercions - https://github.com/drexed/cmdx/blob/main/docs/attributes/coercions.md - # Validations - https://github.com/drexed/cmdx/blob/main/docs/attributes/validations.md + # config.validators.register(:uuid, proc do |value, _options| + # unless value.to_s.match?(/\A[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}\z/i) + # CMDx::Validators::Failure.new("is not a valid UUID") + # end + # end) end diff --git a/lib/generators/cmdx/templates/task.rb.tt b/lib/generators/cmdx/templates/task.rb.tt index b0f698e46..278329d87 100644 --- a/lib/generators/cmdx/templates/task.rb.tt +++ b/lib/generators/cmdx/templates/task.rb.tt @@ -3,7 +3,7 @@ def work # Your logic here... - # Docs: https://github.com/drexed/cmdx + # Docs: https://drexed.github.io/cmdx/getting_started end end diff --git a/lib/generators/cmdx/templates/workflow.rb.tt b/lib/generators/cmdx/templates/workflow.rb.tt index baa989f27..5926df8e6 100644 --- a/lib/generators/cmdx/templates/workflow.rb.tt +++ b/lib/generators/cmdx/templates/workflow.rb.tt @@ -2,7 +2,6 @@ class <%= class_name %> < <%= parent_class_name %> include CMDx::Workflow - tasks Task1, Task2 - # Docs: https://github.com/drexed/cmdx/docs/workflows.md + # Docs: https://drexed.github.io/cmdx/workflows end <% end -%> diff --git a/lib/generators/cmdx/workflow_generator.rb b/lib/generators/cmdx/workflow_generator.rb index 9c77e7707..6f821fae9 100644 --- a/lib/generators/cmdx/workflow_generator.rb +++ b/lib/generators/cmdx/workflow_generator.rb @@ -1,32 +1,12 @@ # frozen_string_literal: true module Cmdx - # Generates CMDx workflow files for Rails applications - # - # This generator creates task classes that inherit from either ApplicationTask - # (if defined) or CMDx::Task. It generates the task file in the standard - # Rails tasks directory structure. class WorkflowGenerator < Rails::Generators::NamedBase source_root File.expand_path("templates", __dir__) desc "Creates a workflow with the given NAME" - # Copies the task template to the Rails application - # - # Creates a new task file at `app/tasks/[class_path]/[file_name].rb` using - # the task template. The file is placed in the standard Rails tasks directory - # structure, maintaining proper namespacing if the task is nested. - # - # @return [void] - # - # @example Basic usage - # rails generate cmdx:workflow SendNotifications - # # => Creates app/tasks/send_notifications.rb - # - # @example Nested task - # rails generate cmdx:workflow Admin::SendNotifications - # # => Creates app/tasks/admin/send_notifications.rb def copy_files path = File.join("app/tasks", class_path, "#{file_name}.rb") template("workflow.rb.tt", path) @@ -34,19 +14,6 @@ def copy_files private - # Determines the appropriate parent class name for the generated task - # - # Attempts to use ApplicationTask if it exists in the application, otherwise - # falls back to CMDx::Task. This allows applications to define their own - # base task class while maintaining compatibility. - # - # @return [Class] The parent class for the generated task - # - # @example - # parent_class_name # => ApplicationTask - # - # @example Fallback behavior - # parent_class_name # => CMDx::Task def parent_class_name ApplicationTask rescue NameError diff --git a/lib/locales/af.yml b/lib/locales/af.yml deleted file mode 100644 index b9f013330..000000000 --- a/lib/locales/af.yml +++ /dev/null @@ -1,55 +0,0 @@ -af: - cmdx: - attributes: - required: "moet toeganklik wees via die %{method} bronmetode" - undefined: "delegeer na ongedefinieerde metode %{method}" - coercions: - into_a: "kon nie na %{type} omskep word nie" - into_an: "kon nie na %{type} omskep word nie" - into_any: "kon nie na een van: %{types} omskep word nie" - unknown: "onbekende %{type} omskep tipe" - returns: - missing: "moet in die konteks gestel word" - faults: - invalid: "Ongeldig" - unspecified: "Ongespesifiseer" - reasons: - unspecified: "Ongespesifiseer" - types: - array: "skikking" - big_decimal: "groot desimale" - boolean: "booleaanse" - complex: "kompleks" - date_time: "datum en tyd" - date: "datum" - float: "drywende punt" - hash: "hash" - integer: "heelgetal" - rational: "rasionaal" - string: "string" - symbol: "simbool" - time: "tyd" - validators: - absence: "moet leeg wees" - exclusion: - of: "moet nie een van wees nie: %{values}" - within: "moet nie binne %{min} en %{max} wees nie" - format: "is 'n ongeldige formaat" - inclusion: - of: "moet een van wees: %{values}" - within: "moet binne %{min} en %{max} wees" - length: - is: "lengte moet %{is} wees" - is_not: "lengte moet nie %{is_not} wees nie" - min: "lengte moet ten minste %{min} wees" - max: "lengte kan maksimum %{max} wees" - not_within: "lengte moet nie binne %{min} en %{max} wees nie" - within: "lengte moet binne %{min} en %{max} wees" - numeric: - is: "moet %{is} wees" - is_not: "moet nie %{is_not} wees nie" - min: "moet ten minste %{min} wees" - max: "kan maksimum %{max} wees" - not_within: "moet nie binne %{min} en %{max} wees nie" - within: "moet binne %{min} en %{max} wees" - presence: "kan nie leeg wees nie" diff --git a/lib/locales/ar.yml b/lib/locales/ar.yml deleted file mode 100644 index 84d286397..000000000 --- a/lib/locales/ar.yml +++ /dev/null @@ -1,55 +0,0 @@ -ar: - cmdx: - attributes: - required: "يجب أن يكون قابلاً للوصول عبر طريقة المصدر %{method}" - undefined: "يفوض إلى طريقة غير معرفة %{method}" - coercions: - into_a: "لا يمكن تحويله إلى %{type}" - into_an: "لا يمكن تحويله إلى %{type}" - into_any: "لا يمكن تحويله إلى واحد من: %{types}" - unknown: "نوع تحويل %{type} غير معروف" - returns: - missing: "يجب تعيينه في السياق" - faults: - invalid: "غير صالح" - unspecified: "غير محدد" - reasons: - unspecified: "غير محدد" - types: - array: "مصفوفة" - big_decimal: "عدد عشري كبير" - boolean: "منطقي" - complex: "مركب" - date_time: "التاريخ والوقت" - date: "التاريخ" - float: "رقم عشري" - hash: "جدول تجزئة" - integer: "عدد صحيح" - rational: "عدد نسبي" - string: "سلسلة نصية" - symbol: "رمز" - time: "الوقت" - validators: - absence: "يجب أن يكون فارغاً" - exclusion: - of: "يجب ألا يكون واحداً من: %{values}" - within: "يجب ألا يكون بين %{min} و %{max}" - format: "تنسيق غير صالح" - inclusion: - of: "يجب أن يكون واحداً من: %{values}" - within: "يجب أن يكون بين %{min} و %{max}" - length: - is: "يجب أن يكون الطول %{is}" - is_not: "يجب ألا يكون الطول %{is_not}" - min: "يجب أن يكون الطول على الأقل %{min}" - max: "يجب أن يكون الطول على الأكثر %{max}" - not_within: "يجب ألا يكون الطول بين %{min} و %{max}" - within: "يجب أن يكون الطول بين %{min} و %{max}" - numeric: - is: "يجب أن يكون %{is}" - is_not: "يجب ألا يكون %{is_not}" - min: "يجب أن يكون على الأقل %{min}" - max: "يجب أن يكون على الأكثر %{max}" - not_within: "يجب ألا يكون بين %{min} و %{max}" - within: "يجب أن يكون بين %{min} و %{max}" - presence: "لا يمكن أن يكون فارغاً" diff --git a/lib/locales/az.yml b/lib/locales/az.yml deleted file mode 100644 index b6cc9e6a9..000000000 --- a/lib/locales/az.yml +++ /dev/null @@ -1,55 +0,0 @@ -az: - cmdx: - attributes: - required: "%{method} mənbə metodu vasitəsilə əlçatan olmalıdır" - undefined: "müəyyən edilməmiş metoda %{method} təyin edir" - coercions: - into_a: "%{type} tipinə çevrilə bilmədi" - into_an: "%{type} tipinə çevrilə bilmədi" - into_any: "aşağıdakılardan birinə çevrilə bilmədi: %{types}" - unknown: "naməlum %{type} çevrilmə tipi" - returns: - missing: "kontekstdə təyin edilməlidir" - faults: - invalid: "Etibarsız" - unspecified: "Göstərilməyib" - reasons: - unspecified: "Göstərilməyib" - types: - array: "massiv" - big_decimal: "böyük onluq" - boolean: "məntiqi" - complex: "mürəkkəb" - date_time: "tarix və vaxt" - date: "tarix" - float: "üzən nöqtə" - hash: "haş" - integer: "tam ədəd" - rational: "rasional" - string: "sətir" - symbol: "simvol" - time: "vaxt" - validators: - absence: "boş olmalıdır" - exclusion: - of: "aşağıdakılardan biri ola bilməz: %{values}" - within: "%{min} və %{max} arasında ola bilməz" - format: "etibarsız formatdır" - inclusion: - of: "aşağıdakılardan biri olmalıdır: %{values}" - within: "%{min} və %{max} arasında olmalıdır" - length: - is: "uzunluq %{is} olmalıdır" - is_not: "uzunluq %{is_not} ola bilməz" - min: "uzunluq ən azı %{min} olmalıdır" - max: "uzunluq ən çoxu %{max} ola bilər" - not_within: "uzunluq %{min} və %{max} arasında ola bilməz" - within: "uzunluq %{min} və %{max} arasında olmalıdır" - numeric: - is: "%{is} olmalıdır" - is_not: "%{is_not} ola bilməz" - min: "ən azı %{min} olmalıdır" - max: "ən çoxu %{max} ola bilər" - not_within: "%{min} və %{max} arasında ola bilməz" - within: "%{min} və %{max} arasında olmalıdır" - presence: "boş ola bilməz" diff --git a/lib/locales/be.yml b/lib/locales/be.yml deleted file mode 100644 index 58847f660..000000000 --- a/lib/locales/be.yml +++ /dev/null @@ -1,55 +0,0 @@ -be: - cmdx: - attributes: - required: "павінен быць даступны праз зыходны метад %{method}" - undefined: "дэлегуе невызначанаму метаду %{method}" - coercions: - into_a: "не ўдалося пераўтварыць у %{type}" - into_an: "не ўдалося пераўтварыць у %{type}" - into_any: "не ўдалося пераўтварыць у адзін з: %{types}" - unknown: "невядомы тып пераўтварэння %{type}" - returns: - missing: "павінна быць усталявана ў кантэксце" - faults: - invalid: "Няправільныя" - unspecified: "Не паказана" - reasons: - unspecified: "Не паказана" - types: - array: "масіў" - big_decimal: "вялікае дзесятковае лік" - boolean: "лагічны" - complex: "камплексны" - date_time: "дата і час" - date: "дата" - float: "лік з плаваючай коскай" - hash: "хэш" - integer: "цэлы лік" - rational: "рацыянальны" - string: "радок" - symbol: "сімвал" - time: "час" - validators: - absence: "павінна быць пустым" - exclusion: - of: "не можа быць адным з: %{values}" - within: "не можа быць паміж %{min} і %{max}" - format: "з'яўляецца несапраўдным фарматам" - inclusion: - of: "павінна быць адным з: %{values}" - within: "павінна быць паміж %{min} і %{max}" - length: - is: "даўжыня павінна быць %{is}" - is_not: "даўжыня не можа быць %{is_not}" - min: "даўжыня павінна быць не менш %{min}" - max: "даўжыня можа быць не больш %{max}" - not_within: "даўжыня не можа быць паміж %{min} і %{max}" - within: "даўжыня павінна быць паміж %{min} і %{max}" - numeric: - is: "павінна быць %{is}" - is_not: "не можа быць %{is_not}" - min: "павінна быць не менш %{min}" - max: "можа быць не больш %{max}" - not_within: "не можа быць паміж %{min} і %{max}" - within: "павінна быць паміж %{min} і %{max}" - presence: "не можа быць пустым" diff --git a/lib/locales/bg.yml b/lib/locales/bg.yml deleted file mode 100644 index 0aa6729cd..000000000 --- a/lib/locales/bg.yml +++ /dev/null @@ -1,55 +0,0 @@ -bg: - cmdx: - attributes: - required: "трябва да е достъпен чрез изходния метод %{method}" - undefined: "делегира към недефиниран метод %{method}" - coercions: - into_a: "не може да бъде преобразуван в %{type}" - into_an: "не може да бъде преобразуван в %{type}" - into_any: "не може да бъде преобразуван в един от: %{types}" - unknown: "неизвестен тип преобразуване %{type}" - returns: - missing: "трябва да бъде зададено в контекста" - faults: - invalid: "Невалидни" - unspecified: "Не е посочена" - reasons: - unspecified: "Не е посочена" - types: - array: "масив" - big_decimal: "голямо десетично число" - boolean: "булев" - complex: "комплексен" - date_time: "дата и час" - date: "дата" - float: "число с плаваща запетая" - hash: "хеш" - integer: "цяло число" - rational: "рационално" - string: "низ" - symbol: "символ" - time: "час" - validators: - absence: "трябва да бъде празно" - exclusion: - of: "не може да бъде един от: %{values}" - within: "не може да бъде между %{min} и %{max}" - format: "е невалиден формат" - inclusion: - of: "трябва да бъде един от: %{values}" - within: "трябва да бъде между %{min} и %{max}" - length: - is: "дължината трябва да бъде %{is}" - is_not: "дължината не може да бъде %{is_not}" - min: "дължината трябва да бъде поне %{min}" - max: "дължината може да бъде най-много %{max}" - not_within: "дължината не може да бъде между %{min} и %{max}" - within: "дължината трябва да бъде между %{min} и %{max}" - numeric: - is: "трябва да бъде %{is}" - is_not: "не може да бъде %{is_not}" - min: "трябва да бъде поне %{min}" - max: "може да бъде най-много %{max}" - not_within: "не може да бъде между %{min} и %{max}" - within: "трябва да бъде между %{min} и %{max}" - presence: "не може да бъде празно" diff --git a/lib/locales/bn.yml b/lib/locales/bn.yml deleted file mode 100644 index d7b7213e3..000000000 --- a/lib/locales/bn.yml +++ /dev/null @@ -1,55 +0,0 @@ -bn: - cmdx: - attributes: - required: "%{method} সোর্স মেথডের মাধ্যমে অ্যাক্সেসযোগ্য হতে হবে" - undefined: "অসংজ্ঞায়িত পদ্ধতিতে %{method} অর্পণ করে" - coercions: - into_a: "%{type} এ রূপান্তর করা যায়নি" - into_an: "%{type} এ রূপান্তর করা যায়নি" - into_any: "নিম্নলিখিতগুলির মধ্যে একটিতে রূপান্তর করা যায়নি: %{types}" - unknown: "অজানা %{type} রূপান্তর প্রকার" - returns: - missing: "প্রসঙ্গে সেট করতে হবে" - faults: - invalid: "অবৈধ" - unspecified: "অনির্দিষ্ট" - reasons: - unspecified: "অনির্দিষ্ট" - types: - array: "অ্যারে" - big_decimal: "বড় দশমিক" - boolean: "বুলিয়ান" - complex: "জটিল" - date_time: "তারিখ এবং সময়" - date: "তারিখ" - float: "ভাসমান বিন্দু" - hash: "হ্যাশ" - integer: "পূর্ণসংখ্যা" - rational: "মূলদ" - string: "স্ট্রিং" - symbol: "প্রতীক" - time: "সময়" - validators: - absence: "খালি হতে হবে" - exclusion: - of: "নিম্নলিখিতগুলির মধ্যে একটি হতে পারবে না: %{values}" - within: "%{min} এবং %{max} এর মধ্যে থাকতে পারবে না" - format: "একটি অবৈধ ফরম্যাট" - inclusion: - of: "নিম্নলিখিতগুলির মধ্যে একটি হতে হবে: %{values}" - within: "%{min} এবং %{max} এর মধ্যে থাকতে হবে" - length: - is: "দৈর্ঘ্য %{is} হতে হবে" - is_not: "দৈর্ঘ্য %{is_not} হতে পারবে না" - min: "দৈর্ঘ্য কমপক্ষে %{min} হতে হবে" - max: "দৈর্ঘ্য সর্বোচ্চ %{max} হতে পারবে" - not_within: "দৈর্ঘ্য %{min} এবং %{max} এর মধ্যে থাকতে পারবে না" - within: "দৈর্ঘ্য %{min} এবং %{max} এর মধ্যে থাকতে হবে" - numeric: - is: "%{is} হতে হবে" - is_not: "%{is_not} হতে পারবে না" - min: "কমপক্ষে %{min} হতে হবে" - max: "সর্বোচ্চ %{max} হতে পারবে" - not_within: "%{min} এবং %{max} এর মধ্যে থাকতে পারবে না" - within: "%{min} এবং %{max} এর মধ্যে থাকতে হবে" - presence: "খালি হতে পারবে না" diff --git a/lib/locales/bs.yml b/lib/locales/bs.yml deleted file mode 100644 index d2f49f1dd..000000000 --- a/lib/locales/bs.yml +++ /dev/null @@ -1,55 +0,0 @@ -bs: - cmdx: - attributes: - required: "mora biti dostupan preko izvorne metode %{method}" - undefined: "delegira nedefiniranoj metodi %{method}" - coercions: - into_a: "nije mogao biti pretvoren u %{type}" - into_an: "nije mogao biti pretvoren u %{type}" - into_any: "nije mogao biti pretvoren u jedan od: %{types}" - unknown: "nepoznati tip pretvorbe %{type}" - returns: - missing: "mora biti postavljeno u kontekstu" - faults: - invalid: "Neispravni" - unspecified: "Nije naveden" - reasons: - unspecified: "Nije naveden" - types: - array: "niz" - big_decimal: "veliki decimalni broj" - boolean: "logički" - complex: "kompleksan" - date_time: "datum i vrijeme" - date: "datum" - float: "broj s pomičnom točkom" - hash: "hash" - integer: "cijeli broj" - rational: "racionalan" - string: "niz znakova" - symbol: "simbol" - time: "vrijeme" - validators: - absence: "mora biti prazno" - exclusion: - of: "ne smije biti jedan od: %{values}" - within: "ne smije biti između %{min} i %{max}" - format: "je nevažeći format" - inclusion: - of: "mora biti jedan od: %{values}" - within: "mora biti između %{min} i %{max}" - length: - is: "dužina mora biti %{is}" - is_not: "dužina ne smije biti %{is_not}" - min: "dužina mora biti najmanje %{min}" - max: "dužina može biti najviše %{max}" - not_within: "dužina ne smije biti između %{min} i %{max}" - within: "dužina mora biti između %{min} i %{max}" - numeric: - is: "mora biti %{is}" - is_not: "ne smije biti %{is_not}" - min: "mora biti najmanje %{min}" - max: "može biti najviše %{max}" - not_within: "ne smije biti između %{min} i %{max}" - within: "mora biti između %{min} i %{max}" - presence: "ne može biti prazno" diff --git a/lib/locales/ca.yml b/lib/locales/ca.yml deleted file mode 100644 index 7d0a6fdb7..000000000 --- a/lib/locales/ca.yml +++ /dev/null @@ -1,55 +0,0 @@ -ca: - cmdx: - attributes: - required: "ha de ser accessible mitjançant el mètode font %{method}" - undefined: "delega al mètode no definit %{method}" - coercions: - into_a: "no es va poder convertir a %{type}" - into_an: "no es va poder convertir a %{type}" - into_any: "no es va poder convertir a un de: %{types}" - unknown: "tipus de conversió %{type} desconegut" - returns: - missing: "ha de ser establert en el context" - faults: - invalid: "No vàlides" - unspecified: "No especificat" - reasons: - unspecified: "No especificat" - types: - array: "array" - big_decimal: "decimal gran" - boolean: "booleà" - complex: "complex" - date_time: "data i hora" - date: "data" - float: "punt flotant" - hash: "hash" - integer: "enter" - rational: "racional" - string: "cadena" - symbol: "símbol" - time: "temps" - validators: - absence: "ha d'estar buit" - exclusion: - of: "no ha de ser un de: %{values}" - within: "no ha d'estar entre %{min} i %{max}" - format: "és un format no vàlid" - inclusion: - of: "ha de ser un de: %{values}" - within: "ha d'estar entre %{min} i %{max}" - length: - is: "la longitud ha de ser %{is}" - is_not: "la longitud no ha de ser %{is_not}" - min: "la longitud ha de ser almenys %{min}" - max: "la longitud ha de ser com a màxim %{max}" - not_within: "la longitud no ha d'estar entre %{min} i %{max}" - within: "la longitud ha d'estar entre %{min} i %{max}" - numeric: - is: "ha de ser %{is}" - is_not: "no ha de ser %{is_not}" - min: "ha de ser almenys %{min}" - max: "ha de ser com a màxim %{max}" - not_within: "no ha d'estar entre %{min} i %{max}" - within: "ha d'estar entre %{min} i %{max}" - presence: "no pot estar buit" diff --git a/lib/locales/cnr.yml b/lib/locales/cnr.yml deleted file mode 100644 index 69dd05ef9..000000000 --- a/lib/locales/cnr.yml +++ /dev/null @@ -1,55 +0,0 @@ -cnr: - cmdx: - attributes: - required: "mora biti dostupan preko izvorne metode %{method}" - undefined: "delegira na nedefinisani metod %{method}" - coercions: - into_a: "nije mogao konvertovati u %{type}" - into_an: "nije mogao konvertovati u %{type}" - into_any: "nije mogao konvertovati u jedan od: %{types}" - unknown: "nepoznati %{type} tip konverzije" - returns: - missing: "mora biti postavljeno u kontekstu" - faults: - invalid: "Neispravni" - unspecified: "Nije dat" - reasons: - unspecified: "Nije dat" - types: - array: "niz" - big_decimal: "veliki decimalni" - boolean: "bulovski" - complex: "kompleksan" - date_time: "datum i vrijeme" - date: "datum" - float: "pokretna tačka" - hash: "hash" - integer: "cijeli broj" - rational: "racionalan" - string: "string" - symbol: "simbol" - time: "vrijeme" - validators: - absence: "mora biti prazan" - exclusion: - of: "ne smije biti jedan od: %{values}" - within: "ne smije biti između %{min} i %{max}" - format: "je nevažeći format" - inclusion: - of: "mora biti jedan od: %{values}" - within: "mora biti između %{min} i %{max}" - length: - is: "dužina mora biti %{is}" - is_not: "dužina ne smije biti %{is_not}" - min: "dužina mora biti najmanje %{min}" - max: "dužina može biti najviše %{max}" - not_within: "dužina ne smije biti između %{min} i %{max}" - within: "dužina mora biti između %{min} i %{max}" - numeric: - is: "mora biti %{is}" - is_not: "ne smije biti %{is_not}" - min: "mora biti najmanje %{min}" - max: "može biti najviše %{max}" - not_within: "ne smije biti između %{min} i %{max}" - within: "mora biti između %{min} i %{max}" - presence: "ne može biti prazan" diff --git a/lib/locales/cs.yml b/lib/locales/cs.yml deleted file mode 100644 index f91d7e9af..000000000 --- a/lib/locales/cs.yml +++ /dev/null @@ -1,55 +0,0 @@ -cs: - cmdx: - attributes: - required: "musí být přístupné přes zdrojovou metodu %{method}" - undefined: "deleguje na nedefinovanou metodu %{method}" - coercions: - into_a: "nelze převést na %{type}" - into_an: "nelze převést na %{type}" - into_any: "nelze převést na jeden z: %{types}" - unknown: "neznámý typ převodu %{type}" - returns: - missing: "musí být nastaveno v kontextu" - faults: - invalid: "Neplatné" - unspecified: "Neurčeno" - reasons: - unspecified: "Neurčeno" - types: - array: "pole" - big_decimal: "velké desetinné číslo" - boolean: "logický" - complex: "komplexní" - date_time: "datum a čas" - date: "datum" - float: "desetinné číslo" - hash: "hash" - integer: "celé číslo" - rational: "racionální" - string: "řetězec" - symbol: "symbol" - time: "čas" - validators: - absence: "musí být prázdné" - exclusion: - of: "nesmí být jedním z: %{values}" - within: "nesmí být mezi %{min} a %{max}" - format: "je neplatný formát" - inclusion: - of: "musí být jedním z: %{values}" - within: "musí být mezi %{min} a %{max}" - length: - is: "délka musí být %{is}" - is_not: "délka nesmí být %{is_not}" - min: "délka musí být alespoň %{min}" - max: "délka může být nejvýše %{max}" - not_within: "délka nesmí být mezi %{min} a %{max}" - within: "délka musí být mezi %{min} a %{max}" - numeric: - is: "musí být %{is}" - is_not: "nesmí být %{is_not}" - min: "musí být alespoň %{min}" - max: "může být nejvýše %{max}" - not_within: "nesmí být mezi %{min} a %{max}" - within: "musí být mezi %{min} a %{max}" - presence: "nesmí být prázdné" diff --git a/lib/locales/cy.yml b/lib/locales/cy.yml deleted file mode 100644 index 773896731..000000000 --- a/lib/locales/cy.yml +++ /dev/null @@ -1,55 +0,0 @@ -cy: - cmdx: - attributes: - required: "rhaid bod yn hygyrch trwy'r dull ffynhonnell %{method}" - undefined: "delegio i'r dull amhendant %{method}" - coercions: - into_a: "ni allwyd ei drawsnewid i %{type}" - into_an: "ni allwyd ei drawsnewid i %{type}" - into_any: "ni allwyd ei drawsnewid i un o: %{types}" - unknown: "math anhysbys o drawsnewid %{type}" - returns: - missing: "rhaid ei osod yn y cyd-destun" - faults: - invalid: "Annilys" - unspecified: "Anhebgor" - reasons: - unspecified: "Anhebgor" - types: - array: "arae" - big_decimal: "degol mawr" - boolean: "booleaidd" - complex: "cymhleth" - date_time: "dyddiad ac amser" - date: "dyddiad" - float: "rhif pwynt arnofio" - hash: "hash" - integer: "cyfanrif" - rational: "rhesymegol" - string: "llinyn" - symbol: "symbol" - time: "amser" - validators: - absence: "rhaid iddo fod yn wag" - exclusion: - of: "ni all fod yn un o: %{values}" - within: "ni all fod rhwng %{min} a %{max}" - format: "yn fformat annilys" - inclusion: - of: "rhaid iddo fod yn un o: %{values}" - within: "rhaid iddo fod rhwng %{min} a %{max}" - length: - is: "rhaid i'r hyd fod yn %{is}" - is_not: "ni all y hyd fod yn %{is_not}" - min: "rhaid i'r hyd fod o leiaf %{min}" - max: "gall y hyd fod yn fwyaf %{max}" - not_within: "ni all y hyd fod rhwng %{min} a %{max}" - within: "rhaid i'r hyd fod rhwng %{min} a %{max}" - numeric: - is: "rhaid iddo fod yn %{is}" - is_not: "ni all fod yn %{is_not}" - min: "rhaid iddo fod o leiaf %{min}" - max: "gall fod yn fwyaf %{max}" - not_within: "ni all fod rhwng %{min} a %{max}" - within: "rhaid iddo fod rhwng %{min} a %{max}" - presence: "ni all fod yn wag" diff --git a/lib/locales/da.yml b/lib/locales/da.yml deleted file mode 100644 index 3cd27300f..000000000 --- a/lib/locales/da.yml +++ /dev/null @@ -1,55 +0,0 @@ -da: - cmdx: - attributes: - required: "skal være tilgængelig via %{method} kildemetoden" - undefined: "delegerer til udefineret metode %{method}" - coercions: - into_a: "kunne ikke konverteres til %{type}" - into_an: "kunne ikke konverteres til %{type}" - into_any: "kunne ikke konverteres til en af: %{types}" - unknown: "ukendt %{type} konverteringstype" - returns: - missing: "skal være sat i konteksten" - faults: - invalid: "Ugyldige" - unspecified: "Uspecificeret" - reasons: - unspecified: "Uspecificeret" - types: - array: "array" - big_decimal: "stort decimaltal" - boolean: "boolesk" - complex: "kompleks" - date_time: "dato og tid" - date: "dato" - float: "flydetal" - hash: "hash" - integer: "heltal" - rational: "rationelt" - string: "streng" - symbol: "symbol" - time: "tid" - validators: - absence: "skal være tom" - exclusion: - of: "må ikke være en af: %{values}" - within: "må ikke være mellem %{min} og %{max}" - format: "er et ugyldigt format" - inclusion: - of: "skal være en af: %{values}" - within: "skal være mellem %{min} og %{max}" - length: - is: "længden skal være %{is}" - is_not: "længden må ikke være %{is_not}" - min: "længden skal være mindst %{min}" - max: "længden må højst være %{max}" - not_within: "længden må ikke være mellem %{min} og %{max}" - within: "længden skal være mellem %{min} og %{max}" - numeric: - is: "skal være %{is}" - is_not: "må ikke være %{is_not}" - min: "skal være mindst %{min}" - max: "må højst være %{max}" - not_within: "må ikke være mellem %{min} og %{max}" - within: "skal være mellem %{min} og %{max}" - presence: "må ikke være tom" diff --git a/lib/locales/de.yml b/lib/locales/de.yml deleted file mode 100644 index d51786122..000000000 --- a/lib/locales/de.yml +++ /dev/null @@ -1,55 +0,0 @@ -de: - cmdx: - attributes: - required: "muss über die Quellmethode %{method} zugänglich sein" - undefined: "delegiert an undefinierte Methode %{method}" - coercions: - into_a: "konnte nicht in %{type} umgewandelt werden" - into_an: "konnte nicht in %{type} umgewandelt werden" - into_any: "konnte nicht in einen von: %{types} umgewandelt werden" - unknown: "unbekannter %{type} Umwandlungstyp" - returns: - missing: "muss im Kontext gesetzt sein" - faults: - invalid: "Ungültig" - unspecified: "Nicht angegeben" - reasons: - unspecified: "Nicht angegeben" - types: - array: "Array" - big_decimal: "große Dezimalzahl" - boolean: "Boolean" - complex: "komplex" - date_time: "Datum und Zeit" - date: "Datum" - float: "Gleitkommazahl" - hash: "Hash" - integer: "Ganzzahl" - rational: "rational" - string: "Zeichenkette" - symbol: "Symbol" - time: "Zeit" - validators: - absence: "muss leer sein" - exclusion: - of: "darf nicht einer von sein: %{values}" - within: "darf nicht zwischen %{min} und %{max} liegen" - format: "ist ein ungültiges Format" - inclusion: - of: "muss einer von sein: %{values}" - within: "muss zwischen %{min} und %{max} liegen" - length: - is: "Länge muss %{is} sein" - is_not: "Länge darf nicht %{is_not} sein" - min: "Länge muss mindestens %{min} sein" - max: "Länge darf höchstens %{max} sein" - not_within: "Länge darf nicht zwischen %{min} und %{max} liegen" - within: "Länge muss zwischen %{min} und %{max} liegen" - numeric: - is: "muss %{is} sein" - is_not: "darf nicht %{is_not} sein" - min: "muss mindestens %{min} sein" - max: "darf höchstens %{max} sein" - not_within: "darf nicht zwischen %{min} und %{max} liegen" - within: "muss zwischen %{min} und %{max} liegen" - presence: "darf nicht leer sein" diff --git a/lib/locales/dz.yml b/lib/locales/dz.yml deleted file mode 100644 index 00f22dfbb..000000000 --- a/lib/locales/dz.yml +++ /dev/null @@ -1,55 +0,0 @@ -dz: - cmdx: - attributes: - required: "%{method} གི་འབྱུང་ཁུངས་ཐབས་ལམ་བརྒྱུད་དེ་འཛུལ་སྤྱོད་འབད་ཚུགསཔ་དགོ།" - undefined: "ཚད་མ་ཆག་པའི་ཐབས་ལམ་ %{method} ལ་ཆ་རྐྱེན་བྱེད།" - coercions: - into_a: "%{type} ལ་བསྒྱུར་མ་ཐུབ།" - into_an: "%{type} ལ་བསྒྱུར་མ་ཐུབ།" - into_any: "འདི་ཚོའི་ནང་ནས་གཅིག་ལ་བསྒྱུར་མ་ཐུབ། %{types}" - unknown: "མ་ཤེས་པའི་ %{type} བསྒྱུར་པའི་རིགས།" - returns: - missing: "སྐབས་དོན་ནང་བཀོད་དགོས" - faults: - invalid: "ཆ་རྐྱེན་མི་ཆོག" - unspecified: "མ་བཟུང་།" - reasons: - unspecified: "མ་བཟུང་།" - types: - array: "ཐིག་ཕྲེང་།" - big_decimal: "ཆེ་བའི་བཅུ་ཆ།" - boolean: "བུལ།" - complex: "ཆག་ཆག་ཅན།" - date_time: "ཟླ་ཚེས་དང་དུས་ཚིགས།" - date: "ཟླ་ཚེས།" - float: "གཡོ་བའི་ཐིག་ཕྲེང་།" - hash: "ཧཱཤ།" - integer: "ཆ་ཆ་མེད་པའི་ཨང་།" - rational: "རིག་པ་ཅན།" - string: "ཡིག་ཚགས།" - symbol: "མཚན་མ།" - time: "དུས་ཚིགས།" - validators: - absence: "སྟོང་པོ་ཆོག་པ་ཆེད་དགོས།" - exclusion: - of: "འདི་ཚོའི་ནང་ནས་གཅིག་མི་ཆོག %{values}" - within: "%{min} དང་ %{max} ཡབ་ཡུམ་ནས་མི་ཆོག" - format: "ཆ་རྐྱེན་མི་ཆོག་པའི་རྣམ་པ།" - inclusion: - of: "འདི་ཚོའི་ནང་ནས་གཅིག་ཆོག་པ་ཆེད་དགོས། %{values}" - within: "%{min} དང་ %{max} ཡབ་ཡུམ་ནས་ཆོག་པ་ཆེད་དགོས།" - length: - is: "རིང་ཚད་ %{is} ཆོག་པ་ཆེད་དགོས།" - is_not: "རིང་ཚད་ %{is_not} མི་ཆོག" - min: "རིང་ཚད་ཆུང་ཤོས་ %{min} ཆོག་པ་ཆེད་དགོས།" - max: "རིང་ཚད་ཆེ་ཤོས་ %{max} ཆོག" - not_within: "རིང་ཚད་ %{min} དང་ %{max} ཡབ་ཡུམ་ནས་མི་ཆོག" - within: "རིང་ཚད་ %{min} དང་ %{max} ཡབ་ཡུམ་ནས་ཆོག་པ་ཆེད་དགོས།" - numeric: - is: "%{is} ཆོག་པ་ཆེད་དགོས།" - is_not: "%{is_not} མི་ཆོག" - min: "ཆུང་ཤོས་ %{min} ཆོག་པ་ཆེད་དགོས།" - max: "ཆེ་ཤོས་ %{max} ཆོག" - not_within: "%{min} དང་ %{max} ཡབ་ཡུམ་ནས་མི་ཆོག" - within: "%{min} དང་ %{max} ཡབ་ཡུམ་ནས་ཆོག་པ་ཆེད་དགོས།" - presence: "སྟོང་པོ་མི་ཆོག" diff --git a/lib/locales/el.yml b/lib/locales/el.yml deleted file mode 100644 index d95b4c2e0..000000000 --- a/lib/locales/el.yml +++ /dev/null @@ -1,55 +0,0 @@ -el: - cmdx: - attributes: - required: "πρέπει να είναι προσβάσιμο μέσω της μεθόδου πηγής %{method}" - undefined: "αντιπροσωπεύει απροσδιόριστη μέθοδο %{method}" - coercions: - into_a: "δεν μπόρεσε να μετατραπεί σε %{type}" - into_an: "δεν μπόρεσε να μετατραπεί σε %{type}" - into_any: "δεν μπόρεσε να μετατραπεί σε ένα από: %{types}" - unknown: "άγνωστος τύπος μετατροπής %{type}" - returns: - missing: "πρέπει να οριστεί στο πλαίσιο" - faults: - invalid: "Μη έγκυρες" - unspecified: "Απροσδιόριστο" - reasons: - unspecified: "Απροσδιόριστο" - types: - array: "πίνακας" - big_decimal: "μεγάλος δεκαδικός" - boolean: "δυαδικός" - complex: "μιγαδικός" - date_time: "ημερομηνία και ώρα" - date: "ημερομηνία" - float: "αριθμός κινητής υποδιαστολής" - hash: "κατακερματισμός" - integer: "ακέραιος" - rational: "ρητός" - string: "συμβολοσειρά" - symbol: "σύμβολο" - time: "ώρα" - validators: - absence: "πρέπει να είναι κενό" - exclusion: - of: "δεν πρέπει να είναι ένα από: %{values}" - within: "δεν πρέπει να είναι μεταξύ %{min} και %{max}" - format: "είναι μη έγκυρη μορφή" - inclusion: - of: "πρέπει να είναι ένα από: %{values}" - within: "πρέπει να είναι μεταξύ %{min} και %{max}" - length: - is: "το μήκος πρέπει να είναι %{is}" - is_not: "το μήκος δεν πρέπει να είναι %{is_not}" - min: "το μήκος πρέπει να είναι τουλάχιστον %{min}" - max: "το μήκος πρέπει να είναι το πολύ %{max}" - not_within: "το μήκος δεν πρέπει να είναι μεταξύ %{min} και %{max}" - within: "το μήκος πρέπει να είναι μεταξύ %{min} και %{max}" - numeric: - is: "πρέπει να είναι %{is}" - is_not: "δεν πρέπει να είναι %{is_not}" - min: "πρέπει να είναι τουλάχιστον %{min}" - max: "πρέπει να είναι το πολύ %{max}" - not_within: "δεν πρέπει να είναι μεταξύ %{min} και %{max}" - within: "πρέπει να είναι μεταξύ %{min} και %{max}" - presence: "δεν μπορεί να είναι κενό" diff --git a/lib/locales/en.yml b/lib/locales/en.yml index a84bc5b44..a69aeaa5b 100644 --- a/lib/locales/en.yml +++ b/lib/locales/en.yml @@ -1,19 +1,14 @@ en: cmdx: attributes: - required: "must be accessible via the %{method} source method" - undefined: "delegates to undefined method %{method}" + required: "is required" coercions: into_a: "could not coerce into a %{type}" into_an: "could not coerce into an %{type}" into_any: "could not coerce into one of: %{types}" - unknown: "unknown %{type} coercion type" - faults: - invalid: "Invalid" - unspecified: "Unspecified" reasons: unspecified: "Unspecified" - returns: + outputs: missing: "must be set in the context" types: array: "array" @@ -43,6 +38,9 @@ en: is_not: "length must not be %{is_not}" min: "length must be at least %{min}" max: "length must be at most %{max}" + gt: "length must be greater than %{gt}" + lt: "length must be less than %{lt}" + nil_value: "must have a length" not_within: "length must not be within %{min} and %{max}" within: "length must be within %{min} and %{max}" numeric: @@ -50,6 +48,9 @@ en: is_not: "must not be %{is_not}" min: "must be at least %{min}" max: "must be at most %{max}" + gt: "must be greater than %{gt}" + lt: "must be less than %{lt}" + nil_value: "must be numeric" not_within: "must not be within %{min} and %{max}" within: "must be within %{min} and %{max}" presence: "cannot be empty" diff --git a/lib/locales/eo.yml b/lib/locales/eo.yml deleted file mode 100644 index 55d848eac..000000000 --- a/lib/locales/eo.yml +++ /dev/null @@ -1,55 +0,0 @@ -eo: - cmdx: - attributes: - required: "devas esti alirebla per la fontmetodo %{method}" - undefined: "delegas al nedifinita metodo %{method}" - coercions: - into_a: "ne povis esti konvertita al %{type}" - into_an: "ne povis esti konvertita al %{type}" - into_any: "ne povis esti konvertita al unu el: %{types}" - unknown: "nekonata %{type} konverta tipo" - returns: - missing: "devas esti agordita en la kunteksto" - faults: - invalid: "Nevalidaj" - unspecified: "Nespecifita" - reasons: - unspecified: "Nespecifita" - types: - array: "tabelo" - big_decimal: "granda dekuma" - boolean: "bulea" - complex: "kompleksa" - date_time: "dato kaj tempo" - date: "dato" - float: "flosanta punkto" - hash: "hash" - integer: "entjero" - rational: "racia" - string: "ĉeno" - symbol: "simbolo" - time: "tempo" - validators: - absence: "devas esti malplena" - exclusion: - of: "ne devas esti unu el: %{values}" - within: "ne devas esti inter %{min} kaj %{max}" - format: "estas nevalida formato" - inclusion: - of: "devas esti unu el: %{values}" - within: "devas esti inter %{min} kaj %{max}" - length: - is: "longo devas esti %{is}" - is_not: "longo ne devas esti %{is_not}" - min: "longo devas esti almenaŭ %{min}" - max: "longo povas esti maksimume %{max}" - not_within: "longo ne devas esti inter %{min} kaj %{max}" - within: "longo devas esti inter %{min} kaj %{max}" - numeric: - is: "devas esti %{is}" - is_not: "ne devas esti %{is_not}" - min: "devas esti almenaŭ %{min}" - max: "povas esti maksimume %{max}" - not_within: "ne devas esti inter %{min} kaj %{max}" - within: "devas esti inter %{min} kaj %{max}" - presence: "ne povas esti malplena" diff --git a/lib/locales/es.yml b/lib/locales/es.yml deleted file mode 100644 index 5f134888b..000000000 --- a/lib/locales/es.yml +++ /dev/null @@ -1,55 +0,0 @@ -es: - cmdx: - attributes: - required: "debe ser accesible a través del método de origen %{method}" - undefined: "delega a método indefinido %{method}" - coercions: - into_a: "no se pudo convertir en un %{type}" - into_an: "no se pudo convertir en un %{type}" - into_any: "no se pudo convertir en uno de: %{types}" - unknown: "tipo de conversión %{type} desconocido" - returns: - missing: "debe estar establecido en el contexto" - faults: - invalid: "Inválidas" - unspecified: "No especificada" - reasons: - unspecified: "No especificada" - types: - array: "array" - big_decimal: "decimal grande" - boolean: "booleano" - complex: "complejo" - date_time: "fecha y hora" - date: "fecha" - float: "flotante" - hash: "hash" - integer: "entero" - rational: "racional" - string: "cadena" - symbol: "símbolo" - time: "tiempo" - validators: - absence: "debe estar vacío" - exclusion: - of: "no debe ser uno de: %{values}" - within: "no debe estar dentro de %{min} y %{max}" - format: "es un formato inválido" - inclusion: - of: "debe ser uno de: %{values}" - within: "debe estar dentro de %{min} y %{max}" - length: - is: "la longitud debe ser %{is}" - is_not: "la longitud no debe ser %{is_not}" - min: "la longitud debe ser al menos %{min}" - max: "la longitud debe ser como máximo %{max}" - not_within: "la longitud no debe estar dentro de %{min} y %{max}" - within: "la longitud debe estar dentro de %{min} y %{max}" - numeric: - is: "debe ser %{is}" - is_not: "no debe ser %{is_not}" - min: "debe ser al menos %{min}" - max: "debe ser como máximo %{max}" - not_within: "no debe estar dentro de %{min} y %{max}" - within: "debe estar dentro de %{min} y %{max}" - presence: "no puede estar vacío" diff --git a/lib/locales/et.yml b/lib/locales/et.yml deleted file mode 100644 index 7dfb9ac2b..000000000 --- a/lib/locales/et.yml +++ /dev/null @@ -1,55 +0,0 @@ -et: - cmdx: - attributes: - required: "peab olema juurdepääsetav lähtemeetodi %{method} kaudu" - undefined: "delegeerib määramata meetodile %{method}" - coercions: - into_a: "ei õnnestunud teisendada %{type} tüübiks" - into_an: "ei õnnestunud teisendada %{type} tüübiks" - into_any: "ei õnnestunud teisendada üheks järgmistest: %{types}" - unknown: "tundmatu %{type} teisendustüüp" - returns: - missing: "peab olema kontekstis määratud" - faults: - invalid: "Kehtetud" - unspecified: "Määramata" - reasons: - unspecified: "Määramata" - types: - array: "massiiv" - big_decimal: "suur kümnendmurd" - boolean: "loogiline" - complex: "kompleksne" - date_time: "kuupäev ja kellaaeg" - date: "kuupäev" - float: "ujukomaarv" - hash: "räsi" - integer: "täisarv" - rational: "ratsionaalne" - string: "string" - symbol: "sümbol" - time: "aeg" - validators: - absence: "peab olema tühi" - exclusion: - of: "ei tohi olla üks järgmistest: %{values}" - within: "ei tohi olla %{min} ja %{max} vahel" - format: "on kehtetu vorming" - inclusion: - of: "peab olema üks järgmistest: %{values}" - within: "peab olema %{min} ja %{max} vahel" - length: - is: "pikkus peab olema %{is}" - is_not: "pikkus ei tohi olla %{is_not}" - min: "pikkus peab olema vähemalt %{min}" - max: "pikkus võib olla maksimaalselt %{max}" - not_within: "pikkus ei tohi olla %{min} ja %{max} vahel" - within: "pikkus peab olema %{min} ja %{max} vahel" - numeric: - is: "peab olema %{is}" - is_not: "ei tohi olla %{is_not}" - min: "peab olema vähemalt %{min}" - max: "võib olla maksimaalselt %{max}" - not_within: "ei tohi olla %{min} ja %{max} vahel" - within: "peab olema %{min} ja %{max} vahel" - presence: "ei tohi olla tühi" diff --git a/lib/locales/eu.yml b/lib/locales/eu.yml deleted file mode 100644 index e24a2fe73..000000000 --- a/lib/locales/eu.yml +++ /dev/null @@ -1,55 +0,0 @@ -eu: - cmdx: - attributes: - required: "eskuragarri egon behar du %{method} iturburu-metodoaren bidez" - undefined: "metodo zehaztugabea %{method} delegatzen du" - coercions: - into_a: "ezin izan da %{type} ra bihurtu" - into_an: "ezin izan da %{type} ra bihurtu" - into_any: "ezin izan da honetako batera bihurtu: %{types}" - unknown: "%{type} bihurtze mota ezezaguna" - returns: - missing: "testuinguruan ezarrita egon behar du" - faults: - invalid: "Baliogabeak" - unspecified: "Zehaztu gabe" - reasons: - unspecified: "Zehaztu gabe" - types: - array: "array" - big_decimal: "hamartar handia" - boolean: "boolearra" - complex: "konplexua" - date_time: "data eta ordua" - date: "data" - float: "puntu mugikorra" - hash: "hash" - integer: "zenbaki osoa" - rational: "arrazoizkoa" - string: "katea" - symbol: "ikurra" - time: "ordua" - validators: - absence: "hutsik izan behar du" - exclusion: - of: "ezin da honetako bat izan: %{values}" - within: "ezin da %{min} eta %{max} artean izan" - format: "formatu baliogabea da" - inclusion: - of: "honetako bat izan behar du: %{values}" - within: "%{min} eta %{max} artean izan behar du" - length: - is: "luzera %{is} izan behar du" - is_not: "luzera ezin da %{is_not} izan" - min: "luzera gutxienez %{min} izan behar du" - max: "luzera gehienez %{max} izan daiteke" - not_within: "luzera ezin da %{min} eta %{max} artean izan" - within: "luzera %{min} eta %{max} artean izan behar du" - numeric: - is: "%{is} izan behar du" - is_not: "ezin da %{is_not} izan" - min: "gutxienez %{min} izan behar du" - max: "gehienez %{max} izan daiteke" - not_within: "ezin da %{min} eta %{max} artean izan" - within: "%{min} eta %{max} artean izan behar du" - presence: "ezin da hutsik izan" diff --git a/lib/locales/fa.yml b/lib/locales/fa.yml deleted file mode 100644 index e3ca777ef..000000000 --- a/lib/locales/fa.yml +++ /dev/null @@ -1,55 +0,0 @@ -fa: - cmdx: - attributes: - required: "باید از طریق متد منبع %{method} قابل دسترسی باشد" - undefined: "به متد تعریف نشده %{method} واگذار می‌کند" - coercions: - into_a: "نمی‌تواند به %{type} تبدیل شود" - into_an: "نمی‌تواند به %{type} تبدیل شود" - into_any: "نمی‌تواند به یکی از اینها تبدیل شود: %{types}" - unknown: "نوع تبدیل %{type} ناشناخته" - returns: - missing: "باید در زمینه تنظیم شود" - faults: - invalid: "نامعتبر" - unspecified: "نامشخص" - reasons: - unspecified: "نامشخص" - types: - array: "آرایه" - big_decimal: "اعشاری بزرگ" - boolean: "بولین" - complex: "مرکب" - date_time: "تاریخ و زمان" - date: "تاریخ" - float: "نقطه شناور" - hash: "هش" - integer: "عدد صحیح" - rational: "گویا" - string: "رشته" - symbol: "نماد" - time: "زمان" - validators: - absence: "باید خالی باشد" - exclusion: - of: "نباید یکی از اینها باشد: %{values}" - within: "نباید بین %{min} و %{max} باشد" - format: "فرمت نامعتبر است" - inclusion: - of: "باید یکی از اینها باشد: %{values}" - within: "باید بین %{min} و %{max} باشد" - length: - is: "طول باید %{is} باشد" - is_not: "طول نباید %{is_not} باشد" - min: "طول باید حداقل %{min} باشد" - max: "طول می‌تواند حداکثر %{max} باشد" - not_within: "طول نباید بین %{min} و %{max} باشد" - within: "طول باید بین %{min} و %{max} باشد" - numeric: - is: "باید %{is} باشد" - is_not: "نباید %{is_not} باشد" - min: "باید حداقل %{min} باشد" - max: "می‌تواند حداکثر %{max} باشد" - not_within: "نباید بین %{min} و %{max} باشد" - within: "باید بین %{min} و %{max} باشد" - presence: "نمی‌تواند خالی باشد" diff --git a/lib/locales/fi.yml b/lib/locales/fi.yml deleted file mode 100644 index 15b7f70a4..000000000 --- a/lib/locales/fi.yml +++ /dev/null @@ -1,55 +0,0 @@ -fi: - cmdx: - attributes: - required: "täytyy olla saavutettavissa lähdemetodin %{method} kautta" - undefined: "delegoi määrittelemättömälle metodille %{method}" - coercions: - into_a: "ei voitu muuntaa tyypiksi %{type}" - into_an: "ei voitu muuntaa tyypiksi %{type}" - into_any: "ei voitu muuntaa yhdeksi seuraavista: %{types}" - unknown: "tuntematon %{type} muunnostyyppi" - returns: - missing: "on asetettava kontekstissa" - faults: - invalid: "Virheelliset" - unspecified: "Määrittämätön" - reasons: - unspecified: "Määrittämätön" - types: - array: "taulukko" - big_decimal: "iso desimaali" - boolean: "totuusarvo" - complex: "kompleksi" - date_time: "päivämäärä ja aika" - date: "päivämäärä" - float: "liukuluku" - hash: "hajautustaulu" - integer: "kokonaisluku" - rational: "rationaaliluku" - string: "merkkijono" - symbol: "symboli" - time: "aika" - validators: - absence: "täytyy olla tyhjä" - exclusion: - of: "ei saa olla yksi seuraavista: %{values}" - within: "ei saa olla välillä %{min} ja %{max}" - format: "on virheellinen muoto" - inclusion: - of: "täytyy olla yksi seuraavista: %{values}" - within: "täytyy olla välillä %{min} ja %{max}" - length: - is: "pituuden täytyy olla %{is}" - is_not: "pituuden ei saa olla %{is_not}" - min: "pituuden täytyy olla vähintään %{min}" - max: "pituuden täytyy olla enintään %{max}" - not_within: "pituuden ei saa olla välillä %{min} ja %{max}" - within: "pituuden täytyy olla välillä %{min} ja %{max}" - numeric: - is: "täytyy olla %{is}" - is_not: "ei saa olla %{is_not}" - min: "täytyy olla vähintään %{min}" - max: "täytyy olla enintään %{max}" - not_within: "ei saa olla välillä %{min} ja %{max}" - within: "täytyy olla välillä %{min} ja %{max}" - presence: "ei saa olla tyhjä" diff --git a/lib/locales/fr.yml b/lib/locales/fr.yml deleted file mode 100644 index 8a474762a..000000000 --- a/lib/locales/fr.yml +++ /dev/null @@ -1,55 +0,0 @@ -fr: - cmdx: - attributes: - required: "doit être accessible via la méthode source %{method}" - undefined: "délègue à une méthode non définie %{method}" - coercions: - into_a: "n'a pas pu être converti en %{type}" - into_an: "n'a pas pu être converti en %{type}" - into_any: "n'a pas pu être converti en l'un de : %{types}" - unknown: "type de conversion %{type} inconnu" - returns: - missing: "doit être défini dans le contexte" - faults: - invalid: "Invalides" - unspecified: "Non spécifié" - reasons: - unspecified: "Non spécifié" - types: - array: "tableau" - big_decimal: "grand décimal" - boolean: "booléen" - complex: "complexe" - date_time: "date et heure" - date: "date" - float: "flottant" - hash: "hash" - integer: "entier" - rational: "rationnel" - string: "chaîne" - symbol: "symbole" - time: "heure" - validators: - absence: "doit être vide" - exclusion: - of: "ne doit pas être l'un de : %{values}" - within: "ne doit pas être dans %{min} et %{max}" - format: "est un format invalide" - inclusion: - of: "doit être l'un de : %{values}" - within: "doit être dans %{min} et %{max}" - length: - is: "la longueur doit être %{is}" - is_not: "la longueur ne doit pas être %{is_not}" - min: "la longueur doit être au moins %{min}" - max: "la longueur doit être au plus %{max}" - not_within: "la longueur ne doit pas être dans %{min} et %{max}" - within: "la longueur doit être dans %{min} et %{max}" - numeric: - is: "doit être %{is}" - is_not: "ne doit pas être %{is_not}" - min: "doit être au moins %{min}" - max: "doit être au plus %{max}" - not_within: "ne doit pas être dans %{min} et %{max}" - within: "doit être dans %{min} et %{max}" - presence: "ne peut pas être vide" diff --git a/lib/locales/fy.yml b/lib/locales/fy.yml deleted file mode 100644 index 8fef47ddb..000000000 --- a/lib/locales/fy.yml +++ /dev/null @@ -1,55 +0,0 @@ -fy: - cmdx: - attributes: - required: "moat tagonklik wêze fia de boarnemetoade %{method}" - undefined: "delegearret nei ûndefiniearre metoade %{method}" - coercions: - into_a: "koe net nei %{type} konvertearje" - into_an: "koe net nei %{type} konvertearje" - into_any: "koe net nei ien fan: %{types} konvertearje" - unknown: "ûnbekende %{type} konverzje type" - returns: - missing: "moat ynsteld wurde yn de kontekst" - faults: - invalid: "Ûnjildige" - unspecified: "Net spesifisearre" - reasons: - unspecified: "Net spesifisearre" - types: - array: "array" - big_decimal: "grutte desimaal" - boolean: "booleaansk" - complex: "kompleks" - date_time: "datum en tiid" - date: "datum" - float: "driuwpunt" - hash: "hash" - integer: "hielgetal" - rational: "rasjonaal" - string: "string" - symbol: "symboal" - time: "tiid" - validators: - absence: "moat leech wêze" - exclusion: - of: "moat net ien fan wêze: %{values}" - within: "moat net binnen %{min} en %{max} wêze" - format: "is in ûnjildich formaat" - inclusion: - of: "moat ien fan wêze: %{values}" - within: "moat binnen %{min} en %{max} wêze" - length: - is: "lingte moat %{is} wêze" - is_not: "lingte moat net %{is_not} wêze" - min: "lingte moat op syn minst %{min} wêze" - max: "lingte kin maksimaal %{max} wêze" - not_within: "lingte moat net binnen %{min} en %{max} wêze" - within: "lingte moat binnen %{min} en %{max} wêze" - numeric: - is: "moat %{is} wêze" - is_not: "moat net %{is_not} wêze" - min: "moat op syn minst %{min} wêze" - max: "kin maksimaal %{max} wêze" - not_within: "moat net binnen %{min} en %{max} wêze" - within: "moat binnen %{min} en %{max} wêze" - presence: "kin net leech wêze" diff --git a/lib/locales/gd.yml b/lib/locales/gd.yml deleted file mode 100644 index cef9ebeae..000000000 --- a/lib/locales/gd.yml +++ /dev/null @@ -1,55 +0,0 @@ -gd: - cmdx: - attributes: - required: "feumaidh e bhith ruigsinneach tron mhodh thùsail %{method}" - undefined: "deileigeas gu modh neo-mhìneachaidh %{method}" - coercions: - into_a: "cha b' urrainn tionndadh gu %{type}" - into_an: "cha b' urrainn tionndadh gu %{type}" - into_any: "cha b' urrainn tionndadh gu aon de: %{types}" - unknown: "seòrsa tionndaidh %{type} neo-aithnichte" - returns: - missing: "feumaidh e bhith air a shuidheachadh sa cho-theacsa" - faults: - invalid: "Neo-dhligheach" - unspecified: "Neo-mhìneachaidh" - reasons: - unspecified: "Neo-mhìneachaidh" - types: - array: "sreath" - big_decimal: "deicheamh mòr" - boolean: "booleach" - complex: "iom-fhillte" - date_time: "ceann-latha agus àm" - date: "ceann-latha" - float: "puing air itealaich" - hash: "hash" - integer: "àireamh shlàn" - rational: "reusanta" - string: "sreang" - symbol: "samhla" - time: "àm" - validators: - absence: "feumaidh e a bhith falamh" - exclusion: - of: "chan fhaod a bhith aon de: %{values}" - within: "chan fhaod a bhith taobh a-staigh %{min} agus %{max}" - format: "tha e na chruth neo-dhligheach" - inclusion: - of: "feumaidh e a bhith aon de: %{values}" - within: "feumaidh e a bhith taobh a-staigh %{min} agus %{max}" - length: - is: "feumaidh an fhad a bhith %{is}" - is_not: "chan fhaod an fhad a bhith %{is_not}" - min: "feumaidh an fhad a bhith co-dhiù %{min}" - max: "faodaidh an fhad a bhith suas ri %{max}" - not_within: "chan fhaod an fhad a bhith taobh a-staigh %{min} agus %{max}" - within: "feumaidh an fhad a bhith taobh a-staigh %{min} agus %{max}" - numeric: - is: "feumaidh e a bhith %{is}" - is_not: "chan fhaod e a bhith %{is_not}" - min: "feumaidh e a bhith co-dhiù %{min}" - max: "faodaidh e a bhith suas ri %{max}" - not_within: "chan fhaod e a bhith taobh a-staigh %{min} agus %{max}" - within: "feumaidh e a bhith taobh a-staigh %{min} agus %{max}" - presence: "chan fhaod e a bhith falamh" diff --git a/lib/locales/gl.yml b/lib/locales/gl.yml deleted file mode 100644 index 0fe418951..000000000 --- a/lib/locales/gl.yml +++ /dev/null @@ -1,55 +0,0 @@ -gl: - cmdx: - attributes: - required: "debe ser accesible a través do método de orixe %{method}" - undefined: "delega ao método non definido %{method}" - coercions: - into_a: "non se puido converter a %{type}" - into_an: "non se puido converter a %{type}" - into_any: "non se puido converter a un de: %{types}" - unknown: "tipo de conversión %{type} descoñecido" - returns: - missing: "debe estar establecido no contexto" - faults: - invalid: "Non válidas" - unspecified: "Non especificada" - reasons: - unspecified: "Non especificada" - types: - array: "array" - big_decimal: "decimal grande" - boolean: "booleano" - complex: "complexo" - date_time: "data e hora" - date: "data" - float: "punto flotante" - hash: "hash" - integer: "enteiro" - rational: "racional" - string: "cadea" - symbol: "símbolo" - time: "tempo" - validators: - absence: "debe estar baleiro" - exclusion: - of: "non debe ser un de: %{values}" - within: "non debe estar entre %{min} e %{max}" - format: "é un formato non válido" - inclusion: - of: "debe ser un de: %{values}" - within: "debe estar entre %{min} e %{max}" - length: - is: "a lonxitude debe ser %{is}" - is_not: "a lonxitude non debe ser %{is_not}" - min: "a lonxitude debe ser polo menos %{min}" - max: "a lonxitude pode ser como máximo %{max}" - not_within: "a lonxitude non debe estar entre %{min} e %{max}" - within: "a lonxitude debe estar entre %{min} e %{max}" - numeric: - is: "debe ser %{is}" - is_not: "non debe ser %{is_not}" - min: "debe ser polo menos %{min}" - max: "pode ser como máximo %{max}" - not_within: "non debe estar entre %{min} e %{max}" - within: "debe estar entre %{min} e %{max}" - presence: "non pode estar baleiro" diff --git a/lib/locales/he.yml b/lib/locales/he.yml deleted file mode 100644 index 308682119..000000000 --- a/lib/locales/he.yml +++ /dev/null @@ -1,55 +0,0 @@ -he: - cmdx: - attributes: - required: "חייב להיות נגיש דרך מתודת המקור %{method}" - undefined: "מאציל לשיטה לא מוגדרת %{method}" - coercions: - into_a: "לא ניתן להמיר ל-%{type}" - into_an: "לא ניתן להמיר ל-%{type}" - into_any: "לא ניתן להמיר לאחד מ: %{types}" - unknown: "סוג המרה %{type} לא ידוע" - returns: - missing: "חייב להיות מוגדר בהקשר" - faults: - invalid: "לא תקינים" - unspecified: "לא מוגדר" - reasons: - unspecified: "לא מוגדר" - types: - array: "מערך" - big_decimal: "עשרוני גדול" - boolean: "בוליאני" - complex: "מורכב" - date_time: "תאריך ושעה" - date: "תאריך" - float: "מספר נקודה צפה" - hash: "גיבוב" - integer: "מספר שלם" - rational: "רציונלי" - string: "מחרוזת" - symbol: "סמל" - time: "זמן" - validators: - absence: "חייב להיות ריק" - exclusion: - of: "לא יכול להיות אחד מ: %{values}" - within: "לא יכול להיות בין %{min} ו-%{max}" - format: "הוא פורמט לא תקין" - inclusion: - of: "חייב להיות אחד מ: %{values}" - within: "חייב להיות בין %{min} ו-%{max}" - length: - is: "האורך חייב להיות %{is}" - is_not: "האורך לא יכול להיות %{is_not}" - min: "האורך חייב להיות לפחות %{min}" - max: "האורך יכול להיות לכל היותר %{max}" - not_within: "האורך לא יכול להיות בין %{min} ו-%{max}" - within: "האורך חייב להיות בין %{min} ו-%{max}" - numeric: - is: "חייב להיות %{is}" - is_not: "לא יכול להיות %{is_not}" - min: "חייב להיות לפחות %{min}" - max: "יכול להיות לכל היותר %{max}" - not_within: "לא יכול להיות בין %{min} ו-%{max}" - within: "חייב להיות בין %{min} ו-%{max}" - presence: "לא יכול להיות ריק" diff --git a/lib/locales/hi.yml b/lib/locales/hi.yml deleted file mode 100644 index fab3bc9f4..000000000 --- a/lib/locales/hi.yml +++ /dev/null @@ -1,55 +0,0 @@ -hi: - cmdx: - attributes: - required: "%{method} स्रोत विधि के माध्यम से सुलभ होना चाहिए" - undefined: "अपरिभाषित विधि %{method} को सौंपता है" - coercions: - into_a: "%{type} में परिवर्तित नहीं किया जा सका" - into_an: "%{type} में परिवर्तित नहीं किया जा सका" - into_any: "निम्नलिखित में से किसी में परिवर्तित नहीं किया जा सका: %{types}" - unknown: "अज्ञात %{type} रूपांतरण प्रकार" - returns: - missing: "संदर्भ में सेट होना चाहिए" - faults: - invalid: "अमान्य" - unspecified: "अनिर्दिष्ट" - reasons: - unspecified: "अनिर्दिष्ट" - types: - array: "सरणी" - big_decimal: "बड़ा दशमलव" - boolean: "बूलियन" - complex: "जटिल" - date_time: "दिनांक और समय" - date: "दिनांक" - float: "फ्लोट" - hash: "हैश" - integer: "पूर्णांक" - rational: "परिमेय" - string: "स्ट्रिंग" - symbol: "प्रतीक" - time: "समय" - validators: - absence: "खाली होना चाहिए" - exclusion: - of: "निम्नलिखित में से कोई नहीं होना चाहिए: %{values}" - within: "%{min} और %{max} के बीच नहीं होना चाहिए" - format: "एक अमान्य प्रारूप है" - inclusion: - of: "निम्नलिखित में से कोई होना चाहिए: %{values}" - within: "%{min} और %{max} के बीच होना चाहिए" - length: - is: "लंबाई %{is} होनी चाहिए" - is_not: "लंबाई %{is_not} नहीं होनी चाहिए" - min: "लंबाई कम से कम %{min} होनी चाहिए" - max: "लंबाई अधिकतम %{max} होनी चाहिए" - not_within: "लंबाई %{min} और %{max} के बीच नहीं होनी चाहिए" - within: "लंबाई %{min} और %{max} के बीच होनी चाहिए" - numeric: - is: "%{is} होना चाहिए" - is_not: "%{is_not} नहीं होना चाहिए" - min: "कम से कम %{min} होना चाहिए" - max: "अधिकतम %{max} होना चाहिए" - not_within: "%{min} और %{max} के बीच नहीं होना चाहिए" - within: "%{min} और %{max} के बीच होना चाहिए" - presence: "खाली नहीं हो सकता" diff --git a/lib/locales/hr.yml b/lib/locales/hr.yml deleted file mode 100644 index 14834bb7f..000000000 --- a/lib/locales/hr.yml +++ /dev/null @@ -1,55 +0,0 @@ -hr: - cmdx: - attributes: - required: "mora biti dostupan putem izvorne metode %{method}" - undefined: "delegira nedefiniranoj metodi %{method}" - coercions: - into_a: "nije mogao biti pretvoren u %{type}" - into_an: "nije mogao biti pretvoren u %{type}" - into_any: "nije mogao biti pretvoren u jedan od: %{types}" - unknown: "nepoznati tip pretvorbe %{type}" - returns: - missing: "mora biti postavljeno u kontekstu" - faults: - invalid: "Neispravni" - unspecified: "Nije naveden" - reasons: - unspecified: "Nije naveden" - types: - array: "niz" - big_decimal: "veliki decimalni broj" - boolean: "logički" - complex: "kompleksan" - date_time: "datum i vrijeme" - date: "datum" - float: "broj s pomičnom točkom" - hash: "hash" - integer: "cijeli broj" - rational: "racionalan" - string: "niz znakova" - symbol: "simbol" - time: "vrijeme" - validators: - absence: "mora biti prazno" - exclusion: - of: "ne smije biti jedan od: %{values}" - within: "ne smije biti između %{min} i %{max}" - format: "je nevažeći format" - inclusion: - of: "mora biti jedan od: %{values}" - within: "mora biti između %{min} i %{max}" - length: - is: "duljina mora biti %{is}" - is_not: "duljina ne smije biti %{is_not}" - min: "duljina mora biti najmanje %{min}" - max: "duljina može biti najviše %{max}" - not_within: "duljina ne smije biti između %{min} i %{max}" - within: "duljina mora biti između %{min} i %{max}" - numeric: - is: "mora biti %{is}" - is_not: "ne smije biti %{is_not}" - min: "mora biti najmanje %{min}" - max: "može biti najviše %{max}" - not_within: "ne smije biti između %{min} i %{max}" - within: "mora biti između %{min} i %{max}" - presence: "ne može biti prazno" diff --git a/lib/locales/hu.yml b/lib/locales/hu.yml deleted file mode 100644 index 7324dc720..000000000 --- a/lib/locales/hu.yml +++ /dev/null @@ -1,55 +0,0 @@ -hu: - cmdx: - attributes: - required: "hozzáférhetőnek kell lennie a(z) %{method} forrásmetóduson keresztül" - undefined: "delegál a nem definiált metódusra %{method}" - coercions: - into_a: "nem sikerült %{type} típusra konvertálni" - into_an: "nem sikerült %{type} típusra konvertálni" - into_any: "nem sikerült konvertálni a következők egyikére: %{types}" - unknown: "ismeretlen %{type} konverziós típus" - returns: - missing: "be kell állítani a kontextusban" - faults: - invalid: "Érvénytelen" - unspecified: "Nincs megadva" - reasons: - unspecified: "Nincs megadva" - types: - array: "tömb" - big_decimal: "nagy decimális" - boolean: "logikai" - complex: "komplex" - date_time: "dátum és idő" - date: "dátum" - float: "lebegőpontos" - hash: "hash" - integer: "egész szám" - rational: "racionális" - string: "karakterlánc" - symbol: "szimbólum" - time: "idő" - validators: - absence: "üresnek kell lennie" - exclusion: - of: "nem lehet egyike a következőknek: %{values}" - within: "nem lehet %{min} és %{max} között" - format: "érvénytelen formátum" - inclusion: - of: "egyike kell legyen a következőknek: %{values}" - within: "%{min} és %{max} között kell lennie" - length: - is: "a hossz %{is} kell legyen" - is_not: "a hossz nem lehet %{is_not}" - min: "a hossz legalább %{min} kell legyen" - max: "a hossz legfeljebb %{max} lehet" - not_within: "a hossz nem lehet %{min} és %{max} között" - within: "a hossz %{min} és %{max} között kell legyen" - numeric: - is: "%{is} kell legyen" - is_not: "nem lehet %{is_not}" - min: "legalább %{min} kell legyen" - max: "legfeljebb %{max} lehet" - not_within: "nem lehet %{min} és %{max} között" - within: "%{min} és %{max} között kell lennie" - presence: "nem lehet üres" diff --git a/lib/locales/hy.yml b/lib/locales/hy.yml deleted file mode 100644 index 8a3d66504..000000000 --- a/lib/locales/hy.yml +++ /dev/null @@ -1,55 +0,0 @@ -hy: - cmdx: - attributes: - required: "պետք է հասանելի լինի %{method} ելակետային մեթոդի միջոցով" - undefined: "անցնում է չսահմանված մեթոդին %{method}" - coercions: - into_a: "չհաջողվեց վերածել %{type} տիպի" - into_an: "չհաջողվեց վերածել %{type} տիպի" - into_any: "չհաջողվեց վերածել հետևյալներից մեկի: %{types}" - unknown: "անհայտ %{type} փոխակերպման տիպ" - returns: - missing: "պետք է սահմանված լինի համատեքստում" - faults: - invalid: "Անվավեր" - unspecified: "Չնշված" - reasons: - unspecified: "Չնշված" - types: - array: "զանգված" - big_decimal: "մեծ տասնորդական" - boolean: "տրամաբանական" - complex: "կոմպլեքս" - date_time: "ամսաթիվ և ժամանակ" - date: "ամսաթիվ" - float: "լողացող կետով թիվ" - hash: "հեշ" - integer: "ամբողջ թիվ" - rational: "ռացիոնալ" - string: "տող" - symbol: "սիմվոլ" - time: "ժամանակ" - validators: - absence: "պdelays delays delays delays delays է delays delaysdelays delays delays delays" - exclusion: - of: "չի կարող լինել հետևյալներից մեկը: %{values}" - within: "չի կարող լինել %{min} և %{max} միջև" - format: "անվավեր ձևաչափ է" - inclusion: - of: "պետք է լինի հետևյալներից մեկը: %{values}" - within: "պետք է լինի %{min} և %{max} միջև" - length: - is: "երկարությունը պետք է լինի %{is}" - is_not: "երկարությունը չի կարող լինել %{is_not}" - min: "երկարությունը պետք է լինի առնվազն %{min}" - max: "երկարությունը կարող է լինել առավելագույնը %{max}" - not_within: "երկարությունը չի կարող լինել %{min} և %{max} միջև" - within: "երկարությունը պետք է լինի %{min} և %{max} միջև" - numeric: - is: "պետք է լինի %{is}" - is_not: "չի կարող լինել %{is_not}" - min: "պետք է լինի առնվազն %{min}" - max: "կարող է լինել առավելագույնը %{max}" - not_within: "չի կարող լինել %{min} և %{max} միջև" - within: "պետք է լինի %{min} և %{max} միջև" - presence: "չի կարող դատարկ լինել" diff --git a/lib/locales/id.yml b/lib/locales/id.yml deleted file mode 100644 index e5af15c8c..000000000 --- a/lib/locales/id.yml +++ /dev/null @@ -1,55 +0,0 @@ -id: - cmdx: - attributes: - required: "harus dapat diakses melalui metode sumber %{method}" - undefined: "mendelegasikan ke metode yang tidak terdefinisi %{method}" - coercions: - into_a: "tidak dapat dikonversi ke %{type}" - into_an: "tidak dapat dikonversi ke %{type}" - into_any: "tidak dapat dikonversi ke salah satu dari: %{types}" - unknown: "jenis konversi %{type} yang tidak dikenal" - returns: - missing: "harus diatur dalam konteks" - faults: - invalid: "Tidak valid" - unspecified: "Tidak ditentukan" - reasons: - unspecified: "Tidak ditentukan" - types: - array: "array" - big_decimal: "desimal besar" - boolean: "boolean" - complex: "kompleks" - date_time: "tanggal dan waktu" - date: "tanggal" - float: "titik mengambang" - hash: "hash" - integer: "bilangan bulat" - rational: "rasional" - string: "string" - symbol: "simbol" - time: "waktu" - validators: - absence: "harus kosong" - exclusion: - of: "tidak boleh salah satu dari: %{values}" - within: "tidak boleh di antara %{min} dan %{max}" - format: "adalah format yang tidak valid" - inclusion: - of: "harus salah satu dari: %{values}" - within: "harus di antara %{min} dan %{max}" - length: - is: "panjang harus %{is}" - is_not: "panjang tidak boleh %{is_not}" - min: "panjang minimal harus %{min}" - max: "panjang maksimal %{max}" - not_within: "panjang tidak boleh di antara %{min} dan %{max}" - within: "panjang harus di antara %{min} dan %{max}" - numeric: - is: "harus %{is}" - is_not: "tidak boleh %{is_not}" - min: "minimal harus %{min}" - max: "maksimal %{max}" - not_within: "tidak boleh di antara %{min} dan %{max}" - within: "harus di antara %{min} dan %{max}" - presence: "tidak boleh kosong" diff --git a/lib/locales/is.yml b/lib/locales/is.yml deleted file mode 100644 index f4998c4a2..000000000 --- a/lib/locales/is.yml +++ /dev/null @@ -1,55 +0,0 @@ -is: - cmdx: - attributes: - required: "verður að vera aðgengilegt í gegnum grunnaðferðina %{method}" - undefined: "fela óskilgreindri aðferð %{method}" - coercions: - into_a: "gat ekki breytt í %{type}" - into_an: "gat ekki breytt í %{type}" - into_any: "gat ekki breytt í einn af: %{types}" - unknown: "óþekktur %{type} breytingargerð" - returns: - missing: "verður að vera stillt í samhenginu" - faults: - invalid: "Ógild" - unspecified: "Ótilgreind" - reasons: - unspecified: "Ótilgreind" - types: - array: "fylki" - big_decimal: "stór tugabrot" - boolean: "rökvísindi" - complex: "flókið" - date_time: "dagsetning og tími" - date: "dagsetning" - float: "kommutala" - hash: "hash" - integer: "heiltala" - rational: "ræða" - string: "strengur" - symbol: "tákn" - time: "tími" - validators: - absence: "verður að vera tómt" - exclusion: - of: "má ekki vera einn af: %{values}" - within: "má ekki vera á milli %{min} og %{max}" - format: "er ógild form" - inclusion: - of: "verður að vera einn af: %{values}" - within: "verður að vera á milli %{min} og %{max}" - length: - is: "lengdin verður að vera %{is}" - is_not: "lengdin má ekki vera %{is_not}" - min: "lengdin verður að vera að minnsta kosti %{min}" - max: "lengdin má að hámarki vera %{max}" - not_within: "lengdin má ekki vera á milli %{min} og %{max}" - within: "lengdin verður að vera á milli %{min} og %{max}" - numeric: - is: "verður að vera %{is}" - is_not: "má ekki vera %{is_not}" - min: "verður að vera að minnsta kosti %{min}" - max: "má að hámarki vera %{max}" - not_within: "má ekki vera á milli %{min} og %{max}" - within: "verður að vera á milli %{min} og %{max}" - presence: "má ekki vera tómur" diff --git a/lib/locales/it.yml b/lib/locales/it.yml deleted file mode 100644 index ffde4ea09..000000000 --- a/lib/locales/it.yml +++ /dev/null @@ -1,55 +0,0 @@ -it: - cmdx: - attributes: - required: "deve essere accessibile tramite il metodo di origine %{method}" - undefined: "delega al metodo non definito %{method}" - coercions: - into_a: "non è stato possibile convertire in %{type}" - into_an: "non è stato possibile convertire in %{type}" - into_any: "non è stato possibile convertire in uno di: %{types}" - unknown: "tipo di conversione %{type} sconosciuto" - returns: - missing: "deve essere impostato nel contesto" - faults: - invalid: "Non validi" - unspecified: "Non specificato" - reasons: - unspecified: "Non specificato" - types: - array: "array" - big_decimal: "decimale grande" - boolean: "booleano" - complex: "complesso" - date_time: "data e ora" - date: "data" - float: "virgola mobile" - hash: "hash" - integer: "intero" - rational: "razionale" - string: "stringa" - symbol: "simbolo" - time: "tempo" - validators: - absence: "deve essere vuoto" - exclusion: - of: "non deve essere uno di: %{values}" - within: "non deve essere tra %{min} e %{max}" - format: "è un formato non valido" - inclusion: - of: "deve essere uno di: %{values}" - within: "deve essere tra %{min} e %{max}" - length: - is: "la lunghezza deve essere %{is}" - is_not: "la lunghezza non deve essere %{is_not}" - min: "la lunghezza deve essere almeno %{min}" - max: "la lunghezza deve essere al massimo %{max}" - not_within: "la lunghezza non deve essere tra %{min} e %{max}" - within: "la lunghezza deve essere tra %{min} e %{max}" - numeric: - is: "deve essere %{is}" - is_not: "non deve essere %{is_not}" - min: "deve essere almeno %{min}" - max: "deve essere al massimo %{max}" - not_within: "non deve essere tra %{min} e %{max}" - within: "deve essere tra %{min} e %{max}" - presence: "non può essere vuoto" diff --git a/lib/locales/ja.yml b/lib/locales/ja.yml deleted file mode 100644 index 217cef673..000000000 --- a/lib/locales/ja.yml +++ /dev/null @@ -1,55 +0,0 @@ -ja: - cmdx: - attributes: - required: "%{method} ソースメソッド経由でアクセスできる必要があります" - undefined: "未定義のメソッド %{method} に委譲します" - coercions: - into_a: "%{type} に変換できませんでした" - into_an: "%{type} に変換できませんでした" - into_any: "次のいずれかに変換できませんでした: %{types}" - unknown: "不明な %{type} 変換タイプ" - returns: - missing: "コンテキストに設定する必要があります" - faults: - invalid: "無効" - unspecified: "未指定" - reasons: - unspecified: "未指定" - types: - array: "配列" - big_decimal: "大きな小数" - boolean: "ブール値" - complex: "複素数" - date_time: "日時" - date: "日付" - float: "浮動小数点数" - hash: "ハッシュ" - integer: "整数" - rational: "有理数" - string: "文字列" - symbol: "シンボル" - time: "時刻" - validators: - absence: "空でなければなりません" - exclusion: - of: "次のいずれかであってはなりません: %{values}" - within: "%{min} と %{max} の間であってはなりません" - format: "無効な形式です" - inclusion: - of: "次のいずれかである必要があります: %{values}" - within: "%{min} と %{max} の間である必要があります" - length: - is: "長さは %{is} である必要があります" - is_not: "長さは %{is_not} であってはなりません" - min: "長さは少なくとも %{min} である必要があります" - max: "長さは最大 %{max} である必要があります" - not_within: "長さは %{min} と %{max} の間であってはなりません" - within: "長さは %{min} と %{max} の間である必要があります" - numeric: - is: "%{is} である必要があります" - is_not: "%{is_not} であってはなりません" - min: "少なくとも %{min} である必要があります" - max: "最大 %{max} である必要があります" - not_within: "%{min} と %{max} の間であってはなりません" - within: "%{min} と %{max} の間である必要があります" - presence: "空であってはなりません" diff --git a/lib/locales/ka.yml b/lib/locales/ka.yml deleted file mode 100644 index d682aba21..000000000 --- a/lib/locales/ka.yml +++ /dev/null @@ -1,55 +0,0 @@ -ka: - cmdx: - attributes: - required: "ხელმისაწვდომი უნდა იყოს %{method} წყაროს მეთოდის საშუალებით" - undefined: "აღწერს განუსაზღვრელ მეთოდს %{method}" - coercions: - into_a: "ვერ გადაიყვანა %{type} ტიპად" - into_an: "ვერ გადაიყვანა %{type} ტიპად" - into_any: "ვერ გადაიყვანა ერთ-ერთად: %{types}" - unknown: "უცნობი %{type} კონვერტაციის ტიპი" - returns: - missing: "კონტექსტში უნდა იყოს დაყენებული" - faults: - invalid: "არასწორი" - unspecified: "მითითებული არ არის" - reasons: - unspecified: "მითითებული არ არის" - types: - array: "მასივი" - big_decimal: "დიდი ათწილადი" - boolean: "ლოგიკური" - complex: "კომპლექსური" - date_time: "თარიღი და დრო" - date: "თარიღი" - float: "მცურავი წერტილის რიცხვი" - hash: "ჰეში" - integer: "მთელი რიცხვი" - rational: "რაციონალური" - string: "სტრიქონი" - symbol: "სიმბოლო" - time: "დრო" - validators: - absence: "უნდა იყოს ცარიელი" - exclusion: - of: "არ უნდა იყოს ერთ-ერთი: %{values}" - within: "არ უნდა იყოს %{min} და %{max} შორის" - format: "არის არასწორი ფორმატი" - inclusion: - of: "უნდა იყოს ერთ-ერთი: %{values}" - within: "უნდა იყოს %{min} და %{max} შორის" - length: - is: "სიგრძე უნდა იყოს %{is}" - is_not: "სიგრძე არ უნდა იყოს %{is_not}" - min: "სიგრძე უნდა იყოს მინიმუმ %{min}" - max: "სიგრძე უნდა იყოს მაქსიმუმ %{max}" - not_within: "სიგრძე არ უნდა იყოს %{min} და %{max} შორის" - within: "სიგრძე უნდა იყოს %{min} და %{max} შორის" - numeric: - is: "უნდა იყოს %{is}" - is_not: "არ უნდა იყოს %{is_not}" - min: "უნდა იყოს მინიმუმ %{min}" - max: "უნდა იყოს მაქსიმუმ %{max}" - not_within: "არ უნდა იყოს %{min} და %{max} შორის" - within: "უნდა იყოს %{min} და %{max} შორის" - presence: "არ შეიძლება იყოს ცარიელი" diff --git a/lib/locales/kk.yml b/lib/locales/kk.yml deleted file mode 100644 index 084461280..000000000 --- a/lib/locales/kk.yml +++ /dev/null @@ -1,55 +0,0 @@ -kk: - cmdx: - attributes: - required: "%{method} бастапқы әдісі арқылы қолжетімді болуы керек" - undefined: "анықталмаған %{method} әдісіне тапсырады" - coercions: - into_a: "%{type} түріне айналдыра алмады" - into_an: "%{type} түріне айналдыра алмады" - into_any: "бұлардың біріне айналдыра алмады: %{types}" - unknown: "белгісіз %{type} айналдыру түрі" - returns: - missing: "контексте орнатылуы керек" - faults: - invalid: "Жарамсыз" - unspecified: "Көрсетілмеген" - reasons: - unspecified: "Көрсетілмеген" - types: - array: "массив" - big_decimal: "үлкен ондық" - boolean: "буль" - complex: "күрделі" - date_time: "күні мен уақыты" - date: "күні" - float: "өзек нүктесі" - hash: "хэш" - integer: "бүтін сан" - rational: "рационал" - string: "жол" - symbol: "символ" - time: "уақыт" - validators: - absence: "бос болуы керек" - exclusion: - of: "бұлардың бірі болмасын: %{values}" - within: "%{min} және %{max} арасында болмасын" - format: "дұрыс емес формат" - inclusion: - of: "бұлардың бірі болуы керек: %{values}" - within: "%{min} және %{max} арасында болуы керек" - length: - is: "ұзындығы %{is} болуы керек" - is_not: "ұзындығы %{is_not} болмасын" - min: "ұзындығы кемінде %{min} болуы керек" - max: "ұзындығы ең көбінде %{max} бола алады" - not_within: "ұзындығы %{min} және %{max} арасында болмасын" - within: "ұзындығы %{min} және %{max} арасында болуы керек" - numeric: - is: "%{is} болуы керек" - is_not: "%{is_not} болмасын" - min: "кемінде %{min} болуы керек" - max: "ең көбінде %{max} бола алады" - not_within: "%{min} және %{max} арасында болмасын" - within: "%{min} және %{max} арасында болуы керек" - presence: "бос болмауы керек" diff --git a/lib/locales/km.yml b/lib/locales/km.yml deleted file mode 100644 index 20485192f..000000000 --- a/lib/locales/km.yml +++ /dev/null @@ -1,55 +0,0 @@ -km: - cmdx: - attributes: - required: "ត្រូវតែអាចចូលប្រើបានតាមរយៈវិធីសាស្ត្រប្រភព %{method}" - undefined: "ប្រគល់ភារកិច្ចទៅឱ្យវិធីសាស្ត្រដែលមិនបានកំណត់ %{method}" - coercions: - into_a: "មិនអាចបំប្លែងទៅជា %{type} បានទេ" - into_an: "មិនអាចបំប្លែងទៅជា %{type} បានទេ" - into_any: "មិនអាចបំប្លែងទៅជាមួយក្នុងចំណោម: %{types} បានទេ" - unknown: "ប្រភេទបំប្លែង %{type} ដែលមិនស្គាល់" - returns: - missing: "ត្រូវតែកំណត់ក្នុងបរិបទ" - faults: - invalid: "មិនត្រឹមត្រូវ" - unspecified: "មិនបានបញ្ជាក់" - reasons: - unspecified: "មិនបានបញ្ជាក់" - types: - array: "អារេ" - big_decimal: "ទសភាគធំ" - boolean: "ប៊ូលីន" - complex: "ស្មុគស្មាញ" - date_time: "កាលបរិច្ឆេទ និងពេលវេលា" - date: "កាលបរិច្ឆេទ" - float: "ចំណុចអណ្តែត" - hash: "ហាស" - integer: "ចំនួនគត់" - rational: "ចំនួនវិជ្ជមាន" - string: "ខ្សែអក្សរ" - symbol: "និមិត្តសញ្ញា" - time: "ពេលវេលា" - validators: - absence: "ត្រូវតែទទេរ" - exclusion: - of: "មិនត្រូវតែជាមួយក្នុងចំណោម: %{values} ទេ" - within: "មិនត្រូវតែនៅចន្លោះ %{min} និង %{max} ទេ" - format: "ជាទម្រង់មិនត្រឹមត្រូវ" - inclusion: - of: "ត្រូវតែជាមួយក្នុងចំណោម: %{values}" - within: "ត្រូវតែនៅចន្លោះ %{min} និង %{max}" - length: - is: "ប្រវែងត្រូវតែ %{is}" - is_not: "ប្រវែងមិនត្រូវតែ %{is_not} ទេ" - min: "ប្រវែងត្រូវតែយ៉ាងតិច %{min}" - max: "ប្រវែងអាចជាអតិបរមា %{max}" - not_within: "ប្រវែងមិនត្រូវតែនៅចន្លោះ %{min} និង %{max} ទេ" - within: "ប្រវែងត្រូវតែនៅចន្លោះ %{min} និង %{max}" - numeric: - is: "ត្រូវតែ %{is}" - is_not: "មិនត្រូវតែ %{is_not} ទេ" - min: "ត្រូវតែយ៉ាងតិច %{min}" - max: "អាចជាអតិបរមា %{max}" - not_within: "មិនត្រូវតែនៅចន្លោះ %{min} និង %{max} ទេ" - within: "ត្រូវតែនៅចន្លោះ %{min} និង %{max}" - presence: "មិនអាចទទេរបានទេ" diff --git a/lib/locales/kn.yml b/lib/locales/kn.yml deleted file mode 100644 index da924e206..000000000 --- a/lib/locales/kn.yml +++ /dev/null @@ -1,55 +0,0 @@ -kn: - cmdx: - attributes: - required: "%{method} ಮೂಲ ವಿಧಾನದ ಮೂಲಕ ಪ್ರವೇಶಿಸಬಹುದಾಗಿರಬೇಕು" - undefined: "ವ್ಯಾಖ್ಯಾನಿಸದ ವಿಧಾನಕ್ಕೆ %{method} ನಿಯೋಜಿಸುತ್ತದೆ" - coercions: - into_a: "%{type} ಗೆ ಪರಿವರ್ತಿಸಲು ಸಾಧ್ಯವಾಗಲಿಲ್ಲ" - into_an: "%{type} ಗೆ ಪರಿವರ್ತಿಸಲು ಸಾಧ್ಯವಾಗಲಿಲ್ಲ" - into_any: "ಈ ಕೆಳಗಿನವುಗಳಲ್ಲಿ ಒಂದಕ್ಕೆ ಪರಿವರ್ತಿಸಲು ಸಾಧ್ಯವಾಗಲಿಲ್ಲ: %{types}" - unknown: "ಅಜ್ಞಾತ %{type} ಪರಿವರ್ತನೆ ಪ್ರಕಾರ" - returns: - missing: "ಸಂದರ್ಭದಲ್ಲಿ ಹೊಂದಿಸಬೇಕು" - faults: - invalid: "ಅಮಾನ್ಯ" - unspecified: "ನಿರ್ದಿಷ್ಟವಾಗಿಲ್ಲ" - reasons: - unspecified: "ನಿರ್ದಿಷ್ಟವಾಗಿಲ್ಲ" - types: - array: "ಅರೇ" - big_decimal: "ದೊಡ್ಡ ದಶಮಾಂಶ" - boolean: "ಬೂಲಿಯನ್" - complex: "ಸಂಕೀರ್ಣ" - date_time: "ದಿನಾಂಕ ಮತ್ತು ಸಮಯ" - date: "ದಿನಾಂಕ" - float: "ತೇಲುವ ಬಿಂದು" - hash: "ಹ್ಯಾಶ್" - integer: "ಪೂರ್ಣಾಂಕ" - rational: "ತರ್ಕಬದ್ಧ" - string: "ಸ್ಟ್ರಿಂಗ್" - symbol: "ಚಿಹ್ನೆ" - time: "ಸಮಯ" - validators: - absence: "ಖಾಲಿಯಾಗಿರಬೇಕು" - exclusion: - of: "ಈ ಕೆಳಗಿನವುಗಳಲ್ಲಿ ಒಂದಾಗಿರಬಾರದು: %{values}" - within: "%{min} ಮತ್ತು %{max} ನಡುವೆ ಇರಬಾರದು" - format: "ಅಮಾನ್ಯ ಫಾರ್ಮ್ಯಾಟ್ ಆಗಿದೆ" - inclusion: - of: "ಈ ಕೆಳಗಿನವುಗಳಲ್ಲಿ ಒಂದಾಗಿರಬೇಕು: %{values}" - within: "%{min} ಮತ್ತು %{max} ನಡುವೆ ಇರಬೇಕು" - length: - is: "ಉದ್ದ %{is} ಆಗಿರಬೇಕು" - is_not: "ಉದ್ದ %{is_not} ಆಗಿರಬಾರದು" - min: "ಉದ್ದ ಕನಿಷ್ಠ %{min} ಆಗಿರಬೇಕು" - max: "ಉದ್ದ ಗರಿಷ್ಠ %{max} ಆಗಿರಬಹುದು" - not_within: "ಉದ್ದ %{min} ಮತ್ತು %{max} ನಡುವೆ ಇರಬಾರದು" - within: "ಉದ್ದ %{min} ಮತ್ತು %{max} ನಡುವೆ ಇರಬೇಕು" - numeric: - is: "%{is} ಆಗಿರಬೇಕು" - is_not: "%{is_not} ಆಗಿರಬಾರದು" - min: "ಕನಿಷ್ಠ %{min} ಆಗಿರಬೇಕು" - max: "ಗರಿಷ್ಠ %{max} ಆಗಿರಬಹುದು" - not_within: "%{min} ಮತ್ತು %{max} ನಡುವೆ ಇರಬಾರದು" - within: "%{min} ಮತ್ತು %{max} ನಡುವೆ ಇರಬೇಕು" - presence: "ಖಾಲಿಯಾಗಿರಬಾರದು" diff --git a/lib/locales/ko.yml b/lib/locales/ko.yml deleted file mode 100644 index f3b29e685..000000000 --- a/lib/locales/ko.yml +++ /dev/null @@ -1,55 +0,0 @@ -ko: - cmdx: - attributes: - required: "%{method} 소스 메서드를 통해 액세스할 수 있어야 합니다" - undefined: "정의되지 않은 메서드 %{method}에 위임합니다" - coercions: - into_a: "%{type}로 변환할 수 없습니다" - into_an: "%{type}로 변환할 수 없습니다" - into_any: "다음 중 하나로 변환할 수 없습니다: %{types}" - unknown: "알 수 없는 %{type} 변환 타입" - returns: - missing: "컨텍스트에 설정되어야 합니다" - faults: - invalid: "잘못된" - unspecified: "지정되지 않음" - reasons: - unspecified: "지정되지 않음" - types: - array: "배열" - big_decimal: "큰 소수" - boolean: "불린" - complex: "복소수" - date_time: "날짜 시간" - date: "날짜" - float: "부동 소수점" - hash: "해시" - integer: "정수" - rational: "유리수" - string: "문자열" - symbol: "심볼" - time: "시간" - validators: - absence: "비어있어야 합니다" - exclusion: - of: "다음 중 하나가 아니어야 합니다: %{values}" - within: "%{min}과 %{max} 사이에 있으면 안 됩니다" - format: "잘못된 형식입니다" - inclusion: - of: "다음 중 하나여야 합니다: %{values}" - within: "%{min}과 %{max} 사이에 있어야 합니다" - length: - is: "길이는 %{is}여야 합니다" - is_not: "길이는 %{is_not}이 아니어야 합니다" - min: "길이는 최소 %{min}이어야 합니다" - max: "길이는 최대 %{max}여야 합니다" - not_within: "길이는 %{min}과 %{max} 사이에 있으면 안 됩니다" - within: "길이는 %{min}과 %{max} 사이에 있어야 합니다" - numeric: - is: "%{is}여야 합니다" - is_not: "%{is_not}이 아니어야 합니다" - min: "최소 %{min}이어야 합니다" - max: "최대 %{max}여야 합니다" - not_within: "%{min}과 %{max} 사이에 있으면 안 됩니다" - within: "%{min}과 %{max} 사이에 있어야 합니다" - presence: "비어있을 수 없습니다" diff --git a/lib/locales/lb.yml b/lib/locales/lb.yml deleted file mode 100644 index 3d6bb96b8..000000000 --- a/lib/locales/lb.yml +++ /dev/null @@ -1,55 +0,0 @@ -lb: - cmdx: - attributes: - required: "muss iwwer d'Quellmethod %{method} zougänglech sinn" - undefined: "delegéiert un onbestëmmte Methode %{method}" - coercions: - into_a: "konnt net an %{type} ëmwandelen" - into_an: "konnt net an %{type} ëmwandelen" - into_any: "konnt net an ee vun dësen ëmwandelen: %{types}" - unknown: "onbekannte %{type} Ëmwandlungstyp" - returns: - missing: "muss am Kontext gesat sinn" - faults: - invalid: "Ongëlteg" - unspecified: "Net spezifizéiert" - reasons: - unspecified: "Net spezifizéiert" - types: - array: "Array" - big_decimal: "Grouss Dezimal" - boolean: "Boolean" - complex: "Komplex" - date_time: "Datum an Zäit" - date: "Datum" - float: "Flottpunkt" - hash: "Hash" - integer: "Ganzzuel" - rational: "Rational" - string: "String" - symbol: "Symbol" - time: "Zäit" - validators: - absence: "muss eidel sinn" - exclusion: - of: "däerf net ee vun dësen sinn: %{values}" - within: "däerf net tëscht %{min} an %{max} sinn" - format: "ass e net valide Format" - inclusion: - of: "muss ee vun dësen sinn: %{values}" - within: "muss tëscht %{min} an %{max} sinn" - length: - is: "Längt muss %{is} sinn" - is_not: "Längt däerf net %{is_not} sinn" - min: "Längt muss mindestens %{min} sinn" - max: "Längt ka maximal %{max} sinn" - not_within: "Längt däerf net tëscht %{min} an %{max} sinn" - within: "Längt muss tëscht %{min} an %{max} sinn" - numeric: - is: "muss %{is} sinn" - is_not: "däerf net %{is_not} sinn" - min: "muss mindestens %{min} sinn" - max: "ka maximal %{max} sinn" - not_within: "däerf net tëscht %{min} an %{max} sinn" - within: "muss tëscht %{min} an %{max} sinn" - presence: "däerf net eidel sinn" diff --git a/lib/locales/lo.yml b/lib/locales/lo.yml deleted file mode 100644 index 290aecc61..000000000 --- a/lib/locales/lo.yml +++ /dev/null @@ -1,55 +0,0 @@ -lo: - cmdx: - attributes: - required: "ຕ້ອງສາມາດເຂົ້າເຖິງໄດ້ຜ່ານວິທີການແຫຼ່ງ %{method}" - undefined: "ມອບໝາຍໃຫ້ກັບວິທີທີ່ບໍ່ໄດ້ກຳນົດ %{method}" - coercions: - into_a: "ບໍ່ສາມາດປ່ຽນເປັນ %{type} ໄດ້" - into_an: "ບໍ່ສາມາດປ່ຽນເປັນ %{type} ໄດ້" - into_any: "ບໍ່ສາມາດປ່ຽນເປັນອັນໃດອັນໜຶ່ງໃນ: %{types} ໄດ້" - unknown: "ປະເພດການປ່ຽນ %{type} ທີ່ບໍ່ຮູ້" - returns: - missing: "ຕ້ອງຖືກກໍານົດໃນບໍລິບົດ" - faults: - invalid: "ບໍ່ຖືກຕ້ອງ" - unspecified: "ບໍ່ໄດ້ກຳນົດ" - reasons: - unspecified: "ບໍ່ໄດ້ກຳນົດ" - types: - array: "ອາເຣ" - big_decimal: "ທົດສະນິຍະສູງ" - boolean: "ບູລຽນ" - complex: "ສັບສົນ" - date_time: "ວັນທີ ແລະ ເວລາ" - date: "ວັນທີ" - float: "ຈຸດລອຍ" - hash: "ແຮຊ" - integer: "ຈຳນວນເຕັມ" - rational: "ຈຳນວນປົກກະຕິ" - string: "ສະຕຣິງ" - symbol: "ສັນຍະລັກ" - time: "ເວລາ" - validators: - absence: "ຕ້ອງເປັນຄ່າວ່າງ" - exclusion: - of: "ບໍ່ຕ້ອງເປັນອັນໃດອັນໜຶ່ງໃນ: %{values}" - within: "ບໍ່ຕ້ອງຢູ່ໃນຊ່ວງ %{min} ແລະ %{max}" - format: "ເປັນຮູບແບບທີ່ບໍ່ຖືກຕ້ອງ" - inclusion: - of: "ຕ້ອງເປັນອັນໃດອັນໜຶ່ງໃນ: %{values}" - within: "ຕ້ອງຢູ່ໃນຊ່ວງ %{min} ແລະ %{max}" - length: - is: "ຄວາມຍາວຕ້ອງເປັນ %{is}" - is_not: "ຄວາມຍາວບໍ່ຕ້ອງເປັນ %{is_not}" - min: "ຄວາມຍາວຕ້ອງຢ່າງໜ້ອຍ %{min}" - max: "ຄວາມຍາວສາມາດສູງສຸດ %{max}" - not_within: "ຄວາມຍາວບໍ່ຕ້ອງຢູ່ໃນຊ່ວງ %{min} ແລະ %{max}" - within: "ຄວາມຍາວຕ້ອງຢູ່ໃນຊ່ວງ %{min} ແລະ %{max}" - numeric: - is: "ຕ້ອງເປັນ %{is}" - is_not: "ບໍ່ຕ້ອງເປັນ %{is_not}" - min: "ຕ້ອງຢ່າງໜ້ອຍ %{min}" - max: "ສາມາດສູງສຸດ %{max}" - not_within: "ບໍ່ຕ້ອງຢູ່ໃນຊ່ວງ %{min} ແລະ %{max}" - within: "ຕ້ອງຢູ່ໃນຊ່ວງ %{min} ແລະ %{max}" - presence: "ບໍ່ສາມາດເປັນຄ່າວ່າງໄດ້" diff --git a/lib/locales/lt.yml b/lib/locales/lt.yml deleted file mode 100644 index cc2c54d8c..000000000 --- a/lib/locales/lt.yml +++ /dev/null @@ -1,55 +0,0 @@ -lt: - cmdx: - attributes: - required: "turi būti pasiekiamas per šaltinio metodą %{method}" - undefined: "deleguoja neapibrėžtą metodą %{method}" - coercions: - into_a: "nepavyko konvertuoti į %{type}" - into_an: "nepavyko konvertuoti į %{type}" - into_any: "nepavyko konvertuoti į vieną iš: %{types}" - unknown: "nežinomas %{type} konvertavimo tipas" - returns: - missing: "turi būti nustatyta kontekste" - faults: - invalid: "Neteisingi" - unspecified: "Nenurodyta" - reasons: - unspecified: "Nenurodyta" - types: - array: "masyvas" - big_decimal: "didelis dešimtainis" - boolean: "loginis" - complex: "kompleksinis" - date_time: "data ir laikas" - date: "data" - float: "slankusis kablelis" - hash: "maišos lentelė" - integer: "sveikasis skaičius" - rational: "racionalusis" - string: "eilutė" - symbol: "simbolis" - time: "laikas" - validators: - absence: "turi būti tuščias" - exclusion: - of: "neturi būti vienas iš: %{values}" - within: "neturi būti tarp %{min} ir %{max}" - format: "yra netinkamas formatas" - inclusion: - of: "turi būti vienas iš: %{values}" - within: "turi būti tarp %{min} ir %{max}" - length: - is: "ilgis turi būti %{is}" - is_not: "ilgis neturi būti %{is_not}" - min: "ilgis turi būti bent %{min}" - max: "ilgis gali būti daugiausiai %{max}" - not_within: "ilgis neturi būti tarp %{min} ir %{max}" - within: "ilgis turi būti tarp %{min} ir %{max}" - numeric: - is: "turi būti %{is}" - is_not: "neturi būti %{is_not}" - min: "turi būti bent %{min}" - max: "gali būti daugiausiai %{max}" - not_within: "neturi būti tarp %{min} ir %{max}" - within: "turi būti tarp %{min} ir %{max}" - presence: "negali būti tuščias" diff --git a/lib/locales/lv.yml b/lib/locales/lv.yml deleted file mode 100644 index e6519d3bd..000000000 --- a/lib/locales/lv.yml +++ /dev/null @@ -1,55 +0,0 @@ -lv: - cmdx: - attributes: - required: "jābūt pieejamam, izmantojot avota metodi %{method}" - undefined: "deleģē nedefinētai metodei %{method}" - coercions: - into_a: "nevarēja konvertēt uz %{type}" - into_an: "nevarēja konvertēt uz %{type}" - into_any: "nevarēja konvertēt uz vienu no: %{types}" - unknown: "nezināms %{type} konvertēšanas tips" - returns: - missing: "jābūt iestatītam kontekstā" - faults: - invalid: "Nederīgi" - unspecified: "Nav norādīts" - reasons: - unspecified: "Nav norādīts" - types: - array: "masīvs" - big_decimal: "liels decimāls" - boolean: "loģiskais" - complex: "komplekss" - date_time: "datums un laiks" - date: "datums" - float: "peldošā komata skaitlis" - hash: "hash" - integer: "vesels skaitlis" - rational: "racionāls" - string: "virkne" - symbol: "simbols" - time: "laiks" - validators: - absence: "jābūt tukšam" - exclusion: - of: "nedrīkst būt viens no: %{values}" - within: "nedrīkst būt starp %{min} un %{max}" - format: "ir nederīgs formāts" - inclusion: - of: "jābūt vienam no: %{values}" - within: "jābūt starp %{min} un %{max}" - length: - is: "garumam jābūt %{is}" - is_not: "garumam nedrīkst būt %{is_not}" - min: "garumam jābūt vismaz %{min}" - max: "garumam drīkst būt maksimāli %{max}" - not_within: "garumam nedrīkst būt starp %{min} un %{max}" - within: "garumam jābūt starp %{min} un %{max}" - numeric: - is: "jābūt %{is}" - is_not: "nedrīkst būt %{is_not}" - min: "jābūt vismaz %{min}" - max: "drīkst būt maksimāli %{max}" - not_within: "nedrīkst būt starp %{min} un %{max}" - within: "jābūt starp %{min} un %{max}" - presence: "nedrīkst būt tukšs" diff --git a/lib/locales/mg.yml b/lib/locales/mg.yml deleted file mode 100644 index e76e26537..000000000 --- a/lib/locales/mg.yml +++ /dev/null @@ -1,55 +0,0 @@ -mg: - cmdx: - attributes: - required: "tsy maintsy azo idirana amin'ny alalan'ny fomba loharano %{method}" - undefined: "ny delegasiona amin'ny fomba tsy voafaritra %{method}" - coercions: - into_a: "tsy afaka ovaina ho %{type}" - into_an: "tsy afaka ovaina ho %{type}" - into_any: "tsy afaka ovaina ho iray amin'ireo: %{types}" - unknown: "karazana fovana %{type} tsy fantatra" - returns: - missing: "tsy maintsy voatondro ao anatin ny contexte" - faults: - invalid: "Tsy mety" - unspecified: "Tsy voafaritra" - reasons: - unspecified: "Tsy voafaritra" - types: - array: "array" - big_decimal: "desimala lehibe" - boolean: "boolean" - complex: "sarotra" - date_time: "daty sy ora" - date: "daty" - float: "teboka mitsingevana" - hash: "hash" - integer: "isa feno" - rational: "isa ara-tsaina" - string: "string" - symbol: "mariky" - time: "ora" - validators: - absence: "tsy maintsy ho banga" - exclusion: - of: "tsy maintsy ho iray amin'ireo: %{values}" - within: "tsy maintsy ho eo anelanelan'ny %{min} sy %{max}" - format: "dia endrika tsy manan-kery" - inclusion: - of: "tsy maintsy ho iray amin'ireo: %{values}" - within: "tsy maintsy ho eo anelanelan'ny %{min} sy %{max}" - length: - is: "ny halavany tsy maintsy %{is}" - is_not: "ny halavany tsy maintsy %{is_not}" - min: "ny halavany tsy maintsy farafahakeliny %{min}" - max: "ny halavany afaka ho ambony indrindra %{max}" - not_within: "ny halavany tsy maintsy ho eo anelanelan'ny %{min} sy %{max}" - within: "ny halavany tsy maintsy ho eo anelanelan'ny %{min} sy %{max}" - numeric: - is: "tsy maintsy %{is}" - is_not: "tsy maintsy %{is_not}" - min: "tsy maintsy farafahakeliny %{min}" - max: "afaka ho ambony indrindra %{max}" - not_within: "tsy maintsy ho eo anelanelan'ny %{min} sy %{max}" - within: "tsy maintsy ho eo anelanelan'ny %{min} sy %{max}" - presence: "tsy afaka ho banga" diff --git a/lib/locales/mk.yml b/lib/locales/mk.yml deleted file mode 100644 index 70b702673..000000000 --- a/lib/locales/mk.yml +++ /dev/null @@ -1,55 +0,0 @@ -mk: - cmdx: - attributes: - required: "мора да биде достапно преку изворниот метод %{method}" - undefined: "делегира на недефиниран метод %{method}" - coercions: - into_a: "не можеше да се конвертира во %{type}" - into_an: "не можеше да се конвертира во %{type}" - into_any: "не можеше да се конвертира во еден од: %{types}" - unknown: "непознат тип на конверзија %{type}" - returns: - missing: "мора да биде поставено во контекстот" - faults: - invalid: "Невалидни" - unspecified: "Не е дадена" - reasons: - unspecified: "Не е дадена" - types: - array: "низа" - big_decimal: "голем децимален" - boolean: "логички" - complex: "комплексен" - date_time: "датум и време" - date: "датум" - float: "број со подвижна запирка" - hash: "хаш" - integer: "цел број" - rational: "рационален" - string: "низа" - symbol: "симбол" - time: "време" - validators: - absence: "мора да биде празно" - exclusion: - of: "не смее да биде еден од: %{values}" - within: "не смее да биде помеѓу %{min} и %{max}" - format: "е невалиден формат" - inclusion: - of: "мора да биде еден од: %{values}" - within: "мора да биде помеѓу %{min} и %{max}" - length: - is: "должината мора да биде %{is}" - is_not: "должината не смее да биде %{is_not}" - min: "должината мора да биде најмалку %{min}" - max: "должината може да биде најмногу %{max}" - not_within: "должината не смее да биде помеѓу %{min} и %{max}" - within: "должината мора да биде помеѓу %{min} и %{max}" - numeric: - is: "мора да биде %{is}" - is_not: "не смее да биде %{is_not}" - min: "мора да биде најмалку %{min}" - max: "може да биде најмногу %{max}" - not_within: "не смее да биде помеѓу %{min} и %{max}" - within: "мора да биде помеѓу %{min} и %{max}" - presence: "не смее да биде празно" diff --git a/lib/locales/ml.yml b/lib/locales/ml.yml deleted file mode 100644 index 0fc1ce192..000000000 --- a/lib/locales/ml.yml +++ /dev/null @@ -1,55 +0,0 @@ -ml: - cmdx: - attributes: - required: "%{method} സോഴ്സ് രീതി വഴി ആക്സസ് ചെയ്യാൻ കഴിയണം" - undefined: "നിർവചിക്കപ്പെടാത്ത മെത്തഡിലേക്ക് %{method} ഡെലഗേറ്റ് ചെയ്യുന്നു" - coercions: - into_a: "%{type} ആയി പരിവർത്തനം ചെയ്യാൻ കഴിയില്ല" - into_an: "%{type} ആയി പരിവർത്തനം ചെയ്യാൻ കഴിയില്ല" - into_any: "ഇനിപ്പറയുന്നവയിൽ ഒന്നായി പരിവർത്തനം ചെയ്യാൻ കഴിയില്ല: %{types}" - unknown: "അജ്ഞാതമായ %{type} പരിവർത്തന തരം" - returns: - missing: "സന്ദർഭത്തിൽ സജ്ജീകരിച്ചിരിക്കണം" - faults: - invalid: "അസാധുവായ" - unspecified: "നിർവചിക്കപ്പെടാത്ത" - reasons: - unspecified: "നിർവചിക്കപ്പെടാത്ത" - types: - array: "അറേ" - big_decimal: "വലിയ ദശാംശം" - boolean: "ബൂളിയൻ" - complex: "സങ്കീർണ്ണം" - date_time: "തീയതിയും സമയവും" - date: "തീയതി" - float: "ഫ്ലോട്ടിംഗ് പോയിന്റ്" - hash: "ഹാഷ്" - integer: "പൂർണ്ണസംഖ്യ" - rational: "ഭിന്നകം" - string: "സ്ട്രിംഗ്" - symbol: "ചിഹ്നം" - time: "സമയം" - validators: - absence: "ശൂന്യമായിരിക്കണം" - exclusion: - of: "ഇനിപ്പറയുന്നവയിൽ ഒന്നാകാൻ പാടില്ല: %{values}" - within: "%{min} ഉം %{max} ഉം തമ്മിൽ ആകാൻ പാടില്ല" - format: "അസാധുവായ ഫോർമാറ്റ് ആണ്" - inclusion: - of: "ഇനിപ്പറയുന്നവയിൽ ഒന്നായിരിക്കണം: %{values}" - within: "%{min} ഉം %{max} ഉം തമ്മിൽ ആയിരിക്കണം" - length: - is: "നീളം %{is} ആയിരിക്കണം" - is_not: "നീളം %{is_not} ആകാൻ പാടില്ല" - min: "നീളം കുറഞ്ഞത് %{min} ആയിരിക്കണം" - max: "നീളം കൂടിയത് %{max} ആകാം" - not_within: "നീളം %{min} ഉം %{max} ഉം തമ്മിൽ ആകാൻ പാടില്ല" - within: "നീളം %{min} ഉം %{max} ഉം തമ്മിൽ ആയിരിക്കണം" - numeric: - is: "%{is} ആയിരിക്കണം" - is_not: "%{is_not} ആകാൻ പാടില്ല" - min: "കുറഞ്ഞത് %{min} ആയിരിക്കണം" - max: "കൂടിയത് %{max} ആകാം" - not_within: "%{min} ഉം %{max} ഉം തമ്മിൽ ആകാൻ പാടില്ല" - within: "%{min} ഉം %{max} ഉം തമ്മിൽ ആയിരിക്കണം" - presence: "ശൂന്യമാകാൻ പാടില്ല" diff --git a/lib/locales/mn.yml b/lib/locales/mn.yml deleted file mode 100644 index b59e72742..000000000 --- a/lib/locales/mn.yml +++ /dev/null @@ -1,55 +0,0 @@ -mn: - cmdx: - attributes: - required: "%{method} эх сурвалжийн аргаар хандах боломжтой байх ёстой" - undefined: "тодорхойлогдоогүй аргад %{method} даалгавар өгнө" - coercions: - into_a: "%{type} болгон хөрвүүлэх боломжгүй байна" - into_an: "%{type} болгон хөрвүүлэх боломжгүй байна" - into_any: "дараах зүйлсийн аль нэг болгон хөрвүүлэх боломжгүй: %{types}" - unknown: "үл мэдэгдэх %{type} хөрвүүлэлтийн төрөл" - returns: - missing: "контекст дотор тохируулагдсан байх ёстой" - faults: - invalid: "Буруу" - unspecified: "Тодорхойлогдоогүй" - reasons: - unspecified: "Тодорхойлогдоогүй" - types: - array: "массив" - big_decimal: "том аравтын" - boolean: "булийн" - complex: "цогц" - date_time: "огноо ба цаг" - date: "огноо" - float: "хөвөгч цэг" - hash: "хэш" - integer: "бүхэл тоо" - rational: "рационал" - string: "мөр" - symbol: "тэмдэг" - time: "цаг" - validators: - absence: "хоосон байх ёстой" - exclusion: - of: "дараах зүйлсийн аль нэг байж болохгүй: %{values}" - within: "%{min} ба %{max} хооронд байж болохгүй" - format: "хүчингүй формат юм" - inclusion: - of: "дараах зүйлсийн аль нэг байх ёстой: %{values}" - within: "%{min} ба %{max} хооронд байх ёстой" - length: - is: "урт %{is} байх ёстой" - is_not: "урт %{is_not} байж болохгүй" - min: "урт хамгийн багадаа %{min} байх ёстой" - max: "урт хамгийн ихдээ %{max} байж болно" - not_within: "урт %{min} ба %{max} хооронд байж болохгүй" - within: "урт %{min} ба %{max} хооронд байх ёстой" - numeric: - is: "%{is} байх ёстой" - is_not: "%{is_not} байж болохгүй" - min: "хамгийн багадаа %{min} байх ёстой" - max: "хамгийн ихдээ %{max} байж болно" - not_within: "%{min} ба %{max} хооронд байж болохгүй" - within: "%{min} ба %{max} хооронд байх ёстой" - presence: "хоосон байж болохгүй" diff --git a/lib/locales/mr-IN.yml b/lib/locales/mr-IN.yml deleted file mode 100644 index 92448596b..000000000 --- a/lib/locales/mr-IN.yml +++ /dev/null @@ -1,55 +0,0 @@ -mr-IN: - cmdx: - attributes: - required: "%{method} स्रोत पद्धतीद्वारे प्रवेश करण्यायोग्य असणे आवश्यक आहे" - undefined: "अपरिभाषित पद्धतीला %{method} नियुक्त करते" - coercions: - into_a: "%{type} मध्ये रूपांतर करता आले नाही" - into_an: "%{type} मध्ये रूपांतर करता आले नाही" - into_any: "खालीलपैकी एकामध्ये रूपांतर करता आले नाही: %{types}" - unknown: "अज्ञात %{type} रूपांतरण प्रकार" - returns: - missing: "संदर्भात सेट केले पाहिजे" - faults: - invalid: "अवैध" - unspecified: "निर्दिष्ट नाही" - reasons: - unspecified: "निर्दिष्ट नाही" - types: - array: "अॅरे" - big_decimal: "मोठे दशांश" - boolean: "बुलियन" - complex: "जटिल" - date_time: "तारीख आणि वेळ" - date: "तारीख" - float: "फ्लोटिंग पॉइंट" - hash: "हॅश" - integer: "पूर्णांक" - rational: "परिमेय" - string: "स्ट्रिंग" - symbol: "चिन्ह" - time: "वेळ" - validators: - absence: "रिक्त असणे आवश्यक आहे" - exclusion: - of: "खालीलपैकी एक असू शकत नाही: %{values}" - within: "%{min} आणि %{max} दरम्यान असू शकत नाही" - format: "अवैध स्वरूप आहे" - inclusion: - of: "खालीलपैकी एक असणे आवश्यक आहे: %{values}" - within: "%{min} आणि %{max} दरम्यान असणे आवश्यक आहे" - length: - is: "लांबी %{is} असणे आवश्यक आहे" - is_not: "लांबी %{is_not} असू शकत नाही" - min: "लांबी किमान %{min} असणे आवश्यक आहे" - max: "लांबी कमाल %{max} असू शकते" - not_within: "लांबी %{min} आणि %{max} दरम्यान असू शकत नाही" - within: "लांबी %{min} आणि %{max} दरम्यान असणे आवश्यक आहे" - numeric: - is: "%{is} असणे आवश्यक आहे" - is_not: "%{is_not} असू शकत नाही" - min: "किमान %{min} असणे आवश्यक आहे" - max: "कमाल %{max} असू शकते" - not_within: "%{min} आणि %{max} दरम्यान असू शकत नाही" - within: "%{min} आणि %{max} दरम्यान असणे आवश्यक आहे" - presence: "रिक्त असू शकत नाही" diff --git a/lib/locales/ms.yml b/lib/locales/ms.yml deleted file mode 100644 index 421f04987..000000000 --- a/lib/locales/ms.yml +++ /dev/null @@ -1,55 +0,0 @@ -ms: - cmdx: - attributes: - required: "mesti boleh diakses melalui kaedah sumber %{method}" - undefined: "delegasi kepada kaedah yang tidak ditakrifkan %{method}" - coercions: - into_a: "tidak boleh ditukar kepada %{type}" - into_an: "tidak boleh ditukar kepada %{type}" - into_any: "tidak boleh ditukar kepada salah satu daripada: %{types}" - unknown: "jenis penukaran %{type} yang tidak diketahui" - returns: - missing: "mesti ditetapkan dalam konteks" - faults: - invalid: "Tidak sah" - unspecified: "Tidak ditentukan" - reasons: - unspecified: "Tidak ditentukan" - types: - array: "array" - big_decimal: "perpuluhan besar" - boolean: "boolean" - complex: "kompleks" - date_time: "tarikh dan masa" - date: "tarikh" - float: "titik terapung" - hash: "hash" - integer: "integer" - rational: "rasional" - string: "rentetan" - symbol: "simbol" - time: "masa" - validators: - absence: "mesti kosong" - exclusion: - of: "tidak boleh menjadi salah satu daripada: %{values}" - within: "tidak boleh berada di antara %{min} dan %{max}" - format: "adalah format yang tidak sah" - inclusion: - of: "mesti menjadi salah satu daripada: %{values}" - within: "mesti berada di antara %{min} dan %{max}" - length: - is: "panjang mesti %{is}" - is_not: "panjang tidak boleh %{is_not}" - min: "panjang mesti sekurang-kurangnya %{min}" - max: "panjang boleh paling banyak %{max}" - not_within: "panjang tidak boleh berada di antara %{min} dan %{max}" - within: "panjang mesti berada di antara %{min} dan %{max}" - numeric: - is: "mesti %{is}" - is_not: "tidak boleh %{is_not}" - min: "mesti sekurang-kurangnya %{min}" - max: "boleh paling banyak %{max}" - not_within: "tidak boleh berada di antara %{min} dan %{max}" - within: "mesti berada di antara %{min} dan %{max}" - presence: "tidak boleh kosong" diff --git a/lib/locales/nb.yml b/lib/locales/nb.yml deleted file mode 100644 index 901ce4d3b..000000000 --- a/lib/locales/nb.yml +++ /dev/null @@ -1,55 +0,0 @@ -nb: - cmdx: - attributes: - required: "må være tilgjengelig via kildemetoden %{method}" - undefined: "delegerer til udefinert metode %{method}" - coercions: - into_a: "kunne ikke konverteres til %{type}" - into_an: "kunne ikke konverteres til %{type}" - into_any: "kunne ikke konverteres til en av: %{types}" - unknown: "ukjent %{type} konverteringstype" - returns: - missing: "må være satt i konteksten" - faults: - invalid: "Ugyldig" - unspecified: "Uspesifisert" - reasons: - unspecified: "Uspesifisert" - types: - array: "array" - big_decimal: "stort desimaltall" - boolean: "boolsk" - complex: "kompleks" - date_time: "dato og tid" - date: "dato" - float: "flyttall" - hash: "hash" - integer: "heltall" - rational: "rasjonalt" - string: "streng" - symbol: "symbol" - time: "tid" - validators: - absence: "må være tom" - exclusion: - of: "må ikke være en av: %{values}" - within: "må ikke være mellom %{min} og %{max}" - format: "er et ugyldig format" - inclusion: - of: "må være en av: %{values}" - within: "må være mellom %{min} og %{max}" - length: - is: "lengden må være %{is}" - is_not: "lengden må ikke være %{is_not}" - min: "lengden må være minst %{min}" - max: "lengden må høyst være %{max}" - not_within: "lengden må ikke være mellom %{min} og %{max}" - within: "lengden må være mellom %{min} og %{max}" - numeric: - is: "må være %{is}" - is_not: "må ikke være %{is_not}" - min: "må være minst %{min}" - max: "må høyst være %{max}" - not_within: "må ikke være mellom %{min} og %{max}" - within: "må være mellom %{min} og %{max}" - presence: "må ikke være tom" diff --git a/lib/locales/ne.yml b/lib/locales/ne.yml deleted file mode 100644 index 39ef4ec8f..000000000 --- a/lib/locales/ne.yml +++ /dev/null @@ -1,55 +0,0 @@ -ne: - cmdx: - attributes: - required: "%{method} स्रोत विधि मार्फत पहुँचयोग्य हुनुपर्छ" - undefined: "अपरिभाषित विधिलाई %{method} नियुक्त गर्दछ" - coercions: - into_a: "%{type} मा रूपान्तरण गर्न सकिएन" - into_an: "%{type} मा रूपान्तरण गर्न सकिएन" - into_any: "निम्न मध्ये एउटामा रूपान्तरण गर्न सकिएन: %{types}" - unknown: "अज्ञात %{type} रूपान्तरण प्रकार" - returns: - missing: "सन्दर्भमा सेट गरिएको हुनुपर्छ" - faults: - invalid: "अवैध" - unspecified: "निर्दिष्ट नभएको" - reasons: - unspecified: "निर्दिष्ट नभएको" - types: - array: "एरे" - big_decimal: "ठूलो दशमलव" - boolean: "बुलियन" - complex: "जटिल" - date_time: "मिति र समय" - date: "मिति" - float: "फ्लोटिङ पोइन्ट" - hash: "ह्यास" - integer: "पूर्णांक" - rational: "परिमेय" - string: "स्ट्रिङ" - symbol: "प्रतीक" - time: "समय" - validators: - absence: "खाली हुनुपर्छ" - exclusion: - of: "निम्न मध्ये एउटा हुन सक्दैन: %{values}" - within: "%{min} र %{max} बीचमा हुन सक्दैन" - format: "अमान्य फारम्याट हो" - inclusion: - of: "निम्न मध्ये एउटा हुनुपर्छ: %{values}" - within: "%{min} र %{max} बीचमा हुनुपर्छ" - length: - is: "लम्बाइ %{is} हुनुपर्छ" - is_not: "लम्बाइ %{is_not} हुन सक्दैन" - min: "लम्बाइ कम्तिमा %{min} हुनुपर्छ" - max: "लम्बाइ अधिकतम %{max} हुन सक्छ" - not_within: "लम्बाइ %{min} र %{max} बीचमा हुन सक्दैन" - within: "लम्बाइ %{min} र %{max} बीचमा हुनुपर्छ" - numeric: - is: "%{is} हुनुपर्छ" - is_not: "%{is_not} हुन सक्दैन" - min: "कम्तिमा %{min} हुनुपर्छ" - max: "अधिकतम %{max} हुन सक्छ" - not_within: "%{min} र %{max} बीचमा हुन सक्दैन" - within: "%{min} र %{max} बीचमा हुनुपर्छ" - presence: "खाली हुन सक्दैन" diff --git a/lib/locales/nl.yml b/lib/locales/nl.yml deleted file mode 100644 index c63d4f0bb..000000000 --- a/lib/locales/nl.yml +++ /dev/null @@ -1,55 +0,0 @@ -nl: - cmdx: - attributes: - required: "moet toegankelijk zijn via de bronmethode %{method}" - undefined: "delegeert naar ongedefinieerde methode %{method}" - coercions: - into_a: "kon niet worden geconverteerd naar %{type}" - into_an: "kon niet worden geconverteerd naar %{type}" - into_any: "kon niet worden geconverteerd naar een van: %{types}" - unknown: "onbekend %{type} conversietype" - returns: - missing: "moet ingesteld zijn in de context" - faults: - invalid: "Ongeldig" - unspecified: "Niet gespecificeerd" - reasons: - unspecified: "Niet gespecificeerd" - types: - array: "array" - big_decimal: "grote decimaal" - boolean: "booleaans" - complex: "complex" - date_time: "datum en tijd" - date: "datum" - float: "zwevend punt" - hash: "hash" - integer: "geheel getal" - rational: "rationaal" - string: "tekenreeks" - symbol: "symbool" - time: "tijd" - validators: - absence: "moet leeg zijn" - exclusion: - of: "mag niet een van zijn: %{values}" - within: "mag niet tussen %{min} en %{max} liggen" - format: "is een ongeldig formaat" - inclusion: - of: "moet een van zijn: %{values}" - within: "moet tussen %{min} en %{max} liggen" - length: - is: "lengte moet %{is} zijn" - is_not: "lengte mag niet %{is_not} zijn" - min: "lengte moet minimaal %{min} zijn" - max: "lengte mag maximaal %{max} zijn" - not_within: "lengte mag niet tussen %{min} en %{max} liggen" - within: "lengte moet tussen %{min} en %{max} liggen" - numeric: - is: "moet %{is} zijn" - is_not: "mag niet %{is_not} zijn" - min: "moet minimaal %{min} zijn" - max: "mag maximaal %{max} zijn" - not_within: "mag niet tussen %{min} en %{max} liggen" - within: "moet tussen %{min} en %{max} liggen" - presence: "mag niet leeg zijn" diff --git a/lib/locales/nn.yml b/lib/locales/nn.yml deleted file mode 100644 index bd70f81e2..000000000 --- a/lib/locales/nn.yml +++ /dev/null @@ -1,55 +0,0 @@ -nn: - cmdx: - attributes: - required: "må vere tilgjengeleg via kjeldemetoden %{method}" - undefined: "delegerer til udefinert metode %{method}" - coercions: - into_a: "kunne ikkje konvertere til %{type}" - into_an: "kunne ikkje konvertere til %{type}" - into_any: "kunne ikkje konvertere til ein av: %{types}" - unknown: "ukjend %{type} konverteringstype" - returns: - missing: "må vere sett i konteksten" - faults: - invalid: "Ugyldig" - unspecified: "Uspesifisert" - reasons: - unspecified: "Uspesifisert" - types: - array: "array" - big_decimal: "stor desimal" - boolean: "boolean" - complex: "kompleks" - date_time: "dato og tid" - date: "dato" - float: "flyttal" - hash: "hash" - integer: "heiltal" - rational: "rasjonal" - string: "streng" - symbol: "symbol" - time: "tid" - validators: - absence: "må vere tom" - exclusion: - of: "må ikkje vere ein av: %{values}" - within: "må ikkje vere innanfor %{min} og %{max}" - format: "er eit ugyldig format" - inclusion: - of: "må vere ein av: %{values}" - within: "må vere innanfor %{min} og %{max}" - length: - is: "lengda må vere %{is}" - is_not: "lengda må ikkje vere %{is_not}" - min: "lengda må vere minst %{min}" - max: "lengda kan vere maksimalt %{max}" - not_within: "lengda må ikkje vere innanfor %{min} og %{max}" - within: "lengda må vere innanfor %{min} og %{max}" - numeric: - is: "må vere %{is}" - is_not: "må ikkje vere %{is_not}" - min: "må vere minst %{min}" - max: "kan vere maksimalt %{max}" - not_within: "må ikkje vere innanfor %{min} og %{max}" - within: "må vere innanfor %{min} og %{max}" - presence: "kan ikkje vere tom" diff --git a/lib/locales/oc.yml b/lib/locales/oc.yml deleted file mode 100644 index fa683b566..000000000 --- a/lib/locales/oc.yml +++ /dev/null @@ -1,55 +0,0 @@ -oc: - cmdx: - attributes: - required: "deu èsser accessible via lo metòde font %{method}" - undefined: "delegar a la metòda non definida %{method}" - coercions: - into_a: "poguèt pas èsser convertit en %{type}" - into_an: "poguèt pas èsser convertit en %{type}" - into_any: "poguèt pas èsser convertit en un de: %{types}" - unknown: "tipe de conversion %{type} desconegut" - returns: - missing: "deu èsser definit dins lo contèxt" - faults: - invalid: "Invalidas" - unspecified: "Non especificada" - reasons: - unspecified: "Non especificada" - types: - array: "array" - big_decimal: "decimal grand" - boolean: "booleen" - complex: "complexe" - date_time: "data e ora" - date: "data" - float: "punt flotant" - hash: "hash" - integer: "entier" - rational: "racional" - string: "cadena" - symbol: "simbol" - time: "ora" - validators: - absence: "deu èsser void" - exclusion: - of: "deu pas èsser un de: %{values}" - within: "deu pas èsser entre %{min} e %{max}" - format: "es un format invalid" - inclusion: - of: "deu èsser un de: %{values}" - within: "deu èsser entre %{min} e %{max}" - length: - is: "la longor deu èsser %{is}" - is_not: "la longor deu pas èsser %{is_not}" - min: "la longor deu èsser almens %{min}" - max: "la longor pòt èsser al màger %{max}" - not_within: "la longor deu pas èsser entre %{min} e %{max}" - within: "la longor deu èsser entre %{min} e %{max}" - numeric: - is: "deu èsser %{is}" - is_not: "deu pas èsser %{is_not}" - min: "deu èsser almens %{min}" - max: "pòt èsser al màger %{max}" - not_within: "deu pas èsser entre %{min} e %{max}" - within: "deu èsser entre %{min} e %{max}" - presence: "pòt pas èsser void" diff --git a/lib/locales/or.yml b/lib/locales/or.yml deleted file mode 100644 index cfe3b21c6..000000000 --- a/lib/locales/or.yml +++ /dev/null @@ -1,55 +0,0 @@ -or: - cmdx: - attributes: - required: "%{method} ଉତ୍ସ ପଦ୍ଧତି ମାଧ୍ୟମରେ ଉପଲବ୍ଧ ହେବା ଆବଶ୍ୟକ" - undefined: "ଅପରିଭାଷିତ ପଦ୍ଧତିକୁ %{method} ନିୟୋଜିତ କରେ" - coercions: - into_a: "%{type} ରେ ପରିବର୍ତ୍ତନ କରି ପାରିଲା ନାହିଁ" - into_an: "%{type} ରେ ପରିବର୍ତ୍ତନ କରି ପାରିଲା ନାହିଁ" - into_any: "ନିମ୍ନଲିଖିତ ମଧ୍ୟରୁ ଗୋଟିଏରେ ପରିବର୍ତ୍ତନ କରି ପାରିଲା ନାହିଁ: %{types}" - unknown: "ଅଜ୍ଞାତ %{type} ପରିବର୍ତ୍ତନ ପ୍ରକାର" - returns: - missing: "ପ୍ରସଙ୍ଗରେ ସେଟ୍ ହୋଇଥିବା ଆବଶ୍ୟକ" - faults: - invalid: "ଅବୈଧ" - unspecified: "ଅନିର୍ଦ୍ଦିଷ୍ଟ" - reasons: - unspecified: "ଅନିର୍ଦ୍ଦିଷ୍ଟ" - types: - array: "ଏରେ" - big_decimal: "ବଡ଼ ଦଶମିକ" - boolean: "ବୁଲିଆନ" - complex: "ଜଟିଳ" - date_time: "ତାରିଖ ଏବଂ ସମୟ" - date: "ତାରିଖ" - float: "ଫ୍ଲୋଟିଂ ପଏଣ୍ଟ" - hash: "ହାସ" - integer: "ପୂର୍ଣ୍ଣସଂଖ୍ୟା" - rational: "ପରିମେୟ" - string: "ସ୍ତ୍ରିଂ" - symbol: "ପ୍ରତୀକ" - time: "ସମୟ" - validators: - absence: "ଖାଲି ହୋଇଥିବା ଆବଶ୍ୟକ" - exclusion: - of: "ନିମ୍ନଲିଖିତ ମଧ୍ୟରୁ ଗୋଟିଏ ହୋଇପାରିବ ନାହିଁ: %{values}" - within: "%{min} ଏବଂ %{max} ମଧ୍ୟରେ ହୋଇପାରିବ ନାହିଁ" - format: "ଏକ ଅବୈଧ ଫର୍ମାଟ" - inclusion: - of: "ନିମ୍ନଲିଖିତ ମଧ୍ୟରୁ ଗୋଟିଏ ହୋଇଥିବା ଆବଶ୍ୟକ: %{values}" - within: "%{min} ଏବଂ %{max} ମଧ୍ୟରେ ହୋଇଥିବା ଆବଶ୍ୟକ" - length: - is: "ଦୈର୍ଘ୍ୟ %{is} ହୋଇଥିବା ଆବଶ୍ୟକ" - is_not: "ଦୈର୍ଘ୍ୟ %{is_not} ହୋଇପାରିବ ନାହିଁ" - min: "ଦୈର୍ଘ୍ୟ ଅତିକମ %{min} ହୋଇଥିବା ଆବଶ୍ୟକ" - max: "ଦୈର୍ଘ୍ୟ ସର୍ବାଧିକ %{max} ହୋଇପାରିବ" - not_within: "ଦୈର୍ଘ୍ୟ %{min} ଏବଂ %{max} ମଧ୍ୟରେ ହୋଇପାରିବ ନାହିଁ" - within: "ଦୈର୍ଘ୍ୟ %{min} ଏବଂ %{max} ମଧ୍ୟରେ ହୋଇଥିବା ଆବଶ୍ୟକ" - numeric: - is: "%{is} ହୋଇଥିବା ଆବଶ୍ୟକ" - is_not: "%{is_not} ହୋଇପାରିବ ନାହିଁ" - min: "ଅତିକମ %{min} ହୋଇଥିବା ଆବଶ୍ୟକ" - max: "ସର୍ବାଧିକ %{max} ହୋଇପାରିବ" - not_within: "%{min} ଏବଂ %{max} ମଧ୍ୟରେ ହୋଇପାରିବ ନାହିଁ" - within: "%{min} ଏବଂ %{max} ମଧ୍ୟରେ ହୋଇଥିବା ଆବଶ୍ୟକ" - presence: "ଖାଲି ହୋଇପାରିବ ନାହିଁ" diff --git a/lib/locales/pa.yml b/lib/locales/pa.yml deleted file mode 100644 index c1da4f027..000000000 --- a/lib/locales/pa.yml +++ /dev/null @@ -1,55 +0,0 @@ -pa: - cmdx: - attributes: - required: "%{method} ਸਰੋਤ ਵਿਧੀ ਰਾਹੀਂ ਪਹੁੰਚਯੋਗ ਹੋਣਾ ਚਾਹੀਦਾ ਹੈ" - undefined: "ਅਪਰਿਭਾਸ਼ਿਤ ਵਿਧੀ ਨੂੰ %{method} ਨਿਰਧਾਰਤ ਕਰਦਾ ਹੈ" - coercions: - into_a: "%{type} ਵਿੱਚ ਬਦਲਿਆ ਨਹੀਂ ਜਾ ਸਕਿਆ" - into_an: "%{type} ਵਿੱਚ ਬਦਲਿਆ ਨਹੀਂ ਜਾ ਸਕਿਆ" - into_any: "ਹੇਠਾਂ ਦਿੱਤੇ ਗਏ ਵਿੱਚੋਂ ਕਿਸੇ ਇੱਕ ਵਿੱਚ ਬਦਲਿਆ ਨਹੀਂ ਜਾ ਸਕਿਆ: %{types}" - unknown: "ਅਣਜਾਣ %{type} ਬਦਲਾਅ ਕਿਸਮ" - returns: - missing: "ਸੰਦਰਭ ਵਿੱਚ ਸੈੱਟ ਹੋਣਾ ਚਾਹੀਦਾ ਹੈ" - faults: - invalid: "ਅਵੈਧ" - unspecified: "ਅਨਿਰਧਾਰਿਤ" - reasons: - unspecified: "ਅਨਿਰਧਾਰਿਤ" - types: - array: "ਐਰੇ" - big_decimal: "ਵੱਡਾ ਦਸ਼ਮਲਵ" - boolean: "ਬੂਲੀਅਨ" - complex: "ਜਟਿਲ" - date_time: "ਤਾਰੀਖ ਅਤੇ ਸਮਾਂ" - date: "ਤਾਰੀਖ" - float: "ਫਲੋਟਿੰਗ ਪੁਆਇੰਟ" - hash: "ਹੈਸ਼" - integer: "ਪੂਰਨ ਅੰਕ" - rational: "ਪਰਿਮੇਯ" - string: "ਸਟ੍ਰਿੰਗ" - symbol: "ਚਿੰਨ੍ਹ" - time: "ਸਮਾਂ" - validators: - absence: "ਖਾਲੀ ਹੋਣਾ ਚਾਹੀਦਾ ਹੈ" - exclusion: - of: "ਹੇਠਾਂ ਦਿੱਤੇ ਗਏ ਵਿੱਚੋਂ ਕੋਈ ਇੱਕ ਨਹੀਂ ਹੋ ਸਕਦਾ: %{values}" - within: "%{min} ਅਤੇ %{max} ਦੇ ਵਿਚਕਾਰ ਨਹੀਂ ਹੋ ਸਕਦਾ" - format: "ਇੱਕ ਅਵੈਧ ਫਾਰਮੈਟ ਹੈ" - inclusion: - of: "ਹੇਠਾਂ ਦਿੱਤੇ ਗਏ ਵਿੱਚੋਂ ਕੋਈ ਇੱਕ ਹੋਣਾ ਚਾਹੀਦਾ ਹੈ: %{values}" - within: "%{min} ਅਤੇ %{max} ਦੇ ਵਿਚਕਾਰ ਹੋਣਾ ਚਾਹੀਦਾ ਹੈ" - length: - is: "ਲੰਬਾਈ %{is} ਹੋਣੀ ਚਾਹੀਦੀ ਹੈ" - is_not: "ਲੰਬਾਈ %{is_not} ਨਹੀਂ ਹੋ ਸਕਦੀ" - min: "ਲੰਬਾਈ ਘੱਟੋ ਘੱਟ %{min} ਹੋਣੀ ਚਾਹੀਦੀ ਹੈ" - max: "ਲੰਬਾਈ ਵੱਧ ਤੋਂ ਵੱਧ %{max} ਹੋ ਸਕਦੀ ਹੈ" - not_within: "ਲੰਬਾਈ %{min} ਅਤੇ %{max} ਦੇ ਵਿਚਕਾਰ ਨਹੀਂ ਹੋ ਸਕਦੀ" - within: "ਲੰਬਾਈ %{min} ਅਤੇ %{max} ਦੇ ਵਿਚਕਾਰ ਹੋਣੀ ਚਾਹੀਦੀ ਹੈ" - numeric: - is: "%{is} ਹੋਣਾ ਚਾਹੀਦਾ ਹੈ" - is_not: "%{is_not} ਨਹੀਂ ਹੋ ਸਕਦਾ" - min: "ਘੱਟੋ ਘੱਟ %{min} ਹੋਣਾ ਚਾਹੀਦਾ ਹੈ" - max: "ਵੱਧ ਤੋਂ ਵੱਧ %{max} ਹੋ ਸਕਦਾ ਹੈ" - not_within: "%{min} ਅਤੇ %{max} ਦੇ ਵਿਚਕਾਰ ਨਹੀਂ ਹੋ ਸਕਦਾ" - within: "%{min} ਅਤੇ %{max} ਦੇ ਵਿਚਕਾਰ ਹੋਣਾ ਚਾਹੀਦਾ ਹੈ" - presence: "ਖਾਲੀ ਨਹੀਂ ਹੋ ਸਕਦਾ" diff --git a/lib/locales/pl.yml b/lib/locales/pl.yml deleted file mode 100644 index 014d19d5e..000000000 --- a/lib/locales/pl.yml +++ /dev/null @@ -1,55 +0,0 @@ -pl: - cmdx: - attributes: - required: "musi być dostępny przez metodę źródłową %{method}" - undefined: "deleguje do niezdefiniowanej metody %{method}" - coercions: - into_a: "nie można przekonwertować na %{type}" - into_an: "nie można przekonwertować na %{type}" - into_any: "nie można przekonwertować na jeden z: %{types}" - unknown: "nieznany typ konwersji %{type}" - returns: - missing: "musi być ustawione w kontekście" - faults: - invalid: "Nieprawidłowe" - unspecified: "Nieokreślone" - reasons: - unspecified: "Nieokreślone" - types: - array: "tablica" - big_decimal: "duża liczba dziesiętna" - boolean: "logiczny" - complex: "zespolony" - date_time: "data i czas" - date: "data" - float: "liczba zmiennoprzecinkowa" - hash: "hash" - integer: "liczba całkowita" - rational: "wymierny" - string: "ciąg znaków" - symbol: "symbol" - time: "czas" - validators: - absence: "musi być puste" - exclusion: - of: "nie może być jednym z: %{values}" - within: "nie może być między %{min} a %{max}" - format: "jest nieprawidłowym formatem" - inclusion: - of: "musi być jednym z: %{values}" - within: "musi być między %{min} a %{max}" - length: - is: "długość musi być %{is}" - is_not: "długość nie może być %{is_not}" - min: "długość musi być co najmniej %{min}" - max: "długość może być co najwyżej %{max}" - not_within: "długość nie może być między %{min} a %{max}" - within: "długość musi być między %{min} a %{max}" - numeric: - is: "musi być %{is}" - is_not: "nie może być %{is_not}" - min: "musi być co najmniej %{min}" - max: "może być co najwyżej %{max}" - not_within: "nie może być między %{min} a %{max}" - within: "musi być między %{min} a %{max}" - presence: "nie może być puste" diff --git a/lib/locales/pt.yml b/lib/locales/pt.yml deleted file mode 100644 index e6fd08631..000000000 --- a/lib/locales/pt.yml +++ /dev/null @@ -1,55 +0,0 @@ -pt: - cmdx: - attributes: - required: "deve ser acessível através do método de origem %{method}" - undefined: "delega para método não definido %{method}" - coercions: - into_a: "não foi possível converter em %{type}" - into_an: "não foi possível converter em %{type}" - into_any: "não foi possível converter em um de: %{types}" - unknown: "tipo de conversão %{type} desconhecido" - returns: - missing: "deve estar definido no contexto" - faults: - invalid: "Inválidas" - unspecified: "Não especificada" - reasons: - unspecified: "Não especificada" - types: - array: "array" - big_decimal: "decimal grande" - boolean: "booleano" - complex: "complexo" - date_time: "data e hora" - date: "data" - float: "ponto flutuante" - hash: "hash" - integer: "inteiro" - rational: "racional" - string: "string" - symbol: "símbolo" - time: "tempo" - validators: - absence: "deve estar vazio" - exclusion: - of: "não deve ser um de: %{values}" - within: "não deve estar entre %{min} e %{max}" - format: "é um formato inválido" - inclusion: - of: "deve ser um de: %{values}" - within: "deve estar entre %{min} e %{max}" - length: - is: "o comprimento deve ser %{is}" - is_not: "o comprimento não deve ser %{is_not}" - min: "o comprimento deve ser pelo menos %{min}" - max: "o comprimento deve ser no máximo %{max}" - not_within: "o comprimento não deve estar entre %{min} e %{max}" - within: "o comprimento deve estar entre %{min} e %{max}" - numeric: - is: "deve ser %{is}" - is_not: "não deve ser %{is_not}" - min: "deve ser pelo menos %{min}" - max: "deve ser no máximo %{max}" - not_within: "não deve estar entre %{min} e %{max}" - within: "deve estar entre %{min} e %{max}" - presence: "não pode estar vazio" diff --git a/lib/locales/rm.yml b/lib/locales/rm.yml deleted file mode 100644 index 7413a4b0e..000000000 --- a/lib/locales/rm.yml +++ /dev/null @@ -1,55 +0,0 @@ -rm: - cmdx: - attributes: - required: "stue esser accessibel via la metoda da funtauna %{method}" - undefined: "delega a la metoda betg definida %{method}" - coercions: - into_a: "na pudeva betg vegnir convertì en %{type}" - into_an: "na pudeva betg vegnir convertì en %{type}" - into_any: "na pudeva betg vegnir convertì en in da: %{types}" - unknown: "tip da conversiun %{type} nunenconuschent" - returns: - missing: "sto esser definì en il context" - faults: - invalid: "Nunvalidas" - unspecified: "Betg specificà" - reasons: - unspecified: "Betg specificà" - types: - array: "array" - big_decimal: "decimal grond" - boolean: "booleic" - complex: "complex" - date_time: "data ed ura" - date: "data" - float: "numer cun punt flotant" - hash: "hash" - integer: "entir" - rational: "razional" - string: "chadaina" - symbol: "simbol" - time: "ura" - validators: - absence: "stoscha esser vid" - exclusion: - of: "na dastga betg esser in da: %{values}" - within: "na dastga betg esser tranter %{min} e %{max}" - format: "è in format nunvalid" - inclusion: - of: "stoscha esser in da: %{values}" - within: "stoscha esser tranter %{min} e %{max}" - length: - is: "la lunghezza stoscha esser %{is}" - is_not: "la lunghezza na dastga betg esser %{is_not}" - min: "la lunghezza stoscha esser almain %{min}" - max: "la lunghezza po esser al pli %{max}" - not_within: "la lunghezza na dastga betg esser tranter %{min} e %{max}" - within: "la lunghezza stoscha esser tranter %{min} e %{max}" - numeric: - is: "stoscha esser %{is}" - is_not: "na dastga betg esser %{is_not}" - min: "stoscha esser almain %{min}" - max: "po esser al pli %{max}" - not_within: "na dastga betg esser tranter %{min} e %{max}" - within: "stoscha esser tranter %{min} e %{max}" - presence: "na po betg esser vid" diff --git a/lib/locales/ro.yml b/lib/locales/ro.yml deleted file mode 100644 index bb548215c..000000000 --- a/lib/locales/ro.yml +++ /dev/null @@ -1,55 +0,0 @@ -ro: - cmdx: - attributes: - required: "trebuie să fie accesibil prin metoda sursă %{method}" - undefined: "delegează la metoda nedefinită %{method}" - coercions: - into_a: "nu a putut fi convertit în %{type}" - into_an: "nu a putut fi convertit în %{type}" - into_any: "nu a putut fi convertit în unul din: %{types}" - unknown: "tip de conversie %{type} necunoscut" - returns: - missing: "trebuie setat în context" - faults: - invalid: "Invalide" - unspecified: "Nespecificat" - reasons: - unspecified: "Nespecificat" - types: - array: "array" - big_decimal: "decimal mare" - boolean: "boolean" - complex: "complex" - date_time: "dată și oră" - date: "dată" - float: "număr cu virgulă mobilă" - hash: "hash" - integer: "întreg" - rational: "rațional" - string: "șir" - symbol: "simbol" - time: "timp" - validators: - absence: "trebuie să fie gol" - exclusion: - of: "nu trebuie să fie unul din: %{values}" - within: "nu trebuie să fie între %{min} și %{max}" - format: "este un format invalid" - inclusion: - of: "trebuie să fie unul din: %{values}" - within: "trebuie să fie între %{min} și %{max}" - length: - is: "lungimea trebuie să fie %{is}" - is_not: "lungimea nu trebuie să fie %{is_not}" - min: "lungimea trebuie să fie cel puțin %{min}" - max: "lungimea trebuie să fie cel mult %{max}" - not_within: "lungimea nu trebuie să fie între %{min} și %{max}" - within: "lungimea trebuie să fie între %{min} și %{max}" - numeric: - is: "trebuie să fie %{is}" - is_not: "nu trebuie să fie %{is_not}" - min: "trebuie să fie cel puțin %{min}" - max: "trebuie să fie cel mult %{max}" - not_within: "nu trebuie să fie între %{min} și %{max}" - within: "trebuie să fie între %{min} și %{max}" - presence: "nu poate fi gol" diff --git a/lib/locales/ru.yml b/lib/locales/ru.yml deleted file mode 100644 index ba74f01de..000000000 --- a/lib/locales/ru.yml +++ /dev/null @@ -1,55 +0,0 @@ -ru: - cmdx: - attributes: - required: "должно быть доступно через исходный метод %{method}" - undefined: "делегирует неопределенному методу %{method}" - coercions: - into_a: "не удалось преобразовать в %{type}" - into_an: "не удалось преобразовать в %{type}" - into_any: "не удалось преобразовать в один из: %{types}" - unknown: "неизвестный тип преобразования %{type}" - returns: - missing: "должно быть установлено в контексте" - faults: - invalid: "Неверные" - unspecified: "Не указано" - reasons: - unspecified: "Не указано" - types: - array: "массив" - big_decimal: "большое десятичное число" - boolean: "логический" - complex: "комплексный" - date_time: "дата и время" - date: "дата" - float: "число с плавающей точкой" - hash: "хэш" - integer: "целое число" - rational: "рациональное" - string: "строка" - symbol: "символ" - time: "время" - validators: - absence: "должен быть пустым" - exclusion: - of: "не должен быть одним из: %{values}" - within: "не должен быть между %{min} и %{max}" - format: "неверный формат" - inclusion: - of: "должен быть одним из: %{values}" - within: "должен быть между %{min} и %{max}" - length: - is: "длина должна быть %{is}" - is_not: "длина не должна быть %{is_not}" - min: "длина должна быть не менее %{min}" - max: "длина должна быть не более %{max}" - not_within: "длина не должна быть между %{min} и %{max}" - within: "длина должна быть между %{min} и %{max}" - numeric: - is: "должен быть %{is}" - is_not: "не должен быть %{is_not}" - min: "должен быть не менее %{min}" - max: "должен быть не более %{max}" - not_within: "не должен быть между %{min} и %{max}" - within: "должен быть между %{min} и %{max}" - presence: "не может быть пустым" diff --git a/lib/locales/sc.yml b/lib/locales/sc.yml deleted file mode 100644 index 2ada6cc7e..000000000 --- a/lib/locales/sc.yml +++ /dev/null @@ -1,55 +0,0 @@ -sc: - cmdx: - attributes: - required: "depet èssere atzessìbile pro mèdiu de su mètodu de orìgine %{method}" - undefined: "delegat a su metodu non definidu %{method}" - coercions: - into_a: "no s'est podidu cunvertire in %{type}" - into_an: "no s'est podidu cunvertire in %{type}" - into_any: "no s'est podidu cunvertire in unu de: %{types}" - unknown: "tipu de cunversione %{type} disconnotu" - returns: - missing: "depet èssere impostadu in su cuntestu" - faults: - invalid: "Non vàlidos" - unspecified: "Non specificadu" - reasons: - unspecified: "Non specificadu" - types: - array: "array" - big_decimal: "decimale mannu" - boolean: "booleanu" - complex: "cumpressu" - date_time: "data e ora" - date: "data" - float: "puntu flotante" - hash: "hash" - integer: "interu" - rational: "razionale" - string: "corda" - symbol: "sìmbulu" - time: "ora" - validators: - absence: "deve èssere bòidu" - exclusion: - of: "non deve èssere unu de: %{values}" - within: "non deve èssere intre %{min} e %{max}" - format: "est unu formatu non vàlidu" - inclusion: - of: "deve èssere unu de: %{values}" - within: "deve èssere intre %{min} e %{max}" - length: - is: "sa longària deve èssere %{is}" - is_not: "sa longària non deve èssere %{is_not}" - min: "sa longària deve èssere a su mancu %{min}" - max: "sa longària potet èssere a su màssimu %{max}" - not_within: "sa longària non deve èssere intre %{min} e %{max}" - within: "sa longària deve èssere intre %{min} e %{max}" - numeric: - is: "deve èssere %{is}" - is_not: "non deve èssere %{is_not}" - min: "deve èssere a su mancu %{min}" - max: "potet èssere a su màssimu %{max}" - not_within: "non deve èssere intre %{min} e %{max}" - within: "deve èssere intre %{min} e %{max}" - presence: "non potet èssere bòidu" diff --git a/lib/locales/sk.yml b/lib/locales/sk.yml deleted file mode 100644 index 863a56b9c..000000000 --- a/lib/locales/sk.yml +++ /dev/null @@ -1,55 +0,0 @@ -sk: - cmdx: - attributes: - required: "musí byť prístupné cez zdrojovú metódu %{method}" - undefined: "deleguje na nedefinovanú metódu %{method}" - coercions: - into_a: "nepodarilo sa previesť na %{type}" - into_an: "nepodarilo sa previesť na %{type}" - into_any: "nepodarilo sa previesť na jeden z: %{types}" - unknown: "neznámy typ konverzie %{type}" - returns: - missing: "musí byť nastavené v kontexte" - faults: - invalid: "Neplatné" - unspecified: "Nešpecifikované" - reasons: - unspecified: "Nešpecifikované" - types: - array: "pole" - big_decimal: "veľké desatinné číslo" - boolean: "logický" - complex: "komplexný" - date_time: "dátum a čas" - date: "dátum" - float: "desatinné číslo" - hash: "hash" - integer: "celé číslo" - rational: "racionálny" - string: "reťazec" - symbol: "symbol" - time: "čas" - validators: - absence: "musí byť prázdne" - exclusion: - of: "nesmie byť jedným z: %{values}" - within: "nesmie byť medzi %{min} a %{max}" - format: "je neplatný formát" - inclusion: - of: "musí byť jedným z: %{values}" - within: "musí byť medzi %{min} a %{max}" - length: - is: "dĺžka musí byť %{is}" - is_not: "dĺžka nesmie byť %{is_not}" - min: "dĺžka musí byť aspoň %{min}" - max: "dĺžka môže byť najviac %{max}" - not_within: "dĺžka nesmie byť medzi %{min} a %{max}" - within: "dĺžka musí byť medzi %{min} a %{max}" - numeric: - is: "musí byť %{is}" - is_not: "nesmie byť %{is_not}" - min: "musí byť aspoň %{min}" - max: "môže byť najviac %{max}" - not_within: "nesmie byť medzi %{min} a %{max}" - within: "musí byť medzi %{min} a %{max}" - presence: "nesmie byť prázdne" diff --git a/lib/locales/sl.yml b/lib/locales/sl.yml deleted file mode 100644 index 59345377e..000000000 --- a/lib/locales/sl.yml +++ /dev/null @@ -1,55 +0,0 @@ -sl: - cmdx: - attributes: - required: "mora biti dostopno prek izvorne metode %{method}" - undefined: "delegira nedefinirani metodi %{method}" - coercions: - into_a: "ni bilo mogoče pretvoriti v %{type}" - into_an: "ni bilo mogoče pretvoriti v %{type}" - into_any: "ni bilo mogoče pretvoriti v enega od: %{types}" - unknown: "neznan tip pretvorbe %{type}" - returns: - missing: "mora biti nastavljeno v kontekstu" - faults: - invalid: "Neveljavni" - unspecified: "Nedoločeno" - reasons: - unspecified: "Nedoločeno" - types: - array: "matrika" - big_decimal: "veliko decimalno število" - boolean: "logično" - complex: "kompleksno" - date_time: "datum in čas" - date: "datum" - float: "število s plavajočo vejico" - hash: "hash" - integer: "celo število" - rational: "racionalno" - string: "niz" - symbol: "simbol" - time: "čas" - validators: - absence: "mora biti prazno" - exclusion: - of: "ne sme biti eden od: %{values}" - within: "ne sme biti med %{min} in %{max}" - format: "je neveljaven format" - inclusion: - of: "mora biti eden od: %{values}" - within: "mora biti med %{min} in %{max}" - length: - is: "dolžina mora biti %{is}" - is_not: "dolžina ne sme biti %{is_not}" - min: "dolžina mora biti vsaj %{min}" - max: "dolžina lahko znaša največ %{max}" - not_within: "dolžina ne sme biti med %{min} in %{max}" - within: "dolžina mora biti med %{min} in %{max}" - numeric: - is: "mora biti %{is}" - is_not: "ne sme biti %{is_not}" - min: "mora biti vsaj %{min}" - max: "lahko znaša največ %{max}" - not_within: "ne sme biti med %{min} in %{max}" - within: "mora biti med %{min} in %{max}" - presence: "ne sme biti prazno" diff --git a/lib/locales/sq.yml b/lib/locales/sq.yml deleted file mode 100644 index 375e077cc..000000000 --- a/lib/locales/sq.yml +++ /dev/null @@ -1,55 +0,0 @@ -sq: - cmdx: - attributes: - required: "duhet të jetë i qasshëm përmes metodës së burimit %{method}" - undefined: "delegon metodën e padefinuar %{method}" - coercions: - into_a: "nuk mund të konvertohet në %{type}" - into_an: "nuk mund të konvertohet në %{type}" - into_any: "nuk mund të konvertohet në një nga: %{types}" - unknown: "tip i panjohur konvertimi %{type}" - returns: - missing: "duhet të vendoset në kontekst" - faults: - invalid: "Të pavlefshme" - unspecified: "E papërcaktuar" - reasons: - unspecified: "E papërcaktuar" - types: - array: "array" - big_decimal: "decimal i madh" - boolean: "boolean" - complex: "kompleks" - date_time: "data dhe koha" - date: "data" - float: "numër me pikë lundruese" - hash: "hash" - integer: "numër i plotë" - rational: "racional" - string: "varg" - symbol: "simbol" - time: "koha" - validators: - absence: "duhet të jetë bosh" - exclusion: - of: "nuk duhet të jetë një nga: %{values}" - within: "nuk duhet të jetë mes %{min} dhe %{max}" - format: "është një format i pavlefshëm" - inclusion: - of: "duhet të jetë një nga: %{values}" - within: "duhet të jetë mes %{min} dhe %{max}" - length: - is: "gjatësia duhet të jetë %{is}" - is_not: "gjatësia nuk duhet të jetë %{is_not}" - min: "gjatësia duhet të jetë të paktën %{min}" - max: "gjatësia duhet të jetë më së shumti %{max}" - not_within: "gjatësia nuk duhet të jetë mes %{min} dhe %{max}" - within: "gjatësia duhet të jetë mes %{min} dhe %{max}" - numeric: - is: "duhet të jetë %{is}" - is_not: "nuk duhet të jetë %{is_not}" - min: "duhet të jetë të paktën %{min}" - max: "duhet të jetë më së shumti %{max}" - not_within: "nuk duhet të jetë mes %{min} dhe %{max}" - within: "duhet të jetë mes %{min} dhe %{max}" - presence: "nuk mund të jetë bosh" diff --git a/lib/locales/sr.yml b/lib/locales/sr.yml deleted file mode 100644 index 6f6d42a58..000000000 --- a/lib/locales/sr.yml +++ /dev/null @@ -1,55 +0,0 @@ -sr: - cmdx: - attributes: - required: "mora biti dostupan preko izvorne metode %{method}" - undefined: "делегира недефинисаној методи %{method}" - coercions: - into_a: "није могао да се конвертује у %{type}" - into_an: "није могао да се конвертује у %{type}" - into_any: "није могао да се конвертује у један од: %{types}" - unknown: "непознат тип конверзије %{type}" - returns: - missing: "мора бити постављено у контексту" - faults: - invalid: "Неисправни" - unspecified: "Није наведен" - reasons: - unspecified: "Није наведен" - types: - array: "низ" - big_decimal: "велики децимални број" - boolean: "логички" - complex: "комплексан" - date_time: "датум и време" - date: "датум" - float: "број са покретном зарезом" - hash: "хаш" - integer: "цео број" - rational: "рационалан" - string: "низ знакова" - symbol: "симбол" - time: "време" - validators: - absence: "мора бити празно" - exclusion: - of: "не сме бити један од: %{values}" - within: "не сме бити између %{min} и %{max}" - format: "је неважећи формат" - inclusion: - of: "мора бити један од: %{values}" - within: "мора бити између %{min} и %{max}" - length: - is: "дужина мора бити %{is}" - is_not: "дужина не сме бити %{is_not}" - min: "дужина мора бити најмање %{min}" - max: "дужина може бити највише %{max}" - not_within: "дужина не сме бити између %{min} и %{max}" - within: "дужина мора бити између %{min} и %{max}" - numeric: - is: "мора бити %{is}" - is_not: "не сме бити %{is_not}" - min: "мора бити најмање %{min}" - max: "може бити највише %{max}" - not_within: "не сме бити између %{min} и %{max}" - within: "мора бити између %{min} и %{max}" - presence: "не може бити празно" diff --git a/lib/locales/st.yml b/lib/locales/st.yml deleted file mode 100644 index 9cfafcf79..000000000 --- a/lib/locales/st.yml +++ /dev/null @@ -1,55 +0,0 @@ -st: - cmdx: - attributes: - required: "e tlameha ho fihlelleha ka mokhoa oa mohloli oa %{method}" - undefined: "e fana ka boikarabello ho mokgwa o sa hlaloswang %{method}" - coercions: - into_a: "ha e ka fetolelwa ho %{type}" - into_an: "ha e ka fetolelwa ho %{type}" - into_any: "ha e ka fetolelwa ho e mong oa: %{types}" - unknown: "mofuta o sa tsejoang oa phetoho %{type}" - returns: - missing: "e tlameha ho hlophisetswa ka moelelo" - faults: - invalid: "Tse sa sebetseng" - unspecified: "Ha e hlalosoe" - reasons: - unspecified: "Ha e hlalosoe" - types: - array: "array" - big_decimal: "decimal e kgolo" - boolean: "boolean" - complex: "e rarahaneng" - date_time: "letsatsi le nako" - date: "letsatsi" - float: "nomoro ea ho fapana" - hash: "hash" - integer: "nomoro e feletseng" - rational: "e rarahaneng" - string: "lentsoe" - symbol: "mokhoa" - time: "nako" - validators: - absence: "e tlameha ho ba e se na letho" - exclusion: - of: "ha e tlameha ho ba e mong oa: %{values}" - within: "ha e tlameha ho ba pakeng tsa %{min} le %{max}" - format: "ke foromo e sa sebetseng" - inclusion: - of: "e tlameha ho ba e mong oa: %{values}" - within: "e tlameha ho ba pakeng tsa %{min} le %{max}" - length: - is: "botelele bo tlameha ho ba %{is}" - is_not: "botelele ha bo tlameha ho ba %{is_not}" - min: "botelele bo tlameha ho ba bonyane %{min}" - max: "botelele bo ka ba ka ho fetisisa %{max}" - not_within: "botelele ha bo tlameha ho ba pakeng tsa %{min} le %{max}" - within: "botelele bo tlameha ho ba pakeng tsa %{min} le %{max}" - numeric: - is: "e tlameha ho ba %{is}" - is_not: "ha e tlameha ho ba %{is_not}" - min: "e tlameha ho ba bonyane %{min}" - max: "e ka ba ka ho fetisisa %{max}" - not_within: "ha e tlameha ho ba pakeng tsa %{min} le %{max}" - within: "e tlameha ho ba pakeng tsa %{min} le %{max}" - presence: "ha e ka ba e se na letho" diff --git a/lib/locales/sv.yml b/lib/locales/sv.yml deleted file mode 100644 index 8a3e831fe..000000000 --- a/lib/locales/sv.yml +++ /dev/null @@ -1,55 +0,0 @@ -sv: - cmdx: - attributes: - required: "måste vara tillgänglig via källmetoden %{method}" - undefined: "delegerar till odefinierad metod %{method}" - coercions: - into_a: "kunde inte konverteras till %{type}" - into_an: "kunde inte konverteras till %{type}" - into_any: "kunde inte konverteras till en av: %{types}" - unknown: "okänd %{type} konverteringstyp" - returns: - missing: "måste vara satt i kontexten" - faults: - invalid: "Ogiltiga" - unspecified: "Ospecificerat" - reasons: - unspecified: "Ospecificerat" - types: - array: "array" - big_decimal: "stort decimaltal" - boolean: "boolesk" - complex: "komplext" - date_time: "datum och tid" - date: "datum" - float: "flyttal" - hash: "hash" - integer: "heltal" - rational: "rationellt" - string: "sträng" - symbol: "symbol" - time: "tid" - validators: - absence: "måste vara tom" - exclusion: - of: "får inte vara en av: %{values}" - within: "får inte vara mellan %{min} och %{max}" - format: "är ett ogiltigt format" - inclusion: - of: "måste vara en av: %{values}" - within: "måste vara mellan %{min} och %{max}" - length: - is: "längden måste vara %{is}" - is_not: "längden får inte vara %{is_not}" - min: "längden måste vara minst %{min}" - max: "längden får högst vara %{max}" - not_within: "längden får inte vara mellan %{min} och %{max}" - within: "längden måste vara mellan %{min} och %{max}" - numeric: - is: "måste vara %{is}" - is_not: "får inte vara %{is_not}" - min: "måste vara minst %{min}" - max: "får högst vara %{max}" - not_within: "får inte vara mellan %{min} och %{max}" - within: "måste vara mellan %{min} och %{max}" - presence: "får inte vara tom" diff --git a/lib/locales/sw.yml b/lib/locales/sw.yml deleted file mode 100644 index 2c7eb1dc4..000000000 --- a/lib/locales/sw.yml +++ /dev/null @@ -1,55 +0,0 @@ -sw: - cmdx: - attributes: - required: "lazima ipatikane kupitia mbinu ya chanzo ya %{method}" - undefined: "inakabidhi kwa njia isiyofafanuliwa %{method}" - coercions: - into_a: "haikuweza kubadilishwa kuwa %{type}" - into_an: "haikuweza kubadilishwa kuwa %{type}" - into_any: "haikuweza kubadilishwa kuwa moja ya: %{types}" - unknown: "aina isiyojulikana ya mabadiliko %{type}" - returns: - missing: "lazima iwekwe katika muktadha" - faults: - invalid: "Zisizofaa" - unspecified: "Haijaspecifikwa" - reasons: - unspecified: "Haijaspecifikwa" - types: - array: "safu" - big_decimal: "desimali kubwa" - boolean: "booleani" - complex: "tata" - date_time: "tarehe na wakati" - date: "tarehe" - float: "nambari ya sehemu" - hash: "hash" - integer: "nambari kamili" - rational: "ya busara" - string: "mstari" - symbol: "ishara" - time: "wakati" - validators: - absence: "lazima iwe tupu" - exclusion: - of: "haipaswi kuwa moja ya: %{values}" - within: "haipaswi kuwa kati ya %{min} na %{max}" - format: "ni muundo batili" - inclusion: - of: "lazima iwe moja ya: %{values}" - within: "lazima iwe kati ya %{min} na %{max}" - length: - is: "urefu lazima uwe %{is}" - is_not: "urefu haupaswi kuwa %{is_not}" - min: "urefu lazima uwe angalau %{min}" - max: "urefu unaweza kuwa zaidi ya %{max}" - not_within: "urefu haupaswi kuwa kati ya %{min} na %{max}" - within: "urefu lazima uwe kati ya %{min} na %{max}" - numeric: - is: "lazima iwe %{is}" - is_not: "haipaswi kuwa %{is_not}" - min: "lazima iwe angalau %{min}" - max: "inaweza kuwa zaidi ya %{max}" - not_within: "haipaswi kuwa kati ya %{min} na %{max}" - within: "lazima iwe kati ya %{min} na %{max}" - presence: "haiwezi kuwa tupu" diff --git a/lib/locales/ta.yml b/lib/locales/ta.yml deleted file mode 100644 index 349655d8c..000000000 --- a/lib/locales/ta.yml +++ /dev/null @@ -1,55 +0,0 @@ -ta: - cmdx: - attributes: - required: "%{method} மூல முறை மூலம் அணுகக்கூடியதாக இருக்க வேண்டும்" - undefined: "வரையறுக்கப்படாத முறைக்கு %{method} ஒப்படைக்கிறது" - coercions: - into_a: "%{type} ஆக மாற்ற முடியவில்லை" - into_an: "%{type} ஆக மாற்ற முடியவில்லை" - into_any: "பின்வருவனவற்றில் ஒன்றாக மாற்ற முடியவில்லை: %{types}" - unknown: "தெரியாத %{type} மாற்ற வகை" - returns: - missing: "சூழலில் அமைக்கப்பட வேண்டும்" - faults: - invalid: "தவறான" - unspecified: "குறிப்பிடப்படாத" - reasons: - unspecified: "குறிப்பிடப்படாத" - types: - array: "வரிசை" - big_decimal: "பெரிய தசமம்" - boolean: "பூலியன்" - complex: "சிக்கலான" - date_time: "தேதி மற்றும் நேரம்" - date: "தேதி" - float: "மிதக்கும் புள்ளி" - hash: "ஹாஷ்" - integer: "முழு எண்" - rational: "விகிதமுறு" - string: "சரம்" - symbol: "குறியீடு" - time: "நேரம்" - validators: - absence: "வெறுமையாக இருக்க வேண்டும்" - exclusion: - of: "பின்வருவனவற்றில் ஒன்றாக இருக்கக்கூடாது: %{values}" - within: "%{min} மற்றும் %{max} இடையே இருக்கக்கூடாது" - format: "தவறான வடிவமாகும்" - inclusion: - of: "பின்வருவனவற்றில் ஒன்றாக இருக்க வேண்டும்: %{values}" - within: "%{min} மற்றும் %{max} இடையே இருக்க வேண்டும்" - length: - is: "நீளம் %{is} ஆக இருக்க வேண்டும்" - is_not: "நீளம் %{is_not} ஆக இருக்கக்கூடாது" - min: "நீளம் குறைந்தபட்சம் %{min} ஆக இருக்க வேண்டும்" - max: "நீளம் அதிகபட்சம் %{max} ஆக இருக்கலாம்" - not_within: "நீளம் %{min} மற்றும் %{max} இடையே இருக்கக்கூடாது" - within: "நீளம் %{min} மற்றும் %{max} இடையே இருக்க வேண்டும்" - numeric: - is: "%{is} ஆக இருக்க வேண்டும்" - is_not: "%{is_not} ஆக இருக்கக்கூடாது" - min: "குறைந்தபட்சம் %{min} ஆக இருக்க வேண்டும்" - max: "அதிகபட்சம் %{max} ஆக இருக்கலாம்" - not_within: "%{min} மற்றும் %{max} இடையே இருக்கக்கூடாது" - within: "%{min} மற்றும் %{max} இடையே இருக்க வேண்டும்" - presence: "வெறுமையாக இருக்கக்கூடாது" diff --git a/lib/locales/te.yml b/lib/locales/te.yml deleted file mode 100644 index 085707f01..000000000 --- a/lib/locales/te.yml +++ /dev/null @@ -1,55 +0,0 @@ -te: - cmdx: - attributes: - required: "%{method} మూల పద్ధతి ద్వారా యాక్సెస్ చేయగలగాలి" - undefined: "నిర్వచించబడని పద్ధతికి %{method} అప్పగిస్తుంది" - coercions: - into_a: "%{type} గా మార్చలేకపోయింది" - into_an: "%{type} గా మార్చలేకపోయింది" - into_any: "ఈ క్రింది వాటిలో ఒకటిగా మార్చలేకపోయింది: %{types}" - unknown: "తెలియని %{type} మార్పు రకం" - returns: - missing: "సందర్భంలో సెట్ చేయబడాలి" - faults: - invalid: "చెల్లని" - unspecified: "నిర్దిష్టం కాదు" - reasons: - unspecified: "నిర్దిష్టం కాదు" - types: - array: "అరే" - big_decimal: "పెద్ద దశాంశం" - boolean: "బూలియన్" - complex: "సంక్లిష్టం" - date_time: "తేదీ మరియు సమయం" - date: "తేదీ" - float: "తేలే బిందువు" - hash: "హాష్" - integer: "పూర్ణాంకం" - rational: "భిన్నరాశి" - string: "స్ట్రింగ్" - symbol: "చిహ్నం" - time: "సమయం" - validators: - absence: "ఖాళీగా ఉండాలి" - exclusion: - of: "ఈ క్రింది వాటిలో ఒకటి కాకూడదు: %{values}" - within: "%{min} మరియు %{max} మధ్య ఉండకూడదు" - format: "చెల్లని ఫార్మాట్" - inclusion: - of: "ఈ క్రింది వాటిలో ఒకటి అయి ఉండాలి: %{values}" - within: "%{min} మరియు %{max} మధ్య ఉండాలి" - length: - is: "పొడవు %{is} అయి ఉండాలి" - is_not: "పొడవు %{is_not} కాకూడదు" - min: "పొడవు కనీసం %{min} అయి ఉండాలి" - max: "పొడవు గరిష్టంగా %{max} అయి ఉండవచ్చు" - not_within: "పొడవు %{min} మరియు %{max} మధ్య ఉండకూడదు" - within: "పొడవు %{min} మరియు %{max} మధ్య ఉండాలి" - numeric: - is: "%{is} అయి ఉండాలి" - is_not: "%{is_not} కాకూడదు" - min: "కనీసం %{min} అయి ఉండాలి" - max: "గరిష్టంగా %{max} అయి ఉండవచ్చు" - not_within: "%{min} మరియు %{max} మధ్య ఉండకూడదు" - within: "%{min} మరియు %{max} మధ్య ఉండాలి" - presence: "ఖాళీగా ఉండకూడదు" diff --git a/lib/locales/th.yml b/lib/locales/th.yml deleted file mode 100644 index ee1a346ce..000000000 --- a/lib/locales/th.yml +++ /dev/null @@ -1,55 +0,0 @@ -th: - cmdx: - attributes: - required: "ต้องสามารถเข้าถึงได้ผ่านวิธีแหล่งที่มา %{method}" - undefined: "มอบหมายให้กับเมธอดที่ไม่ได้กำหนด %{method}" - coercions: - into_a: "ไม่สามารถแปลงเป็น %{type} ได้" - into_an: "ไม่สามารถแปลงเป็น %{type} ได้" - into_any: "ไม่สามารถแปลงเป็นหนึ่งใน: %{types} ได้" - unknown: "ประเภทการแปลง %{type} ที่ไม่รู้จัก" - returns: - missing: "ต้องถูกกำหนดในบริบท" - faults: - invalid: "ไม่ถูกต้อง" - unspecified: "ไม่ระบุ" - reasons: - unspecified: "ไม่ระบุ" - types: - array: "อาร์เรย์" - big_decimal: "ทศนิยมขนาดใหญ่" - boolean: "บูลีน" - complex: "จำนวนเชิงซ้อน" - date_time: "วันที่และเวลา" - date: "วันที่" - float: "จำนวนทศนิยม" - hash: "แฮช" - integer: "จำนวนเต็ม" - rational: "จำนวนตรรกยะ" - string: "สตริง" - symbol: "สัญลักษณ์" - time: "เวลา" - validators: - absence: "ต้องเป็นค่าว่าง" - exclusion: - of: "ต้องไม่ใช่หนึ่งใน: %{values}" - within: "ต้องไม่อยู่ระหว่าง %{min} และ %{max}" - format: "เป็นรูปแบบที่ไม่ถูกต้อง" - inclusion: - of: "ต้องเป็นหนึ่งใน: %{values}" - within: "ต้องอยู่ระหว่าง %{min} และ %{max}" - length: - is: "ความยาวต้องเป็น %{is}" - is_not: "ความยาวต้องไม่เป็น %{is_not}" - min: "ความยาวต้องอย่างน้อย %{min}" - max: "ความยาวต้องไม่เกิน %{max}" - not_within: "ความยาวต้องไม่อยู่ระหว่าง %{min} และ %{max}" - within: "ความยาวต้องอยู่ระหว่าง %{min} และ %{max}" - numeric: - is: "ต้องเป็น %{is}" - is_not: "ต้องไม่เป็น %{is_not}" - min: "ต้องอย่างน้อย %{min}" - max: "ต้องไม่เกิน %{max}" - not_within: "ต้องไม่อยู่ระหว่าง %{min} และ %{max}" - within: "ต้องอยู่ระหว่าง %{min} และ %{max}" - presence: "ไม่สามารถเป็นค่าว่างได้" diff --git a/lib/locales/tl.yml b/lib/locales/tl.yml deleted file mode 100644 index 53e8567a1..000000000 --- a/lib/locales/tl.yml +++ /dev/null @@ -1,55 +0,0 @@ -tl: - cmdx: - attributes: - required: "dapat ma-access sa pamamagitan ng pamamaraang pinagmulan ng %{method}" - undefined: "nag-delegate sa hindi tinukoy na method %{method}" - coercions: - into_a: "hindi ma-convert sa %{type}" - into_an: "hindi ma-convert sa %{type}" - into_any: "hindi ma-convert sa isa sa: %{types}" - unknown: "hindi kilalang %{type} conversion type" - returns: - missing: "dapat itakda sa konteksto" - faults: - invalid: "Hindi wasto" - unspecified: "Hindi tinukoy" - reasons: - unspecified: "Hindi tinukoy" - types: - array: "array" - big_decimal: "malaking decimal" - boolean: "boolean" - complex: "complex" - date_time: "petsa at oras" - date: "petsa" - float: "floating point" - hash: "hash" - integer: "integer" - rational: "rational" - string: "string" - symbol: "symbol" - time: "oras" - validators: - absence: "dapat na walang laman" - exclusion: - of: "hindi dapat na isa sa: %{values}" - within: "hindi dapat na nasa pagitan ng %{min} at %{max}" - format: "ay hindi wastong format" - inclusion: - of: "dapat na isa sa: %{values}" - within: "dapat na nasa pagitan ng %{min} at %{max}" - length: - is: "ang haba ay dapat na %{is}" - is_not: "ang haba ay hindi dapat na %{is_not}" - min: "ang haba ay dapat na hindi bababa sa %{min}" - max: "ang haba ay dapat na hindi hihigit sa %{max}" - not_within: "ang haba ay hindi dapat na nasa pagitan ng %{min} at %{max}" - within: "ang haba ay dapat na nasa pagitan ng %{min} at %{max}" - numeric: - is: "dapat na %{is}" - is_not: "hindi dapat na %{is_not}" - min: "dapat na hindi bababa sa %{min}" - max: "dapat na hindi hihigit sa %{max}" - not_within: "hindi dapat na nasa pagitan ng %{min} at %{max}" - within: "dapat na nasa pagitan ng %{min} at %{max}" - presence: "hindi maaaring walang laman" diff --git a/lib/locales/tr.yml b/lib/locales/tr.yml deleted file mode 100644 index 90838332b..000000000 --- a/lib/locales/tr.yml +++ /dev/null @@ -1,55 +0,0 @@ -tr: - cmdx: - attributes: - required: "%{method} kaynak yöntemi ile erişilebilir olmalıdır" - undefined: "tanımlanmamış metoda %{method} devreder" - coercions: - into_a: "%{type} türüne dönüştürülemedi" - into_an: "%{type} türüne dönüştürülemedi" - into_any: "şunlardan birine dönüştürülemedi: %{types}" - unknown: "bilinmeyen %{type} dönüştürme türü" - returns: - missing: "bağlamda ayarlanmış olmalıdır" - faults: - invalid: "Geçersiz" - unspecified: "Belirtilmemiş" - reasons: - unspecified: "Belirtilmemiş" - types: - array: "dizi" - big_decimal: "büyük ondalık" - boolean: "boole" - complex: "karmaşık" - date_time: "tarih ve saat" - date: "tarih" - float: "kayan nokta" - hash: "hash" - integer: "tam sayı" - rational: "rasyonel" - string: "dize" - symbol: "sembol" - time: "zaman" - validators: - absence: "boş olmalıdır" - exclusion: - of: "şunlardan biri olmamalıdır: %{values}" - within: "%{min} ve %{max} arasında olmamalıdır" - format: "geçersiz bir formattır" - inclusion: - of: "şunlardan biri olmalıdır: %{values}" - within: "%{min} ve %{max} arasında olmalıdır" - length: - is: "uzunluk %{is} olmalıdır" - is_not: "uzunluk %{is_not} olmamalıdır" - min: "uzunluk en az %{min} olmalıdır" - max: "uzunluk en fazla %{max} olmalıdır" - not_within: "uzunluk %{min} ve %{max} arasında olmamalıdır" - within: "uzunluk %{min} ve %{max} arasında olmalıdır" - numeric: - is: "%{is} olmalıdır" - is_not: "%{is_not} olmamalıdır" - min: "en az %{min} olmalıdır" - max: "en fazla %{max} olmalıdır" - not_within: "%{min} ve %{max} arasında olmamalıdır" - within: "%{min} ve %{max} arasında olmalıdır" - presence: "boş olamaz" diff --git a/lib/locales/tt.yml b/lib/locales/tt.yml deleted file mode 100644 index e0fd56089..000000000 --- a/lib/locales/tt.yml +++ /dev/null @@ -1,55 +0,0 @@ -tt: - cmdx: - attributes: - required: "%{method} чыганак методы аша керү мөмкинлеге булырга тиеш" - undefined: "билгеләнмәгән %{method} методына тапшыра" - coercions: - into_a: "%{type} төренә күчерә алмады" - into_an: "%{type} төренә күчерә алмады" - into_any: "боларның берсенә күчерә алмады: %{types}" - unknown: "билгесез %{type} күчерү төре" - returns: - missing: "контекстта урнаштырылган булырга тиеш" - faults: - invalid: "Дөрес булмаган" - unspecified: "Күрсәтелмәгән" - reasons: - unspecified: "Күрсәтелмәгән" - types: - array: "массив" - big_decimal: "зур унарлы" - boolean: "буль" - complex: "комплекс" - date_time: "дата һәм вакыт" - date: "дата" - float: "өзәк ноктасы" - hash: "хэш" - integer: "бөтен сан" - rational: "рациональ" - string: "юл" - symbol: "символ" - time: "вакыт" - validators: - absence: "буш булырга тиеш" - exclusion: - of: "боларның берсе булмасын: %{values}" - within: "%{min} һәм %{max} арасында булмасын" - format: "дөрес булмаган формат" - inclusion: - of: "боларның берсе булырга тиеш: %{values}" - within: "%{min} һәм %{max} арасында булырга тиеш" - length: - is: "озынлыгы %{is} булырга тиеш" - is_not: "озынлыгы %{is_not} булмасын" - min: "озынлыгы ким дигәндә %{min} булырга тиеш" - max: "озынлыгы иң күбесеннә %{max} була ала" - not_within: "озынлыгы %{min} һәм %{max} арасында булмасын" - within: "озынлыгы %{min} һәм %{max} арасында булырга тиеш" - numeric: - is: "%{is} булырга тиеш" - is_not: "%{is_not} булмасын" - min: "ким дигәндә %{min} булырга тиеш" - max: "иң күбесеннә %{max} була ала" - not_within: "%{min} һәм %{max} арасында булмасын" - within: "%{min} һәм %{max} арасында булырга тиеш" - presence: "буш була алмас" diff --git a/lib/locales/ug.yml b/lib/locales/ug.yml deleted file mode 100644 index abd833ca1..000000000 --- a/lib/locales/ug.yml +++ /dev/null @@ -1,55 +0,0 @@ -ug: - cmdx: - attributes: - required: "%{method} مەنبە ئۇسۇلى ئارقىلىق زىيارەت قىلىنىشى كېرەك" - undefined: "ئېنىقلانمىغان %{method} مېتودىغا ۋەكەتلىك قىلىدۇ" - coercions: - into_a: "%{type} غا ئايلاندۇرالمايدۇ" - into_an: "%{type} غا ئايلاندۇرالمايدۇ" - into_any: "بۇلارنىڭ بىرىگە ئايلاندۇرالمايدۇ: %{types}" - unknown: "نامەلۇم %{type} ئايلاندۇرۇش تىپى" - returns: - missing: "مەزمۇندا بېكىتىلىشى كېرەك" - faults: - invalid: "ئىناۋەتسىز" - unspecified: "بەلگىلەنمىگەن" - reasons: - unspecified: "بەلگىلەنمىگەن" - types: - array: "تىزىملىك" - big_decimal: "چوڭ ئونلۇق" - boolean: "بۇل" - complex: "مۇرەككەپ" - date_time: "چېسلا ۋە ۋاقىت" - date: "چېسلا" - float: "سىيرىلغۇ نۇقتا" - hash: "خەش" - integer: "پۈتۈن سان" - rational: "راسىيونال" - string: "تەر" - symbol: "بەلگە" - time: "ۋاقىت" - validators: - absence: "بوش بولۇشى كېرەك" - exclusion: - of: "بۇلارنىڭ بىرى بولماسلىقى كېرەك: %{values}" - within: "%{min} ۋە %{max} ئارىسىدا بولماسلىقى كېرەك" - format: "ئىناۋەتسىز فورمات" - inclusion: - of: "بۇلارنىڭ بىرى بولۇشى كېرەك: %{values}" - within: "%{min} ۋە %{max} ئارىسىدا بولۇشى كېرەك" - length: - is: "ئۇزۇنلۇقى %{is} بولۇشى كېرەك" - is_not: "ئۇزۇنلۇقى %{is_not} بولماسلىقى كېرەك" - min: "ئۇزۇنلۇقى ئەڭ ئاز %{min} بولۇشى كېرەك" - max: "ئۇزۇنلۇقى ئەڭ كۆپ بولغاندا %{max} بولالايدۇ" - not_within: "ئۇزۇنلۇقى %{min} ۋە %{max} ئارىسىدا بولماسلىقى كېرەك" - within: "ئۇزۇنلۇقى %{min} ۋە %{max} ئارىسىدا بولۇشى كېرەك" - numeric: - is: "%{is} بولۇشى كېرەك" - is_not: "%{is_not} بولماسلىقى كېرەك" - min: "ئەڭ ئاز %{min} بولۇشى كېرەك" - max: "ئەڭ كۆپ بولغاندا %{max} بولالايدۇ" - not_within: "%{min} ۋە %{max} ئارىسىدا بولماسلىقى كېرەك" - within: "%{min} ۋە %{max} ئارىسىدا بولۇشى كېرەك" - presence: "بوش بولالمايدۇ" diff --git a/lib/locales/uk.yml b/lib/locales/uk.yml deleted file mode 100644 index c9124a406..000000000 --- a/lib/locales/uk.yml +++ /dev/null @@ -1,55 +0,0 @@ -uk: - cmdx: - attributes: - required: "має бути доступним через вихідний метод %{method}" - undefined: "делегує невизначеному методу %{method}" - coercions: - into_a: "не вдалося перетворити на %{type}" - into_an: "не вдалося перетворити на %{type}" - into_any: "не вдалося перетворити на один з: %{types}" - unknown: "невідомий тип перетворення %{type}" - returns: - missing: "повинно бути встановлено в контексті" - faults: - invalid: "Невірні" - unspecified: "Не вказано" - reasons: - unspecified: "Не вказано" - types: - array: "масив" - big_decimal: "велике десяткове число" - boolean: "логічний" - complex: "комплексний" - date_time: "дата та час" - date: "дата" - float: "число з плаваючою комою" - hash: "хеш" - integer: "ціле число" - rational: "раціональне" - string: "рядок" - symbol: "символ" - time: "час" - validators: - absence: "має бути порожнім" - exclusion: - of: "не може бути одним з: %{values}" - within: "не може бути між %{min} та %{max}" - format: "є недійсним форматом" - inclusion: - of: "має бути одним з: %{values}" - within: "має бути між %{min} та %{max}" - length: - is: "довжина має бути %{is}" - is_not: "довжина не може бути %{is_not}" - min: "довжина має бути не менше %{min}" - max: "довжина має бути не більше %{max}" - not_within: "довжина не може бути між %{min} та %{max}" - within: "довжина має бути між %{min} та %{max}" - numeric: - is: "має бути %{is}" - is_not: "не може бути %{is_not}" - min: "має бути не менше %{min}" - max: "має бути не більше %{max}" - not_within: "не може бути між %{min} та %{max}" - within: "має бути між %{min} та %{max}" - presence: "не може бути порожнім" diff --git a/lib/locales/ur.yml b/lib/locales/ur.yml deleted file mode 100644 index b28fa94f9..000000000 --- a/lib/locales/ur.yml +++ /dev/null @@ -1,55 +0,0 @@ -ur: - cmdx: - attributes: - required: "%{method} ماخذ طریقہ کار کے ذریعے قابل رسائی ہونا ضروری ہے" - undefined: "غیر متعین طریقہ %{method} کو تفویض کرتا ہے" - coercions: - into_a: "%{type} میں تبدیل نہیں کر سکا" - into_an: "%{type} میں تبدیل نہیں کر سکا" - into_any: "ان میں سے کسی ایک میں تبدیل نہیں کر سکا: %{types}" - unknown: "نامعلوم %{type} تبدیلی کی قسم" - returns: - missing: "سیاق و سباق میں سیٹ ہونا ضروری ہے" - faults: - invalid: "غلط" - unspecified: "غیر متعین" - reasons: - unspecified: "غیر متعین" - types: - array: "آرے" - big_decimal: "بڑا اعشاری" - boolean: "بولین" - complex: "پیچیدہ" - date_time: "تاریخ اور وقت" - date: "تاریخ" - float: "شناور نقطہ" - hash: "ہیش" - integer: "مکمل عدد" - rational: "ناطق" - string: "سٹرنگ" - symbol: "علامت" - time: "وقت" - validators: - absence: "خالی ہونا ضروری ہے" - exclusion: - of: "ان میں سے کوئی ایک نہیں ہونا چاہیے: %{values}" - within: "%{min} اور %{max} کے درمیان نہیں ہونا چاہیے" - format: "ایک غلط فارمیٹ ہے" - inclusion: - of: "ان میں سے کوئی ایک ہونا ضروری ہے: %{values}" - within: "%{min} اور %{max} کے درمیان ہونا ضروری ہے" - length: - is: "لمبائی %{is} ہونی چاہیے" - is_not: "لمبائی %{is_not} نہیں ہونی چاہیے" - min: "لمبائی کم از کم %{min} ہونی چاہیے" - max: "لمبائی زیادہ سے زیادہ %{max} ہو سکتی ہے" - not_within: "لمبائی %{min} اور %{max} کے درمیان نہیں ہونی چاہیے" - within: "لمبائی %{min} اور %{max} کے درمیان ہونی چاہیے" - numeric: - is: "%{is} ہونا ضروری ہے" - is_not: "%{is_not} نہیں ہونا چاہیے" - min: "کم از کم %{min} ہونا ضروری ہے" - max: "زیادہ سے زیادہ %{max} ہو سکتا ہے" - not_within: "%{min} اور %{max} کے درمیان نہیں ہونا چاہیے" - within: "%{min} اور %{max} کے درمیان ہونا ضروری ہے" - presence: "خالی نہیں ہو سکتا" diff --git a/lib/locales/uz.yml b/lib/locales/uz.yml deleted file mode 100644 index e317c5b18..000000000 --- a/lib/locales/uz.yml +++ /dev/null @@ -1,55 +0,0 @@ -uz: - cmdx: - attributes: - required: "%{method} manba usuli orqali kirish imkoniyatiga ega bo'lishi kerak" - undefined: "aniqlanmagan %{method} metodiga topshiradi" - coercions: - into_a: "%{type} ga o'tkazib bo'lmadi" - into_an: "%{type} ga o'tkazib bo'lmadi" - into_any: "bularning biriga o'tkazib bo'lmadi: %{types}" - unknown: "noma'lum %{type} o'tkazish turi" - returns: - missing: "kontekstda belgilangan bolishi kerak" - faults: - invalid: "Noto'g'ri" - unspecified: "Ko'rsatilmagan" - reasons: - unspecified: "Ko'rsatilmagan" - types: - array: "massiv" - big_decimal: "katta o'nlik" - boolean: "mantiqiy" - complex: "murakkab" - date_time: "sana va vaqt" - date: "sana" - float: "suzuvchi nuqta" - hash: "xesh" - integer: "butun son" - rational: "ratsional" - string: "qator" - symbol: "belgi" - time: "vaqt" - validators: - absence: "bo'sh bo'lishi kerak" - exclusion: - of: "bularning biri bo'lmasligi kerak: %{values}" - within: "%{min} va %{max} oralig'ida bo'lmasligi kerak" - format: "noto'g'ri format" - inclusion: - of: "bularning biri bo'lishi kerak: %{values}" - within: "%{min} va %{max} oralig'ida bo'lishi kerak" - length: - is: "uzunligi %{is} bo'lishi kerak" - is_not: "uzunligi %{is_not} bo'lmasligi kerak" - min: "uzunligi kamida %{min} bo'lishi kerak" - max: "uzunligi ko'pi bilan %{max} bo'lishi mumkin" - not_within: "uzunligi %{min} va %{max} oralig'ida bo'lmasligi kerak" - within: "uzunligi %{min} va %{max} oralig'ida bo'lishi kerak" - numeric: - is: "%{is} bo'lishi kerak" - is_not: "%{is_not} bo'lmasligi kerak" - min: "kamida %{min} bo'lishi kerak" - max: "ko'pi bilan %{max} bo'lishi mumkin" - not_within: "%{min} va %{max} oralig'ida bo'lmasligi kerak" - within: "%{min} va %{max} oralig'ida bo'lishi kerak" - presence: "bo'sh bo'lishi mumkin emas" diff --git a/lib/locales/vi.yml b/lib/locales/vi.yml deleted file mode 100644 index d53558c25..000000000 --- a/lib/locales/vi.yml +++ /dev/null @@ -1,55 +0,0 @@ -vi: - cmdx: - attributes: - required: "phải có thể truy cập thông qua phương thức nguồn %{method}" - undefined: "ủy quyền cho phương thức không xác định %{method}" - coercions: - into_a: "không thể chuyển đổi thành %{type}" - into_an: "không thể chuyển đổi thành %{type}" - into_any: "không thể chuyển đổi thành một trong: %{types}" - unknown: "kiểu chuyển đổi %{type} không xác định" - returns: - missing: "phải được thiết lập trong ngữ cảnh" - faults: - invalid: "Không hợp lệ" - unspecified: "Không xác định" - reasons: - unspecified: "Không xác định" - types: - array: "mảng" - big_decimal: "số thập phân lớn" - boolean: "boolean" - complex: "phức" - date_time: "ngày và giờ" - date: "ngày" - float: "số thực" - hash: "bảng băm" - integer: "số nguyên" - rational: "số hữu tỷ" - string: "chuỗi" - symbol: "ký hiệu" - time: "thời gian" - validators: - absence: "phải để trống" - exclusion: - of: "không được là một trong: %{values}" - within: "không được nằm giữa %{min} và %{max}" - format: "là định dạng không hợp lệ" - inclusion: - of: "phải là một trong: %{values}" - within: "phải nằm giữa %{min} và %{max}" - length: - is: "độ dài phải là %{is}" - is_not: "độ dài không được là %{is_not}" - min: "độ dài phải ít nhất là %{min}" - max: "độ dài phải nhiều nhất là %{max}" - not_within: "độ dài không được nằm giữa %{min} và %{max}" - within: "độ dài phải nằm giữa %{min} và %{max}" - numeric: - is: "phải là %{is}" - is_not: "không được là %{is_not}" - min: "phải ít nhất là %{min}" - max: "phải nhiều nhất là %{max}" - not_within: "không được nằm giữa %{min} và %{max}" - within: "phải nằm giữa %{min} và %{max}" - presence: "không thể để trống" diff --git a/lib/locales/wo.yml b/lib/locales/wo.yml deleted file mode 100644 index 8fae3dabe..000000000 --- a/lib/locales/wo.yml +++ /dev/null @@ -1,55 +0,0 @@ -wo: - cmdx: - attributes: - required: "dafa war a man a dugg ci mbirum %{method} bi" - undefined: "dafa yokkute ci xam-xam bu ñu waxul %{method}" - coercions: - into_a: "duñu ko man a soppi ci %{type}" - into_an: "duñu ko man a soppi ci %{type}" - into_any: "duñu ko man a soppi ci benn ci: %{types}" - unknown: "xam-xam bu ñu waxul bu soppi %{type}" - returns: - missing: "war na nekk ci contexte bi" - faults: - invalid: "Ñu waxul" - unspecified: "Duñu wax" - reasons: - unspecified: "Duñu wax" - types: - array: "array" - big_decimal: "desimal bu góor" - boolean: "booleen" - complex: "kompleks" - date_time: "bëccëg ak jamono" - date: "bëccëg" - float: "xayma bu dox" - hash: "hash" - integer: "xayma bu mat" - rational: "rasyonel" - string: "xel" - symbol: "simbol" - time: "jamono" - validators: - absence: "dafa war a nekk lu nekk" - exclusion: - of: "duñu war a nekk benn ci: %{values}" - within: "duñu war a nekk ci digg %{min} ak %{max}" - format: "dafa nekk format bu ñu waxul" - inclusion: - of: "dafa war a nekk benn ci: %{values}" - within: "dafa war a nekk ci digg %{min} ak %{max}" - length: - is: "gudd gi dafa war a nekk %{is}" - is_not: "gudd gi duñu war a nekk %{is_not}" - min: "gudd gi dafa war a nekk gën %{min}" - max: "gudd gi dafa man a nekk gën %{max}" - not_within: "gudd gi duñu war a nekk ci digg %{min} ak %{max}" - within: "gudd gi dafa war a nekk ci digg %{min} ak %{max}" - numeric: - is: "dafa war a nekk %{is}" - is_not: "duñu war a nekk %{is_not}" - min: "dafa war a nekk gën %{min}" - max: "dafa man a nekk gën %{max}" - not_within: "duñu war a nekk ci digg %{min} ak %{max}" - within: "dafa war a nekk ci digg %{min} ak %{max}" - presence: "duñu man a nekk lu nekk" diff --git a/lib/locales/zh-CN.yml b/lib/locales/zh-CN.yml deleted file mode 100644 index 328febb66..000000000 --- a/lib/locales/zh-CN.yml +++ /dev/null @@ -1,55 +0,0 @@ -zh-CN: - cmdx: - attributes: - required: "必须通过 %{method} 源方法访问" - undefined: "委托给未定义的方法 %{method}" - coercions: - into_a: "无法转换为 %{type}" - into_an: "无法转换为 %{type}" - into_any: "无法转换为以下之一: %{types}" - unknown: "未知的 %{type} 转换类型" - returns: - missing: "必须在上下文中设置" - faults: - invalid: "无效" - unspecified: "未指定" - reasons: - unspecified: "未指定" - types: - array: "数组" - big_decimal: "大十进制数" - boolean: "布尔值" - complex: "复数" - date_time: "日期时间" - date: "日期" - float: "浮点数" - hash: "哈希" - integer: "整数" - rational: "有理数" - string: "字符串" - symbol: "符号" - time: "时间" - validators: - absence: "必须为空" - exclusion: - of: "不能是以下之一: %{values}" - within: "不能在 %{min} 和 %{max} 之间" - format: "格式无效" - inclusion: - of: "必须是以下之一: %{values}" - within: "必须在 %{min} 和 %{max} 之间" - length: - is: "长度必须是 %{is}" - is_not: "长度不能是 %{is_not}" - min: "长度必须至少为 %{min}" - max: "长度最多为 %{max}" - not_within: "长度不能在 %{min} 和 %{max} 之间" - within: "长度必须在 %{min} 和 %{max} 之间" - numeric: - is: "必须是 %{is}" - is_not: "不能是 %{is_not}" - min: "必须至少为 %{min}" - max: "最多为 %{max}" - not_within: "不能在 %{min} 和 %{max} 之间" - within: "必须在 %{min} 和 %{max} 之间" - presence: "不能为空" diff --git a/lib/locales/zh-HK.yml b/lib/locales/zh-HK.yml deleted file mode 100644 index 346f89590..000000000 --- a/lib/locales/zh-HK.yml +++ /dev/null @@ -1,55 +0,0 @@ -zh-HK: - cmdx: - attributes: - required: "必須透過 %{method} 來源方法存取" - undefined: "委託給未定義的方法 %{method}" - coercions: - into_a: "無法強制轉換成 %{type}" - into_an: "無法強制轉換成 %{type}" - into_any: "無法強制轉換成其中一個:%{types}" - unknown: "未知的 %{type} 強制轉換類型" - returns: - missing: "必須在上下文中設定" - faults: - invalid: "無效" - unspecified: "未指定" - reasons: - unspecified: "未指定" - types: - array: "陣列" - big_decimal: "大十進位" - boolean: "布林值" - complex: "複數" - date_time: "日期和時間" - date: "日期" - float: "浮點數" - hash: "雜湊" - integer: "整數" - rational: "有理數" - string: "字串" - symbol: "符號" - time: "時間" - validators: - absence: "必須是空白" - exclusion: - of: "不可以是其中一個:%{values}" - within: "不可以在 %{min} 和 %{max} 之間" - format: "是無效的格式" - inclusion: - of: "必須是其中一個:%{values}" - within: "必須在 %{min} 和 %{max} 之間" - length: - is: "長度必須是 %{is}" - is_not: "長度不可以是 %{is_not}" - min: "長度至少必須是 %{min}" - max: "長度最多可以是 %{max}" - not_within: "長度不可以在 %{min} 和 %{max} 之間" - within: "長度必須在 %{min} 和 %{max} 之間" - numeric: - is: "必須是 %{is}" - is_not: "不可以是 %{is_not}" - min: "至少必須是 %{min}" - max: "最多可以是 %{max}" - not_within: "不可以在 %{min} 和 %{max} 之間" - within: "必須在 %{min} 和 %{max} 之間" - presence: "不可以是空白" diff --git a/lib/locales/zh-TW.yml b/lib/locales/zh-TW.yml deleted file mode 100644 index 5e4ab1510..000000000 --- a/lib/locales/zh-TW.yml +++ /dev/null @@ -1,55 +0,0 @@ -zh-TW: - cmdx: - attributes: - required: "必須透過 %{method} 來源方法存取" - undefined: "委託給未定義的方法 %{method}" - coercions: - into_a: "無法強制轉換成 %{type}" - into_an: "無法強制轉換成 %{type}" - into_any: "無法強制轉換成其中一個:%{types}" - unknown: "未知的 %{type} 強制轉換類型" - returns: - missing: "必須在上下文中設定" - faults: - invalid: "無效" - unspecified: "未指定" - reasons: - unspecified: "未指定" - types: - array: "陣列" - big_decimal: "大十進位" - boolean: "布林值" - complex: "複數" - date_time: "日期和時間" - date: "日期" - float: "浮點數" - hash: "雜湊" - integer: "整數" - rational: "有理數" - string: "字串" - symbol: "符號" - time: "時間" - validators: - absence: "必須是空白" - exclusion: - of: "不可以是其中一個:%{values}" - within: "不可以在 %{min} 和 %{max} 之間" - format: "是無效的格式" - inclusion: - of: "必須是其中一個:%{values}" - within: "必須在 %{min} 和 %{max} 之間" - length: - is: "長度必須是 %{is}" - is_not: "長度不可以是 %{is_not}" - min: "長度至少必須是 %{min}" - max: "長度最多可以是 %{max}" - not_within: "長度不可以在 %{min} 和 %{max} 之間" - within: "長度必須在 %{min} 和 %{max} 之間" - numeric: - is: "必須是 %{is}" - is_not: "不可以是 %{is_not}" - min: "至少必須是 %{min}" - max: "最多可以是 %{max}" - not_within: "不可以在 %{min} 和 %{max} 之間" - within: "必須在 %{min} 和 %{max} 之間" - presence: "不可以是空白" diff --git a/lib/locales/zh-YUE.yml b/lib/locales/zh-YUE.yml deleted file mode 100644 index 09cc747c9..000000000 --- a/lib/locales/zh-YUE.yml +++ /dev/null @@ -1,55 +0,0 @@ -zh-YUE: - cmdx: - attributes: - required: "必須透過 %{method} 來源方法存取" - undefined: "委託畀未定義嘅方法 %{method}" - coercions: - into_a: "無法強制轉換成 %{type}" - into_an: "無法強制轉換成 %{type}" - into_any: "無法強制轉換成其中一個:%{types}" - unknown: "未知嘅 %{type} 強制轉換類型" - returns: - missing: "必須喺上下文入面設定" - faults: - invalid: "無效" - unspecified: "未指定" - reasons: - unspecified: "未指定" - types: - array: "陣列" - big_decimal: "大十進位" - boolean: "布林值" - complex: "複數" - date_time: "日期同時間" - date: "日期" - float: "浮點數" - hash: "雜湊" - integer: "整數" - rational: "有理數" - string: "字串" - symbol: "符號" - time: "時間" - validators: - absence: "必須係空白" - exclusion: - of: "唔可以係其中一個:%{values}" - within: "唔可以喺 %{min} 同 %{max} 之間" - format: "係無效嘅格式" - inclusion: - of: "必須係其中一個:%{values}" - within: "必須喺 %{min} 同 %{max} 之間" - length: - is: "長度必須係 %{is}" - is_not: "長度唔可以係 %{is_not}" - min: "長度至少必須係 %{min}" - max: "長度最多可以係 %{max}" - not_within: "長度唔可以喺 %{min} 同 %{max} 之間" - within: "長度必須喺 %{min} 同 %{max} 之間" - numeric: - is: "必須係 %{is}" - is_not: "唔可以係 %{is_not}" - min: "至少必須係 %{min}" - max: "最多可以係 %{max}" - not_within: "唔可以喺 %{min} 同 %{max} 之間" - within: "必須喺 %{min} 同 %{max} 之間" - presence: "唔可以係空白" diff --git a/mkdocs.yml b/mkdocs.yml index 7237a7431..0614afa8f 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -17,7 +17,7 @@ theme: icon: repo: fontawesome/brands/github font: - text: Inter + text: IBM Plex Sans code: IBM Plex Mono palette: # Palette toggle for automatic mode @@ -120,20 +120,20 @@ plugins: - basics/context.md: Context object for data sharing, input/output management, and attribute access - basics/chain.md: Execution chain tracking for related tasks within threads Interruptions: - - interruptions/halt.md: Intentional task interruption using skip! and fail! methods - - interruptions/faults.md: Fault exceptions (SkipFault, FailFault) raised by execute! with rich context - - interruptions/exceptions.md: Exception handling differences between execute and execute! methods + - interruptions/signals.md: Intentional task interruption using skip! and fail! methods + - interruptions/faults.md: CMDx::Fault raised by execute! with task class, signal, and cleaned backtrace + - interruptions/exceptions.md: Complete exception hierarchy (Error, DeprecationError, ImplementationError, MiddlewareError, Fault) and execute vs execute! semantics Outcomes: - outcomes/result.md: Result objects exposing execution state, status, context, and metadata - outcomes/states.md: Execution lifecycle states (initialized, executing, complete, interrupted) - outcomes/statuses.md: Business outcome statuses (success, skipped, failed) and transitions Attributes: - - attributes/definitions.md: Attribute declarations (required/optional), validation, and type coercion - - attributes/naming.md: Customizing accessor method names with prefixes and suffixes - - attributes/coercions.md: Automatic type conversion for inputs (string to integer, date parsing, etc.) - - attributes/validations.md: Input validation rules (presence, length, format, numeric, inclusion, exclusion) - - attributes/defaults.md: Default values for optional attributes (static, dynamic, callable) - - attributes/transformations.md: Value transformation after coercion but before validation + - inputs/definitions.md: Attribute declarations (required/optional), validation, and type coercion + - inputs/naming.md: Customizing accessor method names with prefixes and suffixes + - inputs/coercions.md: Automatic type conversion for inputs (string to integer, date parsing, etc.) + - inputs/validations.md: Input validation rules (presence, length, format, numeric, inclusion, exclusion) + - inputs/defaults.md: Default values for optional inputs (static, dynamic, callable) + - inputs/transformations.md: Value transformation after coercion but before validation Features: - callbacks.md: Execution lifecycle callbacks (before_execution, on_success, on_failure, etc.) - middlewares.md: Cross-cutting concerns wrapping task execution (authentication, caching, timeouts) @@ -141,13 +141,13 @@ plugins: - internationalization.md: Multi-language support for error messages and validations (90+ locales) - retries.md: Automatic retry functionality for transient failures with jitter and selective retries - deprecation.md: Managing deprecated tasks with logging, warnings, or execution prevention - - returns.md: Declaring expected context outputs that must be set after task execution + - outputs.md: Declaring expected context outputs that must be set after task execution - workflows.md: Composing multiple tasks into sequential pipelines with conditional execution More: - testing.md: Best practices for testing CMDx tasks and workflows with RSpec - - exceptions.md: Complete exception hierarchy and error type reference - tips_and_tricks.md: Best practices, patterns, and techniques for maintainable CMDx applications - comparison.md: Comparison with other command/service object frameworks + - v2-migration.md: Migration guide from CMDx 1.x to 2.0 nav: - Home: index.md - Documentation: @@ -159,20 +159,20 @@ nav: - Context: basics/context.md - Chain: basics/chain.md - Interruptions: - - Halt: interruptions/halt.md + - Signals: interruptions/signals.md - Faults: interruptions/faults.md - Exceptions: interruptions/exceptions.md - Outcomes: - Result: outcomes/result.md - States: outcomes/states.md - Statuses: outcomes/statuses.md - - Attributes: - - Definitions: attributes/definitions.md - - Naming: attributes/naming.md - - Coercions: attributes/coercions.md - - Validations: attributes/validations.md - - Defaults: attributes/defaults.md - - Transformations: attributes/transformations.md + - Inputs: + - Definitions: inputs/definitions.md + - Naming: inputs/naming.md + - Coercions: inputs/coercions.md + - Validations: inputs/validations.md + - Defaults: inputs/defaults.md + - Transformations: inputs/transformations.md - Features: - Callbacks: callbacks.md - Middlewares: middlewares.md @@ -180,13 +180,13 @@ nav: - Internationalization: internationalization.md - Retries: retries.md - Deprecation: deprecation.md - - Returns: returns.md + - Outputs: outputs.md - Workflows: workflows.md - More: - Testing: testing.md - - Exceptions: exceptions.md - Tips and Tricks: tips_and_tricks.md - Comparison: comparison.md + - v1 → v2 Migration: v2-migration.md - References: - API Documentation: https://drexed.github.io/cmdx/api/index.html - llms.txt: https://drexed.github.io/cmdx/llms.txt diff --git a/skills/SKILL.md b/skills/SKILL.md index d12af4148..8e2b65f3f 100644 --- a/skills/SKILL.md +++ b/skills/SKILL.md @@ -1,40 +1,48 @@ --- name: cmdx -description: Build, debug, and optimize CMDx tasks and workflows in Ruby. Use when creating service/command objects with CMDx, composing business logic into workflows, handling task failures and interruptions, or working with CMDx attributes, callbacks, middleware, and configuration. +description: Build, debug, and document CMDx tasks and workflows in Ruby. Use when creating service/command objects with CMDx, composing tasks into workflows, handling halts and faults, or wiring inputs, outputs, callbacks, middleware, retries, and configuration. Don't use for generic Ruby refactors, Rails controller work, or non-CMDx service objects. --- # CMDx Agent Skill -CMDx is a Ruby framework for composable command/service objects with built-in attribute validation, type coercion, error handling, and observability. +CMDx is a Ruby framework for composable command/service objects with declarative inputs, outputs, coercion, validation, retries, rollback, and structured observability. Deep dives live under [docs/](docs/) and [references/](references/); the [LLM index](https://github.com/drexed/cmdx/blob/main/LLM.md) bundles the full doc tree for one-shot loading. -For full documentation, see the [docs/](docs/) directory or the [LLM reference](https://drexed.github.io/cmdx/llms-full.txt). Key doc pages are linked throughout this skill via progressive disclosure. +## Lifecycle -## Task Lifecycle (CERO) +Every task runs through `CMDx::Runtime` in this order: -Every task follows: **Compose → Execute → React → Observe**. +1. **Middlewares** wrap everything (`call(task) { yield }` chain). +2. **Deprecation** check (may block or log). +3. **`before_execution` callbacks**. +4. **`before_validation` callbacks**. +5. **Input resolution** — fetch, coerce, transform, validate. +6. **`work`** runs inside `catch(CMDx::Signal::TAG)`, wrapped in `retry_on`. +7. **Output verification** — coerce/validate declared outputs. +8. **`rollback`** runs if the signal is `failed` and the task defines `#rollback`. +9. **State callbacks** — `on_complete` or `on_interrupted`. +10. **Status callbacks** — `on_success` / `on_skipped` / `on_failed`. +11. **Outcome callbacks** — `on_ok` (success or skipped) or `on_ko` (skipped or failed). +12. **Result finalize + teardown** — task, errors, and root context are frozen; chain is cleared. -``` -middlewares.call!(task) do - before_validation → define & validate attributes → fail! if errors - before_execution → result.executing! → task.work - verify returns -rescue Fault → result.throw!(fault.result) -rescue StandardError → retry or result.fail! -ensure - result.executed! - on_complete / on_interrupted # by state - on_executed # if execution ran - on_success / on_skipped / on_failed # by status - on_good / on_bad # by status group - log → rollback? → freeze → clear chain -end -``` +`success!` / `skip!` / `fail!` / `throw!` are control-flow tokens that `throw` a `CMDx::Signal` caught by Runtime — anything after a halt is unreachable. They only work inside the signal catch (input resolution, `work`, output verification); calling them from `before_execution` / `before_validation` callbacks or middleware bubbles past Runtime and never produces a `Result`. See [docs/basics/setup.md](docs/basics/setup.md) for the full state diagram. + +## DSL Surface + +| Category | Keywords | +|----------|----------| +| Inputs | `required`, `optional`, `input`, `inputs` | +| Outputs | `output`, `outputs` | +| Callbacks | `before_execution`, `before_validation`, `on_success`, `on_skipped`, `on_failed`, `on_complete`, `on_interrupted`, `on_ok`, `on_ko` | +| Class config | `settings`, `retry_on`, `deprecation`, `register`, `deregister` | +| Halts (inside `work`) | `success!`, `skip!`, `fail!`, `throw!` | +| Workflow | `include CMDx::Workflow`, `task`, `tasks` | +| Execution | `Task.execute`, `Task.execute!` (aliased `call` / `call!`) | ## Minimal Task ```ruby class Greet < CMDx::Task - required :name, type: :string, presence: true + required :name, presence: true def work context.greeting = "Hello, #{name}!" @@ -42,39 +50,34 @@ class Greet < CMDx::Task end result = Greet.execute(name: "World") -result.success? #=> true -result.context.greeting #=> "Hello, World!" +result.success? #=> true +result.context.greeting #=> "Hello, World!" + +# Block form returns the block's value. +Greet.execute(name: "World") { |r| r.context.greeting } #=> "Hello, World!" ``` -## Full-Featured Task +## Realistic Task ```ruby class ProcessPayment < CMDx::Task - settings( - retries: 3, - retry_on: [Gateway::TimeoutError], - retry_jitter: :exponential_backoff, - rollback_on: ["failed"], - tags: ["billing"] - ) + settings(tags: ["billing"]) - register :middleware, CMDx::Middlewares::Timeout, seconds: 10 - register :middleware, CMDx::Middlewares::Runtime + retry_on Gateway::TimeoutError, limit: 3, jitter: :exponential before_execution :find_order on_success :send_receipt - on_failed :alert_support, if: -> { context.amount > 1000 } - required :order_id, type: :integer - required :amount, type: :big_decimal, numeric: { greater_than: 0 } - optional :currency, type: :string, default: "USD", inclusion: { in: %w[USD EUR GBP] } - optional :idempotency_key, type: :string, default: -> { SecureRandom.uuid } + required :order_id, coerce: :integer + required :amount, coerce: :big_decimal, numeric: { gt: 0 } + optional :currency, coerce: :string, default: "USD", inclusion: { in: %w[USD EUR GBP] } - returns :charge_id, :receipt_url + output :charge_id, required: true + output :receipt_url, required: true def work - charge = Gateway.charge!(amount: amount, currency: currency, key: idempotency_key) - context.charge_id = charge.id + charge = Gateway.charge!(amount: amount, currency: currency) + context.charge_id = charge.id context.receipt_url = charge.receipt_url end @@ -92,150 +95,151 @@ class ProcessPayment < CMDx::Task def send_receipt ReceiptMailer.send(@order, context.receipt_url).deliver_later end - - def alert_support - SupportNotifier.high_value_failure(@order, result.reason) - end -end -``` - -## Workflow Example - -Workflows compose tasks into sequential or parallel execution groups. - -```ruby -class OnboardCustomer < CMDx::Task - include CMDx::Workflow - - task ValidateIdentity - task CreateAccount, breakpoints: %w[failed] - task SetupBilling, if: :billing_required? - tasks SendWelcomeEmail, SendWelcomeSms, strategy: :parallel - - private - - def billing_required? - context.plan != "free" - end end - -result = OnboardCustomer.execute(email: "user@example.com", plan: "pro") ``` -Workflows share a single context across all tasks. A failing task halts execution when its status matches the group's breakpoints (default: `["failed"]`). - -For advanced patterns, see [references/workflows.md](references/workflows.md) and [docs/workflows.md](docs/workflows.md). +## Inputs -## Attributes +Declare with `input` / `inputs` / `required` / `optional`. Each generated reader returns the coerced, transformed, validated value — read it instead of `context.<name>`, which still holds the raw input. -Declared with `required`, `optional`, or `attribute`: +Pipeline per input: **Source → Default → Coerce → Transform → Validate**. ```ruby class Example < CMDx::Task - required :email, type: :string, format: { with: URI::MailTo::EMAIL_REGEXP } - optional :role, type: :string, default: "member", inclusion: { in: %w[admin member guest] } - attribute :notes, required: false + required :email, coerce: :string, format: { with: URI::MailTo::EMAIL_REGEXP } + optional :role, coerce: :string, default: "member", inclusion: { in: %w[admin member guest] } + input :notes # optional by default def work - # Attributes accessible as methods: email, role, notes - # These return coerced/validated values (see pitfall #2) + email # coerced/validated + role + notes end end ``` -### Pipeline - -Each attribute flows through: **Source → Coerce → Transform → Validate**. - -### Type coercion - -Built-in types: `:array`, `:big_decimal`, `:boolean`, `:complex`, `:date`, `:datetime`, `:float`, `:hash`, `:integer`, `:rational`, `:string`, `:symbol`, `:time`. +**Coercions** accept a Symbol, an Array (tried in order, first success wins), a Hash (per-coercion options), or any inline `#call(value, task)`-able. Built-ins: `:array`, `:big_decimal`, `:boolean`, `:complex`, `:date`, `:date_time`, `:float`, `:hash`, `:integer`, `:rational`, `:string`, `:symbol`, `:time`. ```ruby -required :count, type: :integer # single type -required :value, types: [Integer, Float] # multiple types +required :count, coerce: :integer +required :value, coerce: %i[rational big_decimal] +required :recorded_at, coerce: { date: { strptime: "%m-%d-%Y" } } ``` -### Validations - -Built-in: `presence`, `absence`, `format`, `length`, `numeric`, `inclusion`, `exclusion`. +**Validators** — shorthand keys: `presence:`, `absence:`, `format:`, `length:`, `numeric:`, `inclusion:`, `exclusion:`, plus inline `validate:`. Numeric/length option keys: `:min`/`:gte`, `:max`/`:lte`, `:gt`, `:lt`, `:within`/`:in`, `:not_within`/`:not_in`, `:is`/`:eq`, `:is_not`/`:not_eq`. ```ruby -required :age, type: :integer, numeric: { greater_than: 0, less_than: 150 } -required :code, type: :string, length: { is: 6 }, format: { with: /\A[A-Z0-9]+\z/ } -optional :tags, type: :array, length: { maximum: 10 } +required :age, coerce: :integer, numeric: { gt: 0, lt: 150 } +required :code, coerce: :string, length: { is: 6 }, format: { with: /\A[A-Z0-9]+\z/ } +optional :tags, coerce: :array, length: { max: 10 } ``` -### Naming +**Naming** — `as:`, `prefix:`, `suffix:` rename the generated reader. ```ruby -attribute :template, prefix: true # method: context_template -attribute :format, prefix: "report_" # method: report_format -attribute :branch, suffix: true # method: branch_context -attribute :scheduled_at, as: :when # method: when +input :template, prefix: true # context_template +input :format, prefix: "report_" # report_format +input :branch, suffix: true # branch_context +input :scheduled_at, as: :scheduled # scheduled ``` -### Transforms +**Sources & transforms**: ```ruby -attribute :email, transform: :strip -attribute :tags, transform: :compact_blank -attribute :score, type: :integer, transform: proc { |v| v.clamp(0, 100) } +input :rate, source: :current_rate # task instance method +input :token, source: TokenGenerator # #call(task) +input :email, transform: :downcase +input :score, coerce: :integer, transform: proc { |v| v.clamp(0, 100) } ``` -For the complete attribute reference, see [references/attributes.md](references/attributes.md). Deep dives: [docs/attributes/definitions.md](docs/attributes/definitions.md), [docs/attributes/coercions.md](docs/attributes/coercions.md), [docs/attributes/validations.md](docs/attributes/validations.md). +Full reference: [references/inputs.md](references/inputs.md). -## Interruptions +## Outputs -### skip! and fail! +Declare keys the task must write to `context`. Verification runs after `work` succeeds (skipped when `work` halted). ```ruby -def work - skip!("Already processed") # halts - skip!("Duplicate", halt: false) # continues - fail!("Not found", code: 404) # halts - fail!("Validation failed", halt: false, errors: validation_errors) # continues +class CreateUser < CMDx::Task + required :email, coerce: :string + + output :user, required: true + output :token, required: true, presence: true + + def work + context.user = User.create!(email: email) + context.token = JwtService.encode(user_id: context.user.id) + end end ``` -### throw! +Outputs share the `coerce:`, `default:`, `transform:`, `validate:`, and `if:` / `unless:` options with inputs. Missing required outputs fail the task with `result.errors[name]`. + +Full reference: [references/outputs.md](references/outputs.md). -Propagates another task's result upward: +## Workflows + +Compose tasks by including `CMDx::Workflow` in a `Task` subclass. Defining `#work` on a workflow raises `CMDx::ImplementationError`. ```ruby -def work - inner_result = InnerTask.execute(context) - throw!(inner_result) if inner_result.failed? +class OnboardCustomer < CMDx::Task + include CMDx::Workflow + + required :email, coerce: :string + + task ValidateIdentity + task CreateAccount + task SetupBilling, if: :billing_required? + tasks SendWelcomeEmail, SendWelcomeSms, strategy: :parallel + + private + + def billing_required? + context.plan != "free" + end end ``` -### Faults +- All tasks share one `Context`. +- The workflow halts on the **first `failed?` result**. Skipped tasks never halt. +- Group options: `strategy:` (`:sequential` default, or `:parallel`), `pool_size:`, `if:` / `unless:`. +- Parallel groups deep-dup context per task and merge back on success only; the failed leaf propagates via `throw!`. -`execute` never raises — returns a `Result`. `execute!` raises `CMDx::FailFault` or `CMDx::SkipFault` when the status matches breakpoints. +Full reference: [references/workflows.md](references/workflows.md). -```ruby -result = MyTask.execute(data: input) -result.success? +## Halting -begin - result = MyTask.execute!(data: input) -rescue CMDx::FailFault => e - e.result # the failed Result - e.context # the context (delegated from result) - e.message # failure reason string (set from result.reason) - e.result.reason # same reason via result +All halt methods `throw` out of `work` — they never return. + +```ruby +def work + success!("Imported #{n} rows", rows: n) # complete + success (with annotation) + skip!("Already processed") # interrupted + skipped + fail!("Not found", code: "NOT_FOUND") # interrupted + failed + throw!(InnerTask.execute(context)) # re-throws if inner failed (no-op otherwise) end ``` -#### Fault matching +Signatures: `success!` / `skip!` / `fail!` take `(reason = nil, **metadata)`. `throw!` takes `(other_result, **metadata)` and is a no-op unless `other_result.failed?`. + +Accumulating errors via `task.errors.add(:attr, "msg")` triggers an automatic fail after `work` — no explicit `fail!` needed. The fail reason is `errors.to_s` (full messages joined with `". "`). -`for?` and `matches?` are class methods that return matcher classes for use in `rescue`: +Full reference: [references/interruptions.md](references/interruptions.md). + +## Faults + +`execute` always returns a `Result`. `execute!` raises `CMDx::Fault` on `failed?` results (skip does not raise) and re-raises the original `StandardError` when `result.cause` was a non-Fault exception. ```ruby -rescue CMDx::FailFault.for?(PaymentTask, BillingTask) => e - # only catches FailFaults from PaymentTask or BillingTask -rescue CMDx::FailFault.matches? { |f| f.result.metadata[:critical] } => e - # only catches FailFaults where the block returns true +begin + MyTask.execute!(data: input) +rescue CMDx::Fault.for?(PaymentTask, BillingTask) => e + e.result # the originating Result (walks origin to the leaf) + e.task # the failing Task class + e.context # frozen context + e.chain # full CMDx::Chain + e.message # result.reason (or localized "unspecified") +rescue CMDx::Fault.matches? { |f| f.result.metadata[:critical] } => e + escalate(e) end ``` @@ -244,255 +248,250 @@ end ```ruby result = MyTask.execute(input: data) -# State: initialized, executing, complete, interrupted -result.state #=> "complete" -result.complete? #=> true -result.interrupted? #=> false +# States: "complete" or "interrupted" +result.state #=> "complete" +result.complete? #=> true +result.interrupted? #=> false -# Status: success, skipped, failed -result.status #=> "success" -result.success? #=> true -result.good? #=> true (success OR skipped) -result.bad? #=> false (skipped OR failed) +# Statuses: "success", "skipped", "failed" +result.status #=> "success" +result.success? #=> true +result.ok? #=> true (success OR skipped — "not failed") +result.ko? #=> false (skipped OR failed — "not success") # Data -result.context # shared Context object -result.reason # skip/fail reason string -result.cause # the Fault that caused interruption -result.metadata # hash of extra data (errors, runtime, etc.) -result.chain # Chain of results in execution - -# Handlers -result.on(:success) { |r| redirect_to(dashboard_path) } - .on(:failed) { |r| render_error(r.reason) } - .on(:skipped) { |r| log_skip(r.reason) } +result.context # shared Context (frozen on root teardown) +result.errors # CMDx::Errors map +result.reason # reason string from success!/skip!/fail! (nil when unset) +result.metadata # frozen hash +result.cause # rescued StandardError (or nil) +result.origin # upstream Result this was echoed from (or nil) +result.retries # Integer +result.duration # Float ms + +# Handlers — chainable, block required +result + .on(:success) { |r| redirect_to(dashboard_path) } + .on(:failed) { |r| render_error(r.reason) } + .on(:skipped) { |r| log_skip(r.reason) } # Pattern matching +# deconstruct: [type, task, state, status, reason, metadata, cause, origin] +case result +in ["Task", _, "complete", "success", *] then handle_success +in ["Task", _, "interrupted", "failed", reason, *] then handle_failure(reason) +end + case result -in ["complete", "success"] then handle_success -in ["interrupted", "failed"] then handle_failure +in { status: "failed", metadata: { retryable: true } } then schedule_retry +in { state: "complete", status: "success" } then celebrate end ``` +Full reference: [references/result.md](references/result.md). + ## Callbacks -Registered as class methods. Accept method names, procs, or blocks. Support `if:`/`unless:` conditions. +Declare via the per-event DSL helpers (`before_execution`, `before_validation`, `on_success`, `on_skipped`, `on_failed`, `on_complete`, `on_interrupted`, `on_ok`, `on_ko`). Each accepts a method name (Symbol), a Proc/lambda (`instance_exec`'d on the task with `task` passed as the block arg, so `->(task) { ... }` is the canonical shape), or a `#call(task)`-able. All forms support `if:` / `unless:` gates. The underlying form is `register :callback, event, callable, **opts`. ```ruby class Example < CMDx::Task + before_execution :init_tracking before_validation :normalize_input - before_execution :load_dependencies - on_success :notify_user, if: -> { context.notify? } - on_failed :log_failure - on_complete :cleanup + on_success :notify, if: -> { context.notify? } + on_failed LogFailureCallback + on_complete ->(task) { Audit.log(task.class.name) } end ``` -### Execution order - -1. `before_validation` — before attribute validation -2. `before_execution` — after validation, before `work` -3. `on_complete` / `on_interrupted` — by state -4. `on_executed` — if execution ran -5. `on_success` / `on_skipped` / `on_failed` — by status -6. `on_good` / `on_bad` — by status group +The `Result` isn't built yet when callbacks run — read `task.context` / `task.errors` inside, or subscribe to the `:task_executed` telemetry event for finalized result data. ## Middleware -Wraps the entire execution. Must yield. +Wraps the entire lifecycle. Interface: `call(task) { yield }` (or `&next_link` for Procs). Must yield or `CMDx::MiddlewareError` is raised. ```ruby -# Built-in -register :middleware, CMDx::Middlewares::Timeout, seconds: 5 -register :middleware, CMDx::Middlewares::Runtime # result.metadata[:runtime] -register :middleware, CMDx::Middlewares::Correlate, id: proc { SecureRandom.uuid } - -# Custom class AuditMiddleware - def self.call(task, **options) + def call(task) AuditLog.start(task.class.name) - yield(task) + yield ensure - AuditLog.finish(task.class.name, task.result.status) + AuditLog.finish(task.class.name) end end -register :middleware, AuditMiddleware +class MyTask < CMDx::Task + register :middleware, AuditMiddleware.new + register :middleware, ->(task, &next_link) { + Timer.track(task.class) { next_link.call } + } + register :middleware, OuterMiddleware, at: 0 # insert at index +end ``` -## Configuration +No middleware ships with the gem — pass instances (or classes responding to `#call(task)`) you author yourself. See [docs/middlewares.md](docs/middlewares.md). -### Global +## Retries ```ruby -CMDx.configure do |config| - config.task_breakpoints = "failed" - config.workflow_breakpoints = ["skipped", "failed"] - config.rollback_on = ["failed"] - config.freeze_results = true - config.backtrace = false - config.logger = Logger.new($stdout) - config.exception_handler = proc { |task, e| ErrorTracker.report(e) } +class Fetch < CMDx::Task + retry_on Net::OpenTimeout, Net::ReadTimeout, + limit: 3, delay: 0.5, max_delay: 5.0, jitter: :exponential + + retry_on Api::Throttled, limit: 5 do |attempt, delay| + delay * (attempt + 1) + end end ``` -### Per-task +- Only retries when the exception matches `retry_on`. Anything else (or a matching exception after the limit) becomes a failed result with `result.cause` set. +- Jitter strategies: `:exponential`, `:half_random`, `:full_random`, `:bounded_random`, a Symbol (task method), a Proc (`instance_exec(attempt, delay)`), or any `#call(attempt, delay)`-able. +- Only `work` is retried — inputs, outputs, and callbacks run once. `task.errors` accumulates across attempts; clear it at the start of `work` if you re-populate per attempt. -```ruby -class MyTask < CMDx::Task - settings( - retries: 3, - retry_on: [Net::TimeoutError], - retry_jitter: :exponential_backoff, - rollback_on: ["failed"], - task_breakpoints: ["failed"], - tags: ["critical"], - log_level: :info, - log_formatter: CMDx::LogFormatters::Json.new, - deprecate: :log - ) -end -``` +Inspect with `result.retries` / `result.retried?`. Docs: [docs/retries.md](docs/retries.md). -For all options, see [references/configuration.md](references/configuration.md) and [docs/configuration.md](docs/configuration.md). +## Rollback -## Returns (Output Contract) +Define `#rollback` to undo side effects. Runtime calls it after `work` when the signal is `failed` (before completion callbacks) and flags `result.rolled_back?`. ```ruby -class CreateUser < CMDx::Task - returns :user, :token - +class ChargeCard < CMDx::Task def work - context.user = User.create!(params) - context.token = generate_token(context.user) + context.charge = Gateway.charge!(context.amount) + end + + def rollback + Gateway.refund!(context.charge.id) if context.charge end end ``` -Missing returns cause the task to fail with validation errors. +Rollback is **per-task**. To compensate across a workflow's earlier successful tasks, use an `on_failed` callback on the workflow class. ## Context -A shared, hash-like object passed through tasks: - ```ruby context[:key] # read -context.key # read (method_missing) +context.key # read (method_missing; nil when absent) context.key = value # write -context.store(:key, value) # write -context.merge!(hash) # bulk write -context.fetch(:key, default) # read with default -context.fetch_or_store(:key, v) # read or write -context.key?(:key) # existence check -context.dig(:a, :b, :c) # nested read -context.delete!(:key) # remove +context.store(:key, value) # explicit write +context.merge(hash) # mutate in place; returns self +context.fetch(:key, default) # Hash#fetch semantics +context.retrieve(:key) { v } # fetch-or-store +context.key?(:key) # existence +context.dig(:a, :b, :c) # nested read +context.delete(:key) # remove ``` -## Retries +`Context` is frozen on root teardown — post-execution mutations raise `FrozenError`. Nested tasks share the same context object unless isolated via `context.deep_dup` (parallel workflow groups do this automatically). + +## Configuration + +`CMDx.configure` sets framework-wide defaults; `settings(...)` overrides per-class logger / formatter / level / backtrace cleaner / tags; everything else uses dedicated DSL. ```ruby -settings retries: 3, retry_on: [Net::TimeoutError] -settings retries: 5, retry_jitter: :exponential_backoff -settings retries: 10, retry_jitter: ->(count) { [count * 0.5, 5.0].min } +CMDx.configure do |config| + config.default_locale = "en" + config.logger = Logger.new($stdout) + config.log_formatter = CMDx::LogFormatters::JSON.new + + config.middlewares.register MyGlobalMiddleware + config.callbacks.register :on_failed, ErrorTracker + config.coercions.register :money, MoneyCoercion + config.validators.register :api_key, ApiKeyValidator + config.telemetry.subscribe(:task_executed) { |event| ... } +end + +class MyTask < CMDx::Task + settings(tags: ["critical"], log_level: Logger::DEBUG) + + retry_on Net::OpenTimeout, limit: 3 + deprecation :warn, if: -> { Rails.env.production? } + + register :middleware, TimingMiddleware.new + register :validator, :api_key, ApiKeyValidator +end ``` -Only retries when the rescued exception matches `retry_on`. Clears errors between attempts. See [docs/retries.md](docs/retries.md). +Full reference (registry matrix, telemetry events, Rails wiring): [references/configuration.md](references/configuration.md). -## Dry Run +## Exceptions + +Flat hierarchy rooted at `CMDx::Error` (aliased `CMDx::Exception`): -```ruby -result = MyTask.execute(data: input, dry_run: true) -result.dry_run? #=> true +``` +StandardError +└── CMDx::Error + ├── CMDx::DefinitionError # input name collides with existing method + ├── CMDx::DeprecationError # deprecation :error was triggered + ├── CMDx::ImplementationError # missing #work, or #work defined on a Workflow + ├── CMDx::MiddlewareError # middleware didn't yield + └── CMDx::Fault # raised by execute! on failed? results ``` -The `work` method still runs — implement dry-run guards inside `work` using `dry_run?` (delegated from task to chain). +`CMDx::Error` subclasses other than `Fault` propagate through `execute` unconverted — they indicate framework misuse, not runtime failure. ## Common Pitfalls ### 1. Forgetting `def work` -Every task must define `work`. Without it, execution raises `CMDx::UndefinedMethodError`. +Raises `CMDx::ImplementationError` at execution time from both `execute` and `execute!`. -### 2. Using `context` vs attribute methods +### 2. Reading `context.foo` instead of the generated reader ```ruby -# Wrong: bypasses validation/coercion +# Wrong: bypasses coercion/validation — returns the raw input def work context.email end -# Right: declare attributes, use generated methods -required :email, type: :string +# Right: use the generated reader +required :email, coerce: :string def work email end ``` -### 3. Not handling `throw!` in nested tasks +Coerced input values live on the task instance, not on `context`. To persist them, write back explicitly (`context.email = email`). -```ruby -# Wrong: inner failure is silently swallowed -def work - InnerTask.execute(context) -end +### 3. Input declaration order -# Right: propagate or check the result -def work - inner = InnerTask.execute(context) - throw!(inner) unless inner.success? -end -``` +Inputs are resolved in declaration order. If one input references another via `source:` or an `if:` predicate, declare the referenced input first. -### 4. Mutating context after freeze +### 4. Middleware that forgets to yield -Results are frozen by default (`freeze_results: true`). Attempting to modify context after execution raises an error. Set `freeze_results: false` if post-execution mutation is needed. +Silently swallowing `yield` raises `CMDx::MiddlewareError` outside the signal catch — the failure is not convertible to a result. Always yield (or `next_link.call`) on every code path, including `rescue` / `ensure`. -### 5. Middleware that doesn't yield +### 5. Mutating the root context after teardown -A middleware that omits `yield` silently swallows execution. The result will be marked as failed with `metadata[:source] == :swallowed_middleware`. +Runtime freezes the root context, task, errors, and chain during teardown. Post-execution writes raise `FrozenError`. Use `context.deep_dup` before teardown when you need a mutable snapshot. -### 6. Breakpoints confusion +### 6. Assuming workflows halt on skip -- `task_breakpoints`: controls when `execute!` raises (default: `["failed"]`) -- `workflow_breakpoints`: controls when a workflow halts (default: `["failed"]`) -- Group breakpoints: `tasks TaskA, TaskB, breakpoints: %w[skipped failed]` -- Empty breakpoints `[]` means never halt +Workflows halt only on `failed?`. Skipped tasks are no-ops; the pipeline continues. To make a step hard-fail, call `fail!` (not `skip!`). -### 7. Missing returns +### 7. `execute!` doesn't always raise `Fault` -Declared `returns` are verified after `work`. If the context doesn't contain the declared keys, the task fails with validation errors. +When `result.cause` is a non-`Fault` `StandardError` (e.g. an `ActiveRecord::RecordNotFound` that slipped through `work`), `execute!` re-raises the **original** exception, not a `Fault`. Match both in production rescue blocks if needed. -### 8. Attribute ordering +### 8. Retries share the same task instance -Attributes are order-dependent. If one attribute references another as a source or condition, declare the referenced attribute first: +`context`, instance variables, and `task.errors` persist across retry attempts. If you re-add errors each time, clear them at the start of `work`, or the post-`work` check will still fail the task. -```ruby -# Correct -required :credentials, source: :database_config -attribute :connection_string, source: :credentials +### 9. `task.result` isn't available inside callbacks -# Wrong: connection_string references credentials before it exists -attribute :connection_string, source: :credentials -required :credentials, source: :database_config -``` +The `Result` is built *after* callbacks run. Inside callbacks, read `task.context` / `task.errors`; for finalized result data, subscribe to the `:task_executed` telemetry event. -### 9. Exception vs Fault +### 10. Calling halt methods outside `work` -`StandardError` exceptions are caught by `execute` and converted to failed results. `execute!` re-raises them. `CMDx::TimeoutError` inherits from `Interrupt`, not `StandardError` — it's always raised. +The signal `catch` only wraps input resolution, `work`, and output verification. `success!` / `skip!` / `fail!` / `throw!` invoked from `before_execution` / `before_validation` callbacks or middleware bubble past Runtime and never produce a `Result`. Use `errors.add` from validation callbacks instead, or move the logic into `work`. ## References -- [Attribute details](references/attributes.md) — coercions, validations, naming, transforms, nesting -- [Workflow patterns](references/workflows.md) — composition, breakpoints, parallel, conditions -- [Interruptions & faults](references/interruptions.md) — skip!/fail!/throw!, propagation strategies, fault matching, errors -- [Result API](references/result.md) — states, statuses, handlers, pattern matching, chain analysis -- [Configuration options](references/configuration.md) — global and per-task settings -- [Testing guide](references/testing.md) — RSpec matchers, setup, patterns - -### Deep-dive docs - -- Basics: [setup](docs/basics/setup.md), [execution](docs/basics/execution.md), [context](docs/basics/context.md), [chain](docs/basics/chain.md) -- Interruptions: [halt](docs/interruptions/halt.md), [faults](docs/interruptions/faults.md), [exceptions](docs/interruptions/exceptions.md) -- Outcomes: [result](docs/outcomes/result.md), [states](docs/outcomes/states.md), [statuses](docs/outcomes/statuses.md) -- Features: [callbacks](docs/callbacks.md), [middlewares](docs/middlewares.md), [workflows](docs/workflows.md), [retries](docs/retries.md), [logging](docs/logging.md) -- [Testing](docs/testing.md) | [Tips & tricks](docs/tips_and_tricks.md) | [Comparison](docs/comparison.md) +- [Inputs](references/inputs.md) — declarations, coercions, validators, naming, transforms, sources, nesting +- [Outputs](references/outputs.md) — declarations, verification, defaults, transforms +- [Workflows](references/workflows.md) — composition, strategies, halt behavior, rollback, nesting +- [Interruptions](references/interruptions.md) — signals, faults, errors, propagation strategies +- [Result](references/result.md) — states, statuses, handlers, pattern matching, chain analysis +- [Configuration](references/configuration.md) — global config, settings, retries, deprecation, registries, telemetry, Rails +- [Testing](references/testing.md) — RSpec patterns using the real public API diff --git a/skills/references/attributes.md b/skills/references/attributes.md deleted file mode 100644 index b5f58b440..000000000 --- a/skills/references/attributes.md +++ /dev/null @@ -1,276 +0,0 @@ -# Attribute Reference - -For full documentation, see [docs/attributes/definitions.md](../docs/attributes/definitions.md), [docs/attributes/coercions.md](../docs/attributes/coercions.md), [docs/attributes/validations.md](../docs/attributes/validations.md), [docs/attributes/naming.md](../docs/attributes/naming.md), [docs/attributes/defaults.md](../docs/attributes/defaults.md), [docs/attributes/transformations.md](../docs/attributes/transformations.md). - -## Declaration Methods - -```ruby -required :name # required attribute -optional :name # optional attribute -attribute :name, required: true # explicit required -attributes :a, :b, required: false # multiple optional -``` - -Remove attributes inherited from a parent class: - -```ruby -remove_attribute :legacy_field -remove_attributes :field_a, :field_b -``` - -## Full Option Signature - -```ruby -attribute :name, - required: true, # required or optional (default: false) - description: "...", # metadata only (also accepts :desc) - type: :string, # single coercion type (symbol or class) - types: [String, Symbol], # multiple allowed types (class constants) - default: "value", # static, method name (symbol), or proc - source: :context, # where to read the value - as: :alias_name, # rename the accessor method - prefix: true, # prefix method with "context_" (true) or custom string - suffix: true, # suffix method with "_context" (true) or custom string - transform: :strip, # post-coercion transformation - if: :condition?, # conditional: only define if truthy - unless: :condition?, # conditional: only define if falsy - presence: true, # validation shorthand - format: { with: /regex/ }, # validation shorthand - length: { minimum: 1 }, # validation shorthand - numeric: { greater_than: 0 },# validation shorthand - inclusion: { in: [1, 2] }, # validation shorthand - exclusion: { in: [0] }, # validation shorthand - absence: true # validation shorthand -``` - -## Source Options - -Controls where the attribute value is read from: - -```ruby -# Default: reads from context hash -attribute :user_id, source: :context - -# Delegate to a task instance method -attribute :rate, source: :current_rate - -# Proc (self is the task via instance_eval) -attribute :config, source: proc { load_config } - -# Lambda -attribute :server, source: -> { Current.server } - -# Class/module responding to .call (receives task as argument) -attribute :token, source: TokenGenerator -``` - -## Default Values - -```ruby -# Static value -attribute :strategy, default: :incremental - -# Method delegation -attribute :granularity, default: :default_granularity - -# Proc (self is the task via instance_eval) -attribute :expire_hours, default: proc { Current.tenant.cache_duration || 24 } - -# Lambda -attribute :compression, default: -> { Current.tenant.premium? ? "gzip" : "none" } -``` - -Defaults are only applied when the source value is `nil`. - -## Type Coercion - -### Built-in types - -| Type | Coerces to | Notes | -|------|-----------|-------| -| `:array` | `Array` | Wraps non-arrays | -| `:big_decimal` | `BigDecimal` | | -| `:boolean` | `TrueClass`/`FalseClass` | Truthy/falsy conversion | -| `:complex` | `Complex` | | -| `:date` | `Date` | Parses strings | -| `:datetime` | `DateTime` | Parses strings | -| `:float` | `Float` | | -| `:hash` | `Hash` | | -| `:integer` | `Integer` | | -| `:rational` | `Rational` | | -| `:string` | `String` | Calls `to_s` | -| `:symbol` | `Symbol` | Calls `to_sym` | -| `:time` | `Time` | Parses strings | - -### Usage - -```ruby -required :count, type: :integer -required :active, type: :boolean -required :value, types: [Integer, Float] # accepts either class -``` - -### Custom coercions - -```ruby -class GeolocationCoercion - def self.call(value, **_options) - case value - when String then Geolocation.parse(value) - when Hash then Geolocation.new(**value) - when Geolocation then value - else raise CMDx::CoercionError, "cannot coerce #{value.class} to Geolocation" - end - end -end - -# Register globally -CMDx.configure { |c| c.coercions = { geolocation: GeolocationCoercion } } - -# Or per-task -register :coercion, :geolocation, GeolocationCoercion -``` - -## Validations - -### Built-in validators - -#### presence / absence - -```ruby -required :name, presence: true -optional :deprecated_field, absence: true -``` - -#### format - -```ruby -required :email, format: { with: URI::MailTo::EMAIL_REGEXP } -required :slug, format: { without: /\s/ } -``` - -#### length - -```ruby -required :code, length: { is: 6 } -required :name, length: { minimum: 2, maximum: 100 } -required :bio, length: { in: 10..500 } -``` - -#### numeric - -```ruby -required :age, numeric: { greater_than: 0, less_than: 150 } -required :price, numeric: { greater_than_or_equal_to: 0 } -required :quantity, numeric: { odd: true } -required :score, numeric: { even: true, in: 0..100 } -``` - -#### inclusion / exclusion - -```ruby -required :role, inclusion: { in: %w[admin member guest] } -required :status, exclusion: { in: %w[banned deleted] } -``` - -### Custom validators - -```ruby -class ApiKeyValidator - def self.call(value, **options) - return if value.match?(/\Aak_[a-z0-9]{32}\z/) - - "must be a valid API key format" - end -end - -# Register globally -CMDx.configure { |c| c.validators = { api_key: ApiKeyValidator } } - -# Or per-task -register :validator, :api_key, ApiKeyValidator - -# Usage -required :key, api_key: true -``` - -## Naming - -### prefix - -```ruby -attribute :template, prefix: true # method: context_template -attribute :format, prefix: "report_" # method: report_format -``` - -### suffix - -```ruby -attribute :branch, suffix: true # method: branch_context -attribute :id, suffix: "_value" # method: id_value -``` - -### as (alias) - -```ruby -attribute :scheduled_at, as: :when -attribute :type, as: :category # avoids reserved word conflicts -``` - -## Transforms - -Applied after coercion, before validation: - -```ruby -attribute :email, transform: :strip -attribute :email, transform: :downcase -attribute :tags, transform: :compact_blank -attribute :tags, transform: :uniq -attribute :score, type: :integer, transform: proc { |v| v.clamp(0, 100) } -attribute :data, transform: proc { |v| v.deep_symbolize_keys } -``` - -## Nested Attributes - -```ruby -required :address do - required :street, type: :string - required :city, type: :string - optional :zip, type: :string -end -``` - -## Shared Options with `with_options` (ActiveSupport) - -Requires ActiveSupport (available in Rails, or `require "active_support/core_ext/object/with_options"` in plain Ruby): - -```ruby -with_options type: :string, presence: true do - required :first_name - required :last_name - required :email, format: { with: URI::MailTo::EMAIL_REGEXP } -end -``` - -## Conditional Attributes - -```ruby -required :billing_address, if: :paid_plan? -optional :coupon_code, unless: :enterprise? -attribute :sso_provider, if: -> { context.auth_method == "sso" } -``` - -## Inheritance - -Child tasks inherit parent attributes and can add or remove them: - -```ruby -class BaseTask < CMDx::Task - required :tenant_id, type: :integer -end - -class ChildTask < BaseTask - required :user_id, type: :integer - remove_attribute :tenant_id # opt out of parent attribute -end -``` diff --git a/skills/references/configuration.md b/skills/references/configuration.md index 1d49f690e..cdbb8ebee 100644 --- a/skills/references/configuration.md +++ b/skills/references/configuration.md @@ -1,227 +1,210 @@ # Configuration Reference -For full documentation, see [docs/configuration.md](../docs/configuration.md). +Docs: [docs/configuration.md](../../docs/configuration.md), [docs/callbacks.md](../../docs/callbacks.md), [docs/middlewares.md](../../docs/middlewares.md), [docs/retries.md](../../docs/retries.md), [docs/deprecation.md](../../docs/deprecation.md). -## Global Configuration +Configuration lives at three levels: -```ruby -CMDx.configure do |config| - # Breakpoints - config.task_breakpoints = "failed" # when execute! raises (String or Array) - config.workflow_breakpoints = ["failed"] # when workflows halt +1. **Global** — `CMDx.configure`, applies to every task unless overridden. +2. **Per-task (`settings`)** — narrow override bag: loggers, formatters, tags, backtrace cleaner. +3. **Per-task DSL** — everything else (retries, deprecation, middleware, callbacks, coercions, validators, inputs, outputs). - # Rollback - config.rollback_on = ["failed"] # statuses that trigger rollback +Subclasses **inherit** all three levels. Registries are duped lazily the first time a subclass touches them. - # Results - config.freeze_results = true # freeze context/result after execution +## Global - # Debugging - config.backtrace = false # include backtraces in results - config.backtrace_cleaner = ->(bt) { bt[0..5] } # filter backtrace lines +```ruby +CMDx.configure do |config| + config.default_locale = "en" + config.backtrace_cleaner = ->(frames) { Rails.backtrace_cleaner.clean(frames) } + config.logger = Logger.new($stdout) + config.log_level = Logger::INFO + config.log_formatter = CMDx::LogFormatters::JSON.new + + config.middlewares.register MyGlobalMiddleware + config.callbacks.register :on_failed, ErrorTracker + config.coercions.register :money, MoneyCoercion + config.validators.register :api_key, ApiKeyValidator + config.telemetry.subscribe(:task_executed) { |event| emit(event) } +end +``` - # Error handling - config.exception_handler = proc { |task, e| APM.report(e) } +All real `Configuration` attributes: - # Logging - config.logger = Logger.new($stdout) +| Attribute | Default | +|-----------|---------| +| `middlewares` | `Middlewares.new` | +| `callbacks` | `Callbacks.new` | +| `coercions` | `Coercions.new` | +| `validators` | `Validators.new` | +| `telemetry` | `Telemetry.new` | +| `default_locale` | `"en"` | +| `backtrace_cleaner` | `nil` | +| `logger` | `Logger.new($stdout)` | +| `log_level` | `Logger::INFO` | +| `log_formatter` | `CMDx::LogFormatters::Line.new` | - # Global registries - config.middlewares = { correlate: CMDx::Middlewares::Correlate } - config.callbacks = { audit: AuditCallback } - config.coercions = { money: MoneyCoercion } - config.validators = { api_key: ApiKeyValidator } -end -``` +`CMDx.reset_configuration!` replaces the configuration with a fresh instance and clears `Task`'s cached registry ivars so new lookups pick up the new config. Intended for test setup/teardown; it does **not** recurse into subclasses. -### Reset +## Per-task `settings` -```ruby -CMDx.reset_configuration! -``` +`settings(...)` stores **only** the following options — everything else is ignored. Every getter falls back to the global configuration. -## Per-Task Settings +| Option | Purpose | +|--------|---------| +| `:logger` | Per-task Logger. | +| `:log_formatter` | Per-task formatter. | +| `:log_level` | Per-task severity. | +| `:backtrace_cleaner` | `#call(frames)`-able for `Fault` backtraces. | +| `:tags` | Array of `Symbol`/`String`, exposed on `result.to_h[:tags]`. | ```ruby class MyTask < CMDx::Task settings( - # Retries - retries: 3, # max retry attempts (default: 0) - retry_on: [Net::TimeoutError, Faraday::Error], # exception classes to retry - retry_jitter: :exponential_backoff, # sleep strategy between retries - - # Breakpoints - task_breakpoints: ["failed"], # overrides global for this task - workflow_breakpoints: ["failed"], # overrides global for workflows using this task - breakpoints: ["failed"], # shorthand alias - - # Rollback - rollback_on: ["failed", "skipped"], # when to call rollback - - # Logging - log_level: :info, # :debug, :info, :warn, :error - log_formatter: CMDx::LogFormatters::Json.new, # formatter instance - - # Metadata - tags: ["billing", "critical"], # arbitrary tags for filtering/logging - - # Deprecation - deprecate: :log, # :raise, :log, :warn, or proc - - # Debugging - backtrace: true, # override global - backtrace_cleaner: ->(bt) { bt }, # override global - - # Error handling - exception_handler: proc { |task, e| ... }, # override global - - # Logger - logger: CustomLogger.new, # override global - - # Returns (alternative to DSL) - returns: [:user, :token] + tags: ["critical", :billing], + log_level: Logger::DEBUG, + backtrace_cleaner: ->(f) { f.reject { |l| l.include?("gems/") } } ) end ``` -## Retry Jitter Options +Calling `settings(...)` with new options merges onto the inherited (or default) Settings and replaces the cache. Calling `settings` with no args returns the current Settings. -```ruby -# Fixed: no delay -settings retries: 3, retry_on: [Error] +## Retries -# Exponential backoff: 2^n seconds -settings retries: 3, retry_jitter: :exponential_backoff +Per-class DSL (not a `settings` option): -# Custom proc: receives retry count (0-indexed) -settings retries: 5, retry_jitter: ->(count) { [count * 0.5, 5.0].min } +```ruby +class Fetch < CMDx::Task + retry_on Net::OpenTimeout, Net::ReadTimeout, + limit: 3, + delay: 0.5, + max_delay: 5.0, + jitter: :exponential +end ``` -## Rollback - -Define a `rollback` method and configure when it triggers: +Options: -```ruby -class ChargeCard < CMDx::Task - settings rollback_on: ["failed"] +| Option | Default | Notes | +|--------|---------|-------| +| `:limit` | `3` | Max retry attempts (so `limit + 1` total tries). | +| `:delay` | `0.5` | Base seconds between attempts; `0` disables sleep. | +| `:max_delay` | — | Upper clamp for computed delay. | +| `:jitter` | — | Symbol (`:exponential`, `:half_random`, `:full_random`, `:bounded_random`), Symbol method on the task, Proc (`instance_exec(attempt, delay)`), or any `#call(attempt, delay)`-able. A block passed to `retry_on` is equivalent to `jitter:`. | - def work - context.charge = Gateway.charge!(context.amount) - end +Multiple `retry_on` calls **merge**: exceptions accumulate, later options override earlier ones. Subclasses inherit and extend the parent's `Retry`. - def rollback - Gateway.refund!(context.charge.id) if context.charge - end +```ruby +retry_on Api::Throttled, limit: 5 do |attempt, delay| + delay * (attempt + 1) end ``` -## Deprecation +Only the `work` block is wrapped. Inputs, outputs, callbacks, and middleware run once. `task.errors` persists across attempts; clear it at the start of `work` if you re-populate per attempt. -```ruby -# Raise on use (development/test) -settings deprecate: :raise +Introspect: `result.retries`, `result.retried?`. -# Log warning -settings deprecate: :log +## Deprecation -# Ruby warning -settings deprecate: :warn +Class-level DSL: -# Dynamic -settings deprecate: proc { Rails.env.development? ? :raise : :log } +```ruby +class LegacyTask < CMDx::Task + deprecation :log # logger.warn + deprecation :warn # Kernel.warn + deprecation :error # raises CMDx::DeprecationError + deprecation :custom_deprecation_handler # task method + deprecation -> { MyTracker.record(self.class) } # Proc (instance_exec on task) + deprecation MyDeprecationHandler # #call(task) + deprecation :log, if: -> { Rails.env.production? } +end ``` -## Log Formatters +Fires **before** `run_lifecycle`. `:error` aborts with `CMDx::DeprecationError` (subclass of `CMDx::Error`) — no result is produced. `:if`/`:unless` gates take the task as argument. -```ruby -settings log_formatter: CMDx::LogFormatters::Line.new # default single-line -settings log_formatter: CMDx::LogFormatters::Json.new # JSON output -settings log_formatter: CMDx::LogFormatters::KeyValue.new # key=value pairs -settings log_formatter: CMDx::LogFormatters::Logstash.new # Logstash-compatible JSON -settings log_formatter: CMDx::LogFormatters::Raw.new # raw hash -``` +## Registrations -## Middleware Registration +`register` and `deregister` dispatch to the six sub-registries: -### Global +| Type | Registry | Notes | +|------|----------|-------| +| `:middleware` | `middlewares` | Accepts `#call(task)`-able or block. Optional `at:` index. | +| `:callback` | `callbacks` | `register :callback, event, callable` or `dsl_method(callable, &)`. | +| `:coercion` | `coercions` | `register :coercion, :name, callable`. | +| `:validator` | `validators` | `register :validator, :name, callable`. | +| `:input` | `inputs` | Typically use `input`/`inputs`/`required`/`optional`. | +| `:output` | `outputs` | Typically use `output`/`outputs`. | ```ruby -CMDx.configure do |config| - config.middlewares = { - timeout: [CMDx::Middlewares::Timeout, { seconds: 5 }], - correlate: CMDx::Middlewares::Correlate - } +class MyTask < CMDx::Task + register :middleware, TimingMiddleware.new + register :middleware, ->(task, &next_link) { next_link.call }, at: 0 + register :callback, :on_failed, ErrorTracker + register :coercion, :money, MoneyCoercion + register :validator, :api_key, ApiKeyValidator + + deregister :middleware, TimingMiddleware + deregister :middleware, at: -1 + deregister :callback, :on_failed # drops all for event + deregister :callback, :on_failed, ErrorTracker # drops just this one end ``` -### Per-task +### Middleware signature + +`call(task) { yield }` — always yield or `CMDx::MiddlewareError` is raised. Procs use `next_link.call`: ```ruby -class MyTask < CMDx::Task - register :middleware, CMDx::Middlewares::Timeout, seconds: 10 - register :middleware, CMDx::Middlewares::Runtime - register :middleware, CMDx::Middlewares::Correlate, id: proc { |t| t.context.request_id } - register :middleware, CustomMiddleware, option: "value" -end +register :middleware, ->(task, &next_link) { + Timer.track(task.class) { next_link.call } +} ``` -### Built-in middleware +No middleware is built into the gem. -| Middleware | Purpose | Key options | -|-----------|---------|-------------| -| `Timeout` | Enforces execution time limit | `seconds:` (default: 3, or method name symbol) | -| `Runtime` | Measures execution time | Stores in `result.metadata[:runtime]` | -| `Correlate` | Adds correlation ID | `id:` (proc, method name, or static), `if:`, `unless:` | +### Callback events -## Callback Registration +`:before_execution`, `:before_validation`, `:on_complete`, `:on_interrupted`, `:on_success`, `:on_skipped`, `:on_failed`, `:on_ok`, `:on_ko`. -### Per-task (class methods) +Each event has a class-level DSL method: `before_execution :method_name`, `on_failed { |task| ... }`, etc. Callbacks accept Symbol (`task.send`), Proc (`instance_exec(task, &)`), or any `#call(task)`-able. Supports `if:`/`unless:` gates. -```ruby -before_validation :method_name -before_execution :method_name, if: :condition? -on_success :method_name, unless: -> { context.silent? } -on_failed proc { |task| ErrorLog.record(task) } -on_complete { |task| task.context.completed_at = Time.current } -``` +Unknown events raise `ArgumentError`. `result` is not yet built during callbacks — subscribe to `:task_executed` telemetry for finalized result data. -### Global +## Rails integration -```ruby -CMDx.configure do |config| - config.callbacks = { - on_failed: [FailureTracker, { severity: :high }] - } -end -``` +`rails g cmdx:install` creates an initializer and a base task class. `rails g cmdx:task Name` and `rails g cmdx:workflow Name` scaffold classes. -## Settings Inheritance +The Railtie wires `config.backtrace_cleaner = Rails.backtrace_cleaner` at load time when `Rails.backtrace_cleaner` is defined — Fault backtraces are cleaned by default under Rails. -Settings cascade: `CMDx.configuration` → parent task → child task. Each level can override. +## Telemetry -```ruby -class BaseTask < CMDx::Task - settings retries: 2, tags: ["base"] -end +Global `config.telemetry` (or per-task inherited registry) publishes lifecycle events. -class ChildTask < BaseTask - settings retries: 5 # overrides parent; tags inherited -end -``` +Events (`CMDx::Telemetry::EVENTS`): + +| Event | Payload keys (in addition to shared) | +|-------|--------------------------------------| +| `:task_started` | — | +| `:task_deprecated` | — | +| `:task_retried` | `attempt:` (Integer) | +| `:task_rolled_back` | — | +| `:task_executed` | `result:` (finalized `Result`) | -## Rails Integration +Shared `Event` fields: `chain_id`, `chain_root`, `task_type`, `task_class`, `task_id`, `name`, `payload`, `timestamp`. ```ruby -# Generate initializer -rails generate cmdx:install +CMDx.configure do |c| + c.telemetry.subscribe(:task_executed) do |event| + StatsD.timing("cmdx.#{event.task_class}", event.payload[:result].duration) + end +end +``` -# Generate task -rails generate cmdx:task ProcessOrder +`emit` is a no-op when no subscribers are registered — telemetry is free when unused. -# Generate locale -rails generate cmdx:locale fr -``` +## Inheritance semantics -The Railtie automatically: -- Loads CMDx locales into `I18n` -- Sets `backtrace_cleaner` to `Rails.backtrace_cleaner` +- `settings`, `retry_on`, `deprecation`, and each registry are inherited via a lazy `dup` (`initialize_copy` on each). +- Subclass changes do not mutate the parent. +- Sibling subclasses are independent once they've cloned. +- `CMDx.reset_configuration!` only invalidates `Task`'s cached ivars — existing subclass caches persist until Ruby reloads the class. diff --git a/skills/references/inputs.md b/skills/references/inputs.md new file mode 100644 index 000000000..1df46b2e0 --- /dev/null +++ b/skills/references/inputs.md @@ -0,0 +1,300 @@ +# Inputs Reference + +Docs: [docs/inputs/definitions.md](../../docs/inputs/definitions.md), [docs/inputs/coercions.md](../../docs/inputs/coercions.md), [docs/inputs/validations.md](../../docs/inputs/validations.md), [docs/inputs/naming.md](../../docs/inputs/naming.md), [docs/inputs/defaults.md](../../docs/inputs/defaults.md), [docs/inputs/transformations.md](../../docs/inputs/transformations.md). + +## Declaration + +```ruby +required :name # required input +optional :name # optional input +input :name # alias for inputs (optional by default) +inputs :a, :b, required: false # multiple + +deregister :input, :legacy_field # remove inherited input + its reader +``` + +`input` is an alias of `inputs`; both accept one or more names and keyword options. `required`/`optional` are shorthands that set `required:` on top of any options passed. + +## Resolution Pipeline + +Per input, in order: + +1. **Fetch** via `:source` (default `:context`). +2. **Default** applied when the fetched value is `nil`. +3. **Coerce** via `:coerce` (one or more coercions; the first successful one wins). +4. **Transform** via `:transform`. +5. **Validate** — declared validator shorthands and inline `:validate` callables. + +## Options + +| Option | Description | +|--------|-------------| +| `required:` | `true`/`false`. Required missing key adds `cmdx.attributes.required` error. | +| `description:` / `desc:` | Metadata for `inputs_schema`. | +| `coerce:` | Symbol, array of symbols, Hash with per-coercion options, Proc, or any `#call`-able. | +| `default:` | Static value, Symbol (method), Proc (`instance_exec`), or `#call(task)`-able. | +| `source:` | `:context` (default), Symbol (method), Proc, or `#call(task)`-able. | +| `as:` | Overrides reader method name. | +| `prefix:` | `true` → `<source>_`, or a String prefix. | +| `suffix:` | `true` → `_<source>`, or a String suffix. | +| `transform:` | Symbol, Proc, or `#call(value, task)`. Applied post-coercion, pre-validation. | +| `if:` / `unless:` | Gate declaration-required check. Signature `(task)` — NOT `(task, value)`. | +| `presence:`, `absence:`, `format:`, `length:`, `numeric:`, `inclusion:`, `exclusion:` | Validator shorthands. | +| `validate:` | Inline validator (Symbol/Proc/`#call`-able). | + +## Sources + +```ruby +required :user_id # context[:user_id] +required :user_id, source: :context # same as above +required :rate, source: :current_rate # task.current_rate (instance method) +required :config, source: proc { load } # instance_exec on task +required :server, source: -> { Current.server } +required :token, source: TokenGenerator # TokenGenerator.call(task) +``` + +When the source object responds to `#key?`, existence is disambiguated from an explicit `nil`. Otherwise (bare `#[]`), an explicit `nil` is treated as "not provided" and triggers the default. + +## Defaults + +```ruby +optional :strategy, default: :incremental # static +optional :granularity, default: :default_granularity # method +optional :expire_hours, default: proc { tenant.cache_duration } # Proc (instance_exec) +optional :compression, default: -> { premium? ? "gzip" : "none" } +optional :client, default: TokenGenerator # #call(task) +``` + +Defaults apply only when the fetched value is `nil`. + +## Coercions + +### Built-ins + +| Symbol | Target | +|--------|--------| +| `:array` | `Array` (wraps non-Arrays) | +| `:big_decimal` | `BigDecimal` | +| `:boolean` | `TrueClass` / `FalseClass` | +| `:complex` | `Complex` | +| `:date` | `Date` | +| `:date_time` | `DateTime` | +| `:float` | `Float` | +| `:hash` | `Hash` | +| `:integer` | `Integer` | +| `:rational` | `Rational` | +| `:string` | `String` | +| `:symbol` | `Symbol` | +| `:time` | `Time` | + +### Usage + +```ruby +required :count, coerce: :integer +required :tags, coerce: :array +required :value, coerce: %i[rational big_decimal] # first success wins +required :recorded_at, coerce: { date: { strptime: "%m-%d-%Y" } } # per-coercion options +required :temp, coerce: ->(v) { Kelvin.parse(v) } # inline callable +``` + +Inline coerce callable arity: + +| Form | Invocation | +|------|------------| +| `Symbol` | `task.send(name, value)` | +| `Proc` | `task.instance_exec(value, &proc)` | +| `#call`-able | `callable.call(value, task)` | + +### Custom coercions + +```ruby +class GeolocationCoercion + def self.call(value, _options = {}) + case value + when String then Geolocation.parse(value) + when Hash then Geolocation.new(**value) + when Geolocation then value + else CMDx::Coercions::Failure.new("cannot coerce #{value.class} to Geolocation") + end + end +end + +# Globally +CMDx.configure { |c| c.coercions.register(:geolocation, GeolocationCoercion) } + +# Per task +register :coercion, :geolocation, GeolocationCoercion + +required :origin, coerce: :geolocation +``` + +Return `CMDx::Coercions::Failure.new("message")` to fail; any other value is treated as the coerced result. + +## Validators + +Shorthands can accept a normalized short form: `Hash` → options, `Array` → `{ in: array }`, `Regexp` → `{ with: regexp }`, `true` → `{}`, `false`/`nil` → skip. + +### Built-in validators + +```ruby +required :name, presence: true +optional :honey_pot, absence: true +required :email, format: { with: URI::MailTo::EMAIL_REGEXP } +required :slug, format: { without: /\s/ } +required :code, length: { is: 6 } +required :name, length: { min: 2, max: 100 } +required :bio, length: { within: 10..500 } +required :age, numeric: { gt: 0, lt: 150 } +required :price, numeric: { gte: 0 } +required :score, numeric: { within: 0..100 } +required :role, inclusion: { in: %w[admin member guest] } +required :status, exclusion: { in: %w[banned deleted] } +``` + +Numeric/length keys: `:min`/`:gte`, `:max`/`:lte`, `:gt`, `:lt`, `:is`/`:eq`, `:is_not`/`:not_eq`, `:within`/`:in`, `:not_within`/`:not_in`. + +Common options for every shorthand: `:allow_nil`, `:message`, `:if`, `:unless`, plus per-rule `<rule>_message` overrides. + +### `:if` / `:unless` arity on validators + +Gates on the validator side receive the **value**: + +| Form | Invocation | Signature | +|------|------------|-----------| +| Symbol | `task.send(name, value)` | `def name(value)` | +| Proc | `task.instance_exec(value, &proc)` | `->(value) { }` | +| `#call`-able | `callable.call(task, value)` | `def call(task, value)` | + +### Custom validators + +```ruby +class ApiKeyValidator + def self.call(value, options = {}) + return if value.match?(/\Aak_[a-z0-9]{32}\z/) + + CMDx::Validators::Failure.new(options[:message] || "invalid API key") + end +end + +# Globally +CMDx.configure { |c| c.validators.register(:api_key, ApiKeyValidator) } + +# Per task +register :validator, :api_key, ApiKeyValidator + +required :token, api_key: true +``` + +### Inline `:validate` + +Accepts Symbol, Proc, `#call`-able, or an Array chain. + +```ruby +required :email, + validate: ->(value) { + CMDx::Validators::Failure.new("invalid") unless value.include?("@") + } + +required :digits, validate: [:first_check, ->(v) { ... }, MyValidator] +``` + +Arity asymmetry (inverted from the `:if`/`:unless` form): + +| Form | Invocation | +|------|------------| +| Symbol | `task.send(name, value)` | +| Proc | `task.instance_exec(value, &proc)` | +| `#call`-able | `callable.call(value, task)` | + +Return a `CMDx::Validators::Failure` to fail the input; anything else (including `nil`) passes. + +## Naming + +```ruby +input :template, prefix: true # context_template +input :format, prefix: "report_" # report_format +input :branch, suffix: true # branch_context +input :id, suffix: "_value" # id_value +input :scheduled_at, as: :scheduled # scheduled +input :type, as: :category # avoids reserved conflicts +``` + +Naming collisions with already-defined methods raise `CMDx::DefinitionError` at registration. + +## Transforms + +Applied post-coercion, pre-validation. + +```ruby +optional :email, coerce: :string, transform: :downcase +optional :tags, coerce: :array, transform: :uniq +optional :score, coerce: :integer, transform: proc { |v| v.clamp(0, 100) } +optional :data, transform: ->(v, _task) { v.deep_symbolize_keys } +``` + +Arity: + +| Form | Invocation | +|------|------------| +| Symbol (responded to by value) | `value.send(name)` | +| Symbol (task method) | `task.send(name, value)` | +| Proc | `task.instance_exec(value, &proc)` | +| `#call`-able | `callable.call(value, task)` | + +## Conditional declaration + +```ruby +required :billing_address, if: :paid_plan? +optional :coupon_code, unless: :enterprise? +input :sso_provider, if: -> { context.auth_method == "sso" } +``` + +Gates at the **input** level receive `(task)` — not `(task, value)` — because they're evaluated before the value exists. + +## Nested inputs + +```ruby +required :address do + required :street, coerce: :string + required :city, coerce: :string + optional :zip, coerce: :string +end +``` + +Each nested input generates its own reader. Children are resolved from the parent's coerced value (anything responding to `#[]`). Children DO NOT receive `:source`; they read from the parent. + +## Shared options (ActiveSupport) + +```ruby +with_options coerce: :string, presence: true do + required :first_name + required :last_name + required :email, format: { with: URI::MailTo::EMAIL_REGEXP } +end +``` + +Requires ActiveSupport (`require "active_support/core_ext/object/with_options"` in plain Ruby). + +## Inheritance + +Subclasses inherit the parent's inputs via a lazy `dup`. Use `deregister :input, :name` to remove specific inputs. + +```ruby +class BaseTask < CMDx::Task + required :tenant_id, coerce: :integer +end + +class ChildTask < BaseTask + required :user_id, coerce: :integer + deregister :input, :tenant_id +end +``` + +Deregister targets the original declared name, not the accessor name (so a `:scheduled_at` input declared with `as: :scheduled` is still removed by `:scheduled_at`). + +## Schema + +```ruby +MyTask.inputs_schema +# => { tenant_id: { name: :tenant_id, description: nil, required: true, options: {...}, children: [] }, ... } +``` diff --git a/skills/references/interruptions.md b/skills/references/interruptions.md index 8cd84e3ec..28ee7ed37 100644 --- a/skills/references/interruptions.md +++ b/skills/references/interruptions.md @@ -1,260 +1,127 @@ # Interruptions Reference -For full documentation, see [docs/interruptions/halt.md](../docs/interruptions/halt.md), [docs/interruptions/faults.md](../docs/interruptions/faults.md), [docs/interruptions/exceptions.md](../docs/interruptions/exceptions.md). +Docs: [docs/interruptions/signals.md](../../docs/interruptions/signals.md), [docs/interruptions/faults.md](../../docs/interruptions/faults.md), [docs/interruptions/exceptions.md](../../docs/interruptions/exceptions.md). -## success! +## Signals -Annotates a successful result with a reason and metadata. Does not change state or status. Delegated from the task to the result. +Four private methods on `Task` halt `work` by `throw`ing a `CMDx::Signal` caught by Runtime. Signatures: -```ruby -success!(reason = nil, **metadata) -``` - -| Param | Default | Effect | -|-------|---------|--------| -| `reason` | `nil` | Human-readable annotation string | -| `**metadata` | `{}` | Arbitrary key-value pairs stored in `result.metadata` | - -Raises `RuntimeError` if the result is not currently `success`. - -## skip! and fail! - -Both are delegated from the task to the result. Callable inside `work`, callbacks, or anywhere with access to the task/result. - -```ruby -skip!(reason = nil, halt: true, cause: nil, **metadata) -fail!(reason = nil, halt: true, cause: nil, **metadata) -``` +| Method | Effect | Signature | +|--------|--------|-----------| +| `success!(reason = nil, **metadata)` | state: `complete`, status: `success` | reason + metadata hash | +| `skip!(reason = nil, **metadata)` | state: `interrupted`, status: `skipped` | reason + metadata hash | +| `fail!(reason = nil, **metadata)` | state: `interrupted`, status: `failed` | reason + metadata hash + signal backtrace | +| `throw!(other_result, **metadata)` | re-throws `other_result`'s failed signal | must receive a `Result`; no-op unless `other_result.failed?` | -| Param | Default | Effect | -|-------|---------|--------| -| `reason` | `"Unspecified"` | Human-readable reason string | -| `halt` | `true` | When true, raises `SkipFault`/`FailFault` to stop execution | -| `cause` | `nil` | Originating exception | -| `**metadata` | `{}` | Arbitrary key-value pairs stored in `result.metadata` | - -### State transitions - -| Method | State | Status | `good?` | `bad?` | -|--------|-------|--------|---------|--------| -| `skip!` | `interrupted` | `skipped` | `true` | `true` | -| `fail!` | `interrupted` | `failed` | `false` | `true` | - -### Idempotency - -Calling `skip!` when already skipped (or `fail!` when already failed) is a no-op. But `skip!` after `fail!` (or vice versa) raises `RuntimeError` — status transitions are one-way from `success`. - -### halt: false - -Continue execution after setting status: +All four raise `FrozenError` if called on a frozen task (i.e. after teardown). They only work inside `work`; calling them elsewhere bypasses the signal `catch`. ```ruby def work - fail!("Missing field", halt: false, field: :email) - # execution continues — useful for collecting multiple issues - fail!("Missing field", halt: false, field: :name) - # second fail! is a no-op since already failed + success!("Imported #{n} rows", rows: n) + skip!("Already processed", idempotency_key: key) + fail!("Not found", code: "NOT_FOUND") + throw!(InnerTask.execute(context)) # no-op if InnerTask succeeded end ``` -### Metadata enrichment - -```ruby -fail!("License not eligible", error_code: "LICENSE.NOT_ELIGIBLE", retry_after: 30.days.from_now) -skip!("Already processed", processed_at: record.processed_at) - -# Access later -result.metadata[:error_code] #=> "LICENSE.NOT_ELIGIBLE" -result.metadata[:retry_after] #=> <Time> -``` - -## throw! +Anything after a halt is unreachable — signals `throw`, they don't raise. -Propagates another task's result upward, copying state, status, reason, cause, and metadata: +## Errors (auto-fail) -```ruby -throw!(result, halt: true, cause: nil, **metadata) -``` +Accumulate validation-style errors on `task.errors`; if any exist after `work`, Runtime throws `Signal.failed(errors.to_s)` automatically — no explicit `fail!` needed. Input resolution, output verification, and any manual `errors.add` during `work` all feed the same container. ```ruby def work - inner = InnerTask.execute(context) - - # Throw on any non-success (copies status from inner result) - throw!(inner) unless inner.success? - - # Throw only on failure - throw!(inner) if inner.failed? - - # Throw with additional metadata - throw!(inner, stage: "validation", can_retry: true) if inner.failed? - - # Throw without halting (rare) - throw!(inner, halt: false) if inner.failed? + errors.add(:email, "invalid format") unless email.include?("@") + errors.add(:age, "must be positive") unless age.positive? + # falls through; Runtime fails the task because errors isn't empty end ``` -Throwing a successful result copies the success state and does not raise (since `halt!` is a no-op on success). - -## Nested Task Propagation Strategies +Errors API: -Three patterns for calling tasks from within other tasks: +| Method | Description | +|--------|-------------| +| `add(key, message)` / `[]=` | Adds; duplicate messages per key are dropped. | +| `[](key)` | Array of messages (frozen empty array when absent). | +| `added?(key, message)` | Boolean membership. | +| `key?(key)` / `for?(key)` | Key presence. | +| `keys` / `size` / `count` / `empty?` | Introspection. | +| `delete(key)` / `clear` | Mutation. | +| `to_h` / `to_hash` | `{ key => [messages] }`. | +| `full_messages` | `{ key => ["<key> <msg>"] }`. | +| `to_s` | `full_messages.values.flatten.join(". ")` — used as fail reason. | -### Swallow (silent) +Frozen on teardown. -```ruby -def work - InnerTask.execute(context) - # Inner failure is ignored — outer task continues and succeeds - # Inner result is still in the chain but doesn't affect outer -end -``` - -Use when the inner task is optional and its failure should not affect the caller. +## Faults -### Throw (propagate result) +Failures raised from strict execution (`execute!`) are `CMDx::Fault`. There is **one** fault class — no `FailFault`/`SkipFault`. `execute!` does **not** raise on skip. ```ruby -def work - inner = InnerTask.execute(context) - throw!(inner) unless inner.success? - # Copies inner's state/status/reason/metadata to this task's result - # Raises SkipFault or FailFault (halt: true by default) -end -``` - -Use when you want to propagate the inner result with full control over when to propagate. The outer result preserves the inner's reason and metadata. Chain analysis tracks both `caused_failure` (inner) and `threw_failure` (outer). - -### Raise (bang execution) - -```ruby -def work - InnerTask.execute!(context) - # Raises FailFault/SkipFault if inner task fails/skips - # Exception propagates up — must be caught by caller or Executor +begin + ProcessPayment.execute!(...) +rescue CMDx::Fault => e + e.message # result.reason (or localized "cmdx.reasons.unspecified") + e.result # the originating (leaf) failed Result + e.task # the failing Task class + e.context # frozen Context + e.chain # the full CMDx::Chain end ``` -Use when inner failure should immediately halt the caller. The exception bypasses `throw!` — the outer task's result is set by the Executor's rescue handler. - -### Strategy comparison +`Fault` delegates `task` / `context` / `chain` to `result`. Its backtrace is set from `result.backtrace` (captured by `fail!`) or `result.cause.backtrace_locations`, cleaned through `task.settings.backtrace_cleaner` if configured. -| Strategy | Inner fails | Outer continues? | Chain tracks cause? | Metadata preserved? | -|----------|------------|-------------------|---------------------|---------------------| -| Swallow | `execute` | Yes | No | No | -| Throw | `execute` + `throw!` | No | Yes (`caused_failure` + `threw_failure`) | Yes | -| Raise | `execute!` | No | Partial | Via exception | - -### Nesting multiple levels +### Matchers ```ruby -# OuterTask → MiddleTask → InnerTask -# Each level chooses its own strategy independently - -class MiddleTask < CMDx::Task - def work - inner = InnerTask.execute(context) - throw!(inner) unless inner.success? # throw strategy - (context.executed ||= []) << :middle - end -end - -class OuterTask < CMDx::Task - def work - middle = MiddleTask.execute(context) - throw!(middle) unless middle.success? # throw strategy - (context.executed ||= []) << :outer - end -end +rescue CMDx::Fault.for?(ProcessPayment, ChargeCard) => e + # any fault where e.task <= ProcessPayment or <= ChargeCard +rescue CMDx::Fault.matches? { |f| f.result.metadata[:critical] } => e + # custom predicate on the fault ``` -## Fault Matching +`for?` raises `ArgumentError` if no tasks given. `matches?` raises `ArgumentError` without a block. -`for?` and `matches?` are **class methods** on `Fault` (and `SkipFault`/`FailFault`) that return matcher classes for use in `rescue`: +### `execute!` raise rules -### Task-specific matching +When `result.failed?` and `strict: true`: -```ruby -begin - Workflow.execute!(data: input) -rescue CMDx::FailFault.for?(PaymentTask, BillingTask) => e - handle_payment_failure(e) -rescue CMDx::SkipFault.for?(AuditTask) => e - log_audit_skip(e) -rescue CMDx::Fault => e - handle_generic_fault(e) -end -``` +- If `result.cause` is a non-`Fault` `StandardError`, **re-raises the original exception** (preserves class + backtrace). +- Otherwise, raises `CMDx::Fault.new(result.caused_failure)` — the deepest leaf in the propagation chain. -### Custom logic matching +Skipped results never raise. Successful results never raise. -```ruby -begin - Workflow.execute!(data: input) -rescue CMDx::FailFault.matches? { |f| f.result.metadata[:critical] } => e - escalate(e) -rescue CMDx::Fault.matches? { |f| f.result.metadata[:retryable] } => e - schedule_retry(e) -end -``` +## Propagation strategies -### Fault data access +Pick per call-site: -```ruby -rescue CMDx::Fault => e - e.result # CMDx::Result - e.task # CMDx::Task instance (delegated from result) - e.context # CMDx::Context (delegated from result) - e.chain # CMDx::Chain (delegated from result) - e.message # reason string (set from result.reason) - e.result.reason # same reason - e.result.status # "failed" or "skipped" - e.result.metadata # metadata hash -end -``` +| Strategy | Call form | Outer task's behavior when inner fails | +|----------|-----------|----------------------------------------| +| Swallow | `InnerTask.execute(context)` | Returns Result; outer keeps going unless you check `result.failed?`. | +| Throw | `throw!(InnerTask.execute(context))` | Echoes the failed signal — outer halts with the leaf as origin. Skips are a no-op. | +| Raise | `InnerTask.execute!(context)` | Raises `CMDx::Fault` (or the original exception) inside `work`. Runtime's outer rescue converts it into an echoed signal anyway. | -## Manual Errors +Inside a workflow, the `Pipeline` uses `throw!` on a failed group result automatically, so the workflow's own result carries the leaf's `origin`/`caused_failure`/`threw_failure`. -Add errors before halting for structured validation messages: +## Exception hierarchy -```ruby -def work - errors.add(:email, "is invalid") - errors.add(:email, "is required") - errors.add(:name, "is too short") - fail!("Validation failed") -end -``` - -### Errors API - -```ruby -errors.add(:attr, "message") # add error -errors.any? # true if errors exist -errors.empty? # true if no errors -errors.size # number of attributes with errors -errors.for?(:attr) # true if attr has errors -errors.to_h # { attr: ["msg1", "msg2"] } -errors.full_messages # { attr: ["attr msg1", "attr msg2"] } -errors.to_s # "attr msg1. attr msg2" -errors.clear # remove all errors -``` - -## Exception Hierarchy +Flat, rooted at `CMDx::Error` (aliased `CMDx::Exception`): ``` StandardError └── CMDx::Error - ├── CMDx::CoercionError # type coercion failed - ├── CMDx::DeprecationError # deprecated task used with :raise - ├── CMDx::UndefinedMethodError # missing work method - ├── CMDx::ValidationError # validation failed - └── CMDx::Fault # base for skip/fail faults - ├── CMDx::SkipFault # raised by skip! + halt - └── CMDx::FailFault # raised by fail! + halt - -Interrupt -└── CMDx::TimeoutError # always raised, NOT caught by rescue StandardError + ├── CMDx::DefinitionError # input reader name collides with existing method + ├── CMDx::DeprecationError # deprecation :error path was taken + ├── CMDx::ImplementationError # missing #work, or #work defined on a Workflow + ├── CMDx::MiddlewareError # middleware didn't yield + └── CMDx::Fault # raised by execute! on failed? results ``` -`execute` catches `StandardError` and converts to failed results. `CMDx::TimeoutError` inherits from `Interrupt` and always propagates. +Runtime's `rescue` chain inside `catch(Signal::TAG)`: + +1. `Fault` → converted to `Signal.echoed(fault.result, cause: fault)` (propagates the leaf). +2. `CMDx::Error` (any other subclass) → **re-raised** past the signal catch; fails the whole execution outside of result machinery. +3. `StandardError` → converted to `Signal.failed("[Class] message", cause: e)`; the exception lives on `result.cause`. + +Because `CMDx::Error` is re-raised, `DefinitionError` / `ImplementationError` / `MiddlewareError` / `DeprecationError` propagate out of `execute` unconverted — they indicate framework misuse, not runtime failure. diff --git a/skills/references/outputs.md b/skills/references/outputs.md new file mode 100644 index 000000000..337af06c0 --- /dev/null +++ b/skills/references/outputs.md @@ -0,0 +1,93 @@ +# Outputs Reference + +Docs: [docs/outputs.md](../../docs/outputs.md). Outputs share coercions, validators, transforms, and defaults with inputs — see [inputs.md](inputs.md). + +## Declaration + +```ruby +output :source # single, optional +output :user, :token, required: true # multiple, required +outputs :generated_at, default: -> { Time.now } + +deregister :output, :audit_log, :request_id +``` + +`output` is an alias of `outputs`. Keys are symbolized. + +## Options + +| Option | Description | +|--------|-------------| +| `required:` | Adds `cmdx.outputs.missing` error if `context[name]` is absent/nil after `work` and no default resolves. | +| `default:` | Static value, Symbol (task method), Proc (`instance_exec`), or `#call(task)`-able. Applied when `context[name]` is nil. | +| `coerce:` | Same as inputs (single Symbol, array, Hash, or callable). | +| `transform:` | Symbol, Proc (`instance_exec(value)`), or `#call(value, task)`. Applied post-coerce, pre-validate. | +| `validate:` | Inline validator (Symbol/Proc/`#call`-able or Array chain). | +| `if:` / `unless:` | Skip the entire check (including `required:`) when the gate fails. Signature `(task)`. | +| Validator shorthands | `presence:`, `absence:`, `format:`, `length:`, `numeric:`, `inclusion:`, `exclusion:`, plus custom validators. | +| `description:` / `desc:` | Metadata for `outputs_schema`. | + +## Verification + +Runs **after `work` succeeds**. Skipped entirely when `work` threw `skip!`, `fail!`, or `throw!`. For each declared output in order: + +1. Evaluate `if:`/`unless:` — skip if gated. +2. Read `task.context[name]`; apply `:default` when value is `nil`. +3. If `required?` and nothing resolved, add `cmdx.outputs.missing`. +4. Coerce; a `Coercions::Failure` short-circuits transform/validate. +5. Apply `:transform`. +6. Run validators. +7. Write the final value back to `task.context[name]`. + +Failures fold into the same terminal "auto-fail" behavior as input errors: `result.reason` = `task.errors.to_s`, `result.errors[name]` exposes messages. `execute!` raises `CMDx::Fault`. + +```ruby +class CreateUser < CMDx::Task + output :user, required: true + def work + # forgot to set context.user + end +end + +result = CreateUser.execute +result.failed? #=> true +result.reason #=> "user must be set in the context" +result.errors.to_h #=> { user: ["must be set in the context"] } +``` + +## Defaults, transforms, validators + +Same mechanisms as inputs — refer to [inputs.md](inputs.md). + +```ruby +output :version, default: "v2" +output :source, default: :default_source # method +output :generated_at, default: -> { Time.now } # instance_exec +output :retention_days, default: "7", coerce: :integer # flows through coerce + +output :email, coerce: :string, transform: :downcase +output :tags, coerce: :array, transform: proc { |v| v.uniq.sort } +output :total, coerce: :big_decimal, numeric: { min: 0.01 } +``` + +## Inheritance + +Subclasses inherit parent outputs via a lazy `dup`. Remove inherited outputs with `deregister :output, :name`. + +```ruby +class ApplicationTask < CMDx::Task + output :audit_log, required: true + output :request_id, required: true +end + +class LightweightTask < ApplicationTask + deregister :output, :audit_log, :request_id +end +``` + +## Schema + +```ruby +MyTask.outputs_schema +# => { user: { name: :user, description: "...", required: true, options: {...} } } +``` diff --git a/skills/references/result.md b/skills/references/result.md index 5b765d134..e511cd78d 100644 --- a/skills/references/result.md +++ b/skills/references/result.md @@ -1,196 +1,157 @@ # Result Reference -For full documentation, see [docs/outcomes/result.md](../docs/outcomes/result.md), [docs/outcomes/states.md](../docs/outcomes/states.md), [docs/outcomes/statuses.md](../docs/outcomes/statuses.md). +Docs: [docs/outcomes/result.md](../../docs/outcomes/result.md), [docs/outcomes/states.md](../../docs/outcomes/states.md), [docs/outcomes/statuses.md](../../docs/outcomes/statuses.md). -## Attributes +A `Result` is the frozen outcome of running a task. It's the only value returned by `execute` (and `execute!` when no fault is raised). -```ruby -result.task # CMDx::Task instance -result.context # CMDx::Context (delegated from task) -result.chain # CMDx::Chain (delegated from task) -result.errors # CMDx::Errors (delegated from task) -result.state # String: "initialized", "executing", "complete", "interrupted" -result.status # String: "success", "skipped", "failed" -result.reason # String or nil — reason from success!/skip!/fail! -result.cause # Exception or nil — originating exception -result.metadata # Hash — arbitrary metadata from success!/skip!/fail!/throw! -result.retries # Integer — number of retry attempts -result.index # Integer — position in chain -result.outcome # String — unified outcome (status or state depending on context) -``` - -## State and Status Matrix +## States -| State | Status | `complete?` | `interrupted?` | `executed?` | `success?` | `skipped?` | `failed?` | `good?` | `bad?` | -|-------|--------|-------------|----------------|-------------|------------|------------|-----------|---------|--------| -| `complete` | `success` | true | false | true | true | false | false | true | false | -| `interrupted` | `skipped` | false | true | true | false | true | false | true | true | -| `interrupted` | `failed` | false | true | true | false | false | true | false | true | +Exactly two values (`CMDx::Signal::STATES`): -Key nuance: **skipped is both `good?` and `bad?`**. `good?` means "not failed" (`!failed?`). `bad?` means "not success" (`!success?`). +| State | Predicate | Meaning | +|-------|-----------|---------| +| `"complete"` | `complete?` | `work` finished normally (no halting signal). | +| `"interrupted"` | `interrupted?` | `work` was halted by `skip!` / `fail!` / `throw!` or by accumulated errors. | -`ok?` is an alias for `good?`. - -## Handlers +## Statuses -The `on` method takes one or more state/status symbols and yields if any match: +Exactly three values (`CMDx::Signal::STATUSES`): -```ruby -result - .on(:success) { |r| process_success(r) } - .on(:failed) { |r| handle_failure(r) } - .on(:skipped) { |r| log_skip(r) } -``` +| Status | Predicate | Derived from | +|--------|-----------|--------------| +| `"success"` | `success?` | reached end of `work` (or `success!`). | +| `"skipped"` | `skipped?` | `skip!`. | +| `"failed"` | `failed?` | `fail!`, `throw!(failed)`, error accumulation, or rescued non-`Fault` `StandardError`. | -### Available handler symbols +Convenience predicates: -| Symbol | Matches when | -|--------|-------------| -| `:success` | `success?` is true | -| `:skipped` | `skipped?` is true | -| `:failed` | `failed?` is true | -| `:complete` | `complete?` is true | -| `:interrupted` | `interrupted?` is true | -| `:executed` | `executed?` is true (complete OR interrupted) | -| `:good` | `good?` is true (success OR skipped) | -| `:ok` | alias for `:good` | -| `:bad` | `bad?` is true (skipped OR failed) | +- `ok?` — `success?` or `skipped?` ("not failed"). +- `ko?` — `skipped?` or `failed?` ("not success"). -Multiple symbols in one call act as OR: +## State / status matrix -```ruby -result.on(:success, :complete) { |r| ... } -``` +| State | Statuses allowed | +|-------|------------------| +| `complete` | `success` | +| `interrupted` | `skipped`, `failed` | -Handlers return `self` for chaining. Raises `ArgumentError` if no block given. +`complete` + `failed` / `interrupted` + `success` never occur. -## Pattern Matching +## Data surface -### Array deconstruction +| Accessor | Description | +|----------|-------------| +| `task` | Task **class** that ran. | +| `type` | `"Task"` or `"Workflow"`. | +| `id` | UUID v7 for this execution. | +| `context` | Shared `Context` (frozen on root). | +| `errors` | `Errors` container (frozen). | +| `state`, `status` | Strings. | +| `reason` | String passed to `success!`/`skip!`/`fail!`, `errors.to_s`, or `nil`. | +| `metadata` | Frozen Hash. | +| `cause` | Rescued `StandardError` (or `nil`). Set when `execute!` re-raises. | +| `origin` | Upstream failed `Result` this was echoed from (via `throw!` or rescued `Fault`), or `nil`. | +| `backtrace` | Array of frames captured by `fail!`/`throw!`, cleaned. | +| `retries` / `retried?` | Integer count + boolean. | +| `duration` | Float ms. | +| `strict?` | `true` when produced via `execute!`. | +| `deprecated?` | `true` when the class has a fired deprecation. | +| `rolled_back?` | `true` when `#rollback` ran. | +| `tags` | `task.settings.tags`. | +| `chain` | `CMDx::Chain`. | +| `chain_index` | Position in chain (Integer or `nil`). | +| `chain_root?` | `true` for the outermost result in the chain. | -Returns `[state, status, reason, cause, metadata]`: +## Chain analysis ```ruby -case result -in ["complete", "success"] - handle_success -in ["interrupted", "failed"] - handle_failure -in ["interrupted", "skipped"] - handle_skip -end +result.caused_failure # deepest failed Result; self when originator; nil unless failed? +result.threw_failure # nearest upstream failed Result; self when originator; nil unless failed? +result.caused_failure? # failed? && origin.nil? +result.thrown_failure? # failed? && !origin.nil? ``` -### Hash deconstruction +`origin` walks one level; `caused_failure` walks recursively to the leaf. For a root-level failure, `caused_failure == threw_failure == self`. -Available keys: `state`, `status`, `reason`, `cause`, `metadata`, `outcome`, `executed`, `good`, `bad`: +## Handlers ```ruby -case result -in { state: "complete", status: "success" } - celebrate -in { status: "failed", metadata: { retryable: true } } - schedule_retry(result) -in { bad: true, metadata: { reason: String => reason } } - escalate(reason) -end +result + .on(:success) { |r| redirect_to(dashboard) } + .on(:skipped) { |r| log_skip(r.reason) } + .on(:failed) { |r| render_error(r.reason) } + .on(:complete) { |r| audit(r) } + .on(:interrupted) { |r| notify(r) } + .on(:ok) { |r| track(r) } + .on(:ko) { |r| escalate(r) } ``` -### With guards +Allowed keys: `:complete`, `:interrupted`, `:success`, `:skipped`, `:failed`, `:ok`, `:ko`. Passing any other symbol raises `ArgumentError`. Missing block raises `ArgumentError`. Returns `self` for chaining. -```ruby -case result -in { status: "failed", metadata: { attempts: n } } if n < 3 - retry_with_delay(result, n * 2) -in { status: "failed", metadata: { attempts: n } } if n >= 3 - mark_permanently_failed(result) -end -``` +`on(:failed, :skipped) { ... }` fires on either. -## Chain Analysis +## Pattern matching -When tasks are nested (via `throw!`), the chain tracks provenance: +### Array form — `deconstruct` + +``` +[type, task, state, status, reason, metadata, cause, origin] +``` ```ruby -result = Workflow.execute(data: input) - -if result.failed? - # The original task that caused the failure (deepest in chain) - original = result.caused_failure - original.task.class.name #=> "InnerTask" - original.reason #=> "Validation failed" - - # The task that propagated (threw) the failure - thrower = result.threw_failure - thrower.task.class.name #=> "MiddleTask" - - # Classification predicates - result.caused_failure? # true if this result was the original cause - result.threw_failure? # true if this result threw a failure from another - result.thrown_failure? # true if this result received a thrown failure (failed? && !caused_failure?) +case result +in ["Task", _, "complete", "success", *] then success +in ["Workflow", _, "interrupted", "failed", reason, *] then fail(reason) +in [_, _, _, "skipped", reason, *] then skip(reason) end ``` -### Metadata for nested failures +### Hash form — `deconstruct_keys` -When the Executor logs/formats failures, metadata includes: +Available keys: -```ruby -result.metadata[:threw_failure] #=> { index: 1, class: "MiddleTask", id: "..." } -result.metadata[:caused_failure] #=> { index: 2, class: "InnerTask", id: "..." } ``` - -## Retry Info +:chain_root, :type, :task, :state, :status, :reason, :metadata, +:cause, :origin, :strict, :deprecated, :retries, :rolled_back, :duration +``` ```ruby -result.retries #=> 2 (number of retry attempts) -result.retried? #=> true (retries > 0) +case result +in { status: "failed", metadata: { retryable: true } } then requeue(result) +in { state: "complete", task: ChargeCard } then confirm +in { rolled_back: true } then audit_rollback +end ``` -## Rollback Info +## Serialization + +### `to_h` -```ruby -result.rolled_back? #=> true (rollback method was called) -``` +Memoized. Always includes: `:chain_id`, `:chain_index`, `:chain_root`, `:type`, `:task`, `:id`, `:context`, `:state`, `:status`, `:reason`, `:metadata`, `:strict`, `:deprecated`, `:retried`, `:retries`, `:duration`, `:tags`. -## Dry Run +When `failed?`, additionally includes: `:cause`, `:origin`, `:threw_failure`, `:caused_failure`, `:rolled_back`. Failure references render as `{ task: TaskClass, id: "uuid" }` — the live `Result` objects aren't walked to avoid cycles. -```ruby -result.dry_run? #=> true (delegated to task, which delegates to chain) -``` +### `to_s` -## Block Yield +Space-separated `key=value.inspect`. Failure references render as `<TaskClass uuid>`. Used as the default log line for every task execution. -Both `execute` and `execute!` accept blocks: +## Chain ```ruby -MyTask.execute(data: input) do |result| - if result.success? - process(result.context) - end -end +result.chain # CMDx::Chain +result.chain.id # UUID v7 +result.chain.size # number of Results in this propagation +result.chain.map(&:task) # Classes, in insertion order ``` -## Serialization +The root result is the **first** element (via `unshift`); non-root results are `push`ed in execution order. Frozen on teardown. -```ruby -result.to_h -#=> { -# state: "interrupted", -# status: "failed", -# outcome: "failed", -# metadata: { error_code: "NOT_FOUND" }, -# reason: "Record not found", -# cause: #<CMDx::FailFault>, -# rolled_back: false, -# threw_failure: { index: 1, class: "MiddleTask", id: "abc" }, -# caused_failure: { index: 2, class: "InnerTask", id: "def" } -# } - -result.to_s -#=> "state=interrupted status=failed reason=\"Record not found\" ..." -``` +## Freezing + +After Runtime's teardown: -## Immutability +- `task`, `errors`, and (when root) `context` and `chain` are frozen. +- `result` itself is immutable (everything is read-only). +- Mutations via `context.key = value` after teardown raise `FrozenError`. -Results are frozen after execution when `freeze_results: true` (default). Attempting to modify context or metadata after freeze raises an error. Set `freeze_results: false` in configuration if post-execution mutation is needed. +Capture anything you need before teardown, or use `context.deep_dup` to get an unfrozen snapshot. diff --git a/skills/references/testing.md b/skills/references/testing.md index 014bf13e1..719de4264 100644 --- a/skills/references/testing.md +++ b/skills/references/testing.md @@ -1,273 +1,240 @@ # Testing Reference -For full documentation, see [docs/testing.md](../docs/testing.md). +Docs: [docs/testing.md](../../docs/testing.md). -## RSpec Setup +CMDx has no custom RSpec helpers — the public API is the test API. `execute` returns a `Result`, `execute!` raises `Fault` (or the original `StandardError`), and every predicate (`success?`, `failed?`, etc.) is automatically exposed as an RSpec matcher (`be_success`, `be_failed`, ...). + +## Basic assertions ```ruby -# spec/spec_helper.rb -require "cmdx" -require "cmdx/rspec" +RSpec.describe CreateUser do + it "succeeds" do + result = CreateUser.execute(email: "dev@example.com", name: "Ada") + + expect(result).to be_success + expect(result.context.user).to be_persisted + end -RSpec.configure do |config| - config.include CMDx::RSpec::Helpers - config.include CMDx::Testing::TaskBuilders - config.include CMDx::Testing::WorkflowBuilders + it "fails when email is blank" do + result = CreateUser.execute(email: "", name: "Ada") - config.before { CMDx.reset_configuration! } - config.after { CMDx::Chain.clear } + expect(result).to be_failed + expect(result.reason).to include("email") + expect(result.errors.to_h).to eq(email: ["cannot be empty"]) + end end ``` -## RSpec Matchers +Auto-generated predicate matchers: `be_complete`, `be_interrupted`, `be_success`, `be_skipped`, `be_failed`, `be_ok`, `be_ko`, `be_retried`, `be_rolled_back`, `be_strict`, `be_deprecated`, `be_chain_root`. -### Status matchers +## Branching with `on` ```ruby -expect(result).to be_successful -expect(result).to have_skipped -expect(result).to have_skipped(reason: "Already processed") -expect(result).to have_failed -expect(result).to have_failed(reason: "Not found") -expect(result).to have_failed(reason: start_with("Invalid")) -expect(result).to have_failed(cause: be_a(CMDx::FailFault)) +it "branches cleanly" do + CreateUser.execute(email: "x@y.com") + .on(:success) { |r| expect(r.context.user).to be_persisted } + .on(:failed) { |r| raise "unexpected: #{r.reason}" } +end ``` -### Failure detail matchers - -```ruby -expect(result).to have_failed( - outcome: CMDx::Result::INTERRUPTED, - threw_failure: hash_including(index: 1, class: start_with("MiddleTask")), - caused_failure: hash_including(index: 2, class: start_with("InnerTask")) -) -``` +## Skip / fail payloads -### Context matchers +`reason` and `metadata` come straight from the halting call's arguments. ```ruby -expect(result).to have_matching_context(user: "John", token: "abc") -expect(result).to have_matching_context(executed: %i[inner middle outer]) -expect(result).to have_empty_context -``` - -### Metadata matchers +it "skips when already processed" do + result = ProcessRefund.execute(refund_id: completed.id) -```ruby -expect(result).to have_matching_metadata( - errors: { - full_message: "email must be present", - messages: { email: ["must be present"] } - } -) -``` + expect(result).to be_skipped + expect(result.reason).to eq("Refund already processed") +end -### Special matchers +it "fails with metadata" do + result = ProcessRefund.execute(refund_id: expired.id) -```ruby -expect(result).to be_dry_run -expect(result).to be_rolled_back + expect(result).to be_failed + expect(result.metadata[:error_code]).to eq("REFUND_EXPIRED") +end ``` -## Testing Patterns +## `execute!` and `Fault` -### Success +`execute!` raises `CMDx::Fault` for every failed path (`fail!`, input/output validation, echoed failures). It does **not** raise on skipped results. If `result.cause` is a non-`Fault` `StandardError`, the original exception is re-raised instead. ```ruby -it "processes the payment" do - result = ProcessPayment.execute(order_id: 1, amount: 99.99) +it "raises Fault on failure" do + expect { + ProcessPayment.execute!(amount: -1) + }.to raise_error(CMDx::Fault) { |fault| + expect(fault.task).to eq(ProcessPayment) + expect(fault.result.errors).to have_key(:amount) + } +end - expect(result).to be_successful - expect(result).to have_matching_context(charge_id: be_present, receipt_url: be_present) +it "re-raises the original exception" do + expect { Importer.execute!(payload: bad_json) } + .to raise_error(JSON::ParserError) end ``` -### Failure +Task-scoped matcher: ```ruby -it "fails when order not found" do - result = ProcessPayment.execute(order_id: -1, amount: 99.99) - - expect(result).to have_failed(reason: "Order not found") -end +expect { BillingWorkflow.execute! } + .to raise_error(CMDx::Fault.for?(ChargeCard, RefundCard)) ``` -### Skip +## Inputs + +Failures produced by input resolution surface through `result.errors` with the input's **accessor name** as the key. The coerced value lives on the task instance, **not** `context` — if you need it in assertions, write it back inside `work` (`context.budget = budget`). ```ruby -it "skips already processed orders" do - result = ProcessPayment.execute(order_id: processed_order.id, amount: 99.99) +it "fails when required inputs are missing" do + result = CreateProject.execute(name: nil) - expect(result).to have_skipped(reason: "Already processed") + expect(result).to be_failed + expect(result.errors.to_h).to have_key(:name) + expect(result.reason).to include("name") end ``` -### Validation errors +## Outputs + +Missing or invalid declared outputs fail the task with the same errors API. ```ruby -it "fails with missing required attributes" do - result = ProcessPayment.execute(order_id: nil) - - expect(result).to have_failed(reason: "Invalid") - expect(result).to have_matching_metadata( - errors: { - messages: { order_id: [be_a(String)] } - } - ) -end -``` +it "fails when a declared output is missing" do + allow(JwtService).to receive(:encode).and_return(nil) -### Bang execution + result = AuthenticateUser.execute(email: "a@b.com", password: "pw") -```ruby -it "raises on failure with execute!" do - expect { - ProcessPayment.execute!(order_id: -1, amount: 99.99) - }.to raise_error(CMDx::FailFault, "Order not found") + expect(result).to be_failed + expect(result.errors).to have_key(:token) end ``` -### Dry run +## Retries ```ruby -it "supports dry run" do - result = ProcessPayment.execute(order_id: 1, amount: 99.99, dry_run: true) +it "retries transient failures" do + call_count = 0 + allow(HTTParty).to receive(:get) do + call_count += 1 + raise Net::ReadTimeout if call_count < 3 - expect(result).to be_dry_run - expect(result).to be_successful -end -``` - -### Returns validation + double(parsed_response: { ok: true }) + end -```ruby -it "fails when returns are missing" do - result = IncompleteTask.execute(data: input) + result = FetchExternalData.execute - expect(result).to have_failed(reason: "Invalid") - expect(result).to have_matching_metadata( - errors: { messages: { user: [be_a(String)] } } - ) + expect(result).to be_success + expect(result.retries).to eq(2) + expect(result).to be_retried end ``` -### Workflows +## Workflows + +The chain contains every `Result` in execution order with the workflow's own result as the root. `origin` / `caused_failure` / `threw_failure` identify the leaf without chain scanning. ```ruby -it "executes all tasks in order" do - result = OnboardCustomer.execute(email: "test@example.com", plan: "pro") - - expect(result).to be_successful - expect(result).to have_matching_context( - user: be_present, - profile: be_present, - welcome_sent: true +it "runs in sequence" do + result = OnboardingWorkflow.execute(user_data: valid_params) + + expect(result).to be_success + expect(result.chain.size).to eq(4) + expect(result.chain.map(&:task)).to eq( + [OnboardingWorkflow, CreateProfile, SetupPreferences, SendWelcome] ) end -it "halts on task failure" do - result = OnboardCustomer.execute(email: nil) +it "halts on first failure" do + result = PaymentWorkflow.execute(invalid_card: true) - expect(result).to have_failed + expect(result).to be_failed + expect(result.origin.task).to eq(ValidateCard) + expect(result.caused_failure.task).to eq(ValidateCard) end ``` -### Callbacks +`caused_failure` walks `origin` recursively to the deepest leaf; `threw_failure` returns the immediate upstream (`origin || self`). -```ruby -it "invokes callbacks in order" do - result = MyTask.execute(data: input) +## Callbacks - expect(result).to be_successful - expect(result).to have_matching_context( - callbacks: %i[before_validation before_execution on_complete on_executed on_success on_good] - ) -end -``` - -### Result handlers +Test through observable side effects — `result` isn't available inside callbacks, so any direct unit test has to inspect `task.context`/`task.errors` instead. ```ruby -it "invokes the correct handler" do - handled = [] - result = MyTask.execute(data: input) - result.on(:success) { handled << :success } - .on(:failed) { handled << :failed } +it "notifies on success" do + allow(GuestNotifier).to receive(:call) + + ProcessBooking.execute(booking_id: booking.id) - expect(handled).to eq([:success]) + expect(GuestNotifier).to have_received(:call) end ``` -### Pattern matching +## Middlewares -```ruby -it "supports pattern matching" do - result = MyTask.execute(data: input) +Run through a real task: - case result - in ["complete", "success"] - expect(result.context.output).to be_present - in ["interrupted", "failed"] - fail "unexpected failure" +```ruby +it "tags context before work" do + klass = Class.new(CMDx::Task) do + register :middleware, TaggingMiddleware.new + def work + context.seen = !context.tagged_at.nil? + end end + + result = klass.execute + + expect(result.context.seen).to be(true) end ``` -## Task Builders +## Direct instantiation -Helpers for creating test task classes: +`Task.new(ctx)` only builds context + errors; it does **not** run the lifecycle. Use `Klass.execute` to run. ```ruby -# Base builder -task_class = create_task_class do - required :input, type: :string - def work - context.output = input.upcase - end -end +it "holds context before execution" do + task = CalculateShipping.new(weight: 2.5, destination: "CA") -# Named task -task_class = create_task_class(name: "CustomName") do - def work = nil + expect(task.context.weight).to eq(2.5) + expect(task.errors).to be_empty end +``` -# Inheriting -child = create_task_class(base: ParentTask) do - optional :extra - def work - super - context.extra = extra - end -end +## Pattern matching -# Preset builders -create_successful_task # work pushes :success to context.executed -create_skipping_task # work calls skip! -create_failing_task # work calls fail! -create_erroring_task # work raises CMDx::TestError -``` +`deconstruct`: `[type, task, state, status, reason, metadata, cause, origin]`. -## Workflow Builders +`deconstruct_keys` exposes: `:chain_root`, `:type`, `:task`, `:state`, `:status`, `:reason`, `:metadata`, `:cause`, `:origin`, `:strict`, `:deprecated`, `:retries`, `:rolled_back`, `:duration`. ```ruby -workflow = create_workflow_class do - task TaskA - task TaskB +it "deconstructs to array" do + result = Build.execute(version: "1.0") + + expect(result.deconstruct) + .to match(["Task", Build, "complete", "success", nil, {}, nil, nil]) end -# Preset builders -create_successful_workflow -create_skipping_workflow -create_failing_workflow -create_erroring_workflow +it "pattern matches on failure" do + result = Build.execute(version: nil) + + case result + in { status: "failed", reason: String => r } then expect(r).to include("version") + else raise "expected failed result" + end +end ``` -## Test Isolation +## Resetting state -Always reset between tests to avoid state leakage: +Use `CMDx.reset_configuration!` in test setup to clear global registries (middleware, callbacks, coercions, validators, telemetry). It only clears `Task`'s cached ivars; existing user-defined subclasses keep their own caches until class reload. ```ruby -config.before { CMDx.reset_configuration! } -config.after { CMDx::Chain.clear } -config.after { CMDx::Middlewares::Correlate.clear } +RSpec.configure do |c| + c.before { CMDx.reset_configuration! } +end ``` diff --git a/skills/references/workflows.md b/skills/references/workflows.md index fa0ba8a7e..59ddba44c 100644 --- a/skills/references/workflows.md +++ b/skills/references/workflows.md @@ -1,202 +1,143 @@ -# Workflow Reference +# Workflows Reference -For full documentation, see [docs/workflows.md](../docs/workflows.md). +Docs: [docs/workflows.md](../../docs/workflows.md). -## Setup +Workflows compose ordered groups of tasks. A workflow is a `Task` subclass that `include`s `CMDx::Workflow`. Inputs, outputs, callbacks, middleware, retries, and settings all work the same as on a plain task. -Include `CMDx::Workflow` in a `CMDx::Task` subclass: +## Setup ```ruby -class MyWorkflow < CMDx::Task +class OnboardCustomer < CMDx::Task include CMDx::Workflow - task StepOne - task StepTwo - task StepThree -end -``` + required :email, coerce: :string -## Task Groups - -### Sequential (default) + task ValidateIdentity + task CreateAccount + tasks SendWelcomeEmail, SendWelcomeSms, strategy: :parallel +end -```ruby -task StepOne -task StepTwo # runs after StepOne completes +OnboardCustomer.execute(email: "user@example.com") ``` -### Grouped +Defining `def work` on a workflow raises `CMDx::ImplementationError` — `#work` is auto-generated to delegate to `Pipeline`. -```ruby -tasks StepA, StepB # same execution group, same options -``` +## Groups -### Parallel +`task` / `tasks` (aliases) register one group per call. Groups run in declaration order and share the workflow's `context`. -```ruby -tasks StepA, StepB, strategy: :parallel -tasks StepC, StepD, strategy: :parallel, pool_size: 4 -``` +### Options -Uses native Ruby threads. The `pool_size` option caps the number of concurrent threads (defaults to task count). +| Option | Description | +|--------|-------------| +| `strategy:` | `:sequential` (default) or `:parallel`. | +| `pool_size:` | Parallel worker thread count. Defaults to `tasks.size`. | +| `if:` / `unless:` | Gate the whole group. Signature `(workflow)` (Symbol → task method; Proc → `instance_exec`; `#call`-able → `callable.call(workflow)`). | -## Breakpoints +Every task class must be a `CMDx::Task` subclass — otherwise registration raises `TypeError`. -Breakpoints control when a workflow halts on task failure or skip. +## Sequential groups -### Workflow-level (class setting) +Default strategy. Each task runs in order on the shared context. The first `failed?` result halts the pipeline and propagates via `throw!`, failing the workflow. A `skipped?` result does **not** halt — the next task still runs. ```ruby -class MyWorkflow < CMDx::Task +class ProcessOrder < CMDx::Task include CMDx::Workflow - settings workflow_breakpoints: %w[skipped failed] - task StepOne - task StepTwo + task ValidateOrder + task ChargePayment + task ShipOrder end ``` -### Group-level (overrides workflow-level) +## Parallel groups -```ruby -task StepOne, breakpoints: %w[failed] -task StepTwo, breakpoints: %w[skipped failed] -tasks StepThree, StepFour, breakpoints: [] # never halt -``` - -### Defaults - -- `workflow_breakpoints`: `["failed"]` (halt on failure) -- Group breakpoints inherit from workflow setting unless overridden -- Empty `[]` means never halt — all tasks run regardless of status - -### Behavior - -When a task's result status matches the group's breakpoints, the workflow calls `throw!(task_result)` which raises a `Fault` and stops further execution. - -## Conditional Execution - -### Method reference +Runs the group's tasks concurrently on a Thread pool. ```ruby -task SendEmail, if: :email_configured? -task SkipAudit, unless: :audit_disabled? - -private - -def email_configured? - context.email.present? -end +tasks SendReceipt, NotifyWarehouse, UpdateAnalytics, + strategy: :parallel, pool_size: 3 ``` -### Lambda/Proc +Behavior: -```ruby -task ApplyDiscount, if: -> { context.total > 100 } -task SendSms, unless: -> { context.phone.blank? } -``` +- Each task receives `context.deep_dup` — mutations are isolated per thread. +- On success, each duplicated context is merged back into the workflow context. +- The first failed result halts the workflow; successful siblings still merge. +- Chain storage is propagated through fiber-local state so nested tasks see the same `CMDx::Chain`. -### Combined +Because parallel tasks receive deep-duplicated contexts, a task that relies on mutations performed by a sibling in the same group will not see them. Split such dependencies into separate groups. -```ruby -task SpecialOffer, if: :eligible?, unless: :already_applied? -``` - -### Group-level conditions +## Conditional groups ```ruby -tasks TaskA, TaskB, TaskC, if: :group_enabled? +task SetupBilling, if: :paid_plan? +task SendTrialEmail, unless: -> { context.plan == "enterprise" } +tasks NotifyOpsA, NotifyOpsB, strategy: :parallel, if: SupportGateCallback ``` -## Context Sharing +## Nested workflows -All tasks in a workflow share the same `Context` object: +Workflows are `Task` subclasses, so they compose as groups: ```ruby -class Workflow < CMDx::Task +class Onboard < CMDx::Task include CMDx::Workflow - - task CreateUser # sets context.user - task CreateProfile # reads context.user, sets context.profile - task SendWelcome # reads context.user and context.profile + task Identity + task BillingWorkflow # another workflow + task SendWelcome end - -result = Workflow.execute(email: "user@example.com") -result.context.user # set by CreateUser -result.context.profile # set by CreateProfile ``` -## Error Propagation - -### Default: halt on failure - -```ruby -class Workflow < CMDx::Task - include CMDx::Workflow +Result chain analysis still works: `result.origin` walks back to the failing leaf, `result.caused_failure` and `result.threw_failure` identify the root cause vs. the re-thrower. - task ValidateInput # fails → workflow halts here - task ProcessData # never runs - task SendNotification # never runs -end -``` +## Halt behavior -### Swallow failures (empty breakpoints) +- The workflow halts on **the first `failed?` result only**. +- `skip!` never halts a workflow. +- The failed leaf's signal is re-thrown through the workflow via `throw!`, so the workflow's `result.reason`/`.metadata`/`.cause` mirror the originating task. +- There is no `breakpoints:` option. -```ruby -tasks OptionalStepA, OptionalStepB, breakpoints: [] -task RequiredStep # always runs regardless of above -``` +## Rollback & compensation -### Mixed strategies +Rollback is **per-task** — each task that defines `#rollback` gets called when that task itself fails. To compensate for **earlier successful tasks** in a failed workflow, use a workflow-level callback: ```ruby -class OrderWorkflow < CMDx::Task +class ProvisionTenant < CMDx::Task include CMDx::Workflow - task ValidateOrder, breakpoints: %w[failed] - task ChargePayment, breakpoints: %w[failed] - tasks SendEmail, SendSms, breakpoints: [], strategy: :parallel - task UpdateAnalytics, breakpoints: [] -end -``` - -## Nested Workflows + on_failed :tear_down -Workflows can include other workflows as tasks: + task CreateSchema + task SeedDefaults + task ActivateBilling -```ruby -class InnerWorkflow < CMDx::Task - include CMDx::Workflow - task StepA - task StepB -end + private -class OuterWorkflow < CMDx::Task - include CMDx::Workflow - task Setup - task InnerWorkflow - task Finalize + def tear_down + CleanupTenantJob.perform_later(result.context.tenant_id) if result.context.tenant_id + end end ``` -## Execution +## Inspecting results ```ruby -# Non-raising: returns Result even on failure -result = MyWorkflow.execute(input: data) +result = Onboard.execute(email: "x@y.com") -# Raising: raises FailFault/SkipFault on breakpoint match -result = MyWorkflow.execute!(input: data) -``` +result.success? # workflow-level status +result.origin # leaf Result that actually failed +result.caused_failure # first task in the chain whose rescue caused it +result.threw_failure # last task that re-threw the signal -## Result Metadata for Failures +result.chain.size # total Results in the chain (workflow + leaves) +result.chain.map(&:task) # [Onboard, ValidateIdentity, CreateAccount, ...] +``` -When a workflow is interrupted, the result contains failure provenance: +## Invariants -```ruby -result = MyWorkflow.execute(data: input) -if result.failed? - result.metadata[:threw_failure] # { index:, class:, ... } — task that threw - result.metadata[:caused_failure] # { index:, class:, ... } — root cause task -end -``` +- `def work` on a workflow → `CMDx::ImplementationError` at load time. +- Empty groups (`tasks` with no args inside a group block) → `ArgumentError` at execute time. +- Invalid `strategy:` → `ArgumentError`. +- Parallel groups cannot share mutable state via context mutations within the same group. +- Workflows inherit the parent class's pipeline via `dup` on inheritance. diff --git a/spec/cmdx/attribute_registry_spec.rb b/spec/cmdx/attribute_registry_spec.rb deleted file mode 100644 index 14b15c067..000000000 --- a/spec/cmdx/attribute_registry_spec.rb +++ /dev/null @@ -1,335 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe CMDx::AttributeRegistry, type: :unit do - let(:attribute1) { instance_double(CMDx::Attribute) } - let(:attribute2) { instance_double(CMDx::Attribute) } - let(:initial_registry) { [attribute1] } - let(:task) { instance_double(CMDx::Task) } - - describe "#initialize" do - context "without arguments" do - subject(:registry) { described_class.new } - - it "initializes with empty registry" do - expect(registry.registry).to eq([]) - end - end - - context "with initial registry" do - subject(:registry) { described_class.new(initial_registry) } - - it "initializes with provided registry" do - expect(registry.registry).to eq(initial_registry) - end - end - end - - describe "#registry" do - subject(:registry) { described_class.new(initial_registry) } - - it "returns the internal registry array" do - expect(registry.registry).to eq(initial_registry) - end - end - - describe "#to_a" do - subject(:registry) { described_class.new(initial_registry) } - - it "aliases to registry method" do - expect(registry.to_a).to eq(registry.registry) - expect(registry.to_a).to eq(initial_registry) - end - end - - describe "#dup" do - subject(:registry) { described_class.new(initial_registry) } - - let(:duplicated_registry) { registry.dup } - - it "creates new instance with duplicated registry" do - expect(duplicated_registry).to be_a(described_class) - expect(duplicated_registry).not_to be(registry) - expect(duplicated_registry.registry).to eq(registry.registry) - expect(duplicated_registry.registry).not_to be(registry.registry) - end - - it "maintains independence between original and duplicate" do - new_attribute = instance_double(CMDx::Attribute) - - duplicated_registry.register(new_attribute) - - expect(duplicated_registry.registry).to include(new_attribute) - expect(registry.registry).not_to include(new_attribute) - end - end - - describe "#register" do - subject(:registry) { described_class.new } - - context "with single attribute" do - it "adds attribute to registry and returns self" do - result = registry.register(attribute1) - - expect(registry.registry).to include(attribute1) - expect(result).to be(registry) - end - end - - context "with multiple attributes as array" do - let(:attributes) { [attribute1, attribute2] } - - it "adds all attributes to registry" do - registry.register(attributes) - - expect(registry.registry).to include(attribute1) - expect(registry.registry).to include(attribute2) - expect(registry.registry.size).to eq(2) - end - end - - context "with non-array attribute" do - it "converts to array and adds to registry" do - registry.register(attribute1) - - expect(registry.registry).to eq([attribute1]) - end - end - - context "when adding to existing registry" do - subject(:registry) { described_class.new(initial_registry) } - - it "appends new attributes to existing ones" do - registry.register(attribute2) - - expect(registry.registry).to eq([attribute1, attribute2]) - expect(registry.registry.size).to eq(2) - end - end - - context "with empty array" do - it "does not modify registry" do - original_size = registry.registry.size - - registry.register([]) - - expect(registry.registry.size).to eq(original_size) - end - end - end - - describe "#deregister" do - subject(:registry) { described_class.new([parent_attribute, other_attribute]) } - - let(:child_attribute) { instance_double(CMDx::Attribute, method_name: :child_attr, children: []) } - let(:parent_attribute) { instance_double(CMDx::Attribute, method_name: :parent_attr, children: [child_attribute]) } - let(:other_attribute) { instance_double(CMDx::Attribute, method_name: :other_attr, children: []) } - - context "with single attribute name" do - it "removes attribute by method_name and returns self" do - result = registry.deregister(:parent_attr) - - expect(registry.registry).not_to include(parent_attribute) - expect(registry.registry).to include(other_attribute) - expect(result).to be(registry) - end - - it "converts string names to symbols" do - registry.deregister("parent_attr") - - expect(registry.registry).not_to include(parent_attribute) - expect(registry.registry).to include(other_attribute) - end - end - - context "with multiple attribute names" do - it "handles array of names" do - registry.deregister(%i[parent_attr other_attr]) - - expect(registry.registry).to be_empty - end - end - - context "with child attribute name" do - it "removes parent attribute when child matches" do - registry.deregister(:child_attr) - - expect(registry.registry).not_to include(parent_attribute) - expect(registry.registry).to include(other_attribute) - end - end - - context "with non-existent attribute name" do - it "does not modify registry" do - original_registry = registry.registry.dup - - registry.deregister(:non_existent) - - expect(registry.registry).to eq(original_registry) - end - end - - context "with nested children" do - subject(:registry) { described_class.new([complex_parent, other_attribute]) } - - let(:grandchild_attribute) { instance_double(CMDx::Attribute, method_name: :grandchild_attr, children: []) } - let(:child_with_children) { instance_double(CMDx::Attribute, method_name: :child_with_children, children: [grandchild_attribute]) } - let(:complex_parent) { instance_double(CMDx::Attribute, method_name: :complex_parent, children: [child_with_children]) } - - it "removes parent when deeply nested child matches" do - registry.deregister(:grandchild_attr) - - expect(registry.registry).not_to include(complex_parent) - expect(registry.registry).to include(other_attribute) - end - end - - context "with empty registry" do - subject(:registry) { described_class.new([]) } - - it "does not raise error" do - expect { registry.deregister(:any_name) }.not_to raise_error - end - end - end - - describe "#define_readers_on!" do - let(:task_class) { Class.new(CMDx::Task) } - - context "with static attributes" do - it "defines reader methods on the task class" do - attr = CMDx::Attribute.new(:username) - registry = described_class.new([attr]) - registry.define_readers_on!(task_class) - - expect(task_class.method_defined?(:username)).to be(true) - end - end - - context "with nested static attributes" do - it "defines readers for parent and children" do - attr = CMDx::Attribute.new(:address) do - required :street - required :city - end - registry = described_class.new([attr]) - registry.define_readers_on!(task_class) - - expect(task_class.method_defined?(:address)).to be(true) - expect(task_class.method_defined?(:street)).to be(true) - expect(task_class.method_defined?(:city)).to be(true) - end - end - - context "with dynamic (Proc) source" do - it "skips the reader definition" do - attr = CMDx::Attribute.new(:username, source: -> { :dynamic }) - registry = described_class.new([attr]) - registry.define_readers_on!(task_class) - - expect(task_class.method_defined?(:username)).to be(false) - end - end - - context "when method already exists as private" do - before { task_class.define_method(:taken_name) { "private" } } - - it "raises a conflict error" do - task_class.send(:private, :taken_name) - attr = CMDx::Attribute.new(:taken_name) - registry = described_class.new([attr]) - - expect { registry.define_readers_on!(task_class) }.to raise_error(/already defined/) - end - end - - context "with subset of attributes" do - it "only defines readers for the given attributes" do - attr1 = CMDx::Attribute.new(:first) - attr2 = CMDx::Attribute.new(:second) - registry = described_class.new([attr1]) - registry.define_readers_on!(task_class, [attr2]) - - expect(task_class.method_defined?(:first)).to be(false) - expect(task_class.method_defined?(:second)).to be(true) - end - end - end - - describe "#undefine_readers_on!" do - let(:task_class) { Class.new(CMDx::Task) } - - it "removes eagerly defined reader methods" do - attr = CMDx::Attribute.new(:username) - registry = described_class.new([attr]) - registry.define_readers_on!(task_class) - - expect(task_class.method_defined?(:username)).to be(true) - - registry.undefine_readers_on!(task_class, [:username]) - - expect(task_class.method_defined?(:username, false)).to be(false) - end - - it "removes nested readers" do - attr = CMDx::Attribute.new(:profile) do - required :bio - end - registry = described_class.new([attr]) - registry.define_readers_on!(task_class) - - registry.undefine_readers_on!(task_class, [:profile]) - - expect(task_class.method_defined?(:profile, false)).to be(false) - expect(task_class.method_defined?(:bio, false)).to be(false) - end - - it "does not raise for non-existent methods" do - registry = described_class.new([]) - - expect { registry.undefine_readers_on!(task_class, [:missing]) }.not_to raise_error - end - end - - describe "#define_and_verify" do - subject(:registry) { described_class.new([attribute1, attribute2]) } - - it "dups each attribute, binds the task to the dup, and verifies without mutating originals" do - bound1 = instance_double(CMDx::Attribute) - bound2 = instance_double(CMDx::Attribute) - - allow(attribute1).to receive(:dup).and_return(bound1) - allow(attribute2).to receive(:dup).and_return(bound2) - - expect(bound1).to receive(:task=).with(task).ordered - expect(bound1).to receive(:define_and_verify_tree).ordered - expect(bound2).to receive(:task=).with(task).ordered - expect(bound2).to receive(:define_and_verify_tree).ordered - - expect(attribute1).not_to receive(:task=) - expect(attribute2).not_to receive(:task=) - - registry.define_and_verify(task) - end - - context "with empty registry" do - subject(:registry) { described_class.new([]) } - - it "does not call any methods" do - expect { registry.define_and_verify(task) }.not_to raise_error - end - end - - context "when attribute raises error" do - it "propagates the error without requiring cleanup" do - bound1 = instance_double(CMDx::Attribute) - allow(attribute1).to receive(:dup).and_return(bound1) - allow(bound1).to receive(:task=) - allow(bound1).to receive(:define_and_verify_tree).and_raise(StandardError, "attribute error") - - expect { registry.define_and_verify(task) }.to raise_error(StandardError, "attribute error") - end - end - end -end diff --git a/spec/cmdx/attribute_spec.rb b/spec/cmdx/attribute_spec.rb deleted file mode 100644 index e19fa0455..000000000 --- a/spec/cmdx/attribute_spec.rb +++ /dev/null @@ -1,707 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe CMDx::Attribute, type: :unit do - let(:task_class) { create_task_class } - let(:task) { task_class.new } - let(:attribute_name) { :test_attr } - let(:attribute_options) { {} } - let(:attribute) { described_class.new(attribute_name, **attribute_options) } - - describe "#initialize" do - subject(:new_attribute) { described_class.new(attribute_name, **attribute_options) } - - context "with basic parameters" do - it "sets the name" do - expect(new_attribute.name).to eq(attribute_name) - end - - it "sets empty options" do - expect(new_attribute.options).to eq({}) - end - - it "initializes empty children array" do - expect(new_attribute.children).to eq([]) - end - - it "sets parent to nil" do - expect(new_attribute.parent).to be_nil - end - - it "sets required to false by default" do - expect(new_attribute.required?).to be(false) - end - - it "sets empty types array" do - expect(new_attribute.types).to eq([]) - end - end - - context "with parent option" do - let(:parent_attribute) { described_class.new(:parent_attr) } - let(:attribute_options) { { parent: parent_attribute } } - - it "sets the parent" do - expect(new_attribute.parent).to eq(parent_attribute) - end - - it "removes parent from options" do - expect(new_attribute.options).not_to have_key(:parent) - end - end - - context "with required option" do - let(:attribute_options) { { required: true } } - - it "sets required to true" do - expect(new_attribute.required?).to be(true) - end - - it "removes required from options" do - expect(new_attribute.options).not_to have_key(:required) - end - end - - context "with optional option" do - let(:attribute_options) { { required: false } } - - it "sets optional to true" do - expect(new_attribute.optional?).to be(true) - end - - it "removes optional from options" do - expect(new_attribute.options).not_to have_key(:required) - end - end - - context "with types option" do - let(:attribute_options) { { types: %i[string integer] } } - - it "sets types array" do - expect(new_attribute.types).to eq(%i[string integer]) - end - - it "removes types from options" do - expect(new_attribute.options).not_to have_key(:types) - end - end - - context "with type option (singular)" do - let(:attribute_options) { { type: :string } } - - it "converts type to types array" do - expect(new_attribute.types).to eq([:string]) - end - - it "removes type from options" do - expect(new_attribute.options).not_to have_key(:type) - end - end - - context "with other options" do - let(:attribute_options) { { source: :context, default: "value", as: :custom_name } } - - it "preserves other options" do - expect(new_attribute.options).to eq({ source: :context, default: "value", as: :custom_name }) - end - end - - context "with block" do - it "executes the block in instance context" do - expect do - described_class.new(attribute_name) { nil } - end.not_to raise_error - end - end - end - - describe ".build" do - subject(:defined_attributes) { described_class.build(*names, **attribute_options) } - - let(:names) { %i[attr1 attr2] } - - context "with multiple names" do - it "creates attributes for each name" do - expect(defined_attributes.size).to eq(2) - expect(defined_attributes.map(&:name)).to eq(%i[attr1 attr2]) - end - - it "returns array of attributes" do - expect(defined_attributes).to all(be_a(described_class)) - end - end - - context "with no names" do - let(:names) { [] } - - it "raises ArgumentError" do - expect { defined_attributes }.to raise_error(ArgumentError, "no attributes given") - end - end - - context "with multiple names and :as option" do - let(:attribute_options) { { as: :custom_name } } - - it "raises ArgumentError" do - expect { defined_attributes }.to raise_error(ArgumentError, "the :as option only supports one attribute per definition") - end - end - - context "with single name and :as option" do - let(:names) { [:attr1] } - let(:attribute_options) { { as: :custom_name } } - - it "creates attribute with custom name" do - expect(defined_attributes.size).to eq(1) - expect(defined_attributes.first.options[:as]).to eq(:custom_name) - end - end - - context "with block" do - it "passes block to each attribute" do - attributes = described_class.build(*names) { nil } - - expect(attributes.size).to eq(2) - end - end - end - - describe ".optional" do - subject(:optional_attributes) { described_class.optional(*names, **attribute_options) } - - let(:names) { %i[attr1 attr2] } - - it "creates attributes with required: false" do - expect(optional_attributes).to all(satisfy { |attr| !attr.required? }) - end - - context "with existing required option" do - let(:attribute_options) { { required: true } } - - it "overrides with required: false" do - expect(optional_attributes).to all(satisfy { |attr| !attr.required? }) - end - end - end - - describe ".required" do - subject(:required_attributes) { described_class.required(*names, **attribute_options) } - - let(:names) { %i[attr1 attr2] } - - it "creates attributes with required: true" do - expect(required_attributes).to all(satisfy(&:required?)) - end - - context "with existing required option" do - let(:attribute_options) { { required: false } } - - it "overrides with required: true" do - expect(required_attributes).to all(satisfy(&:required?)) - end - end - end - - describe "#required?" do - subject { attribute.required? } - - context "when required is false" do - let(:attribute_options) { { required: false } } - - it { is_expected.to be(false) } - end - - context "when required is true" do - let(:attribute_options) { { required: true } } - - it { is_expected.to be(true) } - end - - context "when required is nil" do - it { is_expected.to be(false) } - end - end - - describe "#source" do - subject(:source_value) { attribute.source } - - before { attribute.task = task } - - context "when parent has method_name" do - let(:parent_attribute) do - described_class.new(:parent_attr).tap do |attr| - allow(attr).to receive(:method_name).and_return(:parent_method) - end - end - let(:attribute_options) { { parent: parent_attribute } } - - it "returns parent method_name" do - expect(source_value).to eq(:parent_method) - end - end - - context "without parent" do - context "when source option is a symbol" do - let(:attribute_options) { { source: :custom_source } } - - it "returns the symbol" do - expect(source_value).to eq(:custom_source) - end - end - - context "when source option is a proc" do - let(:attribute_options) { { source: proc { :proc_result } } } - - it "evaluates proc in task context" do - expect(source_value).to eq(:proc_result) - end - end - - context "when source option responds to call" do - let(:callable_source) do - object = Object.new - allow(object).to receive(:call).and_return(:callable_result) - object - end - let(:attribute_options) { { source: callable_source } } - - it "calls the object with task" do - expect(callable_source).to receive(:call).with(task) - expect(source_value).to eq(:callable_result) - end - end - - context "when source option is a string" do - let(:attribute_options) { { source: "string_source" } } - - it "returns the string" do - expect(source_value).to eq("string_source") - end - end - - context "without source option" do - it "returns :context as default" do - expect(source_value).to eq(:context) - end - end - end - - context "with memoization" do - let(:attribute_options) { { source: proc { Time.now.to_f } } } - - it "memoizes the result" do - first_result = attribute.source - second_result = attribute.source - - expect(first_result).to eq(second_result) - end - end - end - - describe "#allocation_name" do - subject(:static_name) { attribute.allocation_name } - - context "when :as option is provided" do - let(:attribute_options) { { as: :custom_method } } - - it "returns the custom method name" do - expect(static_name).to eq(:custom_method) - end - end - - context "with static symbol source" do - let(:attribute_options) { { source: :params } } - - it "returns the attribute name" do - expect(static_name).to eq(attribute_name) - end - end - - context "with prefix and suffix" do - let(:attribute_options) { { prefix: true, suffix: true, source: :params } } - - it "applies prefix and suffix using source" do - expect(static_name).to eq(:params_test_attr_params) - end - end - - context "with Proc source" do - let(:attribute_options) { { source: -> { :dynamic } } } - - it "returns nil" do - expect(static_name).to be_nil - end - end - - context "with callable source" do - let(:callable) { Class.new { def call(_task) = :dynamic }.new } - let(:attribute_options) { { source: callable } } - - it "returns nil" do - expect(static_name).to be_nil - end - end - - context "with static parent" do - let(:parent_attribute) { described_class.new(:parent_attr) } - let(:attribute_options) { { parent: parent_attribute } } - - it "uses parent allocation_name as source" do - expect(static_name).to eq(attribute_name) - end - end - - context "with dynamic parent" do - let(:parent_attribute) { described_class.new(:parent_attr, source: -> { :dynamic }) } - let(:attribute_options) { { parent: parent_attribute } } - - it "returns nil" do - expect(static_name).to be_nil - end - end - - context "with memoization" do - it "memoizes the result" do - first = attribute.allocation_name - second = attribute.allocation_name - - expect(first).to equal(second) - end - end - end - - describe "#method_name" do - subject(:method_name_value) { attribute.method_name } - - before { attribute.task = task } - - context "when :as option is provided" do - let(:attribute_options) { { as: :custom_method } } - - it "returns the custom method name" do - expect(method_name_value).to eq(:custom_method) - end - end - - context "without :as option" do - context "with default settings" do - it "returns the attribute name" do - expect(method_name_value).to eq(attribute_name) - end - end - - context "with prefix option set to true" do - let(:attribute_options) { { prefix: true, source: :params } } - - it "adds source as prefix" do - expect(method_name_value).to eq(:params_test_attr) - end - end - - context "with prefix option set to string" do - let(:attribute_options) { { prefix: "custom" } } - - it "uses custom prefix" do - expect(method_name_value).to eq(:customtest_attr) - end - end - - context "with suffix option set to true" do - let(:attribute_options) { { suffix: true, source: :params } } - - it "adds source as suffix" do - expect(method_name_value).to eq(:test_attr_params) - end - end - - context "with suffix option set to string" do - let(:attribute_options) { { suffix: "custom" } } - - it "uses custom suffix" do - expect(method_name_value).to eq(:test_attrcustom) - end - end - - context "with both prefix and suffix" do - let(:attribute_options) { { prefix: "pre", suffix: "suf" } } - - it "combines both" do - expect(method_name_value).to eq(:pretest_attrsuf) - end - end - end - - context "with memoization" do - it "memoizes the result" do - first_result = attribute.method_name - second_result = attribute.method_name - - expect(first_result).to equal(second_result) - end - end - end - - describe "#define_and_verify_tree" do - let(:child1) { described_class.new(:child1) } - let(:child2) { described_class.new(:child2) } - - before do - attribute.task = task - attribute.children.push(child1, child2) - - allow(attribute).to receive(:define_and_verify) - allow(child1).to receive(:define_and_verify_tree) - allow(child2).to receive(:define_and_verify_tree) - end - - it "calls define_and_verify on self" do - expect(attribute).to receive(:define_and_verify) - - attribute.define_and_verify_tree - end - - it "sets task on all children" do - attribute.define_and_verify_tree - - expect(child1.task).to eq(task) - expect(child2.task).to eq(task) - end - - it "calls define_and_verify_tree on all children" do - expect(child1).to receive(:define_and_verify_tree) - expect(child2).to receive(:define_and_verify_tree) - - attribute.define_and_verify_tree - end - end - - describe "#to_h" do - context "with basic attribute" do - let(:attribute_options) { { type: :string, default: "test" } } - - it "returns a hash representation" do - result = attribute.to_h - - expect(result).to be_a(Hash) - expect(result).to include( - name: :test_attr, - method_name: :test_attr, - required: false, - types: [:string], - children: [] - ) - end - - it "includes options without :if and :unless" do - result = attribute.to_h - - expect(result[:options]).to eq(default: "test") - end - end - - context "with required attribute" do - let(:attribute_options) { { required: true, type: :integer } } - - it "sets required to true" do - expect(attribute.to_h[:required]).to be(true) - end - end - - context "with conditional options" do - let(:attribute_options) { { type: :string, if: :some_condition?, unless: :other_condition?, default: "value" } } - - it "excludes :if and :unless from options" do - result = attribute.to_h - - expect(result[:options]).not_to have_key(:if) - expect(result[:options]).not_to have_key(:unless) - expect(result[:options]).to eq(default: "value") - end - end - - context "with nested children" do - let(:attribute) do - described_class.new(:parent_attr, type: :hash) do - optional :child1, type: :string - required :child2, type: :integer - end - end - - it "includes children as array of hashes" do - result = attribute.to_h - - expect(result[:children]).to be_an(Array) - expect(result[:children].size).to eq(2) - end - - it "recursively converts children to hashes" do - result = attribute.to_h - child_names = result[:children].map { |c| c[:name] } - - expect(child_names).to contain_exactly(:child1, :child2) - end - - it "preserves child metadata" do - result = attribute.to_h - child1 = result[:children].find { |c| c[:name] == :child1 } - child2 = result[:children].find { |c| c[:name] == :child2 } - - expect(child1[:required]).to be(false) - expect(child1[:types]).to eq([:string]) - expect(child2[:required]).to be(true) - expect(child2[:types]).to eq([:integer]) - end - end - - context "with custom method name" do - let(:attribute_options) { { as: :custom_method, type: :string } } - - it "includes the custom method name" do - expect(attribute.to_h[:method_name]).to eq(:custom_method) - end - end - end - - describe "private methods" do - describe "#attribute" do - let(:child_options) { { required: true } } - - before do - attribute.send(:attribute, :child_attr, **child_options) - end - - it "creates child attribute" do - expect(attribute.children.size).to eq(1) - expect(attribute.children.first.name).to eq(:child_attr) - end - - it "sets parent on child" do - expect(attribute.children.first.parent).to eq(attribute) - end - - it "merges parent option with provided options" do - expect(attribute.children.first.required?).to be(true) - end - end - - describe "#attributes" do - let(:names) { %i[child1 child2] } - let(:child_options) { { required: true } } - - before do - attribute.send(:attributes, *names, **child_options) - end - - it "creates multiple child attributes" do - expect(attribute.children.size).to eq(2) - expect(attribute.children.map(&:name)).to eq(%i[child1 child2]) - end - - it "sets parent on all children" do - expect(attribute.children).to all(have_attributes(parent: attribute)) - end - end - - describe "#optional" do - before do - attribute.send(:optional, :child1, :child2, type: :string) - end - - it "creates optional child attributes" do - expect(attribute.children.size).to eq(2) - expect(attribute.children).to all(satisfy { |attr| !attr.required? }) - end - end - - describe "#required" do - before do - attribute.send(:required, :child1, :child2, type: :string) - end - - it "creates required child attributes" do - expect(attribute.children.size).to eq(2) - expect(attribute.children).to all(satisfy(&:required?)) - end - end - - describe "#define_and_verify" do - let(:attribute_value) { instance_double(CMDx::AttributeValue, generate: nil, validate: nil) } - - before do - attribute.task = task - - allow(CMDx::AttributeValue).to receive(:new).and_return(attribute_value) - allow(attribute).to receive(:method_name).and_return(:test_method) - end - - context "when method is not already defined" do - before do - allow(task).to receive(:respond_to?).with(:test_method, true).and_return(false) - end - - it "creates AttributeValue instance" do - expect(CMDx::AttributeValue).to receive(:new).with(attribute) - - attribute.send(:define_and_verify) - end - - it "calls generate and validate on AttributeValue" do - expect(attribute_value).to receive(:generate) - expect(attribute_value).to receive(:validate) - - attribute.send(:define_and_verify) - end - - it "defines method on task class" do - expect(task.class).to receive(:define_method).with(:test_method) - - attribute.send(:define_and_verify) - end - end - - context "when method is already defined" do - before do - allow(task).to receive(:respond_to?).with(:test_method, true).and_return(true) - allow(task.class).to receive(:name).and_return("TestTask") - end - - it "raises error" do - expect do - attribute.send(:define_and_verify) - end.to raise_error(<<~MESSAGE) - The method :test_method is already defined on the TestTask task. - This may be due conflicts with one of the task's user defined or internal methods/attributes. - - Use :as, :prefix, and/or :suffix attribute options to avoid conflicts with existing methods. - MESSAGE - end - end - end - end - - describe "AFFIX constant" do - subject(:affix_proc) { described_class.const_get(:AFFIX) } - - it "is a frozen proc" do - expect(affix_proc).to be_a(Proc) - expect(affix_proc).to be_frozen - end - - context "when value is true" do - it "calls the block" do - result = affix_proc.call(true) { "block_result" } - - expect(result).to eq("block_result") - end - end - - context "when value is not true" do - it "returns the value" do - result = affix_proc.call("custom_value") { "block_result" } - - expect(result).to eq("custom_value") - end - end - end -end diff --git a/spec/cmdx/attribute_value_spec.rb b/spec/cmdx/attribute_value_spec.rb deleted file mode 100644 index 4dd5a1852..000000000 --- a/spec/cmdx/attribute_value_spec.rb +++ /dev/null @@ -1,763 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe CMDx::AttributeValue, type: :unit do - let(:task_class) { create_task_class } - let(:errors) { instance_double(CMDx::Errors, add: nil, for?: false) } - let(:task) { task_class.new } - let(:attribute) { CMDx::Attribute.new(:test_attr, **attribute_options) } - let(:attribute_options) { {} } - let(:attribute_value) { described_class.new(attribute) } - - before do - attribute.task = task - allow(task).to receive_messages(attributes: {}, errors: errors) - end - - describe "#initialize" do - it "sets the attribute" do - expect(attribute_value.attribute).to eq(attribute) - end - end - - describe "#value" do - let(:method_name) { :test_method } - - before do - allow(attribute_value).to receive(:method_name).and_return(method_name) - allow(task).to receive(:attributes).and_return({ method_name => "test_value" }) - end - - it "returns value from attributes hash" do - expect(attribute_value.value).to eq("test_value") - end - end - - describe "#generate" do - let(:method_name) { :test_method } - let(:attributes) { {} } - - before do - allow(attribute_value).to receive(:method_name).and_return(method_name) - allow(task).to receive(:attributes).and_return(attributes) - end - - context "when value already exists in attributes" do - let(:attributes) { { method_name => "existing_value" } } - - it "returns existing value" do - expect(attribute_value.generate).to eq("existing_value") - end - - it "does not call source_value" do - expect(attribute_value).not_to receive(:source_value) - attribute_value.generate - end - end - - context "when value does not exist" do - before do - allow(attribute_value).to receive_messages( - source_value: "sourced", - derive_value: "derived", - coerce_value: "coerced" - ) - end - - it "processes value through pipeline and stores result" do - allow(attribute_value).to receive(:source_value).and_return("sourced") - allow(attribute_value).to receive(:derive_value).with("sourced").and_return("derived") - allow(attribute_value).to receive(:coerce_value).with("derived").and_return("coerced") - - expect { attribute_value.generate }.to change { attributes[method_name] }.to("coerced") - end - - context "when errors occur after source_value" do - before { allow(errors).to receive(:for?).with(method_name).and_return(true) } - - it "returns nil without processing further" do - allow(attribute_value).to receive(:source_value).and_return("sourced") - - expect(attribute_value).not_to receive(:derive_value) - - expect(attribute_value.generate).to be_nil - end - end - - context "when errors occur after derive_value" do - before do - allow(errors).to receive(:for?).with(method_name).and_return(false, true) - allow(attribute_value).to receive(:derive_value).and_return("derived") - end - - it "returns nil without coercing" do - allow(attribute_value).to receive(:source_value).and_return("sourced") - allow(attribute_value).to receive(:derive_value).with("sourced").and_return("derived") - - expect(attribute_value).not_to receive(:coerce_value) - - expect(attribute_value.generate).to be_nil - end - end - - context "when errors occur after coerce_value" do - before do - allow(errors).to receive(:for?).with(method_name).and_return(false, false, true) - allow(attribute_value).to receive(:coerce_value).and_return("coerced") - end - - it "returns nil without storing value" do - allow(attribute_value).to receive(:source_value).and_return("sourced") - allow(attribute_value).to receive(:derive_value).with("sourced").and_return("derived") - allow(attribute_value).to receive(:coerce_value).with("derived").and_return("coerced") - - result = attribute_value.generate - - expect(result).to be_nil - expect(attributes).not_to have_key(method_name) - end - end - end - end - - describe "#validate" do - let(:method_name) { :test_method } - let(:validator_registry) { instance_double(CMDx::ValidatorRegistry) } - let(:task_settings) { mock_settings(validators: validator_registry) } - let(:attribute_options) { { format: /\d+/, presence: true } } - - before do - allow(attribute_value).to receive_messages(method_name: method_name, value: "test_value") - allow(task.class).to receive(:settings).and_return(task_settings) - allow(validator_registry).to receive(:keys).and_return(%i[format presence]) - allow(validator_registry).to receive(:validate) - end - - it "validates each matching validator option" do - expect(validator_registry).to receive(:validate).with(:format, task, "test_value", /\d+/) - expect(validator_registry).to receive(:validate).with(:presence, task, "test_value", true) - - expect { attribute_value.validate }.not_to raise_error - end - - context "when validation fails" do - let(:validation_error) { CMDx::ValidationError.new("invalid format") } - - before do - allow(validator_registry).to receive(:validate).and_raise(validation_error) - end - - it "adds error message" do - expect(errors).to receive(:add).with(method_name, "invalid format").twice - - attribute_value.validate - end - end - - context "when options don't match registry keys" do - let(:attribute_options) { { unknown_option: true } } - - it "does not validate unknown options" do - expect(validator_registry).not_to receive(:validate) - - expect { attribute_value.validate }.not_to raise_error - end - end - end - - describe "private methods" do - describe "#source_value" do - let(:source) { :context } - let(:method_name) { :test_method } - - before do - allow(attribute_value).to receive_messages( - source: source, - method_name: method_name, - required?: false - ) - end - - context "when source is a symbol" do - let(:source) { :config } - - before { allow(task).to receive(:config).and_return("config_value") } - - it "calls method on task" do - allow(task).to receive(:config).and_return("config_value") - - expect(attribute_value.send(:source_value)).to eq("config_value") - end - end - - context "when source is a proc" do - let(:source) { proc { "proc_result" } } - - before { allow(task).to receive(:instance_eval).and_return("proc_result") } - - it "evaluates proc in task context" do - allow(task).to receive(:instance_eval).and_return("proc_result") - - expect(attribute_value.send(:source_value)).to eq("proc_result") - end - end - - context "when source is callable" do - let(:callable) { instance_double("MockCallable", call: "callable_result") } - let(:source) { callable } - - before { allow(source).to receive(:respond_to?).with(:call).and_return(true) } - - it "calls object with task" do - allow(source).to receive(:call).with(task).and_return("callable_result") - - expect(attribute_value.send(:source_value)).to eq("callable_result") - end - end - - context "when source is not callable" do - let(:source) { "string_value" } - - it "returns source directly" do - expect(attribute_value.send(:source_value)).to eq("string_value") - end - end - - context "when source method raises NoMethodError" do - let(:source) { :nonexistent_method } - - before do - allow(task).to receive(:nonexistent_method).and_raise(NoMethodError) - allow(CMDx::Locale).to receive(:t).with("cmdx.attributes.undefined", method: source).and_return("undefined method error") - end - - it "adds error and returns nil" do - expect(errors).to receive(:add).with(method_name, "undefined method error") - - expect(attribute_value.send(:source_value)).to be_nil - end - end - - context "when attribute is required" do - let(:name) { :test_name } - - before do - allow(attribute_value).to receive_messages( - required?: true, - parent: nil, - name: name, - options: {} - ) - allow(CMDx::Utils::Condition).to receive(:evaluate).with(task, {}).and_return(true) - end - - context "with Context source" do - let(:context) { CMDx::Context.new(test_name: "value") } - let(:source) { context } - - before { allow(source).to receive(:respond_to?).with(:call).and_return(false) } - - it "checks if context has key" do - expect(attribute_value.send(:source_value)).to eq(context) - end - - context "when context missing key" do - let(:context) { CMDx::Context.new({}) } - - before do - allow(CMDx::Locale).to receive(:t).with("cmdx.attributes.required", hash_including(:method)).and_return("required error") - allow(CMDx::Utils::Condition).to receive(:evaluate).with(task, {}).and_return(true) - end - - it "adds required error" do - expect(errors).to receive(:add).with(method_name, "required error") - - expect(attribute_value.send(:source_value)).to eq(context) - end - end - end - - context "with Hash source" do - let(:source) { { test_name: "value" } } - - before { allow(source).to receive(:respond_to?).with(:call).and_return(false) } - - it "checks if hash has key" do - expect(attribute_value.send(:source_value)).to eq(source) - end - end - - context "with string-keyed Hash source" do - let(:source) { { "test_name" => "value" } } - - before { allow(source).to receive(:respond_to?).with(:call).and_return(false) } - - it "checks both string and symbol key presence" do - expect(attribute_value.send(:source_value)).to eq(source) - end - - context "when hash missing key" do - let(:source) { { "other_key" => "value" } } - - before do - allow(CMDx::Locale).to receive(:t).with("cmdx.attributes.required", hash_including(:method)).and_return("required error") - end - - it "adds required error" do - expect(errors).to receive(:add).with(method_name, "required error") - - expect(attribute_value.send(:source_value)).to eq(source) - end - end - end - - context "with Proc source" do - let(:source) { proc { "value" } } - - before { allow(task).to receive(:instance_eval).and_return("value") } - - it "assumes proc can provide value" do - expect(attribute_value.send(:source_value)).to eq("value") - end - end - - context "with object that responds to method" do - let(:source_object) { instance_double("MockSource", test_name: "value") } - let(:source) { source_object } - - before do - allow(source).to receive(:respond_to?).with(:call).and_return(false) - allow(source).to receive(:respond_to?).with(name, true).and_return(true) - end - - it "checks if object responds to method" do - expect(attribute_value.send(:source_value)).to eq(source_object) - end - end - - context "when parent is required" do - let(:parent) { instance_double(CMDx::Attribute, required?: true) } - - before { allow(attribute_value).to receive(:parent).and_return(parent) } - - context "when source doesn't provide value" do - let(:source_object) { instance_double("MockSource") } - let(:source) { source_object } - - before do - allow(source).to receive(:respond_to?).with(:call).and_return(false) - allow(source).to receive(:respond_to?).with(name, true).and_return(false) - allow(CMDx::Locale).to receive(:t).with("cmdx.attributes.required", hash_including(:method)).and_return("required error") - allow(attribute_value).to receive(:options).and_return({}) - allow(CMDx::Utils::Condition).to receive(:evaluate).with(task, {}).and_return(true) - end - - it "adds required error" do - expect(errors).to receive(:add).with(method_name, "required error") - - expect(attribute_value.send(:source_value)).to eq(source_object) - end - end - end - - context "with conditional requirement" do - let(:source_object) { instance_double("MockSource") } - let(:source) { source_object } - - before do - allow(source).to receive(:respond_to?).with(:call).and_return(false) - allow(source).to receive(:respond_to?).with(name, true).and_return(false) - allow(attribute_value).to receive(:options).and_return(attribute_options) - end - - context "when condition evaluates to true" do - let(:attribute_options) { { if: :enabled? } } - - before do - allow(CMDx::Utils::Condition).to receive(:evaluate).with(task, attribute_options).and_return(true) - allow(CMDx::Locale).to receive(:t).with("cmdx.attributes.required", hash_including(:method)).and_return("required error") - end - - it "validates required attribute" do - expect(errors).to receive(:add).with(method_name, "required error") - - expect(attribute_value.send(:source_value)).to eq(source_object) - end - end - - context "when condition evaluates to false" do - let(:attribute_options) { { if: :enabled? } } - - before { allow(CMDx::Utils::Condition).to receive(:evaluate).with(task, attribute_options).and_return(false) } - - it "skips required validation" do - expect(errors).not_to receive(:add) - - expect(attribute_value.send(:source_value)).to eq(source_object) - end - end - - context "when no condition is specified" do - let(:attribute_options) { {} } - - before do - allow(CMDx::Utils::Condition).to receive(:evaluate).with(task, attribute_options).and_return(true) - allow(CMDx::Locale).to receive(:t).with("cmdx.attributes.required", hash_including(:method)).and_return("required error") - end - - it "defaults to true and validates required attribute" do - expect(errors).to receive(:add).with(method_name, "required error") - - expect(attribute_value.send(:source_value)).to eq(source_object) - end - end - - context "when condition uses unless" do - let(:attribute_options) { { unless: :disabled? } } - - before { allow(CMDx::Utils::Condition).to receive(:evaluate).with(task, attribute_options).and_return(false) } - - it "skips required validation when unless condition is true" do - expect(errors).not_to receive(:add) - - expect(attribute_value.send(:source_value)).to eq(source_object) - end - end - end - end - end - - describe "#default_value" do - let(:attribute_options) { { default: default_option } } - let(:default_option) { "default_string" } - - context "when default is a string" do - it "returns the string" do - expect(attribute_value.send(:default_value)).to eq("default_string") - end - end - - context "when default is a symbol and task responds to it" do - let(:default_option) { :default_method } - - before do - allow(task).to receive(:respond_to?).with(:default_method, true).and_return(true) - allow(task).to receive(:respond_to?).with(:default_method).and_return(true) - allow(task).to receive(:default_method).and_return("method_result") - end - - it "calls method on task" do - allow(task).to receive(:default_method).and_return("method_result") - - expect(attribute_value.send(:default_value)).to eq("method_result") - end - end - - context "when default is a symbol but task doesn't respond" do - let(:default_option) { :nonexistent_method } - - before { allow(task).to receive(:respond_to?).with(:nonexistent_method, true).and_return(false) } - - it "returns the symbol" do - expect(attribute_value.send(:default_value)).to eq(:nonexistent_method) - end - end - - context "when default is a proc" do - let(:default_option) { proc { "proc_default" } } - - before { allow(task).to receive(:instance_eval).and_return("proc_default") } - - it "evaluates proc in task context" do - allow(task).to receive(:instance_eval).and_return("proc_default") - - expect(attribute_value.send(:default_value)).to eq("proc_default") - end - end - - context "when default is callable" do - let(:callable) { instance_double("MockCallable", call: "callable_default") } - let(:default_option) { callable } - - before { allow(default_option).to receive(:respond_to?).with(:call).and_return(true) } - - it "calls object with task" do - allow(default_option).to receive(:call).with(task).and_return("callable_default") - - expect(attribute_value.send(:default_value)).to eq("callable_default") - end - end - - context "when no default option" do - let(:attribute_options) { {} } - - it "returns nil" do - expect(attribute_value.send(:default_value)).to be_nil - end - end - end - - describe "#derive_value" do - let(:name) { :test_name } - let(:method_name) { :test_method } - - before do - allow(attribute_value).to receive_messages( - name: name, - method_name: method_name, - default_value: "default" - ) - end - - context "when source_value is Context" do - let(:context) { CMDx::Context.new(test_name: "context_value") } - - it "extracts value using name key" do - expect(attribute_value.send(:derive_value, context)).to eq("context_value") - end - - context "when context doesn't have key" do - let(:context) { CMDx::Context.new({}) } - - it "returns default value" do - expect(attribute_value.send(:derive_value, context)).to eq("default") - end - end - end - - context "when source_value is Hash" do - let(:hash) { { test_name: "hash_value" } } - - it "extracts value using name key" do - expect(attribute_value.send(:derive_value, hash)).to eq("hash_value") - end - - context "with string keys" do - let(:hash) { { "test_name" => "hash_value" } } - - it "extracts value using string or symbol key" do - expect(attribute_value.send(:derive_value, hash)).to eq("hash_value") - end - end - end - - context "when source_value responds to attribute name" do - let(:source_object) { instance_double("MockSource", test_name: "object_value") } - - before do - allow(source_object).to receive(:respond_to?).with(:call).and_return(false) - allow(source_object).to receive(:respond_to?).with(name, true).and_return(true) - end - - it "derives value via send" do - expect(attribute_value.send(:derive_value, source_object)).to eq("object_value") - end - - context "when send raises NoMethodError" do - before do - allow(source_object).to receive(:test_name).and_raise(NoMethodError) - allow(CMDx::Locale).to receive(:t).with("cmdx.attributes.undefined", method: name).and_return("undefined error") - end - - it "adds error and returns nil" do - expect(errors).to receive(:add).with(method_name, "undefined error") - - expect(attribute_value.send(:derive_value, source_object)).to be_nil - end - end - end - - context "when source_value does not respond to call or attribute name" do - let(:source_object) { instance_double("MockSource") } - - before do - allow(source_object).to receive(:respond_to?).with(:call).and_return(false) - allow(source_object).to receive(:respond_to?).with(name, true).and_return(false) - end - - it "returns default value" do - expect(attribute_value.send(:derive_value, source_object)).to eq("default") - end - end - - context "when source_value is Proc" do - let(:proc_obj) { proc { |n| "proc_#{n}" } } - - before { allow(task).to receive(:instance_exec).with(name, &proc_obj).and_return("proc_test_name") } - - it "executes proc with name in task context" do - allow(task).to receive(:instance_exec).with(name, &proc_obj).and_return("proc_test_name") - - expect(attribute_value.send(:derive_value, proc_obj)).to eq("proc_test_name") - end - end - - context "when source_value is callable" do - let(:callable) { instance_double("MockCallable", call: "callable_value") } - - before { allow(callable).to receive(:respond_to?).with(:call).and_return(true) } - - it "calls object with task and name" do - allow(callable).to receive(:call).with(task, name).and_return("callable_value") - - expect(attribute_value.send(:derive_value, callable)).to eq("callable_value") - end - end - - context "when source_value is not callable" do - it "returns default value" do - expect(attribute_value.send(:derive_value, "not_callable")).to eq("default") - end - end - - context "when derived_value is nil" do - let(:hash) { { other_key: "value" } } - - it "returns default value" do - expect(attribute_value.send(:derive_value, hash)).to eq("default") - end - end - end - - describe "#transform_value" do - let(:attribute_options) { { transform: transform_option } } - let(:transform_option) { :upcase } - let(:derived_value) { "hello" } - - context "when transform is a symbol and value responds to it" do - let(:transform_option) { :upcase } - - it "calls method on derived value" do - expect(attribute_value.send(:transform_value, derived_value)).to eq("HELLO") - end - end - - context "when transform is a symbol but value doesn't respond to it" do - let(:transform_option) { :nonexistent_method } - - it "returns derived value unchanged" do - expect(attribute_value.send(:transform_value, derived_value)).to eq("hello") - end - end - - context "when transform is a proc" do - let(:transform_option) { proc { |v| "transformed_#{v}" } } - - before { allow(task).to receive(:instance_eval).with(derived_value, &transform_option).and_return("transformed_hello") } - - it "evaluates proc with derived value in task context" do - expect(attribute_value.send(:transform_value, derived_value)).to eq("transformed_hello") - end - end - - context "when transform is callable" do - let(:callable) { instance_double("MockCallable", call: "callable_transformed") } - let(:transform_option) { callable } - - before { allow(transform_option).to receive(:respond_to?).with(:call).and_return(true) } - - it "calls object with derived value" do - allow(transform_option).to receive(:call).with(derived_value).and_return("callable_transformed") - - expect(attribute_value.send(:transform_value, derived_value)).to eq("callable_transformed") - end - end - - context "when transform is not callable" do - let(:transform_option) { "string_transform" } - - it "returns derived value unchanged" do - expect(attribute_value.send(:transform_value, derived_value)).to eq("hello") - end - end - - context "when no transform option" do - let(:attribute_options) { {} } - - it "returns derived value unchanged" do - expect(attribute_value.send(:transform_value, derived_value)).to eq("hello") - end - end - - context "when transform is nil" do - let(:transform_option) { nil } - - it "returns derived value unchanged" do - expect(attribute_value.send(:transform_value, derived_value)).to eq("hello") - end - end - end - - describe "#coerce_value" do - let(:method_name) { :test_method } - let(:coercion_registry) { instance_double(CMDx::CoercionRegistry) } - let(:task_settings) { mock_settings(coercions: coercion_registry) } - let(:types) { %i[string integer] } - - before do - allow(attribute_value).to receive(:method_name).and_return(method_name) - allow(attribute).to receive(:types).and_return(types) - allow(task.class).to receive(:settings).and_return(task_settings) - end - - context "when attribute has no types" do - let(:types) { [] } - - it "returns value unchanged" do - expect(attribute_value.send(:coerce_value, "unchanged")).to eq("unchanged") - end - end - - context "when coercion succeeds on first type" do - before do - allow(coercion_registry).to receive(:coerce).with(:string, task, "123", {}).and_return("coerced_string") - end - - it "returns coerced value" do - expect(attribute_value.send(:coerce_value, "123")).to eq("coerced_string") - end - end - - context "when first coercion fails but second succeeds" do - before do - allow(coercion_registry).to receive(:coerce).with(:string, task, "123", {}).and_raise(CMDx::CoercionError) - allow(coercion_registry).to receive(:coerce).with(:integer, task, "123", {}).and_return(123) - end - - it "returns coerced value from successful type" do - expect(attribute_value.send(:coerce_value, "123")).to eq(123) - end - end - - context "when all coercions fail" do - before do - allow(coercion_registry).to receive(:coerce).and_raise(CMDx::CoercionError) - allow(CMDx::Locale).to receive(:t).with("cmdx.types.string").and_return("String") - allow(CMDx::Locale).to receive(:t).with("cmdx.types.integer").and_return("Integer") - allow(CMDx::Locale).to receive(:t).with("cmdx.coercions.into_any", types: "String, Integer").and_return("coercion error") - end - - it "adds error and returns nil" do - expect(errors).to receive(:add).with(method_name, "coercion error") - - expect(attribute_value.send(:coerce_value, "invalid")).to be_nil - end - end - - context "when coercion fails on intermediate type" do - let(:types) { %i[string integer float] } - - before do - allow(coercion_registry).to receive(:coerce).with(:string, task, "123", {}).and_raise(CMDx::CoercionError) - allow(coercion_registry).to receive(:coerce).with(:integer, task, "123", {}).and_raise(CMDx::CoercionError) - allow(coercion_registry).to receive(:coerce).with(:float, task, "123", {}).and_return(123.0) - end - - it "continues to next type and succeeds" do - expect(attribute_value.send(:coerce_value, "123")).to eq(123.0) - end - end - end - end -end diff --git a/spec/cmdx/callback_registry_spec.rb b/spec/cmdx/callback_registry_spec.rb deleted file mode 100644 index 14e0843e5..000000000 --- a/spec/cmdx/callback_registry_spec.rb +++ /dev/null @@ -1,396 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe CMDx::CallbackRegistry, type: :unit do - subject(:registry) { described_class.new(initial_registry) } - - let(:initial_registry) { {} } - let(:mock_task) { instance_double(CMDx::Task) } - let(:callable_proc) { proc { |task| } } - let(:callable_symbol) { :some_method } - let(:callable_object) { instance_double("MockCallable", call: nil) } - - describe "#initialize" do - context "when no registry is provided" do - subject(:registry) { described_class.new } - - it "initializes with an empty hash" do - expect(registry.registry).to eq({}) - end - end - - context "when a registry is provided" do - let(:initial_registry) { { before_execution: Set.new } } - - it "initializes with the provided registry" do - expect(registry.registry).to eq(initial_registry) - end - end - end - - describe "#registry" do - it "returns the internal registry" do - expect(registry.registry).to eq(initial_registry) - end - end - - describe "#to_h" do - it "aliases the registry method" do - expect(registry.to_h).to eq(registry.registry) - end - end - - describe "#dup" do - context "when registry has values" do - let(:initial_registry) do - { - before_execution: Set.new([[callable_proc], {}]), - on_success: Set.new([[callable_symbol], { if: :success? }]) - } - end - - it "returns a new instance" do - duplicated = registry.dup - - expect(duplicated).not_to be(registry) - expect(duplicated).to be_a(described_class) - end - - it "shares the parent registry until first write" do - duplicated = registry.dup - - expect(duplicated.registry).to eq(registry.registry) - expect(duplicated.registry).to be(registry.registry) - end - - it "materializes on write and does not affect the parent" do - duplicated = registry.dup - original_size = registry.registry[:before_execution].size - - duplicated.register(:before_execution, :new_callback) - - expect(duplicated.registry).not_to be(registry.registry) - expect(duplicated.registry[:before_execution]).not_to be(registry.registry[:before_execution]) - expect(registry.registry[:before_execution].size).to eq(original_size) - end - end - - context "when registry is empty" do - let(:initial_registry) { {} } - - it "returns a new instance with empty registry" do - duplicated = registry.dup - - expect(duplicated.registry).to eq({}) - expect(duplicated.registry).to be(registry.registry) - end - end - end - - describe "#register" do - it "returns self for method chaining" do - result = registry.register(:before_execution, callable_proc) - - expect(result).to be(registry) - end - - context "when registering a single callable" do - it "adds the callable to the registry" do - registry.register(:before_execution, callable_proc) - - expect(registry.registry[:before_execution]).to be_a(Set) - expect(registry.registry[:before_execution]).to include([[callable_proc], {}]) - end - end - - context "when registering multiple callables" do - it "adds all callables as a single entry" do - registry.register(:before_execution, callable_proc, callable_symbol) - - expect(registry.registry[:before_execution]).to include([[callable_proc, callable_symbol], {}]) - end - end - - context "when registering with options" do - let(:options) { { if: :active?, unless: :disabled? } } - - it "stores the options with the callables" do - registry.register(:before_execution, callable_proc, **options) - - expect(registry.registry[:before_execution]).to include([[callable_proc], options]) - end - end - - context "when registering with a block" do - it "adds the block to the callables list" do - block = proc { |task| task.blocked = true } - registry.register(:before_execution, callable_proc, &block) - - expect(registry.registry[:before_execution]).to include([[callable_proc, block], {}]) - end - end - - context "when registering to the same type multiple times" do - it "accumulates callbacks in a Set" do - registry.register(:before_execution, callable_proc) - registry.register(:before_execution, callable_symbol) - - expect(registry.registry[:before_execution].size).to eq(2) - expect(registry.registry[:before_execution]).to include([[callable_proc], {}]) - expect(registry.registry[:before_execution]).to include([[callable_symbol], {}]) - end - end - - context "when registering the same callback twice" do - it "stores only one copy due to Set behavior" do - registry.register(:before_execution, callable_proc) - registry.register(:before_execution, callable_proc) - - expect(registry.registry[:before_execution].size).to eq(1) - end - end - end - - describe "#deregister" do - it "returns self for method chaining" do - registry.register(:before_execution, callable_proc) - result = registry.deregister(:before_execution, callable_proc) - - expect(result).to be(registry) - end - - context "when deregistering a single callable" do - before do - registry.register(:before_execution, callable_proc) - end - - it "removes the callable from the registry" do - registry.deregister(:before_execution, callable_proc) - - expect(registry.registry).not_to have_key(:before_execution) - end - end - - context "when deregistering multiple callables" do - before do - registry.register(:before_execution, callable_proc, callable_symbol) - end - - it "removes the exact callables entry" do - registry.deregister(:before_execution, callable_proc, callable_symbol) - - expect(registry.registry).not_to have_key(:before_execution) - end - end - - context "when deregistering with options" do - let(:options) { { if: :active?, unless: :disabled? } } - - before do - registry.register(:before_execution, callable_proc, **options) - end - - it "removes only the entry with matching options" do - registry.deregister(:before_execution, callable_proc, **options) - - expect(registry.registry).not_to have_key(:before_execution) - end - - it "does not remove entries with different options" do - registry.register(:before_execution, callable_proc, if: :other?) - registry.deregister(:before_execution, callable_proc, **options) - - expect(registry.registry[:before_execution]).to include([[callable_proc], { if: :other? }]) - end - end - - context "when deregistering with a block" do - it "removes the entry with the block" do - block = proc { |task| task.blocked = true } - registry.register(:before_execution, callable_proc, &block) - registry.deregister(:before_execution, callable_proc, &block) - - expect(registry.registry).not_to have_key(:before_execution) - end - end - - context "when deregistering from empty registry" do - it "does not raise an error" do - expect { registry.deregister(:before_execution, callable_proc) }.not_to raise_error - end - - it "returns self" do - result = registry.deregister(:before_execution, callable_proc) - - expect(result).to be(registry) - end - end - - context "when deregistering non-existent callback" do - before do - registry.register(:before_execution, callable_symbol) - end - - it "does not affect existing callbacks" do - registry.deregister(:before_execution, callable_proc) - - expect(registry.registry[:before_execution]).to include([[callable_symbol], {}]) - end - end - - context "when deregistering from non-existent type" do - it "does not raise an error" do - expect { registry.deregister(:non_existent, callable_proc) }.not_to raise_error - end - - it "returns self" do - result = registry.deregister(:non_existent, callable_proc) - - expect(result).to be(registry) - end - end - - context "when deregistering the last callback of a type" do - before do - registry.register(:before_execution, callable_proc) - end - - it "removes the type from the registry" do - registry.deregister(:before_execution, callable_proc) - - expect(registry.registry).not_to have_key(:before_execution) - end - end - - context "when deregistering one of multiple callbacks" do - before do - registry.register(:before_execution, callable_proc) - registry.register(:before_execution, callable_symbol) - end - - it "removes only the specified callback" do - registry.deregister(:before_execution, callable_proc) - - expect(registry.registry[:before_execution]).not_to include([[callable_proc], {}]) - expect(registry.registry[:before_execution]).to include([[callable_symbol], {}]) - end - end - end - - describe "#invoke" do - context "when type is valid" do - before do - allow(CMDx::Utils::Condition).to receive(:evaluate).and_return(true) - end - - context "with symbol callbacks" do - before do - registry.register(:before_execution, callable_symbol) - end - - it "evaluates conditions for each callback entry" do - allow(mock_task).to receive(:send).with(callable_symbol) - expect(CMDx::Utils::Condition).to receive(:evaluate).with(mock_task, {}) - - registry.invoke(:before_execution, mock_task) - end - - it "invokes the symbol via task.send" do - expect(mock_task).to receive(:send).with(callable_symbol) - - registry.invoke(:before_execution, mock_task) - end - end - - context "with proc callbacks" do - before do - registry.register(:before_execution, callable_proc) - end - - it "invokes the proc via task.instance_exec" do - expect(mock_task).to receive(:instance_exec) do |&block| - expect(block).to eq(callable_proc) - end - - registry.invoke(:before_execution, mock_task) - end - end - - context "with callable object callbacks" do - before do - registry.register(:before_execution, callable_object) - end - - it "invokes the callable via .call" do - expect(callable_object).to receive(:call).with(mock_task) - - registry.invoke(:before_execution, mock_task) - end - end - - context "when conditions are not met" do - before do - allow(CMDx::Utils::Condition).to receive(:evaluate).and_return(false) - registry.register(:before_execution, callable_symbol) - end - - it "does not invoke the callables" do - expect(mock_task).not_to receive(:send) - - registry.invoke(:before_execution, mock_task) - end - end - - context "when multiple callback entries exist" do - before do - registry.register(:before_execution, callable_proc, if: :active?) - registry.register(:before_execution, callable_symbol, unless: :disabled?) - allow(mock_task).to receive(:send).with(callable_symbol) - allow(mock_task).to receive(:instance_exec) - end - - it "processes all entries" do - expect(CMDx::Utils::Condition).to receive(:evaluate).twice - - registry.invoke(:before_execution, mock_task) - end - end - end - - context "when type is invalid" do - it "raises TypeError for unknown callback type" do - expect { registry.invoke(:invalid_type, mock_task) } - .to raise_error(TypeError, "unknown callback type :invalid_type") - end - end - - context "when no callbacks are registered for the type" do - it "does not raise an error" do - expect { registry.invoke(:before_execution, mock_task) }.not_to raise_error - end - - it "does not attempt to invoke any callables" do - expect(mock_task).not_to receive(:send) - expect(mock_task).not_to receive(:instance_exec) - - registry.invoke(:before_execution, mock_task) - end - end - - context "when registry value is not a Set" do - before do - allow(CMDx::Utils::Condition).to receive(:evaluate).and_return(true) - registry.registry[:before_execution] = [[callable_proc], {}] - end - - it "handles Array conversion gracefully" do - expect(mock_task).to receive(:instance_exec) do |&block| - expect(block).to eq(callable_proc) - end - - expect { registry.invoke(:before_execution, mock_task) }.not_to raise_error - end - end - end -end diff --git a/spec/cmdx/callbacks_spec.rb b/spec/cmdx/callbacks_spec.rb new file mode 100644 index 000000000..4d57a73c3 --- /dev/null +++ b/spec/cmdx/callbacks_spec.rb @@ -0,0 +1,424 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe CMDx::Callbacks do + subject(:callbacks) { described_class.new } + + describe "#initialize" do + it "starts with an empty registry" do + expect(callbacks.registry).to eq({}) + expect(callbacks).to be_empty + end + end + + describe "#initialize_copy" do + it "deep-copies each event's callable list" do + callbacks.register(:on_success, :hook) + copy = callbacks.dup + + copy.register(:on_success, :extra) + + expect(callbacks.registry[:on_success].map(&:first)).to eq([:hook]) + expect(copy.registry[:on_success].map(&:first)).to eq(%i[hook extra]) + expect(copy.registry[:on_success]).not_to equal(callbacks.registry[:on_success]) + end + end + + describe "#register" do + context "with a Symbol" do + it "appends to the event registry and returns self" do + expect(callbacks.register(:on_success, :hook)).to be(callbacks) + expect(callbacks.registry[:on_success]).to eq([[:hook, {}]]) + end + end + + context "with a callable" do + it "stores the callable object" do + callable = ->(_task) {} + callbacks.register(:on_success, callable) + + expect(callbacks.registry[:on_success]).to eq([[callable, {}]]) + end + end + + context "with a block" do + it "stores the block as a Proc" do + block = proc {} + callbacks.register(:on_success, &block) + + expect(callbacks.registry[:on_success]).to eq([[block, {}]]) + end + end + + context "with conditional options" do + it "captures :if and :unless gates alongside the callback" do + callbacks.register(:on_success, :hook, if: :ready?, unless: :busy?) + + callable, options = callbacks.registry[:on_success].first + expect(callable).to eq(:hook) + expect(options).to eq(if: :ready?, unless: :busy?) + end + end + + context "when registering the same event multiple times" do + it "preserves insertion order" do + callable = ->(_task) {} + callbacks.register(:on_success, :first) + callbacks.register(:on_success, callable) + callbacks.register(:on_success) { :third } + + entries = callbacks.registry[:on_success].map(&:first) + expect(entries.size).to eq(3) + expect(entries[0]).to eq(:first) + expect(entries[1]).to be(callable) + expect(entries[2]).to be_a(Proc) + end + end + + context "when both callable and block are given" do + it "raises ArgumentError" do + expect do + callbacks.register(:on_success, :hook) { :block } + end.to raise_error(ArgumentError, "provide either a callable or a block, not both") + end + end + + context "when the callback is not a Symbol and does not respond to call" do + it "raises ArgumentError" do + expect do + callbacks.register(:on_success, "not callable") + end.to raise_error(ArgumentError, "callback must be a Symbol or respond to #call") + end + end + + context "when neither callable nor block is given" do + it "raises ArgumentError" do + expect do + callbacks.register(:on_success) + end.to raise_error(ArgumentError, "callback must be a Symbol or respond to #call") + end + end + + context "when the event is unknown" do + it "raises ArgumentError listing valid events" do + expect do + callbacks.register(:on_bogus, :hook) + end.to raise_error(ArgumentError, /unknown event :on_bogus, must be one of/) + end + end + end + + describe "#deregister" do + it "removes the event's callbacks and returns self" do + callbacks.register(:on_success, :hook) + + expect(callbacks.deregister(:on_success)).to be(callbacks) + expect(callbacks.registry).not_to have_key(:on_success) + end + + it "is a no-op when the event has no registrations" do + expect { callbacks.deregister(:on_success) }.not_to raise_error + expect(callbacks.registry).to eq({}) + end + + context "when the event is unknown" do + it "raises ArgumentError" do + expect do + callbacks.deregister(:on_bogus) + end.to raise_error(ArgumentError, /unknown event :on_bogus/) + end + end + + context "with a specific callable" do + it "removes only entries matching the Symbol method name" do + callbacks.register(:on_success, :hook) + callbacks.register(:on_success, :other) + + callbacks.deregister(:on_success, :hook) + + expect(callbacks.registry[:on_success].map(&:first)).to eq([:other]) + end + + it "removes a class-level callable by reference" do + handler = Class.new { def self.call(_); end } + callbacks.register(:on_success, handler) + callbacks.register(:on_success, :other) + + callbacks.deregister(:on_success, handler) + + expect(callbacks.registry[:on_success].map(&:first)).to eq([:other]) + end + + it "removes a Proc/Lambda by identity" do + lambda_cb = ->(_t) {} + proc_cb = proc {} + callbacks.register(:on_success, lambda_cb) + callbacks.register(:on_success, proc_cb) + + callbacks.deregister(:on_success, lambda_cb) + + expect(callbacks.registry[:on_success].map(&:first)).to eq([proc_cb]) + end + + it "removes every duplicate registration of the same callable" do + callbacks.register(:on_success, :hook) + callbacks.register(:on_success, :hook) + callbacks.register(:on_success, :other) + + callbacks.deregister(:on_success, :hook) + + expect(callbacks.registry[:on_success].map(&:first)).to eq([:other]) + end + + it "drops the event key when the last matching entry is removed" do + callbacks.register(:on_success, :hook) + + callbacks.deregister(:on_success, :hook) + + expect(callbacks.registry).not_to have_key(:on_success) + end + + it "is a no-op when the callable is not registered" do + callbacks.register(:on_success, :hook) + + callbacks.deregister(:on_success, :missing) + + expect(callbacks.registry[:on_success].map(&:first)).to eq([:hook]) + end + + it "is a no-op when the event has no registrations" do + expect { callbacks.deregister(:on_success, :hook) }.not_to raise_error + expect(callbacks.registry).to eq({}) + end + + it "still raises ArgumentError when the event is unknown" do + expect do + callbacks.deregister(:on_bogus, :hook) + end.to raise_error(ArgumentError, /unknown event :on_bogus/) + end + + it "returns self for chaining" do + callbacks.register(:on_success, :hook) + expect(callbacks.deregister(:on_success, :hook)).to be(callbacks) + end + end + end + + describe "#empty?" do + it "is true when nothing is registered" do + expect(callbacks).to be_empty + end + + it "is false after a registration" do + callbacks.register(:on_success, :hook) + expect(callbacks).not_to be_empty + end + + it "is true again after deregistering the only event" do + callbacks.register(:on_success, :hook) + callbacks.deregister(:on_success) + + expect(callbacks).to be_empty + end + end + + describe "#size" do + it "returns the number of distinct events with registrations" do + callbacks.register(:on_success, :a) + callbacks.register(:on_success, :b) + callbacks.register(:on_failed, :c) + + expect(callbacks.size).to eq(2) + end + end + + describe "#count" do + it "returns the total number of callbacks across all events" do + callbacks.register(:on_success, :a) + callbacks.register(:on_success, :b) + callbacks.register(:on_failed, :c) + + expect(callbacks.count).to eq(3) + end + + it "returns zero when nothing is registered" do + expect(callbacks.count).to eq(0) + end + end + + describe "#process" do + let(:task_class) do + Class.new do + attr_reader :calls + + def initialize + @calls = [] + end + + def do_work + @calls << :do_work + end + end + end + let(:task) { task_class.new } + + it "returns nil when no callbacks are registered for the event" do + expect(callbacks.process(:on_success, task)).to be_nil + end + + context "with a Symbol callback" do + it "sends the method on the task" do + callbacks.register(:on_success, :do_work) + callbacks.process(:on_success, task) + + expect(task.calls).to eq([:do_work]) + end + end + + context "with a Proc callback" do + it "runs the proc via instance_exec and passes the task as the argument" do + received = [] + callbacks.register(:on_success, proc { |t| received << [self, t] }) + + callbacks.process(:on_success, task) + + expect(received.size).to eq(1) + receiver, arg = received.first + expect(receiver).to be(task) + expect(arg).to be(task) + end + end + + context "with a class-level callable" do + it "invokes #call with the task" do + handler = Class.new do + class << self + + attr_reader :received + + def call(task) + (@received ||= []) << task + end + + end + end + callbacks.register(:on_success, handler) + + callbacks.process(:on_success, task) + + expect(handler.received).to eq([task]) + end + end + + context "with multiple callbacks for the same event" do + it "invokes them in registration order" do + sequence = [] + callbacks.register(:on_success, :do_work) + callbacks.register(:on_success, proc { sequence << :proc }) + callbacks.register(:on_success, ->(_t) { sequence << :lambda }) + + callbacks.process(:on_success, task) + + expect(task.calls).to eq([:do_work]) + expect(sequence).to eq(%i[proc lambda]) + end + end + + context "when the event has no callbacks" do + it "does not invoke any task method" do + callbacks.register(:on_failed, :do_work) + callbacks.process(:on_success, task) + + expect(task.calls).to be_empty + end + end + + context "with conditional gates" do + let(:gated_task_class) do + Class.new do + attr_reader :calls + attr_accessor :ready, :busy + + def initialize + @calls = [] + @ready = true + @busy = false + end + + def ready? = ready + def busy? = busy + + def do_work + @calls << :do_work + end + end + end + let(:task) { gated_task_class.new } + + it "runs the callback when :if evaluates truthy" do + callbacks.register(:on_success, :do_work, if: :ready?) + callbacks.process(:on_success, task) + + expect(task.calls).to eq([:do_work]) + end + + it "skips the callback when :if evaluates falsy" do + task.ready = false + callbacks.register(:on_success, :do_work, if: :ready?) + callbacks.process(:on_success, task) + + expect(task.calls).to be_empty + end + + it "skips the callback when :unless evaluates truthy" do + task.busy = true + callbacks.register(:on_success, :do_work, unless: :busy?) + callbacks.process(:on_success, task) + + expect(task.calls).to be_empty + end + + it "supports a Proc gate evaluated against the task (self = task)" do + callbacks.register(:on_success, :do_work, if: proc { ready }) + callbacks.process(:on_success, task) + + expect(task.calls).to eq([:do_work]) + end + + it "supports a callable gate invoked with the task" do + gate = Class.new do + def self.call(task) = task.ready + end + callbacks.register(:on_success, :do_work, if: gate) + callbacks.process(:on_success, task) + + expect(task.calls).to eq([:do_work]) + end + + it "skips a Proc/callable gate's callback when it evaluates falsy" do + callbacks.register(:on_success, :do_work, if: proc { false }) + callbacks.process(:on_success, task) + + expect(task.calls).to be_empty + end + + it "evaluates :if and :unless together (both must pass)" do + callbacks.register(:on_success, :do_work, if: :ready?, unless: :busy?) + callbacks.process(:on_success, task) + expect(task.calls).to eq([:do_work]) + + task.busy = true + task.calls.clear + callbacks.process(:on_success, task) + expect(task.calls).to be_empty + end + end + end + + describe "EVENTS" do + it "is frozen" do + expect(described_class::EVENTS).to be_frozen + end + end +end diff --git a/spec/cmdx/chain_spec.rb b/spec/cmdx/chain_spec.rb index a7e7735ce..ceac2b88e 100644 --- a/spec/cmdx/chain_spec.rb +++ b/spec/cmdx/chain_spec.rb @@ -2,354 +2,271 @@ require "spec_helper" -RSpec.describe CMDx::Chain, type: :unit do +RSpec.describe CMDx::Chain do subject(:chain) { described_class.new } - let(:fiber_or_thread) { Fiber.respond_to?(:storage) ? Fiber.storage : Thread.current } - let(:mock_task) { instance_double(CMDx::Task) } - let(:mock_result) do - instance_double(CMDx::Result, to_h: { id: "result-1" }).tap do |mock| - allow(mock).to receive(:is_a?) do |klass| - klass == CMDx::Result - end - end - end - let(:mock_result2) do - instance_double(CMDx::Result, to_h: { id: "result-2" }).tap do |mock| - allow(mock).to receive(:is_a?) do |klass| - klass == CMDx::Result - end - end - end + let(:task_class) { create_task_class(name: "ChainSampleTask") } + let(:task) { task_class.new } - before do - allow(CMDx::Identifier).to receive(:generate).and_return("chain-id-123") + def build_result(signal = CMDx::Signal::Success, **opts) + CMDx::Result.new(chain, task, signal, **opts) end - describe "#initialize" do - it "generates a unique id" do - expect(CMDx::Identifier).to receive(:generate) - - chain - end + after { described_class.clear } - it "initializes with an empty results array" do - expect(chain.results).to eq([]) + describe ".current" do + it "returns nil when nothing is stored" do + expect(described_class.current).to be_nil end - it "sets the id from Identifier.generate" do - expect(chain.id).to eq("chain-id-123") + it "reads the chain from fiber-local storage" do + described_class.current = chain + expect(described_class.current).to be(chain) end end - describe "attr_readers" do - it "provides read access to id" do - expect(chain.id).to eq("chain-id-123") - end + describe ".current=" do + it "writes to fiber-local storage" do + described_class.current = chain - it "provides read access to results" do - expect(chain.results).to eq([]) + expect(Fiber[described_class::STORAGE_KEY]).to be(chain) end - end - describe ".current" do - after { described_class.clear } + it "does not leak writes from child fibers to the parent" do + described_class.current = chain - context "when no chain is set" do - it "returns nil" do - expect(described_class.current).to be_nil - end - end + Fiber.new { described_class.current = described_class.new }.resume - context "when a chain is set in current thread" do - it "returns the current chain" do - described_class.current = chain - expect(described_class.current).to eq(chain) - end + expect(described_class.current).to be(chain) end end - describe ".current=" do - after { described_class.clear } - - it "sets the current chain in thread storage" do + describe ".clear" do + it "nils out fiber-local storage" do described_class.current = chain - expect(fiber_or_thread[described_class::CONCURRENCY_KEY]).to eq(chain) - end + described_class.clear - it "allows setting to nil" do - described_class.current = chain - described_class.current = nil expect(described_class.current).to be_nil end end - describe ".clear" do - before { described_class.current = chain } + describe "#initialize" do + it "assigns a UUIDv7 id" do + expect(chain.id).to match(/\A\h{8}-\h{4}-7\h{3}-\h{4}-\h{12}\z/) + end - it "sets current chain to nil" do - described_class.clear - expect(described_class.current).to be_nil + it "starts with an empty results array" do + expect(chain.results).to eq([]) + expect(chain).to be_empty end - it "clears thread storage" do - described_class.clear - expect(fiber_or_thread[described_class::CONCURRENCY_KEY]).to be_nil + it "generates a unique id per instance" do + expect(chain.id).not_to eq(described_class.new.id) end end - describe ".build" do - after { described_class.clear } + describe "#push" do + it "appends the result and returns self" do + result = Object.new - context "when result is not a CMDx::Result" do - it "raises TypeError" do - expect { described_class.build("not-a-result") }.to raise_error( - TypeError, "must be a CMDx::Result" - ) - end + expect(chain.push(result)).to be(chain) + expect(chain.results).to eq([result]) + end - it "raises TypeError for nil" do - expect { described_class.build(nil) }.to raise_error( - TypeError, "must be a CMDx::Result" - ) - end + it "is aliased as <<" do + result = Object.new + chain << result + + expect(chain.results).to eq([result]) end - context "when result is a valid CMDx::Result" do - context "when no current chain exists" do - it "creates a new chain and sets it as current" do - result_chain = described_class.build(mock_result) + it "preserves insertion order" do + a = Object.new + b = Object.new + c = Object.new + chain.push(a).push(b).push(c) + + expect(chain.results).to eq([a, b, c]) + end - expect(result_chain).to be_a(described_class) - expect(described_class.current).to eq(result_chain) - expect(result_chain.results).to contain_exactly(mock_result) - end + it "is safe under concurrent pushes" do + threads = Array.new(10) do |i| + Thread.new { 20.times { |j| chain.push([i, j]) } } end + threads.each(&:join) - context "when a current chain already exists" do - before { described_class.current = chain } + expect(chain.results.size).to eq(200) + end + end - it "uses the existing chain and adds the result" do - result_chain = described_class.build(mock_result) + describe "#unshift" do + it "prepends the result and returns self" do + result = Object.new - expect(result_chain).to eq(chain) - expect(chain.results).to contain_exactly(mock_result) - end - end + expect(chain.unshift(result)).to be(chain) + expect(chain.results).to eq([result]) + end - context "when building multiple results" do - before { described_class.current = chain } + it "places the new result before existing ones" do + a = Object.new + b = Object.new + chain.push(a) + chain.unshift(b) - it "adds results in order" do - described_class.build(mock_result) - described_class.build(mock_result2) + expect(chain.results).to eq([b, a]) + end - expect(chain.results).to eq([mock_result, mock_result2]) - end + it "is safe under concurrent unshifts" do + threads = Array.new(10) do |i| + Thread.new { 20.times { |j| chain.unshift([i, j]) } } end + threads.each(&:join) - context "with dry_run option" do - context "when no current chain exists" do - it "creates a new chain with dry_run set to true" do - result_chain = described_class.build(mock_result, dry_run: true) - - expect(result_chain.dry_run?).to be(true) - end + expect(chain.results.size).to eq(200) + end + end - it "creates a new chain with dry_run set to false" do - result_chain = described_class.build(mock_result, dry_run: false) + describe "#root" do + it "returns the result flagged as chain_root" do + non_root = build_result + root = build_result(root: true) + chain.push(non_root).push(root) - expect(result_chain.dry_run?).to be(false) - end - end + expect(chain.root).to be(root) + end - context "when a current chain already exists" do - before { described_class.current = chain } + it "returns nil when no root is present" do + chain.push(build_result) - it "does not update existing chain dry_run status" do - result_chain = described_class.build(mock_result, dry_run: true) + expect(chain.root).to be_nil + end - expect(result_chain).to eq(chain) - expect(result_chain.dry_run?).to be(false) - end - end - end + it "returns nil for an empty chain" do + expect(chain.root).to be_nil end - end - describe "#to_h" do - let(:result_hash1) { { id: "result-1", status: "success" } } - let(:result_hash2) { { id: "result-2", status: "failed" } } + it "returns the first root when multiple are present" do + first = build_result(root: true) + second = build_result(root: true) + chain.push(first).push(second) - before do - allow(mock_result).to receive(:to_h).and_return(result_hash1) - allow(mock_result2).to receive(:to_h).and_return(result_hash2) + expect(chain.root).to be(first) end + end - context "when results array is empty" do - it "returns hash with id and empty results array" do - expect(chain.to_h).to eq( - { - id: "chain-id-123", - dry_run: false, - results: [] - } - ) - end + describe "#state" do + it "returns the state of the root result" do + chain.push(build_result(CMDx::Signal::Success, root: true)) + + expect(chain.state).to eq(CMDx::Signal::COMPLETE) end - context "when results array has results" do - before do - chain.results << mock_result - chain.results << mock_result2 + it "returns nil when no root exists" do + chain.push(build_result) - allow(mock_result).to receive(:to_h).and_return(result_hash1) - allow(mock_result2).to receive(:to_h).and_return(result_hash2) - end + expect(chain.state).to be_nil + end - it "returns hash with id and results converted to hashes" do - expect(chain.to_h).to eq( - { - id: "chain-id-123", - dry_run: false, - results: [result_hash1, result_hash2] - } - ) - end + it "returns nil for an empty chain" do + expect(chain.state).to be_nil + end + end - it "calls to_h on each result" do - expect(mock_result).to receive(:to_h) - expect(mock_result2).to receive(:to_h) + describe "#status" do + it "returns the status of the root result" do + chain.push(build_result(CMDx::Signal::Success, root: true)) - chain.to_h - end + expect(chain.status).to eq(CMDx::Signal::SUCCESS) end - end - describe "#to_s" do - let(:formatted_string) { "id=\"chain-id-123\" dry_run=false results=[]" } + it "returns nil when no root exists" do + chain.push(build_result) - it "converts to hash and formats as string" do - expect(CMDx::Utils::Format).to receive(:to_str).with( - { - id: "chain-id-123", - dry_run: false, - results: [] - } - ) + expect(chain.status).to be_nil + end - chain.to_s + it "returns nil for an empty chain" do + expect(chain.status).to be_nil end end describe "#index" do - it "returns the position of a result in the chain" do - chain.push(mock_result) - chain.push(mock_result2) + it "returns the position of a known result" do + a = Object.new + b = Object.new + chain.push(a).push(b) - expect(chain.index(mock_result)).to eq(0) - expect(chain.index(mock_result2)).to eq(1) + expect(chain.index(a)).to eq(0) + expect(chain.index(b)).to eq(1) end - it "returns nil for a result not in the chain" do - expect(chain.index(mock_result)).to be_nil + it "returns nil when the result is absent" do + expect(chain.index(Object.new)).to be_nil end + end - it "is thread-safe under concurrent push and index" do - results = Array.new(100) do |i| - instance_double(CMDx::Result, to_h: { id: "result-#{i}" }).tap do |mock| - allow(mock).to receive(:is_a?).with(CMDx::Result).and_return(true) - end - end - - threads = results.map { |r| Thread.new { chain.push(r) } } - threads.each(&:join) + describe "#empty?" do + it "is true for a fresh chain" do + expect(chain).to be_empty + end - results.each do |r| - expect(chain.index(r)).to be_a(Integer) - end + it "is false once a result is pushed" do + chain.push(Object.new) + expect(chain).not_to be_empty end end - describe "thread safety" do - after { described_class.clear } - - it "maintains separate chains per thread" do - thread1_chain = nil - thread2_chain = nil - - thread1 = Thread.new do - described_class.current = described_class.new - thread1_chain = described_class.current - end + describe "#size" do + it "returns the number of results" do + expect(chain.size).to eq(0) + chain.push(Object.new).push(Object.new) + expect(chain.size).to eq(2) + end + end - thread2 = Thread.new do - described_class.current = described_class.new - thread2_chain = described_class.current - end + describe "#each" do + it "yields each result in insertion order" do + a = Object.new + b = Object.new + chain.push(a).push(b) - thread1.join - thread2.join + yielded = [] + chain.each { |r| yielded << r } # rubocop:disable Style/MapIntoArray - expect(thread1_chain).not_to eq(thread2_chain) - expect(thread1_chain).to be_a(described_class) - expect(thread2_chain).to be_a(described_class) + expect(yielded).to eq([a, b]) end - it "does not interfere with main thread chain" do - main_chain = described_class.new - described_class.current = main_chain - - thread_chain = nil - thread = Thread.new do - described_class.current = described_class.new - thread_chain = described_class.current - end - thread.join + it "returns an Enumerator without a block" do + chain.push(:a).push(:b) - expect(described_class.current).to eq(main_chain) - expect(thread_chain).not_to eq(main_chain) + expect(chain.each).to be_a(Enumerator) + expect(chain.each.to_a).to eq(%i[a b]) end end - describe "fiber safety" do - after { described_class.clear } + describe "#freeze" do + it "freezes the chain and returns self" do + expect(chain.freeze).to be(chain) + expect(chain).to be_frozen + end - it "maintains separate chains per fiber" do - fiber1_chain = nil - fiber2_chain = nil + it "freezes the underlying results array" do + chain.push(Object.new) + chain.freeze - fiber1 = Fiber.new do - described_class.current = described_class.new - fiber1_chain = described_class.current - end - - fiber2 = Fiber.new do - described_class.current = described_class.new - fiber2_chain = described_class.current - end + expect(chain.results).to be_frozen + end - fiber1.resume - fiber2.resume + it "prevents further mutation via push" do + chain.freeze - expect(fiber1_chain).not_to eq(fiber2_chain) - expect(fiber1_chain).to be_a(described_class) - expect(fiber2_chain).to be_a(described_class) + expect { chain.push(Object.new) }.to raise_error(FrozenError) end - it "does not interfere with main fiber chain" do - main_chain = described_class.new - described_class.current = main_chain - - fiber_chain = nil - fiber = Fiber.new do - described_class.current = described_class.new - fiber_chain = described_class.current - end - fiber.resume + it "prevents further mutation via unshift" do + chain.freeze - expect(described_class.current).to eq(main_chain) - expect(fiber_chain).not_to eq(main_chain) + expect { chain.unshift(Object.new) }.to raise_error(FrozenError) end end end diff --git a/spec/cmdx/coercion_registry_spec.rb b/spec/cmdx/coercion_registry_spec.rb deleted file mode 100644 index f9787fe06..000000000 --- a/spec/cmdx/coercion_registry_spec.rb +++ /dev/null @@ -1,290 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe CMDx::CoercionRegistry, type: :unit do - subject(:registry) { described_class.new(initial_registry) } - - let(:initial_registry) { nil } - let(:mock_coercion) { instance_double("MockCoercion") } - let(:mock_task) { instance_double(CMDx::Task) } - - describe "#initialize" do - context "when no registry is provided" do - subject(:registry) { described_class.new } - - it "initializes with default coercions" do - expect(registry.registry).to include( - array: CMDx::Coercions::Array, - big_decimal: CMDx::Coercions::BigDecimal, - boolean: CMDx::Coercions::Boolean, - complex: CMDx::Coercions::Complex, - date: CMDx::Coercions::Date, - datetime: CMDx::Coercions::DateTime, - float: CMDx::Coercions::Float, - hash: CMDx::Coercions::Hash, - integer: CMDx::Coercions::Integer, - rational: CMDx::Coercions::Rational, - string: CMDx::Coercions::String, - time: CMDx::Coercions::Time - ) - end - end - - context "when a registry is provided" do - let(:initial_registry) { { custom: mock_coercion } } - - it "initializes with the provided registry" do - expect(registry.registry).to eq({ custom: mock_coercion }) - end - end - end - - describe "#registry" do - let(:initial_registry) { { custom: mock_coercion } } - - it "returns the internal registry hash" do - expect(registry.registry).to eq({ custom: mock_coercion }) - end - end - - describe "#to_h" do - let(:initial_registry) { { custom: mock_coercion } } - - it "returns the registry hash" do - expect(registry.to_h).to eq({ custom: mock_coercion }) - end - - it "is an alias for registry" do - expect(registry.method(:to_h)).to eq(registry.method(:registry)) - end - end - - describe "#dup" do - it "returns a new CoercionRegistry instance" do - duplicated = registry.dup - - expect(duplicated).to be_a(described_class) - expect(duplicated).not_to be(registry) - end - - it "shares the parent registry until first write" do - duplicated = registry.dup - - expect(duplicated.registry).to eq(registry.registry) - expect(duplicated.registry).to be(registry.registry) - end - - it "materializes on write and does not affect the parent" do - duplicated = registry.dup - - duplicated.register(:new_type, mock_coercion) - - expect(duplicated.registry).to have_key(:new_type) - expect(registry.registry).not_to have_key(:new_type) - expect(duplicated.registry).not_to be(registry.registry) - end - end - - describe "#register" do - context "when registering a coercion with string name" do - it "adds the coercion to the registry with symbol key" do - registry.register("custom", mock_coercion) - - expect(registry.registry[:custom]).to eq(mock_coercion) - end - - it "returns self for method chaining" do - result = registry.register("custom", mock_coercion) - - expect(result).to be(registry) - end - end - - context "when registering a coercion with symbol name" do - it "adds the coercion to the registry" do - registry.register(:custom, mock_coercion) - - expect(registry.registry[:custom]).to eq(mock_coercion) - end - end - - context "when registering to an existing registry" do - let(:initial_registry) { { existing: "existing_coercion" } } - - it "adds new coercion to existing ones" do - registry.register(:new_type, mock_coercion) - - expect(registry.registry).to include(existing: "existing_coercion", new_type: mock_coercion) - end - end - - context "when registering over an existing coercion" do - let(:initial_registry) { { existing: "old_coercion" } } - - it "overwrites the existing coercion" do - registry.register(:existing, mock_coercion) - - expect(registry.registry[:existing]).to eq(mock_coercion) - end - end - end - - describe "#deregister" do - context "when deregistering with string name" do - before do - registry.register("custom", mock_coercion) - end - - it "removes the coercion from the registry" do - registry.deregister("custom") - - expect(registry.registry).not_to have_key(:custom) - end - - it "returns self for method chaining" do - result = registry.deregister("custom") - - expect(result).to be(registry) - end - end - - context "when deregistering with symbol name" do - before do - registry.register(:custom, mock_coercion) - end - - it "removes the coercion from the registry" do - registry.deregister(:custom) - - expect(registry.registry).not_to have_key(:custom) - end - end - - context "when deregistering from existing registry" do - let(:initial_registry) { { existing: "existing_coercion", custom: mock_coercion } } - - it "removes only the specified coercion" do - registry.deregister(:custom) - - expect(registry.registry).to include(existing: "existing_coercion") - expect(registry.registry).not_to have_key(:custom) - end - end - - context "when deregistering non-existent coercion" do - it "does not raise an error" do - expect { registry.deregister(:nonexistent) }.not_to raise_error - end - - it "returns self" do - result = registry.deregister(:nonexistent) - - expect(result).to be(registry) - end - - it "does not affect existing coercions" do - registry.register(:existing, mock_coercion) - registry.deregister(:nonexistent) - - expect(registry.registry[:existing]).to eq(mock_coercion) - end - end - - context "when deregistering from empty registry" do - let(:initial_registry) { {} } - - it "does not raise an error" do - expect { registry.deregister(:custom) }.not_to raise_error - end - - it "returns self" do - result = registry.deregister(:custom) - - expect(result).to be(registry) - end - end - - context "when deregistering default coercions" do - subject(:registry) { described_class.new } - - it "removes the default coercion" do - registry.deregister(:string) - - expect(registry.registry).not_to have_key(:string) - end - - it "does not affect other default coercions" do - registry.deregister(:string) - - expect(registry.registry).to have_key(:integer) - expect(registry.registry).to have_key(:boolean) - end - end - end - - describe "#coerce" do - let(:initial_registry) { { custom: mock_coercion } } - let(:value) { "test_value" } - let(:options) { { option1: "value1" } } - - context "when coercion type exists" do - it "calls Utils::Call.invoke with correct parameters" do - expect(CMDx::Utils::Call).to receive(:invoke).with( - mock_task, mock_coercion, value, options - ) - - registry.coerce(:custom, mock_task, value, options) - end - - it "returns the result from Utils::Call.invoke" do - allow(CMDx::Utils::Call).to receive(:invoke).and_return("coerced_value") - - result = registry.coerce(:custom, mock_task, value, options) - - expect(result).to eq("coerced_value") - end - - context "with string type name" do - let(:initial_registry) { { "custom" => mock_coercion } } - - it "works with string type names" do - expect(CMDx::Utils::Call).to receive(:invoke).with( - mock_task, mock_coercion, value, options - ) - - registry.coerce("custom", mock_task, value, options) - end - end - end - - context "when options are not provided" do - it "passes empty hash as options" do - expect(CMDx::Utils::Call).to receive(:invoke).with( - mock_task, mock_coercion, value, {} - ) - - registry.coerce(:custom, mock_task, value) - end - end - - context "when coercion type does not exist" do - it "raises TypeError with descriptive message" do - expect { registry.coerce(:nonexistent, mock_task, value) } - .to raise_error(TypeError, "unknown coercion type :nonexistent") - end - - it "raises TypeError for string type names" do - expect { registry.coerce("string", mock_task, value) } - .to raise_error(TypeError, 'unknown coercion type "string"') - end - end - - context "when type is nil" do - it "raises TypeError" do - expect { registry.coerce(nil, mock_task, value) } - .to raise_error(TypeError, "unknown coercion type nil") - end - end - end -end diff --git a/spec/cmdx/coercions/array_spec.rb b/spec/cmdx/coercions/array_spec.rb index d39c7e571..46a667a10 100644 --- a/spec/cmdx/coercions/array_spec.rb +++ b/spec/cmdx/coercions/array_spec.rb @@ -2,215 +2,38 @@ require "spec_helper" -RSpec.describe CMDx::Coercions::Array, type: :unit do - subject(:coercion) { described_class } - +RSpec.describe CMDx::Coercions::Array do describe ".call" do - context "when value is a JSON string starting with '['" do - it "parses valid JSON array string" do - result = coercion.call('["a", "b", "c"]') - - expect(result).to eq(%w[a b c]) - end - - it "parses JSON array with mixed types" do - result = coercion.call('[1, "string", true, null]') - - expect(result).to eq([1, "string", true, nil]) - end - - it "parses empty JSON array" do - result = coercion.call("[]") - - expect(result).to eq([]) - end - - it "parses nested JSON arrays" do - result = coercion.call("[[1, 2], [3, 4]]") - - expect(result).to eq([[1, 2], [3, 4]]) - end - - it "parses JSON array with objects" do - result = coercion.call('[{"key": "value"}, {"number": 42}]') - - expect(result).to eq([{ "key" => "value" }, { "number" => 42 }]) - end - - it "parses JSON null string as empty array" do - result = coercion.call("null") - - expect(result).to eq([]) - end - - it "parses JSON null string with whitespace as empty array" do - result = coercion.call(" null ") - - expect(result).to eq([]) - end - - context "with invalid JSON" do - it "raises CoercionError for malformed JSON" do - expect { coercion.call("[invalid json") } - .to raise_error(CMDx::CoercionError) - end - - it "raises CoercionError for incomplete array" do - expect { coercion.call("[1, 2,") } - .to raise_error(CMDx::CoercionError) - end - - it "raises CoercionError for unquoted strings" do - expect { coercion.call("[unquoted, string]") } - .to raise_error(CMDx::CoercionError) - end - end + it "returns arrays unchanged" do + expect(described_class.call([1, 2])).to eq([1, 2]) end - context "when value is a string not starting with '['" do - it "wraps single string value in array" do - result = coercion.call("hello") - - expect(result).to eq(["hello"]) - end - - it "wraps empty string in array" do - result = coercion.call("") - - expect(result).to eq([""]) - end - - it "wraps string starting with other characters" do - result = coercion.call("{key: value}") - - expect(result).to eq(["{key: value}"]) - end - - it "wraps numeric string in array" do - result = coercion.call("123") - - expect(result).to eq(["123"]) - end + it "parses a JSON array string" do + expect(described_class.call("[1,2,3]")).to eq([1, 2, 3]) end - context "when value is already an array" do - it "returns the array unchanged" do - input = [1, 2, 3] - - result = coercion.call(input) - - expect(result).to eq([1, 2, 3]) - end - - it "returns empty array unchanged" do - input = [] - - result = coercion.call(input) - - expect(result).to eq([]) - end - - it "returns nested array unchanged" do - input = [[1, 2], [3, 4]] - - result = coercion.call(input) - - expect(result).to eq([[1, 2], [3, 4]]) - end - end - - context "when value is nil" do - it "converts nil to empty array" do - result = coercion.call(nil) - - expect(result).to eq([]) - end - end - - context "when value is a number" do - it "wraps integer in array" do - result = coercion.call(42) - - expect(result).to eq([42]) - end - - it "wraps float in array" do - result = coercion.call(3.14) - - expect(result).to eq([3.14]) - end - - it "wraps zero in array" do - result = coercion.call(0) - - expect(result).to eq([0]) - end + it "wraps a non-array JSON value in an array" do + expect(described_class.call("42")).to eq(["42"]) end - context "when value is a boolean" do - it "wraps true in array" do - result = coercion.call(true) - - expect(result).to eq([true]) - end - - it "wraps false in array" do - result = coercion.call(false) - - expect(result).to eq([false]) - end + it "wraps unparseable strings in a single-element array" do + expect(described_class.call("not json")).to eq(["not json"]) end - context "when value is a hash" do - it "converts hash to array of key-value pairs" do - input = { key: "value" } - - result = coercion.call(input) - - expect(result).to eq([[:key, "value"]]) - end - - it "converts empty hash to empty array" do - result = coercion.call({}) - - expect(result).to eq([]) - end + it "calls to_a when available" do + expect(described_class.call(1..3)).to eq([1, 2, 3]) end - context "when value is an enumerable object" do - it "converts range to array" do - result = coercion.call(1..3) - - expect(result).to eq([1, 2, 3]) - end - - it "converts set to array" do - input = Set.new([1, 2, 3]) - - result = coercion.call(input) - - expect(result).to eq([1, 2, 3]) - end + it "wraps scalars in an array" do + expect(described_class.call(42)).to eq([42]) end - context "with options parameter" do - it "ignores options when processing JSON string" do - result = coercion.call('["a", "b"]', { unused: "option" }) - - expect(result).to eq(%w[a b]) - end - - it "ignores options when wrapping non-JSON values" do - result = coercion.call("hello", { unused: "option" }) - - expect(result).to eq(["hello"]) - end - - it "works with empty options hash" do - result = coercion.call([1, 2, 3], {}) + it "returns a coercion failure when to_a raises TypeError" do + value = Object.new + def value.to_a; raise TypeError; end # rubocop:disable Style/SingleLineMethods - expect(result).to eq([1, 2, 3]) - end + result = described_class.call(value) + expect(result).to be_a(CMDx::Coercions::Failure) end end end diff --git a/spec/cmdx/coercions/big_decimal_spec.rb b/spec/cmdx/coercions/big_decimal_spec.rb index 7f7cd6428..62f8c0fc1 100644 --- a/spec/cmdx/coercions/big_decimal_spec.rb +++ b/spec/cmdx/coercions/big_decimal_spec.rb @@ -1,168 +1,29 @@ # frozen_string_literal: true require "spec_helper" +require "bigdecimal" -RSpec.describe CMDx::Coercions::BigDecimal, type: :unit do - subject(:coercion) { described_class } - +RSpec.describe CMDx::Coercions::BigDecimal do describe ".call" do - context "when value is a valid string" do - it "coerces string integer to BigDecimal" do - result = coercion.call("123") - - expect(result).to be_a(BigDecimal) - expect(result).to eq(BigDecimal(123)) - end - - it "coerces string decimal to BigDecimal" do - result = coercion.call("123.456") - - expect(result).to be_a(BigDecimal) - expect(result).to eq(BigDecimal("123.456")) - end - - it "coerces negative string to BigDecimal" do - result = coercion.call("-123.456") - - expect(result).to be_a(BigDecimal) - expect(result).to eq(BigDecimal("-123.456")) - end - - it "coerces string with scientific notation to BigDecimal" do - result = coercion.call("1.23e4") - - expect(result).to be_a(BigDecimal) - expect(result).to eq(BigDecimal("1.23e4")) - end - - it "coerces zero string to BigDecimal" do - result = coercion.call("0") - - expect(result).to be_a(BigDecimal) - expect(result).to eq(BigDecimal(0)) - end + it "returns a BigDecimal unchanged" do + bd = BigDecimal("1.5") + expect(described_class.call(bd)).to eq(bd) end - context "when value is a numeric type" do - it "coerces integer to BigDecimal" do - result = coercion.call(123) - - expect(result).to be_a(BigDecimal) - expect(result).to eq(BigDecimal(123)) - end - - it "coerces float to BigDecimal" do - result = coercion.call(123.456) - - expect(result).to be_a(BigDecimal) - expect(result).to eq(BigDecimal("123.456")) - end - - it "coerces rational to BigDecimal" do - result = coercion.call(Rational(22, 7)) - - expect(result).to be_a(BigDecimal) - expect(result.to_f).to be_within(0.001).of(3.14285) - end - - it "coerces existing BigDecimal" do - original = BigDecimal("123.456") - result = coercion.call(original) - - expect(result).to be_a(BigDecimal) - expect(result).to eq(original) - end + it "parses numeric strings" do + expect(described_class.call("3.14")).to eq(BigDecimal("3.14")) end - context "with precision option" do - it "uses custom precision when provided" do - result = coercion.call("123.456789", precision: 4) - - expect(result).to be_a(BigDecimal) - expect(result).to eq(BigDecimal("123.456789", 4)) - end - - it "uses default precision when not provided" do - result = coercion.call("123.456789") - - expect(result).to be_a(BigDecimal) - expect(result).to eq(BigDecimal("123.456789", 14)) - end - - it "uses zero precision" do - result = coercion.call("123.456", precision: 0) - - expect(result).to be_a(BigDecimal) - expect(result).to eq(BigDecimal("123.456", 0)) - end + it "accepts the precision option" do + expect(described_class.call("1.25", precision: 4)).to be_a(BigDecimal) end - context "with invalid values" do - it "raises CoercionError for non-numeric string" do - expect { coercion.call("invalid") } - .to raise_error(CMDx::CoercionError, "could not coerce into a big decimal") - end - - it "raises CoercionError for empty string" do - expect { coercion.call("") } - .to raise_error(CMDx::CoercionError, "could not coerce into a big decimal") - end - - it "raises CoercionError for nil" do - expect { coercion.call(nil) } - .to raise_error(CMDx::CoercionError, "could not coerce into a big decimal") - end - - it "raises CoercionError for boolean" do - expect { coercion.call(true) } - .to raise_error(CMDx::CoercionError, "could not coerce into a big decimal") - end - - it "raises CoercionError for array" do - expect { coercion.call([1, 2, 3]) } - .to raise_error(CMDx::CoercionError, "could not coerce into a big decimal") - end - - it "raises CoercionError for hash" do - expect { coercion.call({ key: "value" }) } - .to raise_error(CMDx::CoercionError, "could not coerce into a big decimal") - end - - it "raises CoercionError for symbol" do - expect { coercion.call(:symbol) } - .to raise_error(CMDx::CoercionError, "could not coerce into a big decimal") - end - - it "raises CoercionError for complex number" do - expect { coercion.call(Complex(1, 2)) } - .to raise_error(CMDx::CoercionError, "could not coerce into a big decimal") - end + it "returns a Failure for invalid input" do + expect(described_class.call("not a number")).to be_a(CMDx::Coercions::Failure) end - context "with edge cases" do - it "coerces string with leading/trailing whitespace" do - result = coercion.call(" 123.456 ") - - expect(result).to be_a(BigDecimal) - expect(result).to eq(BigDecimal("123.456")) - end - - it "coerces string with plus sign" do - result = coercion.call("+123.456") - - expect(result).to be_a(BigDecimal) - expect(result).to eq(BigDecimal("123.456")) - end - - it "raises CoercionError for string with invalid characters" do - expect { coercion.call("123.45.67") } - .to raise_error(CMDx::CoercionError, "could not coerce into a big decimal") - end - - it "raises CoercionError for string with letters mixed in" do - expect { coercion.call("12a3.456") } - .to raise_error(CMDx::CoercionError, "could not coerce into a big decimal") - end + it "returns a Failure for nil" do + expect(described_class.call(nil)).to be_a(CMDx::Coercions::Failure) end end end diff --git a/spec/cmdx/coercions/boolean_spec.rb b/spec/cmdx/coercions/boolean_spec.rb index cef3be908..14bd40017 100644 --- a/spec/cmdx/coercions/boolean_spec.rb +++ b/spec/cmdx/coercions/boolean_spec.rb @@ -2,251 +2,31 @@ require "spec_helper" -RSpec.describe CMDx::Coercions::Boolean, type: :unit do - subject(:coercion) { described_class } - +RSpec.describe CMDx::Coercions::Boolean do describe ".call" do - context "when value is truthy" do - it "coerces string 'true' to true" do - result = coercion.call("true") - - expect(result).to be(true) - end - - it "coerces string 'TRUE' to true" do - result = coercion.call("TRUE") - - expect(result).to be(true) - end - - it "coerces string 'True' to true" do - result = coercion.call("True") - - expect(result).to be(true) - end - - it "coerces string 't' to true" do - result = coercion.call("t") - - expect(result).to be(true) - end - - it "coerces string 'T' to true" do - result = coercion.call("T") - - expect(result).to be(true) - end - - it "coerces string 'yes' to true" do - result = coercion.call("yes") - - expect(result).to be(true) - end - - it "coerces string 'YES' to true" do - result = coercion.call("YES") - - expect(result).to be(true) - end - - it "coerces string 'Yes' to true" do - result = coercion.call("Yes") - - expect(result).to be(true) - end - - it "coerces string 'y' to true" do - result = coercion.call("y") - - expect(result).to be(true) - end - - it "coerces string 'Y' to true" do - result = coercion.call("Y") - - expect(result).to be(true) - end - - it "coerces string '1' to true" do - result = coercion.call("1") - - expect(result).to be(true) - end - - it "coerces integer 1 to true" do - result = coercion.call(1) - - expect(result).to be(true) - end - - it "coerces boolean true to true" do - result = coercion.call(true) - - expect(result).to be(true) + %w[true yes on y 1 t True YES ON].each do |input| + it "is true for #{input.inspect}" do + expect(described_class.call(input)).to be(true) end end - context "when value is falsey" do - it "coerces string 'false' to false" do - result = coercion.call("false") - - expect(result).to be(false) - end - - it "coerces string 'FALSE' to false" do - result = coercion.call("FALSE") - - expect(result).to be(false) - end - - it "coerces string 'False' to false" do - result = coercion.call("False") - - expect(result).to be(false) - end - - it "coerces string 'f' to false" do - result = coercion.call("f") - - expect(result).to be(false) - end - - it "coerces string 'F' to false" do - result = coercion.call("F") - - expect(result).to be(false) - end - - it "coerces string 'no' to false" do - result = coercion.call("no") - - expect(result).to be(false) - end - - it "coerces string 'NO' to false" do - result = coercion.call("NO") - - expect(result).to be(false) - end - - it "coerces string 'No' to false" do - result = coercion.call("No") - - expect(result).to be(false) - end - - it "coerces string 'n' to false" do - result = coercion.call("n") - - expect(result).to be(false) - end - - it "coerces string 'N' to false" do - result = coercion.call("N") - - expect(result).to be(false) - end - - it "coerces string '0' to false" do - result = coercion.call("0") - - expect(result).to be(false) - end - - it "coerces integer 0 to false" do - result = coercion.call(0) - - expect(result).to be(false) - end - - it "coerces boolean false to false" do - result = coercion.call(false) - - expect(result).to be(false) - end - - it "coerces nil to false" do - result = coercion.call(nil) - - expect(result).to be(false) - end - - it "coerces empty string to false" do - result = coercion.call("") - - expect(result).to be(false) + %w[false no off n 0 f FALSE No OFF].each do |input| + it "is false for #{input.inspect}" do + expect(described_class.call(input)).to be(false) end end - context "when value is invalid" do - it "raises CoercionError for invalid string" do - expect { coercion.call("invalid") } - .to raise_error(CMDx::CoercionError, "could not coerce into a boolean") - end - - it "raises CoercionError for array" do - expect { coercion.call([1, 2, 3]) } - .to raise_error(CMDx::CoercionError, "could not coerce into a boolean") - end - - it "raises CoercionError for hash" do - expect { coercion.call({ key: "value" }) } - .to raise_error(CMDx::CoercionError, "could not coerce into a boolean") - end - - it "raises CoercionError for symbol" do - expect { coercion.call(:symbol) } - .to raise_error(CMDx::CoercionError, "could not coerce into a boolean") - end - - it "raises CoercionError for float" do - expect { coercion.call(3.14) } - .to raise_error(CMDx::CoercionError, "could not coerce into a boolean") - end - - it "raises CoercionError for integer 2" do - expect { coercion.call(2) } - .to raise_error(CMDx::CoercionError, "could not coerce into a boolean") - end - - it "raises CoercionError for negative integer" do - expect { coercion.call(-1) } - .to raise_error(CMDx::CoercionError, "could not coerce into a boolean") - end - - it "raises CoercionError for partial match string" do - expect { coercion.call("truth") } - .to raise_error(CMDx::CoercionError, "could not coerce into a boolean") - end - - it "raises CoercionError for string with whitespace" do - expect { coercion.call(" true ") } - .to raise_error(CMDx::CoercionError, "could not coerce into a boolean") - end - - it "raises CoercionError for string with mixed case that doesn't match patterns" do - expect { coercion.call("TrUeX") } - .to raise_error(CMDx::CoercionError, "could not coerce into a boolean") - end + it "handles booleans via their string form" do + expect(described_class.call(true)).to be(true) + expect(described_class.call(false)).to be(false) end - context "with options parameter" do - it "ignores options parameter for valid truthy value" do - result = coercion.call("true", { some: "option" }) - - expect(result).to be(true) - end - - it "ignores options parameter for valid falsey value" do - result = coercion.call("false", { some: "option" }) - - expect(result).to be(false) - end + it "returns a Failure for nil" do + expect(described_class.call(nil)).to be_a(CMDx::Coercions::Failure) + end - it "ignores options parameter for invalid value" do - expect { coercion.call("invalid", { some: "option" }) } - .to raise_error(CMDx::CoercionError, "could not coerce into a boolean") - end + it "returns a Failure for unrecognized strings" do + expect(described_class.call("maybe")).to be_a(CMDx::Coercions::Failure) end end end diff --git a/spec/cmdx/coercions/coerce_spec.rb b/spec/cmdx/coercions/coerce_spec.rb new file mode 100644 index 000000000..d04832d72 --- /dev/null +++ b/spec/cmdx/coercions/coerce_spec.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe CMDx::Coercions::Coerce do + let(:task_class) do + create_task_class do + def double_it(v) = v * 2 + end + end + let(:task) { task_class.new } + + describe ".call" do + it "sends a symbol handler to the task" do + expect(described_class.call(task, 21, :double_it)).to eq(42) + end + + it "invokes a proc handler via instance_exec on the task" do + handler = proc { |v| "#{context.class.name}-#{v}" } + expect(described_class.call(task, 1, handler)).to eq("CMDx::Context-1") + end + + it "invokes a callable object with the value and task" do + handler = Class.new do + class << self + + attr_reader :received_task + + def call(value, task) + @received_task = task + value.to_s.reverse + end + + end + end + + expect(described_class.call(task, "abc", handler)).to eq("cba") + expect(handler.received_task).to be(task) + end + + it "raises for unsupported handlers" do + expect do + described_class.call(task, 1, Object.new) + end.to raise_error(ArgumentError, /must be a Symbol, Proc, or respond to #call/) + end + end +end diff --git a/spec/cmdx/coercions/complex_spec.rb b/spec/cmdx/coercions/complex_spec.rb index 757171e39..d27591c8b 100644 --- a/spec/cmdx/coercions/complex_spec.rb +++ b/spec/cmdx/coercions/complex_spec.rb @@ -2,219 +2,27 @@ require "spec_helper" -RSpec.describe CMDx::Coercions::Complex, type: :unit do - subject(:coercion) { described_class } - +RSpec.describe CMDx::Coercions::Complex do describe ".call" do - context "when value is a valid string" do - it "coerces string integer to Complex" do - result = coercion.call("123") - - expect(result).to be_a(Complex) - expect(result).to eq(Complex(123)) - end - - it "coerces string decimal to Complex" do - result = coercion.call("123.456") - - expect(result).to be_a(Complex) - expect(result).to eq(Complex(123.456)) - end - - it "coerces negative string to Complex" do - result = coercion.call("-123.456") - - expect(result).to be_a(Complex) - expect(result).to eq(Complex(-123.456)) - end - - it "coerces string with imaginary unit to Complex" do - result = coercion.call("3+4i") - - expect(result).to be_a(Complex) - expect(result).to eq(Complex(3, 4)) - end - - it "coerces string with negative imaginary unit to Complex" do - result = coercion.call("3-4i") - - expect(result).to be_a(Complex) - expect(result).to eq(Complex(3, -4)) - end - - it "coerces pure imaginary string to Complex" do - result = coercion.call("4i") - - expect(result).to be_a(Complex) - expect(result).to eq(Complex(0, 4)) - end - - it "coerces zero string to Complex" do - result = coercion.call("0") - - expect(result).to be_a(Complex) - expect(result).to eq(Complex(0)) - end - - it "coerces string with scientific notation to Complex" do - result = coercion.call("1.23e4") - - expect(result).to be_a(Complex) - expect(result).to eq(Complex(1.23e4)) - end + it "returns a Complex unchanged" do + c = Complex(1, 2) + expect(described_class.call(c)).to be(c) end - context "when value is a numeric type" do - it "coerces integer to Complex" do - result = coercion.call(123) - - expect(result).to be_a(Complex) - expect(result).to eq(Complex(123)) - end - - it "coerces float to Complex" do - result = coercion.call(123.456) - - expect(result).to be_a(Complex) - expect(result).to eq(Complex(123.456)) - end - - it "coerces rational to Complex" do - result = coercion.call(Rational(22, 7)) - - expect(result).to be_a(Complex) - expect(result).to eq(Complex(Rational(22, 7))) - end - - it "coerces existing Complex" do - original = Complex(3, 4) - result = coercion.call(original) - - expect(result).to be_a(Complex) - expect(result).to eq(original) - end - - it "coerces zero to Complex" do - result = coercion.call(0) - - expect(result).to be_a(Complex) - expect(result).to eq(Complex(0)) - end - - it "coerces negative number to Complex" do - result = coercion.call(-42) - - expect(result).to be_a(Complex) - expect(result).to eq(Complex(-42)) - end + it "parses numeric strings" do + expect(described_class.call("1+2i")).to eq(Complex(1, 2)) end - context "with invalid values" do - it "raises CoercionError for non-numeric string" do - expect { coercion.call("invalid") } - .to raise_error(CMDx::CoercionError, "could not coerce into a complex") - end - - it "raises CoercionError for empty string" do - expect { coercion.call("") } - .to raise_error(CMDx::CoercionError, "could not coerce into a complex") - end - - it "raises CoercionError for nil" do - expect { coercion.call(nil) } - .to raise_error(CMDx::CoercionError, "could not coerce into a complex") - end - - it "raises CoercionError for boolean" do - expect { coercion.call(true) } - .to raise_error(CMDx::CoercionError, "could not coerce into a complex") - end - - it "raises CoercionError for array" do - expect { coercion.call([1, 2, 3]) } - .to raise_error(CMDx::CoercionError, "could not coerce into a complex") - end - - it "raises CoercionError for hash" do - expect { coercion.call({ key: "value" }) } - .to raise_error(CMDx::CoercionError, "could not coerce into a complex") - end - - it "raises CoercionError for symbol" do - expect { coercion.call(:symbol) } - .to raise_error(CMDx::CoercionError, "could not coerce into a complex") - end - - it "raises CoercionError for Object" do - expect { coercion.call(Object.new) } - .to raise_error(CMDx::CoercionError, "could not coerce into a complex") - end + it "accepts the imaginary option" do + expect(described_class.call(3, imaginary: 4)).to eq(Complex(3, 4)) end - context "with edge cases" do - it "coerces string with decimal imaginary unit" do - result = coercion.call("1.5+2.7i") - - expect(result).to be_a(Complex) - expect(result).to eq(Complex(1.5, 2.7)) - end - - it "coerces string with just 'i' as imaginary unit" do - result = coercion.call("i") - - expect(result).to be_a(Complex) - expect(result).to eq(Complex(0, 1)) - end - - it "coerces string with negative imaginary unit 'i'" do - result = coercion.call("-i") - - expect(result).to be_a(Complex) - expect(result).to eq(Complex(0, -1)) - end - - it "coerces string with 'j' notation" do - result = coercion.call("3+4j") - - expect(result).to be_a(Complex) - expect(result).to eq(Complex(3, 4)) - end - - it "raises CoercionError for string with spaces" do - expect { coercion.call("3 + 4i") } - .to raise_error(CMDx::CoercionError, "could not coerce into a complex") - end - - it "raises CoercionError for string with multiple operators" do - expect { coercion.call("3++4i") } - .to raise_error(CMDx::CoercionError, "could not coerce into a complex") - end - - it "raises CoercionError for malformed complex string" do - expect { coercion.call("3+i4") } - .to raise_error(CMDx::CoercionError, "could not coerce into a complex") - end + it "returns a Failure for unparseable input" do + expect(described_class.call("nope")).to be_a(CMDx::Coercions::Failure) end - context "with options parameter" do - it "ignores options parameter for valid complex number" do - result = coercion.call("3+4i", { some: "option" }) - - expect(result).to be_a(Complex) - expect(result).to eq(Complex(3, 4)) - end - - it "ignores options parameter for valid numeric value" do - result = coercion.call(42, { some: "option" }) - - expect(result).to be_a(Complex) - expect(result).to eq(Complex(42)) - end - - it "ignores options parameter for invalid value" do - expect { coercion.call("invalid", { some: "option" }) } - .to raise_error(CMDx::CoercionError, "could not coerce into a complex") - end + it "returns a Failure for nil" do + expect(described_class.call(nil)).to be_a(CMDx::Coercions::Failure) end end end diff --git a/spec/cmdx/coercions/date_spec.rb b/spec/cmdx/coercions/date_spec.rb index 0a395d7a0..ba6816a53 100644 --- a/spec/cmdx/coercions/date_spec.rb +++ b/spec/cmdx/coercions/date_spec.rb @@ -1,231 +1,37 @@ # frozen_string_literal: true require "spec_helper" +require "date" -RSpec.describe CMDx::Coercions::Date, type: :unit do - subject(:coercion) { described_class } - +RSpec.describe CMDx::Coercions::Date do describe ".call" do - context "when value is already an analog type" do - it "returns Date unchanged" do - date = Date.new(2023, 12, 25) - result = coercion.call(date) - - expect(result).to eq(date) - end - - it "converts DateTime to Date" do - datetime = DateTime.new(2023, 12, 25, 14, 30, 0) - result = coercion.call(datetime) - - expect(result).to be_a(Date) - expect(result).not_to be_a(DateTime) - expect(result).to eq(Date.new(2023, 12, 25)) - end - - it "converts Time to Date" do - time = Time.new(2023, 12, 25, 14, 30, 0) - result = coercion.call(time) - - expect(result).to be_a(Date) - expect(result).to eq(Date.new(2023, 12, 25)) - end + it "returns a Date unchanged" do + d = Date.new(2024, 1, 2) + expect(described_class.call(d)).to be(d) end - context "when value is a parseable string" do - it "parses ISO 8601 date string" do - result = coercion.call("2023-12-25") - - expect(result).to be_a(Date) - expect(result.year).to eq(2023) - expect(result.month).to eq(12) - expect(result.day).to eq(25) - end - - it "parses YYYY/MM/DD format date string" do - result = coercion.call("2023/12/25") - - expect(result).to be_a(Date) - expect(result.year).to eq(2023) - expect(result.month).to eq(12) - expect(result.day).to eq(25) - end - - it "parses DD/MM/YYYY format date string" do - result = coercion.call("25/12/2023") - - expect(result).to be_a(Date) - expect(result.year).to eq(2023) - expect(result.month).to eq(12) - expect(result.day).to eq(25) - end - - it "parses DD.MM.YYYY format date string" do - result = coercion.call("25.12.2023") - - expect(result).to be_a(Date) - expect(result.year).to eq(2023) - expect(result.month).to eq(12) - expect(result.day).to eq(25) - end - - it "parses textual date format" do - result = coercion.call("Dec 25, 2023") - - expect(result).to be_a(Date) - expect(result.year).to eq(2023) - expect(result.month).to eq(12) - expect(result.day).to eq(25) - end - - it "parses date with time component, extracting date part" do - result = coercion.call("2023-12-25 14:30:00") - - expect(result).to be_a(Date) - expect(result.year).to eq(2023) - expect(result.month).to eq(12) - expect(result.day).to eq(25) - end + it "parses an ISO date string" do + expect(described_class.call("2024-01-02")).to eq(Date.new(2024, 1, 2)) end - context "with strptime option" do - it "parses date using custom format" do - result = coercion.call("25-Dec-2023", strptime: "%d-%b-%Y") - - expect(result).to be_a(Date) - expect(result.year).to eq(2023) - expect(result.month).to eq(12) - expect(result.day).to eq(25) - end - - it "parses date using different custom format" do - result = coercion.call("2023/12/25", strptime: "%Y/%m/%d") - - expect(result).to be_a(Date) - expect(result.year).to eq(2023) - expect(result.month).to eq(12) - expect(result.day).to eq(25) - end - - it "parses date with year first format" do - result = coercion.call("23-12-25", strptime: "%y-%m-%d") - - expect(result).to be_a(Date) - expect(result.year).to eq(2023) - expect(result.month).to eq(12) - expect(result.day).to eq(25) - end - - it "raises CoercionError when string doesn't match strptime format" do - expect { coercion.call("invalid-date", strptime: "%Y-%m-%d") } - .to raise_error(CMDx::CoercionError, "could not coerce into a date") - end + it "honors :strptime" do + expect(described_class.call("02/01/2024", strptime: "%d/%m/%Y")).to eq(Date.new(2024, 1, 2)) end - context "when value cannot be coerced" do - it "raises CoercionError for invalid date string" do - expect { coercion.call("not-a-date") } - .to raise_error(CMDx::CoercionError, "could not coerce into a date") - end - - it "raises CoercionError for empty string" do - expect { coercion.call("") } - .to raise_error(CMDx::CoercionError, "could not coerce into a date") - end - - it "raises CoercionError for nil" do - expect { coercion.call(nil) } - .to raise_error(CMDx::CoercionError, "could not coerce into a date") - end - - it "raises CoercionError for integer" do - expect { coercion.call(123) } - .to raise_error(CMDx::CoercionError, "could not coerce into a date") - end - - it "raises CoercionError for float" do - expect { coercion.call(12.3) } - .to raise_error(CMDx::CoercionError, "could not coerce into a date") - end - - it "raises CoercionError for boolean" do - expect { coercion.call(true) } - .to raise_error(CMDx::CoercionError, "could not coerce into a date") - end - - it "raises CoercionError for array" do - expect { coercion.call([2023, 12, 25]) } - .to raise_error(CMDx::CoercionError, "could not coerce into a date") - end - - it "raises CoercionError for hash" do - expect { coercion.call({ year: 2023, month: 12, day: 25 }) } - .to raise_error(CMDx::CoercionError, "could not coerce into a date") - end - - it "raises CoercionError for symbol" do - expect { coercion.call(:date) } - .to raise_error(CMDx::CoercionError, "could not coerce into a date") - end - - it "raises CoercionError for object" do - expect { coercion.call(Object.new) } - .to raise_error(CMDx::CoercionError, "could not coerce into a date") - end - - it "raises CoercionError for invalid date components" do - expect { coercion.call("2023-13-45") } - .to raise_error(CMDx::CoercionError, "could not coerce into a date") - end - - it "raises CoercionError for US format date string" do - expect { coercion.call("12/25/2023") } - .to raise_error(CMDx::CoercionError, "could not coerce into a date") - end - - it "raises CoercionError for malformed date string" do - expect { coercion.call("2023/25/12/extra") } - .to raise_error(CMDx::CoercionError, "could not coerce into a date") - end - - it "raises CoercionError for partially valid date string" do - expect { coercion.call("2023-12") } - .to raise_error(CMDx::CoercionError, "could not coerce into a date") - end + it "calls #to_date when present" do + expect(described_class.call(Time.new(2024, 1, 2))).to eq(Date.new(2024, 1, 2)) end - context "with edge cases" do - it "handles leap year date" do - result = coercion.call("2024-02-29") - - expect(result).to be_a(Date) - expect(result.year).to eq(2024) - expect(result.month).to eq(2) - expect(result.day).to eq(29) - end - - it "raises CoercionError for invalid leap year date" do - expect { coercion.call("2023-02-29") } - .to raise_error(CMDx::CoercionError, "could not coerce into a date") - end - - it "handles end of year date" do - result = coercion.call("2023-12-31") - - expect(result).to be_a(Date) - expect(result.year).to eq(2023) - expect(result.month).to eq(12) - expect(result.day).to eq(31) - end + it "returns a Failure for an unknown value type" do + expect(described_class.call(42)).to be_a(CMDx::Coercions::Failure) + end - it "handles start of year date" do - result = coercion.call("2023-01-01") + it "returns a Failure for a malformed string" do + expect(described_class.call("not a date")).to be_a(CMDx::Coercions::Failure) + end - expect(result).to be_a(Date) - expect(result.year).to eq(2023) - expect(result.month).to eq(1) - expect(result.day).to eq(1) - end + it "returns a Failure when strptime fails" do + expect(described_class.call("x", strptime: "%d/%m/%Y")).to be_a(CMDx::Coercions::Failure) end end end diff --git a/spec/cmdx/coercions/date_time_spec.rb b/spec/cmdx/coercions/date_time_spec.rb index e44c545d4..e2428361f 100644 --- a/spec/cmdx/coercions/date_time_spec.rb +++ b/spec/cmdx/coercions/date_time_spec.rb @@ -1,180 +1,35 @@ # frozen_string_literal: true require "spec_helper" +require "date" -RSpec.describe CMDx::Coercions::DateTime, type: :unit do - subject(:coercion) { described_class } - +RSpec.describe CMDx::Coercions::DateTime do describe ".call" do - context "when value is already an analog type" do - it "converts Date to DateTime" do - date = Date.new(2023, 12, 25) - result = coercion.call(date) - - expect(result).to be_a(DateTime) - expect(result.year).to eq(2023) - expect(result.month).to eq(12) - expect(result.day).to eq(25) - end - - it "returns DateTime unchanged" do - datetime = DateTime.new(2023, 12, 25, 14, 30, 0) - result = coercion.call(datetime) - - expect(result).to eq(datetime) - end - - it "converts Time to DateTime" do - time = Time.new(2023, 12, 25, 14, 30, 0) - result = coercion.call(time) - - expect(result).to be_a(DateTime) - expect(result.year).to eq(2023) - expect(result.month).to eq(12) - expect(result.day).to eq(25) - end + it "returns a DateTime unchanged" do + dt = DateTime.new(2024, 1, 2, 3, 4, 5) + expect(described_class.call(dt)).to be(dt) end - context "when value is a parseable string" do - it "parses ISO 8601 datetime string" do - result = coercion.call("2023-12-25T14:30:00") - - expect(result).to be_a(DateTime) - expect(result.year).to eq(2023) - expect(result.month).to eq(12) - expect(result.day).to eq(25) - expect(result.hour).to eq(14) - expect(result.minute).to eq(30) - expect(result.second).to eq(0) - end - - it "parses date string" do - result = coercion.call("2023-12-25") - - expect(result).to be_a(DateTime) - expect(result.year).to eq(2023) - expect(result.month).to eq(12) - expect(result.day).to eq(25) - end - - it "parses datetime with timezone" do - result = coercion.call("2023-12-25T14:30:00+02:00") - - expect(result).to be_a(DateTime) - expect(result.year).to eq(2023) - expect(result.month).to eq(12) - expect(result.day).to eq(25) - expect(result.hour).to eq(14) - expect(result.minute).to eq(30) - expect(result.offset).to eq(Rational(2, 24)) - end - - it "parses slash format date" do - result = coercion.call("2023/12/25") - - expect(result).to be_a(DateTime) - expect(result.year).to eq(2023) - expect(result.month).to eq(12) - expect(result.day).to eq(25) - end - - it "parses natural language date" do - result = coercion.call("December 25, 2023") - - expect(result).to be_a(DateTime) - expect(result.year).to eq(2023) - expect(result.month).to eq(12) - expect(result.day).to eq(25) - end + it "parses an ISO-8601 string" do + expect(described_class.call("2024-01-02T03:04:05Z")) + .to eq(DateTime.new(2024, 1, 2, 3, 4, 5)) end - context "with strptime option" do - it "parses string using custom format" do - result = coercion.call("25-12-2023 14:30", strptime: "%d-%m-%Y %H:%M") - - expect(result).to be_a(DateTime) - expect(result.year).to eq(2023) - expect(result.month).to eq(12) - expect(result.day).to eq(25) - expect(result.hour).to eq(14) - expect(result.minute).to eq(30) - end - - it "parses date-only string with custom format" do - result = coercion.call("25/12/2023", strptime: "%d/%m/%Y") - - expect(result).to be_a(DateTime) - expect(result.year).to eq(2023) - expect(result.month).to eq(12) - expect(result.day).to eq(25) - end - - it "raises error when string doesn't match strptime format" do - expect { coercion.call("2023-12-25", strptime: "%d/%m/%Y") } - .to raise_error(CMDx::CoercionError, "could not coerce into a date time") - end + it "honors :strptime" do + expect(described_class.call("02-01-2024", strptime: "%d-%m-%Y")) + .to eq(DateTime.new(2024, 1, 2)) end - context "when value is invalid" do - it "raises CoercionError for invalid date string" do - expect { coercion.call("invalid-date") } - .to raise_error(CMDx::CoercionError, "could not coerce into a date time") - end - - it "raises CoercionError for empty string" do - expect { coercion.call("") } - .to raise_error(CMDx::CoercionError, "could not coerce into a date time") - end - - it "raises CoercionError for nil" do - expect { coercion.call(nil) } - .to raise_error(CMDx::CoercionError, "could not coerce into a date time") - end - - it "raises CoercionError for integer" do - expect { coercion.call(123) } - .to raise_error(CMDx::CoercionError, "could not coerce into a date time") - end - - it "raises CoercionError for float" do - expect { coercion.call(12.34) } - .to raise_error(CMDx::CoercionError, "could not coerce into a date time") - end - - it "raises CoercionError for boolean" do - expect { coercion.call(true) } - .to raise_error(CMDx::CoercionError, "could not coerce into a date time") - end - - it "raises CoercionError for array" do - expect { coercion.call([2023, 12, 25]) } - .to raise_error(CMDx::CoercionError, "could not coerce into a date time") - end - - it "raises CoercionError for hash" do - expect { coercion.call({ year: 2023, month: 12, day: 25 }) } - .to raise_error(CMDx::CoercionError, "could not coerce into a date time") - end - - it "raises CoercionError for symbol" do - expect { coercion.call(:datetime) } - .to raise_error(CMDx::CoercionError, "could not coerce into a date time") - end - - it "raises CoercionError for object" do - expect { coercion.call(Object.new) } - .to raise_error(CMDx::CoercionError, "could not coerce into a date time") - end + it "calls #to_datetime when present" do + expect(described_class.call(Date.new(2024, 1, 2))).to eq(DateTime.new(2024, 1, 2)) + end - it "raises CoercionError for invalid date components" do - expect { coercion.call("2023-13-32") } - .to raise_error(CMDx::CoercionError, "could not coerce into a date time") - end + it "returns a Failure for unknown types" do + expect(described_class.call(42)).to be_a(CMDx::Coercions::Failure) + end - it "raises CoercionError for malformed datetime string" do - expect { coercion.call("2023/12/25T25:61:70") } - .to raise_error(CMDx::CoercionError, "could not coerce into a date time") - end + it "returns a Failure for malformed strings" do + expect(described_class.call("nope")).to be_a(CMDx::Coercions::Failure) end end end diff --git a/spec/cmdx/coercions/float_spec.rb b/spec/cmdx/coercions/float_spec.rb index 86349be0e..f1825940b 100644 --- a/spec/cmdx/coercions/float_spec.rb +++ b/spec/cmdx/coercions/float_spec.rb @@ -2,226 +2,22 @@ require "spec_helper" -RSpec.describe CMDx::Coercions::Float, type: :unit do - subject(:coercion) { described_class } - +RSpec.describe CMDx::Coercions::Float do describe ".call" do - context "when value is a valid string" do - it "coerces string integer to Float" do - result = coercion.call("123") - - expect(result).to be_a(Float) - expect(result).to eq(123.0) - end - - it "coerces string decimal to Float" do - result = coercion.call("123.456") - - expect(result).to be_a(Float) - expect(result).to eq(123.456) - end - - it "coerces negative string to Float" do - result = coercion.call("-123.456") - - expect(result).to be_a(Float) - expect(result).to eq(-123.456) - end - - it "coerces zero string to Float" do - result = coercion.call("0") - - expect(result).to be_a(Float) - expect(result).to eq(0.0) - end - - it "coerces negative zero string to Float" do - result = coercion.call("-0") - - expect(result).to be_a(Float) - expect(result).to eq(-0.0) - end - - it "coerces string with scientific notation to Float" do - result = coercion.call("1.23e4") - - expect(result).to be_a(Float) - expect(result).to eq(1.23e4) - end - - it "coerces string with negative scientific notation to Float" do - result = coercion.call("-1.23e-4") - - expect(result).to be_a(Float) - expect(result).to eq(-1.23e-4) - end - - it "coerces string with positive exponent notation to Float" do - result = coercion.call("1.5E+2") - - expect(result).to be_a(Float) - expect(result).to eq(150.0) - end - - it "coerces string with leading/trailing whitespace to Float" do - result = coercion.call(" 123.45 ") - - expect(result).to be_a(Float) - expect(result).to eq(123.45) - end + it "converts numeric strings" do + expect(described_class.call("3.14")).to eq(3.14) end - context "when value is a numeric type" do - it "coerces integer to Float" do - result = coercion.call(42) - - expect(result).to be_a(Float) - expect(result).to eq(42.0) - end - - it "coerces negative integer to Float" do - result = coercion.call(-42) - - expect(result).to be_a(Float) - expect(result).to eq(-42.0) - end - - it "coerces zero integer to Float" do - result = coercion.call(0) - - expect(result).to be_a(Float) - expect(result).to eq(0.0) - end - - it "returns Float unchanged" do - value = 123.456 - result = coercion.call(value) - - expect(result).to be_a(Float) - expect(result).to eq(value) - expect(result).to be(value) - end - - it "coerces BigDecimal to Float" do - value = BigDecimal("123.456") - result = coercion.call(value) - - expect(result).to be_a(Float) - expect(result).to eq(123.456) - end - - it "coerces Rational to Float" do - value = Rational(3, 4) - result = coercion.call(value) - - expect(result).to be_a(Float) - expect(result).to eq(0.75) - end - - it "coerces Complex with zero imaginary part to Float" do - value = Complex(123.456, 0) - result = coercion.call(value) - - expect(result).to be_a(Float) - expect(result).to eq(123.456) - end - end - - context "when value is an invalid type" do - it "raises CoercionError for non-numeric string" do - expect { coercion.call("not_a_number") } - .to raise_error(CMDx::CoercionError, "could not coerce into a float") - end - - it "raises CoercionError for empty string" do - expect { coercion.call("") } - .to raise_error(CMDx::CoercionError, "could not coerce into a float") - end - - it "raises CoercionError for string with letters mixed with numbers" do - expect { coercion.call("123abc") } - .to raise_error(CMDx::CoercionError, "could not coerce into a float") - end - - it "raises CoercionError for string with multiple decimal points" do - expect { coercion.call("123.45.67") } - .to raise_error(CMDx::CoercionError, "could not coerce into a float") - end - - it "raises CoercionError for nil" do - expect { coercion.call(nil) } - .to raise_error(CMDx::CoercionError, "could not coerce into a float") - end - - it "raises CoercionError for boolean true" do - expect { coercion.call(true) } - .to raise_error(CMDx::CoercionError, "could not coerce into a float") - end - - it "raises CoercionError for boolean false" do - expect { coercion.call(false) } - .to raise_error(CMDx::CoercionError, "could not coerce into a float") - end - - it "raises CoercionError for array" do - expect { coercion.call([1, 2, 3]) } - .to raise_error(CMDx::CoercionError, "could not coerce into a float") - end - - it "raises CoercionError for hash" do - expect { coercion.call({ key: "value" }) } - .to raise_error(CMDx::CoercionError, "could not coerce into a float") - end - - it "raises CoercionError for symbol" do - expect { coercion.call(:symbol) } - .to raise_error(CMDx::CoercionError, "could not coerce into a float") - end - - it "raises CoercionError for Complex with non-zero imaginary part" do - expect { coercion.call(Complex(1, 2)) } - .to raise_error(CMDx::CoercionError, "could not coerce into a float") - end - - it "raises CoercionError for infinity string" do - expect { coercion.call("Infinity") } - .to raise_error(CMDx::CoercionError, "could not coerce into a float") - end - - it "raises CoercionError for negative infinity string" do - expect { coercion.call("-Infinity") } - .to raise_error(CMDx::CoercionError, "could not coerce into a float") - end - - it "raises CoercionError for NaN string" do - expect { coercion.call("NaN") } - .to raise_error(CMDx::CoercionError, "could not coerce into a float") - end + it "converts integers" do + expect(described_class.call(42)).to eq(42.0) end - context "when value is out of range" do - it "converts extremely large numbers to Infinity" do - large_number = "1" + ("0" * 400) - result = coercion.call(large_number) - - expect(result).to be_a(Float) - expect(result).to be_infinite - expect(result).to be > 0 - end + it "returns a Failure for unparseable input" do + expect(described_class.call("nope")).to be_a(CMDx::Coercions::Failure) end - context "with options parameter" do - it "ignores options and coerces successfully" do - result = coercion.call("123.45", precision: 2) - - expect(result).to be_a(Float) - expect(result).to eq(123.45) - end - - it "ignores options and raises error for invalid input" do - expect { coercion.call("invalid", precision: 2) } - .to raise_error(CMDx::CoercionError, "could not coerce into a float") - end + it "returns a Failure for nil" do + expect(described_class.call(nil)).to be_a(CMDx::Coercions::Failure) end end end diff --git a/spec/cmdx/coercions/hash_spec.rb b/spec/cmdx/coercions/hash_spec.rb index 86d232701..684214bce 100644 --- a/spec/cmdx/coercions/hash_spec.rb +++ b/spec/cmdx/coercions/hash_spec.rb @@ -2,286 +2,43 @@ require "spec_helper" -RSpec.describe CMDx::Coercions::Hash, type: :unit do - subject(:coercion) { described_class } - +RSpec.describe CMDx::Coercions::Hash do describe ".call" do - context "when value is nil" do - it "returns an empty hash" do - hash = {} - - result = coercion.call(nil) - - expect(result).to be_a(Hash) - expect(result).to eq(hash) - end + it "returns an empty hash for nil" do + expect(described_class.call(nil)).to eq({}) end - context "when value is already a Hash" do - it "returns the hash unchanged" do - hash = { key: "value", nested: { inner: "data" } } - - result = coercion.call(hash) - - expect(result).to be_a(Hash) - expect(result).to eq(hash) - expect(result).to be(hash) - end - - it "returns an empty hash unchanged" do - hash = {} - - result = coercion.call(hash) - - expect(result).to be_a(Hash) - expect(result).to eq({}) - expect(result).to be(hash) - end + it "returns a Hash unchanged" do + h = { a: 1 } + expect(described_class.call(h)).to be(h) end - context "when value is an Array" do - it "converts even-length array to hash" do - array = [:key1, "value1", :key2, "value2"] - - result = coercion.call(array) - - expect(result).to be_a(Hash) - expect(result).to eq(key1: "value1", key2: "value2") - end - - it "converts array with string keys to hash" do - array = %w[name John age 30] - - result = coercion.call(array) - - expect(result).to be_a(Hash) - expect(result).to eq("name" => "John", "age" => "30") - end - - it "converts array with mixed types to hash" do - array = [:symbol, 123, "string", true] - - result = coercion.call(array) - - expect(result).to be_a(Hash) - expect(result).to eq(symbol: 123, "string" => true) - end - - it "converts empty array to empty hash" do - result = coercion.call([]) - - expect(result).to be_a(Hash) - expect(result).to eq({}) - end - - it "raises CoercionError for odd-length array" do - expect { coercion.call([:key1, "value1", :key2]) } - .to raise_error(CMDx::CoercionError, "could not coerce into a hash") - end + it "parses JSON object strings" do + expect(described_class.call('{"a":1}')).to eq({ "a" => 1 }) end - context "when value is a JSON string" do - it "parses valid JSON object string" do - json_string = '{"name": "John", "age": 30}' - - result = coercion.call(json_string) - - expect(result).to be_a(Hash) - expect(result).to eq("name" => "John", "age" => 30) - end - - it "parses JSON string with nested objects" do - json_string = '{"user": {"name": "John", "details": {"age": 30}}}' - - result = coercion.call(json_string) - - expect(result).to be_a(Hash) - expect(result).to eq("user" => { "name" => "John", "details" => { "age" => 30 } }) - end - - it "parses JSON string with arrays" do - json_string = '{"tags": ["ruby", "programming"], "count": 2}' - - result = coercion.call(json_string) - - expect(result).to be_a(Hash) - expect(result).to eq("tags" => %w[ruby programming], "count" => 2) - end - - it "parses empty JSON object" do - result = coercion.call("{}") - - expect(result).to be_a(Hash) - expect(result).to eq({}) - end - - it "parses JSON null string as empty hash" do - result = coercion.call("null") - - expect(result).to be_a(Hash) - expect(result).to eq({}) - end - - it "parses JSON null string with whitespace as empty hash" do - result = coercion.call(" null ") - - expect(result).to be_a(Hash) - expect(result).to eq({}) - end - - it "raises CoercionError for invalid JSON" do - expect { coercion.call('{"invalid": json}') } - .to raise_error(CMDx::CoercionError, "could not coerce into a hash") - end - - it "raises CoercionError for JSON array string" do - expect { coercion.call('["not", "a", "hash"]') } - .to raise_error(CMDx::CoercionError, "could not coerce into a hash") - end - - it "raises CoercionError for JSON string primitive" do - expect { coercion.call('"just a string"') } - .to raise_error(CMDx::CoercionError, "could not coerce into a hash") - end - - it "raises CoercionError for unclosed JSON" do - expect { coercion.call('{"unclosed": "object"') } - .to raise_error(CMDx::CoercionError, "could not coerce into a hash") - end + it "returns a Failure for JSON arrays" do + expect(described_class.call("[1,2]")).to be_a(CMDx::Coercions::Failure) end - context "when value is an object that responds to to_h" do - it "converts the object to a hash" do - object = Object.new - def object.to_h - { key: "value" } - end - - result = coercion.call(object) - - expect(result).to be_a(Hash) - expect(result).to eq(key: "value") - end + it "calls #to_hash when available" do + obj = Object.new + def obj.to_hash = { x: 1 } + expect(described_class.call(obj)).to eq({ x: 1 }) end - context "when value is invalid" do - it "raises CoercionError for string not starting with '{'" do - expect { coercion.call("not json") } - .to raise_error(CMDx::CoercionError, "could not coerce into a hash") - end - - it "raises CoercionError for integer" do - expect { coercion.call(123) } - .to raise_error(CMDx::CoercionError, "could not coerce into a hash") - end - - it "raises CoercionError for float" do - expect { coercion.call(123.45) } - .to raise_error(CMDx::CoercionError, "could not coerce into a hash") - end - - it "raises CoercionError for boolean true" do - expect { coercion.call(true) } - .to raise_error(CMDx::CoercionError, "could not coerce into a hash") - end - - it "raises CoercionError for boolean false" do - expect { coercion.call(false) } - .to raise_error(CMDx::CoercionError, "could not coerce into a hash") - end - - it "raises CoercionError for symbol" do - expect { coercion.call(:symbol) } - .to raise_error(CMDx::CoercionError, "could not coerce into a hash") - end - - it "raises CoercionError for object" do - expect { coercion.call(Object.new) } - .to raise_error(CMDx::CoercionError, "could not coerce into a hash") - end - - it "raises CoercionError for empty string" do - expect { coercion.call("") } - .to raise_error(CMDx::CoercionError, "could not coerce into a hash") - end - - it "raises CoercionError for string starting with '{' but invalid JSON" do - expect { coercion.call("{not valid json}") } - .to raise_error(CMDx::CoercionError, "could not coerce into a hash") - end + it "falls back to #to_h" do + obj = Object.new + def obj.to_h = { y: 2 } + expect(described_class.call(obj)).to eq({ y: 2 }) end - context "with options parameter" do - it "ignores options and converts valid hash" do - hash = { key: "value" } - - result = coercion.call(hash, some: "option") - - expect(result).to be_a(Hash) - expect(result).to eq(hash) - end - - it "ignores options and converts valid array" do - array = [:key, "value"] - - result = coercion.call(array, precision: 2) - - expect(result).to be_a(Hash) - expect(result).to eq(key: "value") - end - - it "ignores options and converts valid JSON string" do - json_string = '{"test": "value"}' - - result = coercion.call(json_string, format: :json) - - expect(result).to be_a(Hash) - expect(result).to eq("test" => "value") - end - - it "ignores options and raises error for invalid input" do - expect { coercion.call("invalid", some: "option") } - .to raise_error(CMDx::CoercionError, "could not coerce into a hash") - end + it "returns a Failure for unknown types" do + expect(described_class.call(42)).to be_a(CMDx::Coercions::Failure) end - context "with edge cases" do - it "handles array with nil values" do - array = [:key1, nil, :key2, "value"] - - result = coercion.call(array) - - expect(result).to be_a(Hash) - expect(result).to eq(key1: nil, key2: "value") - end - - it "handles JSON string with null values" do - json_string = '{"key1": null, "key2": "value"}' - - result = coercion.call(json_string) - - expect(result).to be_a(Hash) - expect(result).to eq("key1" => nil, "key2" => "value") - end - - it "handles JSON string with special characters" do - json_string = '{"special": "\\n\\t\\r\\"", "unicode": "\\u0041"}' - - result = coercion.call(json_string) - - expect(result).to be_a(Hash) - expect(result).to eq("special" => "\n\t\r\"", "unicode" => "A") - end - - it "handles very large JSON string" do - large_hash = (1..100).to_h { |i| ["key#{i}", "value#{i}"] } - json_string = large_hash.to_json - - result = coercion.call(json_string) - - expect(result).to be_a(Hash) - expect(result).to eq(large_hash) - end + it "returns a Failure for malformed JSON strings" do + expect(described_class.call("{bad")).to be_a(CMDx::Coercions::Failure) end end end diff --git a/spec/cmdx/coercions/integer_spec.rb b/spec/cmdx/coercions/integer_spec.rb index f9eac66b1..9da0ae61a 100644 --- a/spec/cmdx/coercions/integer_spec.rb +++ b/spec/cmdx/coercions/integer_spec.rb @@ -2,203 +2,26 @@ require "spec_helper" -RSpec.describe CMDx::Coercions::Integer, type: :unit do - subject(:coercion) { described_class } - +RSpec.describe CMDx::Coercions::Integer do describe ".call" do - context "when value is a valid string" do - it "coerces string integer to Integer" do - result = coercion.call("123") - - expect(result).to be_a(Integer) - expect(result).to eq(123) - end - - it "coerces negative string to Integer" do - result = coercion.call("-456") - - expect(result).to be_a(Integer) - expect(result).to eq(-456) - end - - it "coerces zero string to Integer" do - result = coercion.call("0") - - expect(result).to be_a(Integer) - expect(result).to eq(0) - end - - it "coerces string with leading/trailing whitespace to Integer" do - result = coercion.call(" 789 ") - - expect(result).to be_a(Integer) - expect(result).to eq(789) - end - - it "coerces string with positive sign to Integer" do - result = coercion.call("+42") - - expect(result).to be_a(Integer) - expect(result).to eq(42) - end - - it "coerces octal string to Integer" do - result = coercion.call("0777") - - expect(result).to be_a(Integer) - expect(result).to eq(511) - end - - it "coerces hexadecimal string to Integer" do - result = coercion.call("0xFF") - - expect(result).to be_a(Integer) - expect(result).to eq(255) - end - - it "coerces binary string to Integer" do - result = coercion.call("0b1010") - - expect(result).to be_a(Integer) - expect(result).to eq(10) - end + it "converts numeric strings" do + expect(described_class.call("42")).to eq(42) end - context "when value is a numeric type" do - it "coerces Integer to Integer" do - result = coercion.call(123) - - expect(result).to be_a(Integer) - expect(result).to eq(123) - end - - it "coerces negative Integer to Integer" do - result = coercion.call(-456) - - expect(result).to be_a(Integer) - expect(result).to eq(-456) - end - - it "coerces Float to Integer" do - result = coercion.call(123.0) - - expect(result).to be_a(Integer) - expect(result).to eq(123) - end - - it "coerces negative Float to Integer" do - result = coercion.call(-456.0) - - expect(result).to be_a(Integer) - expect(result).to eq(-456) - end - - it "coerces Rational to Integer" do - result = coercion.call(Rational(15, 3)) - - expect(result).to be_a(Integer) - expect(result).to eq(5) - end - - it "coerces BigDecimal to Integer" do - result = coercion.call(BigDecimal(123)) - - expect(result).to be_a(Integer) - expect(result).to eq(123) - end - - it "coerces Complex with zero imaginary part to Integer" do - result = coercion.call(Complex(123, 0)) - - expect(result).to be_a(Integer) - expect(result).to eq(123) - end + it "converts floats" do + expect(described_class.call(3.9)).to eq(3) end - context "when value has fractional part" do - it "truncates Float with fractional part to Integer" do - result = coercion.call(123.456) - - expect(result).to be_a(Integer) - expect(result).to eq(123) - end - - it "truncates negative Float with fractional part to Integer" do - result = coercion.call(-123.456) - - expect(result).to be_a(Integer) - expect(result).to eq(-123) - end - - it "raises CoercionError for string decimal" do - expect { coercion.call("123.789") }.to raise_error(CMDx::CoercionError) - end + it "returns a Failure for unparseable input" do + expect(described_class.call("nope")).to be_a(CMDx::Coercions::Failure) end - context "when value is invalid" do - it "raises CoercionError for invalid string" do - expect { coercion.call("abc") }.to raise_error(CMDx::CoercionError) - end - - it "raises CoercionError for empty string" do - expect { coercion.call("") }.to raise_error(CMDx::CoercionError) - end - - it "raises CoercionError for nil" do - expect { coercion.call(nil) }.to raise_error(CMDx::CoercionError) - end - - it "raises CoercionError for boolean" do - expect { coercion.call(true) }.to raise_error(CMDx::CoercionError) - end - - it "raises CoercionError for array" do - expect { coercion.call([1, 2, 3]) }.to raise_error(CMDx::CoercionError) - end - - it "raises CoercionError for hash" do - expect { coercion.call({ a: 1 }) }.to raise_error(CMDx::CoercionError) - end - - it "raises CoercionError for object" do - expect { coercion.call(Object.new) }.to raise_error(CMDx::CoercionError) - end - - it "raises CoercionError for Complex with non-zero imaginary part" do - expect { coercion.call(Complex(1, 2)) }.to raise_error(CMDx::CoercionError) - end - - it "raises CoercionError for Infinity" do - expect { coercion.call(Float::INFINITY) }.to raise_error(CMDx::CoercionError) - end - - it "raises CoercionError for NaN" do - expect { coercion.call(Float::NAN) }.to raise_error(CMDx::CoercionError) - end - - it "raises CoercionError for string with invalid characters" do - expect { coercion.call("123abc") }.to raise_error(CMDx::CoercionError) - end - - it "raises CoercionError for string with multiple decimal points" do - expect { coercion.call("12.34.56") }.to raise_error(CMDx::CoercionError) - end - - it "raises CoercionError for value that triggers RangeError" do - # Test using a value that would trigger RangeError in Integer conversion - very_large_number = ("9" * 1000) + ".0" - - expect { coercion.call(very_large_number) }.to raise_error(CMDx::CoercionError) - end + it "returns a Failure for nil" do + expect(described_class.call(nil)).to be_a(CMDx::Coercions::Failure) end - context "with options parameter" do - it "accepts options parameter but ignores it" do - result = coercion.call("123", { unused_option: true }) - - expect(result).to be_a(Integer) - expect(result).to eq(123) - end + it "returns a Failure for Float::INFINITY" do + expect(described_class.call(Float::INFINITY)).to be_a(CMDx::Coercions::Failure) end end end diff --git a/spec/cmdx/coercions/rational_spec.rb b/spec/cmdx/coercions/rational_spec.rb index aed4f6439..6af562e79 100644 --- a/spec/cmdx/coercions/rational_spec.rb +++ b/spec/cmdx/coercions/rational_spec.rb @@ -2,255 +2,27 @@ require "spec_helper" -RSpec.describe CMDx::Coercions::Rational, type: :unit do - subject(:coercion) { described_class } - +RSpec.describe CMDx::Coercions::Rational do describe ".call" do - context "when value is a valid string" do - it "coerces string integer to Rational" do - result = coercion.call("123") - - expect(result).to be_a(Rational) - expect(result).to eq(Rational(123)) - end - - it "coerces string fraction to Rational" do - result = coercion.call("3/4") - - expect(result).to be_a(Rational) - expect(result).to eq(Rational(3, 4)) - end - - it "coerces string decimal to Rational" do - result = coercion.call("0.5") - - expect(result).to be_a(Rational) - expect(result).to eq(Rational(1, 2)) - end - - it "coerces negative string to Rational" do - result = coercion.call("-456") - - expect(result).to be_a(Rational) - expect(result).to eq(Rational(-456)) - end - - it "coerces negative fraction string to Rational" do - result = coercion.call("-3/4") - - expect(result).to be_a(Rational) - expect(result).to eq(Rational(-3, 4)) - end - - it "coerces zero string to Rational" do - result = coercion.call("0") - - expect(result).to be_a(Rational) - expect(result).to eq(Rational(0)) - end - - it "coerces string with positive sign to Rational" do - result = coercion.call("+42") - - expect(result).to be_a(Rational) - expect(result).to eq(Rational(42)) - end - - it "coerces string with leading/trailing whitespace to Rational" do - result = coercion.call(" 3/5 ") - - expect(result).to be_a(Rational) - expect(result).to eq(Rational(3, 5)) - end - - it "coerces improper fraction string to Rational" do - result = coercion.call("7/3") - - expect(result).to be_a(Rational) - expect(result).to eq(Rational(7, 3)) - end - - it "coerces decimal with many digits to Rational" do - result = coercion.call("0.125") - - expect(result).to be_a(Rational) - expect(result).to eq(Rational(1, 8)) - end - - it "coerces scientific notation string to Rational" do - result = coercion.call("1e3") - - expect(result).to be_a(Rational) - expect(result).to eq(Rational(1000)) - end + it "returns a Rational unchanged" do + r = Rational(1, 2) + expect(described_class.call(r)).to be(r) end - context "when value is a valid number" do - it "coerces integer to Rational" do - result = coercion.call(42) - - expect(result).to be_a(Rational) - expect(result).to eq(Rational(42)) - end - - it "coerces negative integer to Rational" do - result = coercion.call(-42) - - expect(result).to be_a(Rational) - expect(result).to eq(Rational(-42)) - end - - it "coerces zero to Rational" do - result = coercion.call(0) - - expect(result).to be_a(Rational) - expect(result).to eq(Rational(0)) - end - - it "coerces float to Rational" do - result = coercion.call(0.75) - - expect(result).to be_a(Rational) - expect(result).to eq(Rational(3, 4)) - end - - it "coerces negative float to Rational" do - result = coercion.call(-0.25) - - expect(result).to be_a(Rational) - expect(result).to eq(Rational(-1, 4)) - end - - it "coerces BigDecimal to Rational" do - result = coercion.call(BigDecimal("0.333")) - - expect(result).to be_a(Rational) - expect(result).to eq(Rational(333, 1000)) - end - - it "coerces existing Rational to Rational" do - input = Rational(5, 8) - result = coercion.call(input) - - expect(result).to be_a(Rational) - expect(result).to eq(Rational(5, 8)) - expect(result).to be(input) - end - - it "coerces Complex with zero imaginary part to Rational" do - result = coercion.call(Complex(3, 0)) - - expect(result).to be_a(Rational) - expect(result).to eq(Rational(3)) - end + it "parses numeric strings" do + expect(described_class.call("1/3")).to eq(Rational(1, 3)) end - context "when value is invalid" do - it "raises CoercionError for non-numeric string" do - expect { coercion.call("not_a_number") } - .to raise_error(CMDx::CoercionError, "could not coerce into a rational") - end - - it "raises CoercionError for empty string" do - expect { coercion.call("") } - .to raise_error(CMDx::CoercionError, "could not coerce into a rational") - end - - it "raises CoercionError for string with letters mixed with numbers" do - expect { coercion.call("123abc") } - .to raise_error(CMDx::CoercionError, "could not coerce into a rational") - end - - it "raises CoercionError for string with invalid fraction" do - expect { coercion.call("3//4") } - .to raise_error(CMDx::CoercionError, "could not coerce into a rational") - end - - it "raises CoercionError for nil" do - expect { coercion.call(nil) } - .to raise_error(CMDx::CoercionError, "could not coerce into a rational") - end - - it "raises CoercionError for boolean true" do - expect { coercion.call(true) } - .to raise_error(CMDx::CoercionError, "could not coerce into a rational") - end - - it "raises CoercionError for boolean false" do - expect { coercion.call(false) } - .to raise_error(CMDx::CoercionError, "could not coerce into a rational") - end - - it "raises CoercionError for array" do - expect { coercion.call([1, 2, 3]) } - .to raise_error(CMDx::CoercionError, "could not coerce into a rational") - end - - it "raises CoercionError for hash" do - expect { coercion.call({ key: "value" }) } - .to raise_error(CMDx::CoercionError, "could not coerce into a rational") - end - - it "raises CoercionError for symbol" do - expect { coercion.call(:symbol) } - .to raise_error(CMDx::CoercionError, "could not coerce into a rational") - end - - it "raises CoercionError for object" do - expect { coercion.call(Object.new) } - .to raise_error(CMDx::CoercionError, "could not coerce into a rational") - end - - it "raises CoercionError for Complex with non-zero imaginary part" do - expect { coercion.call(Complex(1, 2)) } - .to raise_error(CMDx::CoercionError, "could not coerce into a rational") - end - - it "raises CoercionError for Infinity" do - expect { coercion.call(Float::INFINITY) } - .to raise_error(CMDx::CoercionError, "could not coerce into a rational") - end - - it "raises CoercionError for NaN" do - expect { coercion.call(Float::NAN) } - .to raise_error(CMDx::CoercionError, "could not coerce into a rational") - end - - it "raises CoercionError for division by zero string" do - expect { coercion.call("1/0") } - .to raise_error(CMDx::CoercionError, "could not coerce into a rational") - end - - it "raises CoercionError for string with multiple decimal points" do - expect { coercion.call("1.2.3") } - .to raise_error(CMDx::CoercionError, "could not coerce into a rational") - end - - it "raises CoercionError for string with multiple slashes" do - expect { coercion.call("1/2/3") } - .to raise_error(CMDx::CoercionError, "could not coerce into a rational") - end - - it "raises CoercionError for string with text after number" do - expect { coercion.call("42units") } - .to raise_error(CMDx::CoercionError, "could not coerce into a rational") - end + it "accepts :denominator" do + expect(described_class.call(3, denominator: 4)).to eq(Rational(3, 4)) end - context "with options parameter" do - it "accepts options parameter but ignores it" do - result = coercion.call("3/4", { unused_option: true }) - - expect(result).to be_a(Rational) - expect(result).to eq(Rational(3, 4)) - end - - it "accepts empty options hash" do - result = coercion.call("1/2", {}) + it "returns a Failure for unparseable input" do + expect(described_class.call("nope")).to be_a(CMDx::Coercions::Failure) + end - expect(result).to be_a(Rational) - expect(result).to eq(Rational(1, 2)) - end + it "returns a Failure for zero division" do + expect(described_class.call(1, denominator: 0)).to be_a(CMDx::Coercions::Failure) end end end diff --git a/spec/cmdx/coercions/string_spec.rb b/spec/cmdx/coercions/string_spec.rb index 9a3e2ee02..5a164db0d 100644 --- a/spec/cmdx/coercions/string_spec.rb +++ b/spec/cmdx/coercions/string_spec.rb @@ -2,274 +2,18 @@ require "spec_helper" -RSpec.describe CMDx::Coercions::String, type: :unit do - subject(:coercion) { described_class } - +RSpec.describe CMDx::Coercions::String do describe ".call" do - context "when value is already a String" do - it "returns the string unchanged" do - string = "hello world" - - result = coercion.call(string) - - expect(result).to be_a(String) - expect(result).to eq("hello world") - expect(result).to be(string) - end - - it "returns an empty string unchanged" do - string = "" - - result = coercion.call(string) - - expect(result).to be_a(String) - expect(result).to eq("") - expect(result).to be(string) - end - - it "returns string with special characters unchanged" do - string = "hello\nworld\t!" - - result = coercion.call(string) - - expect(result).to be_a(String) - expect(result).to eq("hello\nworld\t!") - expect(result).to be(string) - end - - it "returns unicode string unchanged" do - string = "héllo wörld 🌍" - - result = coercion.call(string) - - expect(result).to be_a(String) - expect(result).to eq("héllo wörld 🌍") - expect(result).to be(string) - end - end - - context "when value is a Symbol" do - it "converts symbol to string" do - result = coercion.call(:hello) - - expect(result).to be_a(String) - expect(result).to eq("hello") - end - - it "converts symbol with underscores to string" do - result = coercion.call(:hello_world) - - expect(result).to be_a(String) - expect(result).to eq("hello_world") - end - - it "converts empty symbol to string" do - result = coercion.call(:"") - - expect(result).to be_a(String) - expect(result).to eq("") - end + it "converts numbers" do + expect(described_class.call(42)).to eq("42") end - context "when value is a numeric type" do - it "converts integer to string" do - result = coercion.call(123) - - expect(result).to be_a(String) - expect(result).to eq("123") - end - - it "converts negative integer to string" do - result = coercion.call(-456) - - expect(result).to be_a(String) - expect(result).to eq("-456") - end - - it "converts zero to string" do - result = coercion.call(0) - - expect(result).to be_a(String) - expect(result).to eq("0") - end - - it "converts float to string" do - result = coercion.call(123.456) - - expect(result).to be_a(String) - expect(result).to eq("123.456") - end - - it "converts Rational to string" do - result = coercion.call(Rational(3, 4)) - - expect(result).to be_a(String) - expect(result).to eq("3/4") - end - - it "converts Complex to string" do - result = coercion.call(Complex(3, 4)) - - expect(result).to be_a(String) - expect(result).to eq("3+4i") - end - - it "converts BigDecimal to string" do - result = coercion.call(BigDecimal("123.456")) - - expect(result).to be_a(String) - expect(result).to eq("0.123456e3") - end + it "converts symbols" do + expect(described_class.call(:hello)).to eq("hello") end - context "when value is a boolean" do - it "converts true to string" do - result = coercion.call(true) - - expect(result).to be_a(String) - expect(result).to eq("true") - end - - it "converts false to string" do - result = coercion.call(false) - - expect(result).to be_a(String) - expect(result).to eq("false") - end - end - - context "when value is nil" do - it "converts nil to empty string" do - result = coercion.call(nil) - - expect(result).to be_a(String) - expect(result).to eq("") - end - end - - context "when value is a collection" do - it "converts array to string" do - result = coercion.call([1, 2, 3]) - - expect(result).to be_a(String) - expect(result).to eq("[1, 2, 3]") - end - - it "converts empty array to string" do - result = coercion.call([]) - - expect(result).to be_a(String) - expect(result).to eq("[]") - end - - it "converts hash to string" do - result = coercion.call({ a: 1, b: 2 }) - - expect(result).to be_a(String) - - if RubyVersion.min?(3.4) - expect(result).to eq("{a: 1, b: 2}") - else - expect(result).to eq("{:a=>1, :b=>2}") - end - end - - it "converts empty hash to string" do - result = coercion.call({}) - - expect(result).to be_a(String) - expect(result).to eq("{}") - end - - it "converts set to string" do - result = coercion.call(Set.new([1, 2, 3])) - - expect(result).to be_a(String) - - if RubyVersion.min?(4.0) - expect(result).to eq("Set[1, 2, 3]") - else - expect(result).to eq("#<Set: {1, 2, 3}>") - end - end - end - - context "when value is a special object" do - it "converts class to string" do - result = coercion.call(String) - - expect(result).to be_a(String) - expect(result).to eq("String") - end - - it "converts module to string" do - result = coercion.call(Enumerable) - - expect(result).to be_a(String) - expect(result).to eq("Enumerable") - end - - it "converts regex to string" do - result = coercion.call(/hello/) - - expect(result).to be_a(String) - expect(result).to eq("(?-mix:hello)") - end - - it "converts range to string" do - result = coercion.call(1..5) - - expect(result).to be_a(String) - expect(result).to eq("1..5") - end - - it "converts time to string" do - time = Time.new(2023, 12, 25, 10, 30, 45) - - result = coercion.call(time) - - expect(result).to be_a(String) - expect(result).to include("2023-12-25") - end - - it "converts date to string" do - date = Date.new(2023, 12, 25) - - result = coercion.call(date) - - expect(result).to be_a(String) - expect(result).to eq("2023-12-25") - end - end - - context "when value has custom to_s method" do - it "converts object with custom to_s to string" do - custom_object = Object.new - def custom_object.to_s - "custom object" - end - - result = coercion.call(custom_object) - - expect(result).to be_a(String) - expect(result).to eq("custom object") - end - end - - context "when options are provided" do - it "ignores options parameter" do - result = coercion.call(123, { format: :uppercase }) - - expect(result).to be_a(String) - expect(result).to eq("123") - end - - it "works with empty options hash" do - result = coercion.call("hello", {}) - - expect(result).to be_a(String) - expect(result).to eq("hello") - end + it "returns strings unchanged" do + expect(described_class.call("hi")).to eq("hi") end end end diff --git a/spec/cmdx/coercions/symbol_spec.rb b/spec/cmdx/coercions/symbol_spec.rb index a82483913..3c46de318 100644 --- a/spec/cmdx/coercions/symbol_spec.rb +++ b/spec/cmdx/coercions/symbol_spec.rb @@ -2,228 +2,23 @@ require "spec_helper" -RSpec.describe CMDx::Coercions::Symbol, type: :unit do - subject(:coercion) { described_class } - +RSpec.describe CMDx::Coercions::Symbol do describe ".call" do - context "when value is already a Symbol" do - it "returns the symbol unchanged" do - symbol = :hello - - result = coercion.call(symbol) - - expect(result).to be_a(Symbol) - expect(result).to eq(:hello) - expect(result).to be(symbol) - end - - it "returns empty symbol unchanged" do - symbol = :"" - - result = coercion.call(symbol) - - expect(result).to be_a(Symbol) - expect(result).to eq(:"") - expect(result).to be(symbol) - end - - it "returns symbol with special characters unchanged" do - symbol = :hello_world! - - result = coercion.call(symbol) - - expect(result).to be_a(Symbol) - expect(result).to eq(:hello_world!) - expect(result).to be(symbol) - end - - it "returns symbol with unicode characters unchanged" do - symbol = :héllo_wörld_🌍 - - result = coercion.call(symbol) - - expect(result).to be_a(Symbol) - expect(result).to eq(:héllo_wörld_🌍) - expect(result).to be(symbol) - end - end - - context "when value is a String" do - it "converts string to symbol" do - result = coercion.call("hello") - - expect(result).to be_a(Symbol) - expect(result).to eq(:hello) - end - - it "converts empty string to empty symbol" do - result = coercion.call("") - - expect(result).to be_a(Symbol) - expect(result).to eq(:"") - end - - it "converts string with special characters to symbol" do - result = coercion.call("hello_world!") - - expect(result).to be_a(Symbol) - expect(result).to eq(:hello_world!) - end - - it "converts string with spaces to symbol" do - result = coercion.call("hello world") - - expect(result).to be_a(Symbol) - expect(result).to eq(:"hello world") - end - - it "converts string with unicode characters to symbol" do - result = coercion.call("héllo wörld 🌍") - - expect(result).to be_a(Symbol) - expect(result).to eq(:"héllo wörld 🌍") - end - - it "converts string with newlines and tabs to symbol" do - result = coercion.call("hello\nworld\t!") - - expect(result).to be_a(Symbol) - expect(result).to eq(:"hello\nworld\t!") - end - - it "converts numeric string to symbol" do - result = coercion.call("123") - - expect(result).to be_a(Symbol) - expect(result).to eq(:"123") - end + it "returns a Symbol unchanged" do + expect(described_class.call(:a)).to eq(:a) end - context "when value has custom to_sym method" do - it "uses the custom to_sym method" do - custom_object = Object.new - def custom_object.to_sym - :custom_symbol - end - - result = coercion.call(custom_object) - - expect(result).to be_a(Symbol) - expect(result).to eq(:custom_symbol) - end + it "converts strings to symbols" do + expect(described_class.call("hello")).to eq(:hello) end - context "when value is invalid" do - it "raises CoercionError for nil" do - expect { coercion.call(nil) } - .to raise_error(CMDx::CoercionError, "could not coerce into a symbol") - end - - it "raises CoercionError for integer" do - expect { coercion.call(123) } - .to raise_error(CMDx::CoercionError, "could not coerce into a symbol") - end - - it "raises CoercionError for float" do - expect { coercion.call(3.14) } - .to raise_error(CMDx::CoercionError, "could not coerce into a symbol") - end - - it "raises CoercionError for rational" do - expect { coercion.call(Rational(3, 4)) } - .to raise_error(CMDx::CoercionError, "could not coerce into a symbol") - end - - it "raises CoercionError for complex" do - expect { coercion.call(Complex(1, 2)) } - .to raise_error(CMDx::CoercionError, "could not coerce into a symbol") - end - - it "raises CoercionError for BigDecimal" do - expect { coercion.call(BigDecimal("123.456")) } - .to raise_error(CMDx::CoercionError, "could not coerce into a symbol") - end - - it "raises CoercionError for boolean true" do - expect { coercion.call(true) } - .to raise_error(CMDx::CoercionError, "could not coerce into a symbol") - end - - it "raises CoercionError for boolean false" do - expect { coercion.call(false) } - .to raise_error(CMDx::CoercionError, "could not coerce into a symbol") - end - - it "raises CoercionError for array" do - expect { coercion.call([1, 2, 3]) } - .to raise_error(CMDx::CoercionError, "could not coerce into a symbol") - end - - it "raises CoercionError for hash" do - expect { coercion.call({ key: "value" }) } - .to raise_error(CMDx::CoercionError, "could not coerce into a symbol") - end - - it "raises CoercionError for object without to_sym method" do - expect { coercion.call(Object.new) } - .to raise_error(CMDx::CoercionError, "could not coerce into a symbol") - end - - it "raises CoercionError for class" do - expect { coercion.call(String) } - .to raise_error(CMDx::CoercionError, "could not coerce into a symbol") - end - - it "raises CoercionError for module" do - expect { coercion.call(Enumerable) } - .to raise_error(CMDx::CoercionError, "could not coerce into a symbol") - end - - it "raises CoercionError for proc" do - expect { coercion.call(proc { "hello" }) } - .to raise_error(CMDx::CoercionError, "could not coerce into a symbol") - end - - it "raises CoercionError for lambda" do - expect { coercion.call(-> { "hello" }) } - .to raise_error(CMDx::CoercionError, "could not coerce into a symbol") - end - - it "raises CoercionError for method object" do - expect { coercion.call("test".method(:upcase)) } - .to raise_error(CMDx::CoercionError, "could not coerce into a symbol") - end - - it "raises CoercionError for regex" do - expect { coercion.call(/pattern/) } - .to raise_error(CMDx::CoercionError, "could not coerce into a symbol") - end - - it "raises CoercionError for range" do - expect { coercion.call(1..10) } - .to raise_error(CMDx::CoercionError, "could not coerce into a symbol") - end - - it "raises CoercionError for set" do - expect { coercion.call(Set.new([1, 2, 3])) } - .to raise_error(CMDx::CoercionError, "could not coerce into a symbol") - end + it "converts numbers to symbols" do + expect(described_class.call(42)).to eq(:"42") end - context "with options parameter" do - it "ignores options and converts value normally" do - result = coercion.call("hello", { some: "option" }) - - expect(result).to be_a(Symbol) - expect(result).to eq(:hello) - end - - it "works with empty options hash" do - result = coercion.call("world", {}) - - expect(result).to be_a(Symbol) - expect(result).to eq(:world) - end + it "returns a Failure for objects without a to_s" do + obj = BasicObject.new + expect(described_class.call(obj)).to be_a(CMDx::Coercions::Failure) end end end diff --git a/spec/cmdx/coercions/time_spec.rb b/spec/cmdx/coercions/time_spec.rb index c444d012a..cb718b1c8 100644 --- a/spec/cmdx/coercions/time_spec.rb +++ b/spec/cmdx/coercions/time_spec.rb @@ -1,226 +1,39 @@ # frozen_string_literal: true require "spec_helper" +require "date" -RSpec.describe CMDx::Coercions::Time, type: :unit do - subject(:coercion) { described_class } - +RSpec.describe CMDx::Coercions::Time do describe ".call" do - context "when value is already an analog type" do - it "converts DateTime to Time" do - datetime = DateTime.new(2023, 12, 25, 14, 30, 0) - - result = coercion.call(datetime) - - expect(result).to be_a(Time) - expect(result.year).to eq(2023) - expect(result.month).to eq(12) - expect(result.day).to eq(25) - end - - it "returns Time unchanged" do - time = Time.new(2023, 12, 25, 14, 30, 0) - - result = coercion.call(time) - - expect(result).to eq(time) - end + it "returns a Time unchanged" do + t = Time.new(2024, 1, 2, 3, 4, 5) + expect(described_class.call(t)).to be(t) end - context "when value responds to to_time" do - it "calls to_time on the value" do - date = Date.new(2023, 12, 25) - - result = coercion.call(date) - - expect(result).to be_a(Time) - expect(result.year).to eq(2023) - expect(result.month).to eq(12) - expect(result.day).to eq(25) - end - - it "returns the to_time result" do - time_result = Time.new(2023, 1, 1) - value = instance_double("MockTime", to_time: time_result) - - result = coercion.call(value) - - expect(result).to eq(time_result) - end + it "parses a string" do + expect(described_class.call("2024-01-02 03:04:05")) + .to eq(Time.new(2024, 1, 2, 3, 4, 5)) end - context "with strptime option" do - it "parses string using custom format" do - result = coercion.call("25-12-2023 14:30", strptime: "%d-%m-%Y %H:%M") - - expect(result).to be_a(Time) - expect(result.year).to eq(2023) - expect(result.month).to eq(12) - expect(result.day).to eq(25) - expect(result.hour).to eq(14) - expect(result.min).to eq(30) - end - - it "parses date string with custom format" do - result = coercion.call("2023/12/25", strptime: "%Y/%m/%d") - - expect(result).to be_a(Time) - expect(result.year).to eq(2023) - expect(result.month).to eq(12) - expect(result.day).to eq(25) - end - - it "raises CoercionError when string doesn't match strptime format" do - expect { coercion.call("2023-12-25", strptime: "%d/%m/%Y") } - .to raise_error(CMDx::CoercionError, "could not coerce into a time") - end - - it "raises CoercionError for invalid date with strptime" do - expect { coercion.call("32/13/2023", strptime: "%d/%m/%Y") } - .to raise_error(CMDx::CoercionError, "could not coerce into a time") - end + it "honors :strptime" do + expect(described_class.call("02-01-2024", strptime: "%d-%m-%Y")) + .to eq(Time.new(2024, 1, 2)) end - context "when value is a parseable string" do - it "parses ISO 8601 time string" do - result = coercion.call("2023-12-25T14:30:00") - - expect(result).to be_a(Time) - expect(result.year).to eq(2023) - expect(result.month).to eq(12) - expect(result.day).to eq(25) - expect(result.hour).to eq(14) - expect(result.min).to eq(30) - expect(result.sec).to eq(0) - end - - it "parses date string" do - result = coercion.call("2023-12-25") - - expect(result).to be_a(Time) - expect(result.year).to eq(2023) - expect(result.month).to eq(12) - expect(result.day).to eq(25) - end - - it "parses time string" do - result = coercion.call("14:30:00") - - expect(result).to be_a(Time) - expect(result.hour).to eq(14) - expect(result.min).to eq(30) - expect(result.sec).to eq(0) - end - - it "parses RFC 2822 formatted string" do - result = coercion.call("Mon, 25 Dec 2023 14:30:00 +0000") - - expect(result).to be_a(Time) - expect(result.year).to eq(2023) - expect(result.month).to eq(12) - expect(result.day).to eq(25) - expect(result.hour).to eq(14) - expect(result.min).to eq(30) - end - - it "parses human-readable date string" do - result = coercion.call("December 25, 2023") - - expect(result).to be_a(Time) - expect(result.year).to eq(2023) - expect(result.month).to eq(12) - expect(result.day).to eq(25) - end + it "converts numerics via Time.at" do + expect(described_class.call(0)).to eq(Time.at(0)) end - context "when value cannot be coerced" do - it "raises CoercionError for invalid string" do - expect { coercion.call("invalid-time") } - .to raise_error(CMDx::CoercionError, "could not coerce into a time") - end - - it "raises CoercionError for empty string" do - expect { coercion.call("") } - .to raise_error(CMDx::CoercionError, "could not coerce into a time") - end - - it "raises CoercionError for nil" do - expect { coercion.call(nil) } - .to raise_error(CMDx::CoercionError, "could not coerce into a time") - end - - it "raises CoercionError for integer" do - expect { coercion.call(123) } - .to raise_error(CMDx::CoercionError, "could not coerce into a time") - end - - it "raises CoercionError for float" do - expect { coercion.call(123.45) } - .to raise_error(CMDx::CoercionError, "could not coerce into a time") - end - - it "raises CoercionError for boolean true" do - expect { coercion.call(true) } - .to raise_error(CMDx::CoercionError, "could not coerce into a time") - end - - it "raises CoercionError for boolean false" do - expect { coercion.call(false) } - .to raise_error(CMDx::CoercionError, "could not coerce into a time") - end - - it "raises CoercionError for array" do - expect { coercion.call([1, 2, 3]) } - .to raise_error(CMDx::CoercionError, "could not coerce into a time") - end - - it "raises CoercionError for hash" do - expect { coercion.call({ year: 2023 }) } - .to raise_error(CMDx::CoercionError, "could not coerce into a time") - end - - it "raises CoercionError for symbol" do - expect { coercion.call(:time) } - .to raise_error(CMDx::CoercionError, "could not coerce into a time") - end - - it "raises CoercionError for object" do - expect { coercion.call(Object.new) } - .to raise_error(CMDx::CoercionError, "could not coerce into a time") - end - - it "raises CoercionError when Time.parse raises ArgumentError" do - allow(Time).to receive(:parse).and_raise(ArgumentError) - - expect { coercion.call("some-string") } - .to raise_error(CMDx::CoercionError, "could not coerce into a time") - end - - it "raises CoercionError when Time.parse raises TypeError" do - allow(Time).to receive(:parse).and_raise(TypeError) - - expect { coercion.call("some-string") } - .to raise_error(CMDx::CoercionError, "could not coerce into a time") - end + it "calls #to_time when available" do + expect(described_class.call(DateTime.new(2024, 1, 2))).to be_a(Time) end - context "without options" do - it "calls Time.parse when no options provided" do - time_string = "2023-12-25T14:30:00" - parsed_time = Time.new(2023, 12, 25, 14, 30, 0) - - expect(Time).to receive(:parse).with(time_string).and_return(parsed_time) - - result = coercion.call(time_string) - - expect(result).to eq(parsed_time) - end - - it "does not call Time.strptime when no strptime option" do - expect(Time).not_to receive(:strptime) + it "returns a Failure for unknown types" do + expect(described_class.call(Object.new)).to be_a(CMDx::Coercions::Failure) + end - coercion.call("2023-12-25") - end + it "returns a Failure for malformed strings" do + expect(described_class.call("nope")).to be_a(CMDx::Coercions::Failure) end end end diff --git a/spec/cmdx/coercions_spec.rb b/spec/cmdx/coercions_spec.rb new file mode 100644 index 000000000..7db12ab07 --- /dev/null +++ b/spec/cmdx/coercions_spec.rb @@ -0,0 +1,148 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe CMDx::Coercions do + subject(:coercions) { described_class.new } + + let(:task) { create_task_class.new } + + describe "#initialize" do + it "registers the built-in coercions" do + expect(coercions.registry.keys).to include( + :array, :big_decimal, :boolean, :complex, :date, :date_time, + :float, :hash, :integer, :rational, :string, :symbol, :time + ) + end + end + + describe "#initialize_copy" do + it "dups the registry on dup" do + copy = coercions.dup + copy.deregister(:integer) + + expect(coercions.registry).to have_key(:integer) + expect(copy.registry).not_to have_key(:integer) + end + end + + describe "#register" do + it "adds a callable" do + callable = ->(v, **) { v.to_s } + coercions.register(:stringy, callable) + + expect(coercions.lookup(:stringy)).to be(callable) + end + + it "accepts a block" do + coercions.register(:upper) { |v, **| v.to_s.upcase } + + expect(coercions.lookup(:upper).call("hi")).to eq("HI") + end + + it "raises when given both a callable and a block" do + expect do + coercions.register(:bad, ->(v, **) { v }) { |v, **| v } + end.to raise_error(ArgumentError, /either a callable or a block/) + end + + it "raises when the handler does not respond to call" do + expect do + coercions.register(:bad, Object.new) + end.to raise_error(ArgumentError, /must respond to #call/) + end + end + + describe "#deregister" do + it "removes a key" do + coercions.deregister(:integer) + + expect(coercions.registry).not_to have_key(:integer) + end + end + + describe "#lookup" do + it "raises on unknown keys" do + expect { coercions.lookup(:bogus) }.to raise_error(ArgumentError, "unknown coercion: bogus") + end + end + + describe "#empty? / #size" do + it "reflects the registry" do + expect(coercions).not_to be_empty + expect(coercions.size).to eq(13) + end + end + + describe "#extract" do + it "returns EMPTY_ARRAY for empty options" do + expect(coercions.extract({})).to eq([]) + end + + it "returns EMPTY_ARRAY when :coerce is nil" do + expect(coercions.extract(coerce: nil)).to eq([]) + end + + it "wraps a single symbol" do + expect(coercions.extract(coerce: :integer)).to eq([[:integer, {}]]) + end + + it "expands an array of symbols" do + expect(coercions.extract(coerce: %i[integer string])).to eq([[:integer, {}], [:string, {}]]) + end + + it "expands a hash with true values to empty options" do + expect(coercions.extract(coerce: { integer: true })).to eq([[:integer, {}]]) + end + + it "preserves hash option values" do + expect(coercions.extract(coerce: { date: { format: "%Y" } })).to eq([[:date, { format: "%Y" }]]) + end + + it "handles a callable" do + callable = ->(v) { v } + expect(coercions.extract(coerce: callable)).to eq([[callable, {}]]) + end + + it "raises for an unsupported scalar" do + expect do + coercions.extract(coerce: 42) + end.to raise_error(ArgumentError, /unsupported type format/) + end + + it "raises for an unsupported entry inside an array" do + expect do + coercions.extract(coerce: [42]) + end.to raise_error(ArgumentError, /unsupported coerce entry/) + end + end + + describe "#coerce" do + it "returns the value untouched when there are no rules" do + expect(coercions.coerce(task, :x, "42", [])).to eq("42") + end + + it "applies a known coercion symbol" do + expect(coercions.coerce(task, :x, "42", [[:integer, {}]])).to eq(42) + end + + it "falls back to inline handler via Coerce" do + handler = ->(v) { v.to_i * 2 } + expect(coercions.coerce(task, :x, "3", [[handler, {}]])).to eq(6) + end + + it "records the failure on the task's errors and returns the Failure" do + result = coercions.coerce(task, :x, "abc", [[:integer, {}]]) + + expect(result).to be_a(described_class::Failure) + expect(task.errors[:x]).to include(result.message) + end + + it "aggregates failures as 'into_any' when multiple built-ins fail" do + result = coercions.coerce(task, :x, Object.new, [[:integer, {}], [:float, {}]]) + + expect(result).to be_a(described_class::Failure) + expect(result.message).to match(/(integer|float)/) + end + end +end diff --git a/spec/cmdx/configuration_spec.rb b/spec/cmdx/configuration_spec.rb index 829f60d5f..4b5c77d36 100644 --- a/spec/cmdx/configuration_spec.rb +++ b/spec/cmdx/configuration_spec.rb @@ -2,277 +2,95 @@ require "spec_helper" -RSpec.describe CMDx::Configuration, type: :unit do - subject(:configuration) { described_class.new } +# rubocop:disable RSpec/MultipleDescribes +RSpec.describe CMDx::Configuration do + subject(:config) { described_class.new } describe "#initialize" do - it "initializes middlewares registry" do - expect(configuration.middlewares).to be_a(CMDx::MiddlewareRegistry) - expect(configuration.middlewares.registry).to be_empty - end - - it "initializes callbacks registry" do - expect(configuration.callbacks).to be_a(CMDx::CallbackRegistry) - expect(configuration.callbacks.registry).to be_empty - end - - it "initializes coercions registry with default coercions" do - expect(configuration.coercions).to be_a(CMDx::CoercionRegistry) - expect(configuration.coercions.registry).to include( - array: CMDx::Coercions::Array, - boolean: CMDx::Coercions::Boolean, - string: CMDx::Coercions::String, - integer: CMDx::Coercions::Integer, - float: CMDx::Coercions::Float, - hash: CMDx::Coercions::Hash, - big_decimal: CMDx::Coercions::BigDecimal, - complex: CMDx::Coercions::Complex, - date: CMDx::Coercions::Date, - datetime: CMDx::Coercions::DateTime, - rational: CMDx::Coercions::Rational, - time: CMDx::Coercions::Time + it "builds fresh registries for middlewares, callbacks, coercions, validators, telemetry" do + expect(config.middlewares).to be_a(CMDx::Middlewares) + expect(config.callbacks).to be_a(CMDx::Callbacks) + expect(config.coercions).to be_a(CMDx::Coercions) + expect(config.validators).to be_a(CMDx::Validators) + expect(config.telemetry).to be_a(CMDx::Telemetry) + end + + it "defaults locale, backtrace_cleaner, and log settings" do + expect(config).to have_attributes( + default_locale: "en", + backtrace_cleaner: nil, + log_level: Logger::INFO ) + expect(config.log_formatter).to be_a(CMDx::LogFormatters::Line) + expect(config.logger).to be_a(Logger) end - it "initializes validators registry with default validators" do - expect(configuration.validators).to be_a(CMDx::ValidatorRegistry) - expect(configuration.validators.registry).to include( - presence: CMDx::Validators::Presence, - format: CMDx::Validators::Format, - inclusion: CMDx::Validators::Inclusion, - exclusion: CMDx::Validators::Exclusion, - length: CMDx::Validators::Length, - numeric: CMDx::Validators::Numeric - ) - end - - it "sets breakpoints to default values" do - expect(configuration.task_breakpoints).to eq(%w[failed]) - expect(configuration.workflow_breakpoints).to eq(%w[failed]) - end - - it "sets dump_context to false by default" do - expect(configuration.dump_context).to be(false) - end - - it "sets locale to en by default" do - expect(configuration.default_locale).to eq("en") - end - - it "initializes logger with default configuration" do - logger = configuration.logger - - expect(logger).to be_a(Logger) - expect(logger.progname).to eq("cmdx") - expect(logger.formatter).to be_a(CMDx::LogFormatters::Line) - expect(logger.level).to eq(Logger::INFO) + it "gives each instance independent registries" do + other = described_class.new + expect(config.middlewares).not_to be(other.middlewares) + expect(config.callbacks).not_to be(other.callbacks) end end - describe "attribute accessors" do - describe "#middlewares" do - let(:custom_registry) { CMDx::MiddlewareRegistry.new } - - it "allows setting and getting middlewares" do - configuration.middlewares = custom_registry - - expect(configuration.middlewares).to eq(custom_registry) - end - - context "with nil value" do - it "accepts nil assignment" do - expect { configuration.middlewares = nil }.not_to raise_error - expect(configuration.middlewares).to be_nil - end - end - end - - describe "#callbacks" do - let(:custom_registry) { CMDx::CallbackRegistry.new } - - it "allows setting and getting callbacks" do - configuration.callbacks = custom_registry - - expect(configuration.callbacks).to eq(custom_registry) - end - - context "with nil value" do - it "accepts nil assignment" do - expect { configuration.callbacks = nil }.not_to raise_error - expect(configuration.callbacks).to be_nil - end - end - end - - describe "#coercions" do - let(:custom_registry) { CMDx::CoercionRegistry.new } - - it "allows setting and getting coercions" do - configuration.coercions = custom_registry - - expect(configuration.coercions).to eq(custom_registry) - end - - context "with nil value" do - it "accepts nil assignment" do - expect { configuration.coercions = nil }.not_to raise_error - expect(configuration.coercions).to be_nil - end - end - end - - describe "#validators" do - let(:custom_registry) { CMDx::ValidatorRegistry.new } + describe "attribute assignment" do + it "allows reading and writing each configuration attribute" do + logger = Logger.new(nil) + cleaner = ->(bt) { bt } - it "allows setting and getting validators" do - configuration.validators = custom_registry + config.logger = logger + config.backtrace_cleaner = cleaner + config.default_locale = "fr" - expect(configuration.validators).to eq(custom_registry) - end - - context "with nil value" do - it "accepts nil assignment" do - expect { configuration.validators = nil }.not_to raise_error - expect(configuration.validators).to be_nil - end - end + expect(config.logger).to be(logger) + expect(config.backtrace_cleaner).to be(cleaner) + expect(config.default_locale).to eq("fr") end + end +end - describe "#task_breakpoints" do - let(:custom_breakpoints) { %w[failed error timeout] } - - it "allows setting and getting task_breakpoints" do - configuration.task_breakpoints = custom_breakpoints - - expect(configuration.task_breakpoints).to eq(custom_breakpoints) - end - - context "with empty array" do - it "accepts empty array assignment" do - configuration.task_breakpoints = [] - - expect(configuration.task_breakpoints).to eq([]) - end - end - - context "with nil value" do - it "accepts nil assignment" do - configuration.task_breakpoints = nil - - expect(configuration.task_breakpoints).to be_nil - end - end - end - - describe "#workflow_breakpoints" do - let(:custom_breakpoints) { %w[failed timeout interrupted] } - - it "allows setting and getting workflow_breakpoints" do - configuration.workflow_breakpoints = custom_breakpoints - - expect(configuration.workflow_breakpoints).to eq(custom_breakpoints) - end - - context "with empty array" do - it "accepts empty array assignment" do - configuration.workflow_breakpoints = [] - - expect(configuration.workflow_breakpoints).to eq([]) - end - end - - context "with nil value" do - it "accepts nil assignment" do - configuration.workflow_breakpoints = nil - - expect(configuration.workflow_breakpoints).to be_nil - end - end +RSpec.describe CMDx do + describe ".configuration" do + it "returns the same Configuration instance across calls" do + first = described_class.configuration + second = described_class.configuration + expect(first).to be(second) end - describe "#dump_context" do - it "allows setting and getting dump_context" do - configuration.dump_context = true - - expect(configuration.dump_context).to be(true) - end + it "returns a Configuration" do + expect(described_class.configuration).to be_a(CMDx::Configuration) end + end - describe "#default_locale" do - it "allows setting and getting locale" do - configuration.default_locale = "es" - - expect(configuration.default_locale).to eq("es") - end + describe ".configure" do + it "yields the configuration and returns it" do + yielded = nil + result = described_class.configure { |c| yielded = c } - context "with nil value" do - it "accepts nil assignment" do - expect { configuration.default_locale = nil }.not_to raise_error - expect(configuration.default_locale).to be_nil - end - end + expect(yielded).to be(described_class.configuration) + expect(result).to be(described_class.configuration) end - describe "#logger" do - let(:custom_logger) { Logger.new($stderr, progname: "test") } - - it "allows setting and getting logger" do - configuration.logger = custom_logger - - expect(configuration.logger).to eq(custom_logger) - end - - context "with nil value" do - it "accepts nil assignment" do - expect { configuration.logger = nil }.not_to raise_error - expect(configuration.logger).to be_nil - end - end + it "raises without a block" do + expect { described_class.configure }.to raise_error(ArgumentError, "block required") end end - describe "#to_h" do - let(:result) { configuration.to_h } - - it "returns hash with all configuration attributes" do - expect(result).to include( - middlewares: configuration.middlewares, - callbacks: configuration.callbacks, - coercions: configuration.coercions, - validators: configuration.validators, - task_breakpoints: configuration.task_breakpoints, - workflow_breakpoints: configuration.workflow_breakpoints, - dump_context: configuration.dump_context, - default_locale: configuration.default_locale, - logger: configuration.logger - ) - end - - context "with modified attributes" do - let(:custom_breakpoints) { %w[custom_failed custom_error] } - let(:custom_logger) { Logger.new($stderr, progname: "test") } - - before do - configuration.task_breakpoints = custom_breakpoints - configuration.logger = custom_logger - end - - it "reflects the current attribute values" do - expect(result[:task_breakpoints]).to eq(custom_breakpoints) - expect(result[:logger]).to eq(custom_logger) - end + describe ".reset_configuration!" do + it "replaces the configuration with a new instance" do + old = described_class.configuration + described_class.reset_configuration! + expect(described_class.configuration).not_to be(old) end - context "with nil attributes" do - before do - configuration.middlewares = nil - configuration.logger = nil - end + it "clears Task-level instance variables so they re-pull from config" do + described_class.reset_configuration! - it "includes nil values in the hash" do - expect(result[:middlewares]).to be_nil - expect(result[:logger]).to be_nil - end + expect(CMDx::Task.instance_variable_get(:@middlewares)).to be_nil + expect(CMDx::Task.instance_variable_get(:@callbacks)).to be_nil + expect(CMDx::Task.instance_variable_get(:@coercions)).to be_nil + expect(CMDx::Task.instance_variable_get(:@validators)).to be_nil + expect(CMDx::Task.instance_variable_get(:@telemetry)).to be_nil end end end +# rubocop:enable RSpec/MultipleDescribes diff --git a/spec/cmdx/context_spec.rb b/spec/cmdx/context_spec.rb index 02a5a8f74..1a6e8cc60 100644 --- a/spec/cmdx/context_spec.rb +++ b/spec/cmdx/context_spec.rb @@ -2,464 +2,281 @@ require "spec_helper" -RSpec.describe CMDx::Context, type: :unit do - subject(:context) { described_class.new(initial_data) } - - let(:initial_data) { { name: "John", age: 30 } } - - describe "#initialize" do - context "when given a hash" do - let(:initial_data) { { name: "Alice", age: 25 } } - - it "converts keys to symbols and stores the data" do - expect(context.table).to eq(name: "Alice", age: 25) - end - end - - context "when given an object that responds to to_hash" do - let(:initial_data) do - object = Object.new - def object.to_hash - { "city" => "NYC", "country" => "USA" } - end - object - end - - it "converts to hash and symbolizes keys" do - expect(context.table).to eq(city: "NYC", country: "USA") - end - end - - context "when given an object that responds to to_h" do - let(:initial_data) do - object = Object.new - def object.to_h - { "score" => 100, "level" => 5 } - end - object - end - - it "converts to hash and symbolizes keys" do - expect(context.table).to eq(score: 100, level: 5) - end - end - - context "when given an object that responds to neither to_h nor to_hash" do - let(:initial_data) { "invalid" } - - it "raises ArgumentError" do - expect { context }.to raise_error(ArgumentError, "must respond to `to_h` or `to_hash`") - end - end - - context "when given string keys" do - let(:initial_data) { { "name" => "Bob", "active" => true } } - - it "converts string keys to symbols" do - expect(context.table).to eq(name: "Bob", active: true) - end - end - - context "when given no arguments" do - subject(:context) { described_class.new } - - it "creates empty context" do - expect(context.table).to eq({}) - end - end - end - +RSpec.describe CMDx::Context do describe ".build" do - context "when given a Context instance that is not frozen" do - let(:existing_context) { described_class.new(name: "test") } - - it "returns the same instance" do - result = described_class.build(existing_context) - - expect(result).to be(existing_context) - end + it "returns the same instance when given an unfrozen Context" do + ctx = described_class.new(a: 1) + expect(described_class.build(ctx)).to be(ctx) end - context "when given a frozen Context instance" do - let(:frozen_context) { described_class.new(name: "test").freeze } - - it "creates a new Context instance" do - result = described_class.build(frozen_context) + it "wraps a frozen Context in a new instance" do + ctx = described_class.new(a: 1).freeze + built = described_class.build(ctx) - expect(result).not_to be(frozen_context) - expect(result.table).to eq(name: "test") - end + expect(built).not_to be(ctx) + expect(built.to_h).to eq(a: 1) end - context "when given an object that responds to context" do - let(:object_with_context) do - object = Object.new - def object.context - { user_id: 123 } - end - object - end + it "unwraps an object that responds to #context" do + ctx = described_class.new(a: 1) + holder = Struct.new(:context).new(ctx) - it "recursively builds from the context method result" do - result = described_class.build(object_with_context) - - expect(result).to be_a(described_class) - expect(result.table).to eq(user_id: 123) - end + expect(described_class.build(holder)).to be(ctx) end - context "when given a hash" do - let(:hash_data) { { role: "admin", permissions: %w[read write] } } - - it "creates new Context instance" do - result = described_class.build(hash_data) - - expect(result).to be_a(described_class) - expect(result.table).to eq(role: "admin", permissions: %w[read write]) - end + it "builds from a hash" do + built = described_class.build(a: 1, b: 2) + expect(built.to_h).to eq(a: 1, b: 2) end - context "when given nil" do - it "creates empty Context instance" do - result = described_class.build(nil) - - expect(result).to be_a(described_class) - expect(result.table).to eq({}) - end + it "builds an empty context by default" do + expect(described_class.build).to be_empty end end - describe "#[]" do - it "retrieves value by symbol key" do - expect(context[:name]).to eq("John") - end - - it "retrieves value by string key converted to symbol" do - expect(context["name"]).to eq("John") + describe "#initialize" do + it "accepts a hash and stringified keys are symbolized" do + ctx = described_class.new("a" => 1, b: 2) + expect(ctx.to_h).to eq(a: 1, b: 2) end - it "returns nil for non-existent key" do - expect(context[:missing]).to be_nil + it "accepts any object with to_hash" do + hashable = Class.new { def to_hash = { x: 1 } }.new + expect(described_class.new(hashable).to_h).to eq(x: 1) end - end - describe "#store and #[]=" do - it "stores value with symbol key" do - context.store(:email, "john@example.com") - - expect(context[:email]).to eq("john@example.com") + it "accepts any object with to_h" do + hashish = Class.new { def to_h = { y: 2 } }.new + expect(described_class.new(hashish).to_h).to eq(y: 2) end - it "stores value with string key converted to symbol" do - context["phone"] = "555-1234" - - expect(context[:phone]).to eq("555-1234") - end - - it "overwrites existing value" do - context[:age] = 35 - - expect(context[:age]).to eq(35) + it "raises ArgumentError for objects that respond to neither" do + expect { described_class.new(Object.new) } + .to raise_error(ArgumentError, "must respond to `to_h` or `to_hash`") end end - describe "#fetch" do - it "retrieves existing value" do - expect(context.fetch(:name)).to eq("John") - end - - it "retrieves value with string key converted to symbol" do - expect(context.fetch("age")).to eq(30) - end + describe "key access" do + subject(:ctx) { described_class.new(a: 1, b: 2) } - it "returns default value for missing key" do - expect(context.fetch(:missing, "default")).to eq("default") + it "reads via []" do + expect(ctx[:a]).to eq(1) + expect(ctx["a"]).to eq(1) end - it "executes block for missing key" do - result = context.fetch(:missing) { 1 + 1 } - - expect(result).to eq(2) + it "writes via store / []=" do + ctx.store(:c, 3) + ctx["d"] = 4 + expect(ctx.to_h).to eq(a: 1, b: 2, c: 3, d: 4) end - it "raises KeyError for missing key without default" do - expect { context.fetch(:missing) }.to raise_error(KeyError) + it "fetches with a default" do + expect(ctx.fetch(:missing, :default)).to eq(:default) + expect(ctx.fetch(:a)).to eq(1) end - end - - describe "#fetch_or_store" do - it "returns existing value when key exists" do - result = context.fetch_or_store(:name, "Default") - expect(result).to eq("John") - expect(context.table).to include(name: "John") + it "fetches with a block" do + expect(ctx.fetch(:missing) { :block }).to eq(:block) # rubocop:disable Style/RedundantFetchBlock end - it "stores and returns default value when key does not exist" do - result = context.fetch_or_store(:email, "john@example.com") - - expect(result).to eq("john@example.com") - expect(context.table).to include(email: "john@example.com") + it "raises on fetch miss without default" do + expect { ctx.fetch(:missing) }.to raise_error(KeyError) end - it "converts string key to symbol" do - result = context.fetch_or_store("phone", "555-1234") - - expect(result).to eq("555-1234") - expect(context.table).to include(phone: "555-1234") + it "digs into nested values" do + nested = described_class.new(a: { b: { c: 1 } }) + expect(nested.dig(:a, :b, :c)).to eq(1) end + end - it "returns existing value and does not execute block when key exists" do - block_called = false - result = context.fetch_or_store(:name) do - block_called = true - "Default" - end + describe "#retrieve" do + subject(:ctx) { described_class.new } - expect(result).to eq("John") - expect(block_called).to be(false) + it "returns the existing value without writing when present" do + ctx.store(:a, 1) + expect(ctx.retrieve(:a, 99)).to eq(1) + expect(ctx[:a]).to eq(1) end - it "stores nil when no value or block provided" do - result = context.fetch_or_store(:status) - - expect(result).to be_nil - expect(context.table).to include(status: nil) + it "stores and returns the default when missing" do + expect(ctx.retrieve(:a, 42)).to eq(42) + expect(ctx[:a]).to eq(42) end - it "overwrites existing value when block is provided and key exists" do - context[:counter] = 5 - result = context.fetch_or_store(:counter) { 10 } - - expect(result).to eq(5) - expect(context.table).to include(counter: 5) + it "stores and returns the block result when missing" do + expect(ctx.retrieve(:a) { :computed }).to eq(:computed) + expect(ctx[:a]).to eq(:computed) end - it "handles complex default values" do - default_hash = { active: true, role: "admin" } - result = context.fetch_or_store(:settings, default_hash) - - expect(result).to eq(default_hash) - expect(context.table).to include(settings: default_hash) + it "prefers the block over the default" do + expect(ctx.retrieve(:a, :fallback) { :block }).to eq(:block) end end - describe "#merge!" do - context "when given a hash" do - it "merges new data and returns self" do - result = context.merge!(email: "john@example.com", active: true) - - expect(result).to be(context) - expect(context.table).to include( - name: "John", - age: 30, - email: "john@example.com", - active: true - ) - end - end - - context "when given object with to_h" do - let(:mergeable) do - object = Object.new - def object.to_h - { "status" => "active", "role" => "user" } - end - object - end - - it "converts to hash and merges with symbol keys" do - context.merge!(mergeable) - - expect(context.table).to include(status: "active", role: "user") - end - end - - context "when given empty hash" do - it "returns self without changes" do - original_table = context.table.dup - result = context.merge!({}) - - expect(result).to be(context) - expect(context.table).to eq(original_table) - end + describe "#merge" do + it "returns self after merging a hash" do + ctx = described_class.new(a: 1) + expect(ctx.merge(b: 2)).to be(ctx) + expect(ctx.to_h).to eq(a: 1, b: 2) end it "overwrites existing keys" do - context.merge!(name: "Jane", age: 25) + ctx = described_class.new(a: 1) + ctx.merge(a: 99) + expect(ctx[:a]).to eq(99) + end - expect(context.table).to eq(name: "Jane", age: 25) + it "accepts another Context" do + ctx = described_class.new(a: 1) + other = described_class.new(b: 2) + ctx.merge(other) + expect(ctx.to_h).to eq(a: 1, b: 2) end end - describe "#delete!" do - it "deletes existing key and returns value" do - result = context.delete!(:name) + describe "predicates and introspection" do + subject(:ctx) { described_class.new(a: 1) } - expect(result).to eq("John") - expect(context.table).not_to have_key(:name) + it "key? uses symbol-coerced keys" do + expect(ctx.key?(:a)).to be(true) + expect(ctx.key?("a")).to be(true) + expect(ctx.key?(:b)).to be(false) end - it "deletes key with string converted to symbol" do - result = context.delete!("age") - - expect(result).to eq(30) - expect(context.table).not_to have_key(:age) + it "keys and values" do + expect(ctx.keys).to eq([:a]) + expect(ctx.values).to eq([1]) end - it "returns nil for non-existent key" do - result = context.delete!(:missing) - - expect(result).to be_nil + it "empty? and size" do + expect(ctx).not_to be_empty + expect(ctx.size).to eq(1) + expect(described_class.new).to be_empty end + end - it "executes block for non-existent key" do - result = context.delete!(:missing) { "not found" } + describe "iteration" do + subject(:ctx) { described_class.new(a: 1, b: 2) } - expect(result).to eq("not found") + it "each yields symbol/value pairs" do + pairs = [] + ctx.each { |k, v| pairs << [k, v] } # rubocop:disable Style/MapIntoArray + expect(pairs).to eq([[:a, 1], [:b, 2]]) end - end - - describe "#clear!" do - it "clears all data and returns self" do - result = context.clear! - expect(result).to be(context) - expect(context.table).to be_empty + it "each_key and each_value" do + expect(ctx.each_key.to_a).to eq(%i[a b]) + expect(ctx.each_value.to_a).to eq([1, 2]) end end - describe "#eql? and #==" do - let(:other_context) { described_class.new(name: "John", age: 30) } - let(:different_context) { described_class.new(name: "Jane", age: 25) } - - it "returns true for contexts with same data" do - expect(context).to eql(other_context) - expect(context).to eq(other_context) + describe "#delete and #clear" do + it "delete removes the key and returns its value" do + ctx = described_class.new(a: 1) + expect(ctx.delete(:a)).to eq(1) + expect(ctx).to be_empty end - it "returns false for contexts with different data" do - expect(context).not_to eql(different_context) - expect(context).not_to eq(different_context) + it "delete uses the block when absent" do + ctx = described_class.new + result = ctx.delete(:missing) { :default } + expect(result).to eq(:default) end - it "returns false when compared to non-Context object" do - expect(context).not_to eql({ name: "John", age: 30 }) - expect(context).not_to eq({ name: "John", age: 30 }) + it "clear empties the table and returns self" do + ctx = described_class.new(a: 1) + expect(ctx.clear).to be(ctx) + expect(ctx).to be_empty end end - describe "#key?" do - it "returns true for existing symbol key" do - expect(context.key?(:name)).to be(true) + describe "equality" do + it "eql? is true for contexts with equal hashes" do + a = described_class.new(a: 1) + b = described_class.new(a: 1) + expect(a).to eql(b) end - it "returns true for existing key given as string" do - expect(context.key?("name")).to be(true) + it "eql? is false for different classes" do + expect(described_class.new(a: 1)).not_to eql({ a: 1 }) end - it "returns false for non-existent key" do - expect(context.key?(:missing)).to be(false) + it "hash matches the underlying table" do + ctx = described_class.new(a: 1) + expect(ctx.hash).to eq({ a: 1 }.hash) end end - describe "#dig" do - let(:initial_data) do - { - user: { - profile: { - name: "John", - settings: { theme: "dark" } - } - } - } + describe "#to_s" do + it "renders space-separated k=value.inspect pairs" do + ctx = described_class.new(a: 1, name: "Jane") + expect(ctx.to_s).to eq('a=1 name="Jane"') end + end - it "digs into nested hash with symbol keys" do - expect(context.dig(:user, :profile, :name)).to eq("John") - end + describe "#deep_dup" do + it "returns a new context with independent nested data" do + ctx = described_class.new(a: { b: [1, 2] }) + copy = ctx.deep_dup - it "digs into nested hash with string key converted to symbol" do - expect(context.dig("user", :profile, :settings, :theme)).to eq("dark") - end + copy[:a][:b] << 3 - it "returns nil for non-existent nested path" do - expect(context.dig(:user, :missing, :key)).to be_nil + expect(ctx[:a][:b]).to eq([1, 2]) + expect(copy[:a][:b]).to eq([1, 2, 3]) + expect(copy).not_to be(ctx) end - it "returns nil for partial path" do - expect(context.dig(:user, :profile, :missing)).to be_nil + it "preserves immutable scalars" do + ctx = described_class.new(n: 1, s: :x, t: true, f: false, z: nil) + copy = ctx.deep_dup + expect(copy.to_h).to eq(n: 1, s: :x, t: true, f: false, z: nil) end - end - describe "#to_h" do - it "returns the internal table" do - expect(context.to_h).to eq(context.table) - expect(context.to_h).to be(context.table) - end - end - - describe "#to_s" do - it "delegates to Utils::Format.to_str" do - allow(CMDx::Utils::Format).to receive(:to_str).with(context.table).and_return("formatted string") + it "falls back to the original value when dup raises" do + unduppable = Class.new { def dup = raise "nope" }.new + ctx = described_class.new(val: unduppable) - expect(context.to_s).to eq("formatted string") + expect(ctx.deep_dup[:val]).to be(unduppable) end end - describe "#each" do - it "delegates to table" do - result = [] - context.each { |key, value| result << [key, value] } # rubocop:disable Style/MapIntoArray - - expect(result).to contain_exactly([:name, "John"], [:age, 30]) + describe "#freeze" do + it "freezes both the context and its table" do + ctx = described_class.new(a: 1).freeze + expect(ctx).to be_frozen + expect(ctx.to_h).to be_frozen end end - describe "#map" do - it "delegates to table" do - result = context.map { |key, value| "#{key}:#{value}" } + describe "dynamic accessors" do + subject(:ctx) { described_class.new(name: "Jane") } - expect(result).to contain_exactly("name:John", "age:30") + it "reads via method name" do + expect(ctx.name).to eq("Jane") end - end - describe "method_missing and respond_to_missing?" do - context "when method name matches existing key" do - it "returns the value" do - expect(context.name).to eq("John") - expect(context.age).to eq(30) - end - - it "responds to the method" do - expect(context).to respond_to(:name) - expect(context).to respond_to(:age) - end + it "writes via foo= method" do + ctx.age = 30 + expect(ctx[:age]).to eq(30) end - context "when method name does not match any key" do - it "returns nil" do - expect(context.missing_method).to be_nil - end + it "foo? returns boolean based on truthiness" do + ctx.enabled = false + ctx.ready = "yes" - it "does not respond to the method" do - expect(context).not_to respond_to(:missing_method) - end + expect(ctx.enabled?).to be(false) + expect(ctx.ready?).to be(true) + expect(ctx.unknown?).to be(false) end - context "when method name ends with equals sign" do - it "stores the value" do - context.email = "john@example.com" - - expect(context.table).to include(email: "john@example.com") - end + it "returns nil for unknown keys (dynamic reader)" do + expect(ctx.missing).to be_nil end - context "when checking private method visibility" do - it "delegates to super for respond_to_missing?" do - expect(context.respond_to?(:name, true)).to be(true) - expect(context.respond_to?(:missing, true)).to be(false) - end + it "respond_to? is true for existing keys and setter suffixes" do + expect(ctx.respond_to?(:name)).to be(true) + expect(ctx.respond_to?(:new_thing=)).to be(true) + expect(ctx.respond_to?(:ready?)).to be(true) end end end diff --git a/spec/cmdx/deprecation_spec.rb b/spec/cmdx/deprecation_spec.rb new file mode 100644 index 000000000..dba1a8da3 --- /dev/null +++ b/spec/cmdx/deprecation_spec.rb @@ -0,0 +1,102 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe CMDx::Deprecation do + let(:logger) { Logger.new(log_output) } + let(:log_output) { StringIO.new } + let(:task) do + log = logger + Class.new do + define_method(:logger) { log } + def active? = true + def inactive? = false + def custom_handler = @handler_called = true + attr_reader :handler_called + end.new + end + + def run(value, **opts) + yielded = false + described_class.new(value, opts).execute(task) { yielded = true } + yielded + end + + describe "#execute" do + it "is a no-op when value is nil" do + expect(run(nil)).to be(false) + end + + it "skips when the if-guard is false" do + expect(run(:log, if: :inactive?)).to be(false) + end + + it "skips when the unless-guard is true" do + expect(run(:log, unless: :active?)).to be(false) + end + + it "runs the provided block before dispatching" do + expect(run(:log)).to be(true) + end + + context "with :log" do + it "logs a warning via the task's logger" do + run(:log) + expect(log_output.string).to include("DEPRECATED:", task.class.to_s) + end + end + + context "with :warn" do + it "writes a warning to Kernel.warn" do + expect { run(:warn) }.to output(/DEPRECATED: migrate/).to_stderr + end + end + + context "with :error" do + it "raises DeprecationError" do + expect { run(:error) }.to raise_error(CMDx::DeprecationError, /usage prohibited/) + end + end + + context "with a Symbol" do + it "sends the method on the task" do + run(:custom_handler) + expect(task.handler_called).to be(true) + end + end + + context "with a Proc" do + it "evaluates the proc via instance_exec and passes the task" do + received = nil + probe = proc { |t| received = [self, t] } + described_class.new(probe).execute(task) { nil } + + expect(received).to eq([task, task]) + end + end + + context "with a callable object" do + it "invokes #call with the task" do + callable = Class.new do + class << self + + attr_reader :received + + def call(task) = @received = task + + end + end + described_class.new(callable).execute(task) { nil } + + expect(callable.received).to be(task) + end + end + + context "with an unsupported value" do + it "raises ArgumentError" do + expect { described_class.new(123).execute(task) { nil } } + .to raise_error(ArgumentError, "deprecation must be a Symbol, Proc, or respond to #call") + end + end + end +end diff --git a/spec/cmdx/deprecator_spec.rb b/spec/cmdx/deprecator_spec.rb deleted file mode 100644 index 35edc4a69..000000000 --- a/spec/cmdx/deprecator_spec.rb +++ /dev/null @@ -1,177 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe CMDx::Deprecator, type: :unit do - let(:mock_task) { instance_double("MockTask") } - let(:mock_task_class) { class_double(CMDx::Task, name: "TestTask") } - let(:mock_logger) { instance_double(Logger) } - let(:task_settings) { mock_settings(deprecate: deprecate_value) } - let(:deprecate_value) { false } - - before do - allow(mock_task_class).to receive(:settings).and_return(task_settings) - allow(mock_task).to receive_messages(class: mock_task_class, logger: mock_logger) - allow(mock_logger).to receive(:warn) - end - - describe "#restrict" do - context "when deprecate setting is nil or false" do - let(:deprecate_value) { nil } - - it "does nothing for nil" do - expect { described_class.restrict(mock_task) }.not_to raise_error - end - - context "when deprecate setting is false" do - let(:deprecate_value) { false } - - it "does nothing for false" do - expect { described_class.restrict(mock_task) }.not_to raise_error - end - end - end - - context "when deprecate setting is true" do - let(:deprecate_value) { true } - - it "raises DeprecationError" do - expect { described_class.restrict(mock_task) }.to raise_error( - CMDx::DeprecationError, "TestTask usage prohibited" - ) - end - end - - context "when deprecate setting contains 'raise'" do - let(:deprecate_value) { "raise" } - - it "raises DeprecationError" do - expect { described_class.restrict(mock_task) }.to raise_error( - CMDx::DeprecationError, "TestTask usage prohibited" - ) - end - - context "when deprecate setting is 'custom_raise'" do - let(:deprecate_value) { "custom_raise" } - - it "raises error for unevaluable deprecation value" do - expect { described_class.restrict(mock_task) }.to raise_error( - RuntimeError, 'cannot evaluate "custom_raise"' - ) - end - end - end - - context "when deprecate setting contains 'log'" do - let(:deprecate_value) { "log" } - - it "logs a warning message" do - expect(mock_logger).to receive(:warn) - - described_class.restrict(mock_task) - end - - context "when deprecate setting is 'custom_log'" do - let(:deprecate_value) { "custom_log" } - - it "raises error for unevaluable deprecation value" do - expect { described_class.restrict(mock_task) }.to raise_error( - RuntimeError, 'cannot evaluate "custom_log"' - ) - end - end - end - - context "when deprecate setting contains 'warn'" do - let(:deprecate_value) { "warn" } - - it "calls warn with deprecation message" do - expect(described_class).to receive(:warn).with( - "[TestTask] DEPRECATED: migrate to a replacement or discontinue use", - category: :deprecated - ) - - described_class.restrict(mock_task) - end - - context "when deprecate setting is 'custom_warn'" do - let(:deprecate_value) { "custom_warn" } - - it "raises error for unevaluable deprecation value" do - expect { described_class.restrict(mock_task) }.to raise_error( - RuntimeError, 'cannot evaluate "custom_warn"' - ) - end - end - end - - context "when deprecate setting is an unknown type" do - let(:deprecate_value) { "unknown" } - - it "raises an error for unknown deprecation type" do - expect { described_class.restrict(mock_task) }.to raise_error( - RuntimeError, 'cannot evaluate "unknown"' - ) - end - end - - context "when deprecate setting is a symbol" do - let(:deprecate_value) { :test_method } - - it "evaluates the symbol and processes the result" do - allow(mock_task).to receive(:test_method).and_return("symbol_result") - - expect { described_class.restrict(mock_task) }.to raise_error( - RuntimeError, 'unknown deprecation type "symbol_result"' - ) - end - end - - context "when deprecate setting is a proc" do - let(:deprecate_value) { proc { "log" } } - - it "evaluates the proc and processes the result" do - expect(mock_logger).to receive(:warn) - - described_class.restrict(mock_task) - end - end - - context "when deprecate setting is a callable object" do - let(:callable_object) { instance_double("MockCallable", call: "warn") } - let(:deprecate_value) { callable_object } - - it "calls the object and processes the result" do - allow(callable_object).to receive(:call).with(mock_task).and_return("warn") - expect(described_class).to receive(:warn).with( - "[TestTask] DEPRECATED: migrate to a replacement or discontinue use", - category: :deprecated - ) - - described_class.restrict(mock_task) - end - end - - context "when deprecate setting returns a boolean from symbol" do - let(:deprecate_value) { :check_deprecated } - - it "evaluates symbol and handles boolean result" do - allow(mock_task).to receive(:check_deprecated).and_return(false) - - expect { described_class.restrict(mock_task) }.not_to raise_error - end - end - - context "when deprecate setting returns true from symbol" do - let(:deprecate_value) { :check_deprecated } - - it "evaluates symbol and raises error for true result" do - allow(mock_task).to receive(:check_deprecated).and_return(true) - - expect { described_class.restrict(mock_task) }.to raise_error( - CMDx::DeprecationError, "TestTask usage prohibited" - ) - end - end - end -end diff --git a/spec/cmdx/errors_spec.rb b/spec/cmdx/errors_spec.rb index 370746f40..362bde43e 100644 --- a/spec/cmdx/errors_spec.rb +++ b/spec/cmdx/errors_spec.rb @@ -2,424 +2,194 @@ require "spec_helper" -RSpec.describe CMDx::Errors, type: :unit do +RSpec.describe CMDx::Errors do subject(:errors) { described_class.new } describe "#initialize" do - it "initializes with empty messages hash" do - expect(errors.messages).to eq({}) - end - - it "is empty by default" do + it "starts empty" do expect(errors).to be_empty + expect(errors.messages).to eq({}) end end describe "#add" do - context "when adding a valid message" do - it "adds a message for an attribute" do - errors.add(:name, "is required") - - expect(errors.messages[:name]).to include("is required") - end - - it "creates a Set for the attribute if it doesn't exist" do - errors.add(:email, "is invalid") - - expect(errors.messages[:email]).to be_a(Set) - end - - it "adds multiple messages for the same attribute" do - errors.add(:password, "is too short") - errors.add(:password, "must contain numbers") - - expect(errors.messages[:password]).to include("is too short", "must contain numbers") - expect(errors.messages[:password].size).to eq(2) - end - - it "does not duplicate the same message for an attribute" do - errors.add(:username, "is taken") - errors.add(:username, "is taken") - - expect(errors.messages[:username].size).to eq(1) - expect(errors.messages[:username]).to include("is taken") - end - - it "handles string attributes" do - errors.add("category", "is invalid") - - expect(errors.messages["category"]).to include("is invalid") - end - - it "handles symbol attributes" do - errors.add(:status, "is not allowed") - - expect(errors.messages[:status]).to include("is not allowed") - end + it "appends a message under the key" do + errors.add(:name, "is required") + expect(errors[:name]).to include("is required") end - context "when adding an empty message" do - it "does not add empty string messages" do - errors.add(:name, "") - - expect(errors.messages).to eq({}) - expect(errors).to be_empty - end - - it "raises an error when trying to add nil messages" do - expect { errors.add(:name, nil) }.to raise_error(NoMethodError, /undefined method.*empty.*for nil/) - end + it "deduplicates identical messages" do + errors.add(:name, "is required") + errors.add(:name, "is required") + expect(errors[:name].size).to eq(1) end - end - - describe "#for?" do - context "when attribute has errors" do - before do - errors.add(:email, "is required") - end - it "returns true for attributes with errors" do - expect(errors.for?(:email)).to be(true) - end + it "accumulates distinct messages under the same key" do + errors.add(:name, "is required") + errors.add(:name, "is too short") + expect(errors[:name]).to contain_exactly("is required", "is too short") end - context "when attribute has no errors" do - it "returns false for attributes without errors" do - expect(errors.for?(:name)).to be(false) - end - - it "returns false for non-existent attributes" do - expect(errors.for?(:nonexistent)).to be(false) - end + it "is aliased as []=" do + errors[:name] = "is required" + expect(errors[:name]).to include("is required") end + end - context "when attribute exists but has empty errors" do - before do - errors.messages[:status] = Set.new - end + describe "#[]" do + it "returns the array of messages for a key" do + errors.add(:age, "too young") + expect(errors[:age]).to be_a(Array) + expect(errors[:age]).to include("too young") + end - it "returns false for attributes with empty error sets" do - expect(errors.for?(:status)).to be(false) - end + it "returns an empty set when the key is absent" do + expect(errors[:missing]).to be_empty end end - describe "#any?" do - context "when no errors have been added" do - it "returns false" do - expect(errors.any?).to be(false) - end + describe "#added?" do + it "is true when the exact message was added" do + errors.add(:name, "is required") + expect(errors.added?(:name, "is required")).to be(true) end - context "when errors have been added" do - before do - errors.add(:name, "is required") - end + it "is false for a different message" do + errors.add(:name, "is required") + expect(errors.added?(:name, "is too short")).to be(false) + end - it "returns true" do - expect(errors.any?).to be(true) - end + it "is false when the key is absent" do + expect(errors.added?(:missing, "x")).to be(false) end end - describe "#clear" do - context "when errors have been added" do - before do - errors.add(:name, "is required") - errors.add(:email, "is invalid") - end - - it "removes all errors" do - errors.clear + describe "#key?" do + it "is true only for keys that have at least one message" do + errors.add(:name, "bad") + expect(errors.key?(:name)).to be(true) + expect(errors.key?(:age)).to be(false) + end + end - expect(errors).to be_empty - end + describe "#keys" do + it "lists keys in insertion order" do + errors.add(:a, "x") + errors.add(:b, "y") + expect(errors.keys).to eq(%i[a b]) end end - describe "#size" do - context "when no errors have been added" do - it "returns 0" do - expect(errors.size).to eq(0) - end + describe "#size and #count" do + before do + errors.add(:a, "x") + errors.add(:a, "y") + errors.add(:b, "z") end - context "when errors have been added for multiple attributes" do - before do - errors.add(:name, "is required") - errors.add(:email, "is invalid") - end + it "size returns the number of keys" do + expect(errors.size).to eq(2) + end - it "returns the number of attributes with errors" do - expect(errors.size).to eq(2) - end + it "count returns the total number of messages" do + expect(errors.count).to eq(3) end end - describe "#empty?" do - context "when no errors have been added" do - it "returns true" do - expect(errors).to be_empty - end + describe "iteration" do + before do + errors.add(:a, "x") + errors.add(:b, "y") end - context "when errors have been added" do - before do - errors.add(:name, "is required") - end - - it "returns false" do - expect(errors).not_to be_empty - end + it "each yields [key, set] pairs" do + pairs = [] + errors.each { |k, v| pairs << [k, v.to_a] } # rubocop:disable Style/MapIntoArray + expect(pairs).to eq([[:a, ["x"]], [:b, ["y"]]]) end - context "when only empty messages were attempted to be added" do - before do - errors.add(:name, "") - end + it "each_key yields each key" do + expect(errors.each_key.to_a).to eq(%i[a b]) + end - it "returns true" do - expect(errors).to be_empty - end + it "each_value yields each set" do + expect(errors.each_value.map(&:to_a)).to eq([["x"], ["y"]]) end end - describe "#to_h" do - context "when there are no errors" do - it "returns an empty hash" do - expect(errors.to_h).to eq({}) - end + describe "#delete" do + it "removes all messages for the key" do + errors.add(:a, "x") + errors.delete(:a) + expect(errors).to be_empty end + end - context "when there are errors" do - before do - errors.add(:name, "is required") - errors.add(:name, "is too short") - errors.add(:email, "is invalid") - end - - it "returns a hash with arrays as values" do - result = errors.to_h - - expect(result[:name]).to be_a(Array) - expect(result[:email]).to be_a(Array) - end - - it "converts Sets to Arrays for each attribute" do - result = errors.to_h - - expect(result[:name]).to contain_exactly("is required", "is too short") - expect(result[:email]).to contain_exactly("is invalid") - end - - it "preserves all error messages" do - result = errors.to_h - - expect(result[:name].size).to eq(2) - expect(result[:email].size).to eq(1) - end + describe "#clear" do + it "removes all messages" do + errors.add(:a, "x") + errors.add(:b, "y") + errors.clear + expect(errors).to be_empty end end describe "#full_messages" do - context "when there are no errors" do - it "returns an empty hash" do - expect(errors.full_messages).to eq({}) - end - end - - context "when there are errors" do - before do - errors.add(:name, "is required") - errors.add(:name, "is too short") - errors.add(:email, "is invalid") - end - - it "returns a hash with full messages as values" do - result = errors.full_messages - - expect(result[:name]).to be_a(Array) - expect(result[:email]).to be_a(Array) - end - - it "includes attribute names in the messages" do - result = errors.full_messages - - expect(result[:name]).to contain_exactly("name is required", "name is too short") - expect(result[:email]).to contain_exactly("email is invalid") - end - - it "preserves all error messages" do - result = errors.full_messages + it "prefixes each message with its key" do + errors.add(:name, "is required") + errors.add(:age, "too young") - expect(result[:name].size).to eq(2) - expect(result[:email].size).to eq(1) - end + expect(errors.full_messages).to eq( + name: ["name is required"], + age: ["age too young"] + ) end + end - context "when there are mixed attribute types" do - before do - errors.add(:symbol_attr, "symbol error") - errors.add("string_attr", "string error") - end - - it "handles both string and symbol attributes" do - result = errors.full_messages + describe "#to_h" do + it "converts sets to arrays" do + errors.add(:name, "is required") + errors.add(:name, "is too short") - expect(result[:symbol_attr]).to contain_exactly("symbol_attr symbol error") - expect(result["string_attr"]).to contain_exactly("string_attr string error") - end + expect(errors.to_h[:name]).to contain_exactly("is required", "is too short") end end describe "#to_hash" do - context "when there are no errors" do - it "returns an empty hash when full is false" do - expect(errors.to_hash).to eq({}) - end - - it "returns an empty hash when full is true" do - expect(errors.to_hash(true)).to eq({}) - end + before do + errors.add(:name, "is required") end - context "when there are errors" do - before do - errors.add(:name, "is required") - errors.add(:name, "is too short") - errors.add(:email, "is invalid") - end - - context "when full is false (default)" do - it "returns a hash with arrays as values" do - result = errors.to_hash - - expect(result[:name]).to be_a(Array) - expect(result[:email]).to be_a(Array) - end - - it "converts Sets to Arrays for each attribute" do - result = errors.to_hash - - expect(result[:name]).to contain_exactly("is required", "is too short") - expect(result[:email]).to contain_exactly("is invalid") - end - - it "preserves all error messages" do - result = errors.to_hash - - expect(result[:name].size).to eq(2) - expect(result[:email].size).to eq(1) - end - end - - context "when full is true" do - it "returns a hash with full messages as values" do - result = errors.to_hash(true) - - expect(result[:name]).to be_a(Array) - expect(result[:email]).to be_a(Array) - end - - it "includes attribute names in the messages" do - result = errors.to_hash(true) - - expect(result[:name]).to contain_exactly("name is required", "name is too short") - expect(result[:email]).to contain_exactly("email is invalid") - end - - it "preserves all error messages" do - result = errors.to_hash(true) - - expect(result[:name].size).to eq(2) - expect(result[:email].size).to eq(1) - end - end + it "returns the plain hash when full is false" do + expect(errors.to_hash).to eq(name: ["is required"]) end - context "when there are mixed attribute types" do - before do - errors.add(:symbol_attr, "symbol error") - errors.add("string_attr", "string error") - end - - it "handles both string and symbol attributes when full is false" do - result = errors.to_hash - - expect(result[:symbol_attr]).to contain_exactly("symbol error") - expect(result["string_attr"]).to contain_exactly("string error") - end - - it "handles both string and symbol attributes when full is true" do - result = errors.to_hash(true) - - expect(result[:symbol_attr]).to contain_exactly("symbol_attr symbol error") - expect(result["string_attr"]).to contain_exactly("string_attr string error") - end + it "returns full messages when full is true" do + expect(errors.to_hash(true)).to eq(name: ["name is required"]) end end describe "#to_s" do - context "when there are no errors" do - it "returns an empty string" do - expect(errors.to_s).to eq("") - end - end - - context "when there is one error for one attribute" do - before do - errors.add(:name, "is required") - end + it "joins full messages with '. '" do + errors.add(:name, "is required") + errors.add(:age, "too young") - it "returns a formatted string" do - expect(errors.to_s).to eq("name is required") - end + expect(errors.to_s).to eq("name is required. age too young") end - context "when there are multiple errors for one attribute" do - before do - errors.add(:password, "is too short") - errors.add(:password, "must contain numbers") - end - - it "returns all errors for the attribute separated by periods" do - result = errors.to_s - - expect(result).to include("password is too short") - expect(result).to include("password must contain numbers") - expect(result.split(". ").length).to eq(2) - end + it "returns an empty string when no messages are present" do + expect(errors.to_s).to eq("") end + end - context "when there are errors for multiple attributes" do - before do - errors.add(:name, "is required") - errors.add(:email, "is invalid") - errors.add(:password, "is too short") - end - - it "returns all errors separated by periods" do - result = errors.to_s - - expect(result).to include("name is required") - expect(result).to include("email is invalid") - expect(result).to include("password is too short") - expect(result.split(". ").length).to eq(3) - end - end - - context "when there are mixed attribute types" do - before do - errors.add(:symbol_attr, "symbol error") - errors.add("string_attr", "string error") - end - - it "handles both string and symbol attributes" do - result = errors.to_s + describe "#freeze" do + it "freezes the messages hash and each message set" do + errors.add(:name, "is required") + errors.freeze - expect(result).to include("symbol_attr symbol error") - expect(result).to include("string_attr string error") - end + expect(errors).to be_frozen + expect(errors.messages).to be_frozen + expect(errors.messages[:name]).to be_frozen end end end diff --git a/spec/cmdx/executor_spec.rb b/spec/cmdx/executor_spec.rb deleted file mode 100644 index 9bfadfd42..000000000 --- a/spec/cmdx/executor_spec.rb +++ /dev/null @@ -1,1162 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe CMDx::Executor, type: :unit do - subject(:worker) { described_class.new(task) } - - let(:task_class) { create_successful_task(name: "TestTask") } - let(:task) { task_class.new } - - describe "#initialize" do - it "assigns the task" do - expect(worker.task).to eq(task) - end - - it "provides read access to task attribute" do - expect(described_class.instance_methods).to include(:task) - expect(described_class.private_instance_methods).not_to include(:task) - end - end - - describe ".execute" do - context "with raise: false" do - it "creates worker instance and calls execute" do - expect(described_class).to receive(:new).with(task).and_return(worker) - expect(worker).to receive(:execute).and_return(:result) - - result = described_class.execute(task, raise: false) - - expect(result).to eq(:result) - end - end - - context "with raise: true" do - it "creates worker instance and calls execute!" do - expect(described_class).to receive(:new).with(task).and_return(worker) - expect(worker).to receive(:execute!).and_return(:result) - - result = described_class.execute(task, raise: true) - - expect(result).to eq(:result) - end - end - - context "without raise parameter" do - it "defaults to raise: false" do - expect(described_class).to receive(:new).with(task).and_return(worker) - expect(worker).to receive(:execute).and_return(:result) - - described_class.execute(task) - end - end - end - - describe "#execute" do - let(:middlewares) { instance_double(CMDx::MiddlewareRegistry) } - let(:logger) { instance_double(Logger) } - let(:callbacks) { instance_double(CMDx::CallbackRegistry) } - - before do - allow(callbacks).to receive_messages(invoke: nil, empty?: false) - allow(task.class).to receive(:settings).and_return(mock_settings(middlewares: middlewares, callbacks: callbacks)) - allow(task).to receive(:logger).and_return(logger) - allow(logger).to receive(:info) - allow(worker).to receive(:freeze_execution!) - - # Setup result state to support proper transitions - allow(task.result).to receive_messages(to_h: { test: "data" }, state: "executing", executing?: true, executed?: true, success?: true) - allow(task.resolver).to receive(:complete!) - allow(task.resolver).to receive(:executed!) - end - - context "when execution is successful" do - it "calls middleware with task and executes successfully" do - expect(middlewares).to receive(:call!).with(task).and_yield - expect(worker).to receive(:pre_execution!) - expect(worker).to receive(:execution!) - expect(task.resolver).to receive(:executed!) - expect(worker).to receive(:post_execution!) - expect(worker).to receive(:freeze_execution!) - - worker.execute - end - - it "logs execution information" do - expect(middlewares).to receive(:call!).with(task).and_yield - - allow(worker).to receive(:pre_execution!) - allow(worker).to receive(:execution!) - allow(task.resolver).to receive(:executed!) - allow(worker).to receive(:post_execution!) - - expect(logger).to receive(:info) - - worker.execute - end - end - - context "when UndefinedMethodError is raised" do - let(:undefined_error) { CMDx::UndefinedMethodError.new("undefined method") } - - it "re-raises the exception without clearing chain" do - expect(middlewares).to receive(:call!).with(task).and_yield - - allow(worker).to receive(:pre_execution!) - allow(worker).to receive(:execution!).and_raise(undefined_error) - - expect(CMDx::Chain).to receive(:clear).at_least(:once) - - expect { worker.execute }.to raise_error(CMDx::UndefinedMethodError) - end - end - - context "when Fault is raised" do - let(:fault_result) { instance_double(CMDx::Result, reason: "test failure") } - let(:fault) { CMDx::FailFault.new(fault_result) } - - it "calls throw! on task result with fault result" do - expect(middlewares).to receive(:call!).with(task).and_yield - - allow(worker).to receive(:pre_execution!) - allow(worker).to receive(:execution!).and_raise(fault) - - expect(task.resolver).to receive(:throw!).with(fault_result, halt: false, cause: fault) - - allow(task.resolver).to receive(:executed!) - allow(worker).to receive(:post_execution!) - - worker.execute - end - - it "continues with normal execution flow" do - expect(middlewares).to receive(:call!).with(task).and_yield - - allow(worker).to receive(:pre_execution!) - allow(worker).to receive(:execution!).and_raise(fault) - allow(task.resolver).to receive(:throw!) - - expect(task.resolver).to receive(:executed!) - expect(worker).to receive(:post_execution!) - expect(worker).to receive(:freeze_execution!) - - worker.execute - end - end - - context "when StandardError is raised" do - let(:standard_error) { StandardError.new("something went wrong") } - - it "calls fail! on task result with formatted error message" do - expect(middlewares).to receive(:call!).with(task).and_yield - - allow(worker).to receive(:pre_execution!) - allow(worker).to receive(:execution!).and_raise(standard_error) - - expect(task.resolver).to receive(:fail!).with("[StandardError] something went wrong", halt: false, cause: standard_error, source: :exception) - - allow(task.resolver).to receive(:executed!) - allow(worker).to receive(:post_execution!) - - worker.execute - end - - it "continues with normal execution flow" do - expect(middlewares).to receive(:call!).with(task).and_yield - - allow(worker).to receive(:pre_execution!) - allow(worker).to receive(:execution!).and_raise(standard_error) - allow(task.resolver).to receive(:fail!) - - expect(task.resolver).to receive(:executed!) - expect(worker).to receive(:post_execution!) - expect(worker).to receive(:freeze_execution!) - - worker.execute - end - end - - context "when custom error is raised" do - let(:custom_error) { CMDx::TestError.new("test error") } - - it "calls fail! on task result with formatted error message" do - expect(middlewares).to receive(:call!).with(task).and_yield - - allow(worker).to receive(:pre_execution!) - allow(worker).to receive(:execution!).and_raise(custom_error) - - expect(task.resolver).to receive(:fail!).with("[CMDx::TestError] test error", halt: false, cause: custom_error, source: :exception) - - allow(task.resolver).to receive(:executed!) - allow(worker).to receive(:post_execution!) - - worker.execute - end - end - end - - describe "#execute!" do - let(:middlewares) { instance_double(CMDx::MiddlewareRegistry) } - let(:logger) { instance_double(Logger) } - let(:callbacks) { instance_double(CMDx::CallbackRegistry) } - - before do - allow(callbacks).to receive_messages(invoke: nil, empty?: false) - allow(task.class).to receive(:settings).and_return(mock_settings(middlewares: middlewares, callbacks: callbacks)) - allow(task).to receive(:logger).and_return(logger) - allow(logger).to receive(:info) - allow(worker).to receive(:freeze_execution!) - - # Setup result state to support proper transitions - allow(task.result).to receive_messages(to_h: { test: "data" }, state: "executing", executing?: true, executed?: true, success?: true) - allow(task.resolver).to receive(:complete!) - allow(task.resolver).to receive(:executed!) - end - - context "when execution is successful" do - it "calls middleware with task and executes successfully" do - expect(middlewares).to receive(:call!).with(task).and_yield - expect(worker).to receive(:pre_execution!) - expect(worker).to receive(:execution!) - expect(task.resolver).to receive(:executed!) - expect(worker).to receive(:post_execution!) - expect(worker).to receive(:freeze_execution!) - - worker.execute! - end - end - - context "when UndefinedMethodError is raised" do - let(:undefined_error) { CMDx::UndefinedMethodError.new("undefined method") } - - it "calls raise_exception with the error" do - expect(middlewares).to receive(:call!).with(task).and_yield - - allow(worker).to receive(:pre_execution!) - allow(worker).to receive(:execution!).and_raise(undefined_error) - - expect(worker).to receive(:raise_exception).with(undefined_error).and_raise(undefined_error) - - expect { worker.execute! }.to raise_error(undefined_error) - end - end - - context "when Fault is raised" do - let(:fault_result) { instance_double(CMDx::Result, status: "failed", reason: "test failure") } - let(:fault) { CMDx::FailFault.new(fault_result) } - - context "when halt_execution? returns false" do - it "calls throw!, executed!, and post_execution!" do - expect(middlewares).to receive(:call!).with(task).and_yield - - allow(worker).to receive(:pre_execution!) - allow(worker).to receive(:execution!).and_raise(fault) - - expect(task.resolver).to receive(:throw!).with(fault_result, halt: false, cause: fault) - expect(task.resolver).to receive(:executed!) - expect(worker).to receive(:halt_execution?).with(fault).and_return(false) - expect(worker).to receive(:post_execution!) - - worker.execute! - end - end - - context "when halt_execution? returns true" do - it "calls throw! and raise_exception" do - expect(middlewares).to receive(:call!).with(task).and_yield - - allow(worker).to receive(:pre_execution!) - allow(worker).to receive(:execution!).and_raise(fault) - - expect(task.resolver).to receive(:throw!).with(fault_result, halt: false, cause: fault) - expect(worker).to receive(:halt_execution?).with(fault).and_return(true) - expect(worker).to receive(:raise_exception).with(fault).and_raise(fault) - - expect { worker.execute! }.to raise_error(fault) - end - end - end - - context "when StandardError is raised" do - let(:standard_error) { StandardError.new("something went wrong") } - - it "calls fail! and raise_exception" do - expect(middlewares).to receive(:call!).with(task).and_yield - - allow(worker).to receive(:pre_execution!) - allow(worker).to receive(:execution!).and_raise(standard_error) - - expect(task.resolver).to receive(:fail!).with("[StandardError] something went wrong", halt: false, cause: standard_error, source: :exception) - expect(worker).to receive(:raise_exception).with(standard_error).and_raise(standard_error) - - expect { worker.execute! }.to raise_error(standard_error) - end - end - end - - describe "#halt_execution?" do - let(:fault_resolver) { instance_double(CMDx::Resolver) } - let(:fault_task) { instance_double(CMDx::Task, resolver: fault_resolver) } - let(:fault_result) { instance_double(CMDx::Result, status: "failed", task: fault_task, reason: "test failure", strict?: true) } - let(:fault) { CMDx::FailFault.new(fault_result) } - - context "when resolver has strict: false" do - let(:fault_result) { instance_double(CMDx::Result, status: "failed", task: fault_task, reason: "test failure", strict?: false) } - - before do - allow(task.class).to receive(:settings).and_return(mock_settings(breakpoints: %w[failed skipped])) - end - - it "returns false regardless of breakpoints config" do - expect(worker.send(:halt_execution?, fault)).to be(false) - end - end - - context "when breakpoints setting exists" do - before do - allow(task.class).to receive(:settings).and_return(mock_settings(breakpoints: %w[failed skipped])) - end - - context "when exception result status is in breakpoints" do - it "returns true" do - expect(worker.send(:halt_execution?, fault)).to be(true) - end - end - - context "when exception result status is not in breakpoints" do - let(:success_resolver) { instance_double(CMDx::Resolver) } - let(:success_task) { instance_double(CMDx::Task, resolver: success_resolver) } - let(:success_result) { instance_double(CMDx::Result, status: "success", task: success_task, reason: "test success", strict?: true) } - let(:success_fault) { CMDx::SkipFault.new(success_result) } - - it "returns false" do - expect(worker.send(:halt_execution?, success_fault)).to be(false) - end - end - end - - context "when task_breakpoints setting exists" do - before do - allow(task.class).to receive(:settings).and_return(mock_settings(task_breakpoints: [:failed])) - end - - it "converts symbols to strings and checks inclusion" do - expect(worker.send(:halt_execution?, fault)).to be(true) - end - end - - context "when no breakpoints are configured" do - before do - allow(task.class).to receive(:settings).and_return(mock_settings(task_breakpoints: nil)) - end - - it "returns false" do - expect(worker.send(:halt_execution?, fault)).to be(false) - end - end - - context "when breakpoints is nil" do - before do - allow(task.class).to receive(:settings).and_return(mock_settings(breakpoints: nil, task_breakpoints: nil)) - end - - it "returns false" do - expect(worker.send(:halt_execution?, fault)).to be(false) - end - end - - context "with duplicate breakpoints" do - before do - allow(task.class).to receive(:settings).and_return(mock_settings(breakpoints: ["failed", "failed", :failed])) - end - - it "removes duplicates after string conversion" do - expect(worker.send(:halt_execution?, fault)).to be(true) - end - end - end - - describe "#retry_execution?" do - let(:exception) { StandardError.new("test error") } - let(:logger) { instance_double(Logger) } - - before do - allow(logger).to receive(:warn) - allow(task).to receive_messages(logger: logger, to_h: { id: "123" }) - end - - context "when retries is not configured" do - before do - allow(task.class).to receive(:settings).and_return(mock_settings) - end - - it "returns false" do - expect(worker.send(:retry_execution?, exception)).to be(false) - end - end - - context "when retries is 0" do - before do - allow(task.class).to receive(:settings).and_return(mock_settings(retries: 0)) - end - - it "returns false" do - expect(worker.send(:retry_execution?, exception)).to be(false) - end - end - - context "when retries are exhausted" do - before do - allow(task.class).to receive(:settings).and_return(mock_settings(retries: 2)) - allow(task.result).to receive(:retries).and_return(2) - end - - it "returns false" do - expect(worker.send(:retry_execution?, exception)).to be(false) - end - end - - context "when exception type does not match retry_on" do - before do - allow(task.class).to receive(:settings).and_return(mock_settings(retries: 3, retry_on: [ArgumentError])) - allow(task.result).to receive(:retries).and_return(0) - end - - it "returns false" do - expect(worker.send(:retry_execution?, exception)).to be(false) - end - end - - context "when retry should happen" do - before do - allow(task.class).to receive(:settings).and_return(mock_settings(retries: 3)) - allow(task.result).to receive_messages(retries: 0, :retries= => nil) - end - - it "returns true" do - expect(worker.send(:retry_execution?, exception)).to be(true) - end - - it "increments retry count on result" do - allow(task.result).to receive_messages(retries: 1, :retries= => nil) - - expect(task.result).to receive(:retries=).with(2) - - worker.send(:retry_execution?, exception) - end - - it "clears task errors" do - task.errors.add(:base, "previous error") - - worker.send(:retry_execution?, exception) - - expect(task.errors).to be_empty - end - - it "logs warning with reason and remaining retries" do - allow(task.result).to receive_messages(retries: 0, :retries= => nil) - - expect(logger).to receive(:warn) do |&block| - result = block.call - expect(result[:reason]).to eq("[StandardError] test error") - expect(result[:remaining_retries]).to eq(3) - end - - worker.send(:retry_execution?, exception) - end - end - - context "with retry_on configuration" do - context "when exception matches configured type" do - before do - allow(task.class).to receive(:settings).and_return(mock_settings(retries: 2, retry_on: [StandardError])) - allow(task.result).to receive_messages(retries: 0, :retries= => nil) - end - - it "returns true" do - expect(worker.send(:retry_execution?, exception)).to be(true) - end - end - - context "when exception is subclass of configured type" do - let(:custom_error) { CMDx::TestError.new("test error") } - - before do - allow(task.class).to receive(:settings).and_return(mock_settings(retries: 2, retry_on: [StandardError])) - allow(task.result).to receive_messages(retries: 0, :retries= => nil) - end - - it "returns true" do - expect(worker.send(:retry_execution?, custom_error)).to be(true) - end - end - - context "when multiple exception types are configured" do - before do - allow(task.class).to receive(:settings).and_return(mock_settings(retries: 2, retry_on: [ArgumentError, StandardError])) - allow(task.result).to receive_messages(retries: 0, :retries= => nil) - end - - it "returns true if exception matches any type" do - expect(worker.send(:retry_execution?, exception)).to be(true) - end - end - end - - context "with retry_jitter as numeric value" do - before do - allow(task.class).to receive(:settings).and_return(mock_settings(retries: 3, retry_jitter: 0.5)) - allow(task.result).to receive_messages(retries: 1, :retries= => nil) - end - - it "sleeps for jitter multiplied by current retries" do - expect(worker).to receive(:sleep).with(0.5) - - worker.send(:retry_execution?, exception) - end - - context "when first retry" do - before do - allow(task.result).to receive_messages(retries: 0, :retries= => nil) - end - - it "does not sleep when jitter calculation is 0" do - expect(worker).not_to receive(:sleep) - - worker.send(:retry_execution?, exception) - end - end - - context "when second retry" do - before do - allow(task.result).to receive_messages(retries: 1, :retries= => nil) - end - - it "sleeps for jitter * 1" do - expect(worker).to receive(:sleep).with(0.5) - - worker.send(:retry_execution?, exception) - end - end - - context "when third retry" do - before do - allow(task.result).to receive_messages(retries: 2, :retries= => nil) - end - - it "sleeps for jitter * 2" do - expect(worker).to receive(:sleep).with(1.0) - - worker.send(:retry_execution?, exception) - end - end - end - - context "with retry_jitter as symbol" do - before do - allow(task.class).to receive(:settings).and_return(mock_settings(retries: 3, retry_jitter: :custom_jitter)) - allow(task.result).to receive_messages(retries: 1, :retries= => nil) - allow(task).to receive(:custom_jitter).with(1).and_return(2.5) - end - - it "calls method on task with current retries" do - expect(task).to receive(:custom_jitter).with(1).and_return(2.5) - expect(worker).to receive(:sleep).with(2.5) - - worker.send(:retry_execution?, exception) - end - end - - context "with retry_jitter as proc" do - let(:jitter_proc) { ->(retries) { retries * 0.75 } } - - before do - allow(task.class).to receive(:settings).and_return(mock_settings(retries: 3, retry_jitter: jitter_proc)) - allow(task.result).to receive_messages(retries: 2, :retries= => nil) - end - - it "instance_execs proc with current retries" do - expect(worker).to receive(:sleep).with(1.5) - - worker.send(:retry_execution?, exception) - end - end - - context "with retry_jitter as callable object" do - let(:jitter_callable) do - Class.new do - def call(_task, retries) - retries * 1.25 - end - end.new - end - - before do - allow(task.class).to receive(:settings).and_return(mock_settings(retries: 3, retry_jitter: jitter_callable)) - allow(task.result).to receive_messages(retries: 2, :retries= => nil) - end - - it "calls object with task and current retries" do - expect(jitter_callable).to receive(:call).with(task, 2).and_return(2.5) - expect(worker).to receive(:sleep).with(2.5) - - worker.send(:retry_execution?, exception) - end - end - - context "when jitter calculation returns negative value" do - before do - allow(task.class).to receive(:settings).and_return(mock_settings(retries: 3, retry_jitter: -0.5)) - allow(task.result).to receive_messages(retries: 1, :retries= => nil) - end - - it "does not sleep" do - expect(worker).not_to receive(:sleep) - - worker.send(:retry_execution?, exception) - end - end - - context "when jitter calculation returns zero" do - before do - allow(task.class).to receive(:settings).and_return(mock_settings(retries: 3, retry_jitter: 0)) - allow(task.result).to receive_messages(retries: 1, :retries= => nil) - end - - it "does not sleep" do - expect(worker).not_to receive(:sleep) - - worker.send(:retry_execution?, exception) - end - end - end - - describe "#raise_exception" do - let(:exception) { StandardError.new("test error") } - - it "clears the chain and raises the exception" do - expect(CMDx::Chain).to receive(:clear).at_least(:once) - - expect { worker.send(:raise_exception, exception) }.to raise_error(exception) - end - end - - describe "#invoke_callbacks" do - let(:callbacks) { instance_double(CMDx::CallbackRegistry) } - - before do - allow(task.class).to receive(:settings).and_return(mock_settings(callbacks: callbacks)) - end - - it "delegates to callbacks registry with type and task" do - expect(callbacks).to receive(:invoke).with(:before_validation, task) - - worker.send(:invoke_callbacks, :before_validation) - end - end - - describe "#pre_execution!" do - let(:callbacks) { instance_double(CMDx::CallbackRegistry) } - let(:attributes) { instance_double(CMDx::AttributeRegistry) } - let(:errors) { instance_double(CMDx::Errors) } - - before do - allow(task.class).to receive(:settings).and_return( - mock_settings(callbacks: callbacks, attributes: attributes) - ) - allow(task).to receive(:errors).and_return(errors) - allow(callbacks).to receive(:invoke) - allow(attributes).to receive(:define_and_verify) - end - - context "when task has no errors" do - before do - allow(errors).to receive(:empty?).and_return(true) - end - - it "invokes before_validation callback and defines attributes" do - expect(callbacks).to receive(:invoke).with(:before_validation, task) - expect(attributes).to receive(:define_and_verify).with(task) - - worker.send(:pre_execution!) - end - end - - context "when task has errors" do - before do - allow(errors).to receive_messages( - empty?: false, - to_s: "Validation failed", - to_h: { name: ["is required"] } - ) - allow(task.resolver).to receive(:fail!) - end - - it "calls fail! on result with error information" do - expect(task.resolver).to receive(:fail!).with( - "Invalid", - source: :validation, - errors: { - full_message: "Validation failed", - messages: { name: ["is required"] } - } - ) - - worker.send(:pre_execution!) - end - end - end - - describe "#execution!" do - let(:callbacks) { instance_double(CMDx::CallbackRegistry) } - - before do - allow(task.class).to receive(:settings).and_return(mock_settings(callbacks: callbacks)) - allow(callbacks).to receive(:invoke) - allow(task.resolver).to receive(:executing!) - allow(task).to receive(:work) - end - - it "invokes before_execution callback, sets executing state, and calls work" do - expect(callbacks).to receive(:invoke).with(:before_execution, task) - expect(task.resolver).to receive(:executing!) - expect(task).to receive(:work) - - worker.send(:execution!) - end - end - - describe "#verify_context_returns!" do - let(:errors) { task.errors } - - context "when no returns are declared" do - before do - allow(task.class).to receive(:settings).and_return(mock_settings(returns: [])) - allow(task.result).to receive(:success?).and_return(true) - end - - it "does not fail the result" do - expect(task.result).not_to receive(:fail!) - - worker.send(:verify_context_returns!) - end - end - - context "when returns setting is nil" do - before do - allow(task.class).to receive(:settings).and_return(mock_settings(returns: nil)) - allow(task.result).to receive(:success?).and_return(true) - end - - it "does not fail the result" do - expect(task.result).not_to receive(:fail!) - - worker.send(:verify_context_returns!) - end - end - - context "when result is not success" do - before do - allow(task.class).to receive(:settings).and_return(mock_settings(returns: [:user])) - allow(task.result).to receive(:success?).and_return(false) - end - - it "skips verification" do - expect(task.result).not_to receive(:fail!) - - worker.send(:verify_context_returns!) - end - end - - context "when all declared returns are present in context" do - before do - allow(task.class).to receive(:settings).and_return(mock_settings(returns: %i[user token])) - allow(task.result).to receive(:success?).and_return(true) - task.context[:user] = "John" - task.context[:token] = "abc123" - end - - it "does not fail the result" do - expect(task.result).not_to receive(:fail!) - - worker.send(:verify_context_returns!) - end - end - - context "when some declared returns are missing" do - before do - allow(task.class).to receive(:settings).and_return(mock_settings(returns: %i[user token])) - allow(task.result).to receive(:success?).and_return(true) - task.context[:user] = "John" - end - - it "adds errors for missing returns and fails the result" do - expect(task.resolver).to receive(:fail!).with( - "Invalid", - source: :context, - errors: { - full_message: "token must be set in the context", - messages: { token: ["must be set in the context"] } - } - ) - - worker.send(:verify_context_returns!) - end - end - - context "when all declared returns are missing" do - before do - allow(task.class).to receive(:settings).and_return(mock_settings(returns: %i[user token])) - allow(task.result).to receive(:success?).and_return(true) - end - - it "adds errors for all missing returns and fails the result" do - expect(task.resolver).to receive(:fail!).with( - "Invalid", - source: :context, - errors: { - full_message: "user must be set in the context. token must be set in the context", - messages: { - user: ["must be set in the context"], - token: ["must be set in the context"] - } - } - ) - - worker.send(:verify_context_returns!) - end - end - end - - describe "#post_execution!" do - let(:callbacks) { instance_double(CMDx::CallbackRegistry) } - let(:result) { instance_double(CMDx::Result) } - - before do - allow(task.class).to receive(:settings).and_return(mock_settings(callbacks: callbacks)) - allow(task).to receive(:result).and_return(result) - allow(callbacks).to receive(:invoke) - end - - context "when callback registry is empty" do - before do - allow(callbacks).to receive(:empty?).and_return(true) - end - - it "skips all callback invocations" do - expect(callbacks).not_to receive(:invoke) - - worker.send(:post_execution!) - end - end - - context "when result is executed and good" do - before do - allow(callbacks).to receive(:empty?).and_return(false) - allow(result).to receive_messages( - state: "complete", - status: "success", - executed?: true, - good?: true, - bad?: false - ) - end - - it "invokes all appropriate callbacks" do - expect(callbacks).to receive(:invoke).with(:on_complete, task) - expect(callbacks).to receive(:invoke).with(:on_executed, task) - expect(callbacks).to receive(:invoke).with(:on_success, task) - expect(callbacks).to receive(:invoke).with(:on_good, task) - - worker.send(:post_execution!) - end - end - - context "when result is failed and bad" do - before do - allow(callbacks).to receive(:empty?).and_return(false) - allow(result).to receive_messages( - state: "interrupted", - status: "failed", - executed?: false, - good?: false, - bad?: true - ) - end - - it "invokes all appropriate callbacks" do - expect(callbacks).to receive(:invoke).with(:on_interrupted, task) - expect(callbacks).to receive(:invoke).with(:on_failed, task) - expect(callbacks).to receive(:invoke).with(:on_bad, task) - - worker.send(:post_execution!) - end - end - - context "when result is skipped" do - before do - allow(callbacks).to receive(:empty?).and_return(false) - allow(result).to receive_messages( - state: "interrupted", - status: "skipped", - executed?: false, - good?: true, - bad?: false - ) - end - - it "invokes appropriate callbacks for skipped state" do - expect(callbacks).to receive(:invoke).with(:on_interrupted, task) - expect(callbacks).to receive(:invoke).with(:on_skipped, task) - expect(callbacks).to receive(:invoke).with(:on_good, task) - - worker.send(:post_execution!) - end - end - end - - describe "#finalize_execution!" do - let(:logger) { instance_double(Logger) } - - before do - allow(worker).to receive(:freeze_execution!) - allow(task).to receive(:logger).and_return(logger) - allow(logger).to receive(:info) - allow(task.result).to receive(:to_h).and_return({ id: "123", status: "success" }) - end - - it "freezes the task" do - expect(worker).to receive(:freeze_execution!) - - worker.send(:finalize_execution!) - end - - it "logs the result information at info level" do - expect(task).to receive(:logger) - expect(logger).to receive(:info) - - worker.send(:finalize_execution!) - end - - context "when logger block is called" do - it "calls to_h on task result" do - expect(task.result).to receive(:to_h).and_return({ id: "123", status: "success" }) - - expect(logger).to receive(:info) do |&block| - # When the block is called, it should return the result of to_h - expect(block.call).to eq({ id: "123", status: "success" }) - end - - worker.send(:finalize_execution!) - end - end - end - - describe "#rollback_execution!" do - context "when task does not respond to rollback" do - before do - allow(task).to receive(:respond_to?).with(:rollback).and_return(false) - allow(task.result).to receive(:status).and_return("failed") - end - - it "does not call rollback" do - expect(task).not_to receive(:rollback) - - worker.send(:rollback_execution!) - end - end - - context "when task responds to rollback" do - before do - allow(task).to receive(:respond_to?).with(:rollback).and_return(true) - allow(task).to receive(:rollback) - end - - context "when rollpoints setting exists" do - before do - allow(task.class).to receive(:settings).and_return(mock_settings(rollback_on: %w[failed skipped])) - end - - context "when result status is in rollpoints" do - before do - allow(task.result).to receive(:status).and_return("failed") - end - - it "calls rollback and marks result as rolled back" do - expect(task).to receive(:rollback) - - worker.send(:rollback_execution!) - - expect(task.result.rolled_back?).to be(true) - end - end - - context "when result status is not in rollpoints" do - before do - allow(task.result).to receive(:status).and_return("success") - end - - it "does not call rollback" do - expect(task).not_to receive(:rollback) - - worker.send(:rollback_execution!) - end - end - end - - context "when no rollpoints are configured" do - before do - allow(task.class).to receive(:settings).and_return(mock_settings(rollback_on: [])) - allow(task.result).to receive(:status).and_return("failed") - end - - it "does not call rollback" do - expect(task).not_to receive(:rollback) - - worker.send(:rollback_execution!) - end - end - - context "when rollpoints is nil" do - before do - allow(task.class).to receive(:settings).and_return(mock_settings(rollback_on: nil)) - allow(task.result).to receive(:status).and_return("failed") - end - - it "does not call rollback" do - expect(task).not_to receive(:rollback) - - worker.send(:rollback_execution!) - end - end - - context "with duplicate rollpoints" do - before do - allow(task.class).to receive(:settings).and_return(mock_settings(rollback_on: ["failed", "failed", :failed])) - allow(task.result).to receive(:status).and_return("failed") - end - - it "removes duplicates after string conversion and calls rollback" do - expect(task).to receive(:rollback).once - - worker.send(:rollback_execution!) - end - end - - context "with multiple statuses in rollpoints" do - before do - allow(task.class).to receive(:settings).and_return(mock_settings(rollback_on: %w[failed skipped])) - end - - it "calls rollback and marks result as rolled back when status is failed" do - allow(task.result).to receive(:status).and_return("failed") - - expect(task).to receive(:rollback) - - worker.send(:rollback_execution!) - - expect(task.result.rolled_back?).to be(true) - end - - it "calls rollback and marks result as rolled back when status is skipped" do - allow(task.result).to receive(:status).and_return("skipped") - - expect(task).to receive(:rollback) - - worker.send(:rollback_execution!) - - expect(task.result.rolled_back?).to be(true) - end - end - end - end - - describe "#verify_middleware_yield!" do - context "when result is still initialized (middleware did not yield)" do - before do - allow(task.result).to receive(:initialized?).and_return(true) - end - - it "marks result as failed and transitions to executed" do - expect(task.resolver).to receive(:fail!).with( - CMDx::Locale.t("cmdx.faults.invalid"), - halt: false, - source: :middleware - ) - expect(task.resolver).to receive(:executed!) - - worker.send(:verify_middleware_yield!) - end - end - - context "when result has progressed past initialized" do - before do - allow(task.result).to receive(:initialized?).and_return(false) - end - - it "does nothing" do - expect(task.resolver).not_to receive(:fail!) - expect(task.resolver).not_to receive(:executed!) - - worker.send(:verify_middleware_yield!) - end - end - - context "when middleware swallows the block during #execute" do - let(:swallowing_task_class) { create_successful_task(name: "SwallowTestTask") } - - before do - swallowing_task_class.settings.middlewares.register( - Class.new do - def self.call(_task, **_opts); end - end - ) - end - - it "detects and fails the result" do - result = swallowing_task_class.execute - - expect(result.failed?).to be(true) - expect(result.metadata[:source]).to eq(:middleware) - expect(result.interrupted?).to be(true) - end - end - end - - describe "#clear_chain!" do - let(:chain) { task.chain } - - after { CMDx::Chain.clear } - - context "when result is the first in the chain" do - it "clears the chain when current matches task chain" do - expect(task.result.index).to eq(0) - expect { worker.send(:clear_chain!) }.to change(CMDx::Chain, :current).to(nil) - end - end - - context "when result is not the first in the chain" do - before do - other_task = task_class.new - described_class.new(other_task) - end - - it "does not clear the chain" do - expect(task.result.index).not_to eq(0) - expect { worker.send(:clear_chain!) }.not_to change(CMDx::Chain, :current) - end - end - - context "when current chain is a different instance" do - it "does not clear the chain" do - worker # force creation so task.chain is established - - other_chain = CMDx::Chain.new - CMDx::Chain.current = other_chain - - worker.send(:clear_chain!) - expect(CMDx::Chain.current).to equal(other_chain) - end - end - end -end diff --git a/spec/cmdx/fault_spec.rb b/spec/cmdx/fault_spec.rb new file mode 100644 index 000000000..725cf8065 --- /dev/null +++ b/spec/cmdx/fault_spec.rb @@ -0,0 +1,154 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe CMDx::Fault do + let(:chain) { CMDx::Chain.new } + let(:task_class) { create_task_class(name: "SampleTask") } + let(:task) { task_class.new } + + def build_result(signal, klass: task_class, **opts) + instance = klass.new + CMDx::Result.new(chain, instance, signal, **opts) + end + + describe "#initialize" do + let(:signal) { CMDx::Signal.failed("boom") } + let(:result) { build_result(signal) } + + it "stores the result and exposes task/signal/context/chain" do + fault = described_class.new(result) + + expect(fault).to have_attributes( + result: result, + task: task_class, + context: result.context, + chain: chain + ) + end + + it "uses the signal's reason as the message" do + expect(described_class.new(result).message).to eq("boom") + end + + it "falls back to a localized unspecified message when reason is nil" do + result_without_reason = build_result(CMDx::Signal.failed) + + expect(described_class.new(result_without_reason).message) + .to eq(CMDx::I18nProxy.t("cmdx.reasons.unspecified")) + end + + it "descends from CMDx::Error" do + expect(described_class.new(result)).to be_a(CMDx::Error) + end + end + + describe "backtrace handling" do + context "when the signal carries a backtrace" do + it "applies it to the fault" do + frames = %w[a.rb:1 b.rb:2] + result = build_result(CMDx::Signal.failed("b", backtrace: frames)) + + expect(described_class.new(result).backtrace).to eq(frames) + end + end + + context "when the signal has a cause with backtrace_locations" do + it "uses the cause's backtrace" do + cause = + begin + raise StandardError, "inner" + rescue StandardError => e + e + end + result = build_result(CMDx::Signal.failed("b", cause: cause)) + + expect(described_class.new(result).backtrace).to eq(cause.backtrace_locations.map(&:to_s)) + end + end + + context "when neither backtrace source is present" do + it "leaves backtrace nil" do + expect(described_class.new(build_result(CMDx::Signal.failed("b"))).backtrace).to be_nil + end + end + + context "with a backtrace_cleaner configured on task settings" do + it "runs the cleaner over the frames" do + task_class.settings(backtrace_cleaner: ->(frames) { frames.map { |f| "cleaned:#{f}" } }) + result = build_result(CMDx::Signal.failed("b", backtrace: %w[a b])) + + expect(described_class.new(result).backtrace).to eq(%w[cleaned:a cleaned:b]) + end + + it "keeps the original frames when the cleaner returns a falsy value" do + task_class.settings(backtrace_cleaner: ->(_frames) {}) + result = build_result(CMDx::Signal.failed("b", backtrace: %w[a b])) + + expect(described_class.new(result).backtrace).to eq(%w[a b]) + end + end + end + + describe ".for?" do + let(:parent_task) { create_task_class(name: "ParentTask") } + let(:child_task) { create_task_class(base: parent_task, name: "ChildTask") } + let(:other_task) { create_task_class(name: "OtherTask") } + let(:signal) { CMDx::Signal.failed("boom") } + + it "raises when called with no tasks" do + expect { described_class.for? }.to raise_error(ArgumentError, "at least one task required") + end + + it "matches faults whose task is <= one of the given tasks" do + matcher = described_class.for?(parent_task) + fault_child = described_class.new(build_result(signal, klass: child_task)) + fault_other = described_class.new(build_result(signal, klass: other_task)) + + expect(matcher === fault_child).to be(true) + expect(matcher === fault_other).to be(false) + end + + it "accepts a flat array of tasks" do + matcher = described_class.for?([parent_task, other_task]) + expect(matcher === described_class.new(build_result(signal, klass: other_task))).to be(true) + end + + it "only matches instances of the class that defined it" do + subclass = Class.new(described_class) + matcher = subclass.for?(parent_task) + + parent_fault = described_class.new(build_result(signal, klass: parent_task)) + + expect(matcher === parent_fault).to be(false) + end + end + + describe ".matches?" do + let(:signal) { CMDx::Signal.failed("boom") } + let(:result) { build_result(signal) } + + it "raises when called without a block" do + expect { described_class.matches? }.to raise_error(ArgumentError, "block required") + end + + it "matches when both the class check and the block return truthy" do + matcher = described_class.matches? { |f| f.result.reason == "boom" } + fault = described_class.new(result) + + expect(matcher === fault).to be(true) + end + + it "does not match when the block returns false" do + matcher = described_class.matches? { |_f| false } + fault = described_class.new(result) + + expect(matcher === fault).to be(false) + end + + it "does not match non-Fault objects" do + matcher = described_class.matches? { true } + expect(matcher === "not a fault").to be(false) + end + end +end diff --git a/spec/cmdx/faults_spec.rb b/spec/cmdx/faults_spec.rb deleted file mode 100644 index 1572309a5..000000000 --- a/spec/cmdx/faults_spec.rb +++ /dev/null @@ -1,189 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe CMDx::Fault, type: :unit do - let(:task_class) { create_successful_task(name: "TestTask") } - let(:task) { task_class.new } - let(:result) do - task.resolver.fail!("test failure reason", halt: false) - task.result - end - - describe "#initialize" do - subject(:fault) { described_class.new(result) } - - it "initializes with result and sets reason from result" do - expect(fault.result).to eq(result) - expect(fault.message).to eq("test failure reason") - end - - it "inherits from CMDx::Error" do - expect(fault).to be_a(CMDx::Error) - end - - it "inherits from StandardError" do - expect(fault).to be_a(StandardError) - end - - context "when result has no reason" do - let(:result) do - task.result.tap do |r| - r.instance_variable_set(:@reason, nil) - end - end - - it "initializes with nil message passed to Error" do - expect(fault.message).to eq("CMDx::Fault") - end - end - end - - describe ".for?" do - let(:task_class_a) { create_successful_task(name: "TaskA") } - let(:task_class_b) { create_successful_task(name: "TaskB") } - let(:task_a) { task_class_a.new } - let(:task_b) { task_class_b.new } - let(:fault_a) { described_class.new(task_a.result) } - let(:fault_b) { described_class.new(task_b.result) } - - context "when matching single task class" do - subject(:custom_fault_class) { described_class.for?(task_class_a) } - - it "returns a new fault class" do - expect(custom_fault_class).to be_a(Class) - expect(custom_fault_class.superclass).to eq(described_class) - end - - it "matches faults from specified task class" do - allow(fault_a).to receive(:task).and_return(fault_a.result.task) - expect(custom_fault_class === fault_a).to be(true) - end - - it "does not match faults from other task classes" do - allow(fault_b).to receive(:task).and_return(fault_b.result.task) - expect(custom_fault_class === fault_b).to be(false) - end - - it "does not match non-fault objects" do - expect(custom_fault_class === "not a fault").to be(false) - end - - it "stores task classes in instance variable" do - expect(custom_fault_class.instance_variable_get(:@tasks)).to eq([task_class_a]) - end - end - - context "when matching multiple task classes" do - subject(:custom_fault_class) { described_class.for?(task_class_a, task_class_b) } - - it "matches faults from any specified task class" do - allow(fault_a).to receive(:task).and_return(fault_a.result.task) - allow(fault_b).to receive(:task).and_return(fault_b.result.task) - - expect(custom_fault_class === fault_a).to be(true) - expect(custom_fault_class === fault_b).to be(true) - end - - it "stores all task classes in instance variable" do - expect(custom_fault_class.instance_variable_get(:@tasks)).to eq([task_class_a, task_class_b]) - end - end - - context "when no task classes provided" do - subject(:custom_fault_class) { described_class.for? } - - it "returns fault class that matches no faults" do - allow(fault_a).to receive(:task).and_return(fault_a.result.task) - allow(fault_b).to receive(:task).and_return(fault_b.result.task) - - expect(custom_fault_class === fault_a).to be(false) - expect(custom_fault_class === fault_b).to be(false) - end - - it "stores empty array in instance variable" do - expect(custom_fault_class.instance_variable_get(:@tasks)).to eq([]) - end - end - end - - describe ".matches?" do - let(:fault_with_metadata) do - task.resolver.fail!("failure", halt: false, metadata: { error_code: 500 }) - described_class.new(task.result) - end - - context "when block is provided" do - subject(:custom_fault_class) do - described_class.matches? { |fault| fault.result.reason == "failure" } - end - - it "returns a new fault class" do - expect(custom_fault_class).to be_a(Class) - expect(custom_fault_class.superclass).to eq(described_class) - end - - it "matches faults that satisfy the block condition" do - expect(custom_fault_class === fault_with_metadata).to be_truthy - end - - it "does not match faults that don't satisfy the block condition" do - simple_fault = described_class.new(result) - expect(custom_fault_class === simple_fault).to be(false) - end - - it "does not match non-fault objects" do - expect(custom_fault_class === "not a fault").to be(false) - end - - it "stores block in instance variable" do - expect(custom_fault_class.instance_variable_get(:@block)).to be_a(Proc) - end - end - - context "when no block is provided" do - it "raises ArgumentError" do - expect { described_class.matches? }.to raise_error(ArgumentError, "block required") - end - end - - context "when block returns falsy values" do - subject(:custom_fault_class) do - described_class.matches? { |fault| fault.result.metadata[:nonexistent] } - end - - it "does not match when block returns nil" do - simple_fault = described_class.new(result) - expect(custom_fault_class === simple_fault).to be_falsy - end - - it "does not match when block returns false" do - custom_false_class = described_class.matches? { |_| false } - simple_fault = described_class.new(result) - expect(custom_false_class === simple_fault).to be_falsy - end - end - - context "when block returns truthy values" do - it "matches when block returns true" do - custom_true_class = described_class.matches? { |_| true } - simple_fault = described_class.new(result) - expect(custom_true_class === simple_fault).to be_truthy - end - - it "matches when block returns truthy object" do - custom_truthy_class = described_class.matches? { |_| "truthy" } - simple_fault = described_class.new(result) - expect(custom_truthy_class === simple_fault).to be_truthy - end - end - end - - describe "task accessor" do - subject(:fault) { described_class.new(result) } - - it "provides access to task through result" do - expect(fault.result.task).to eq(task) - end - end -end diff --git a/spec/cmdx/i18n_proxy_spec.rb b/spec/cmdx/i18n_proxy_spec.rb new file mode 100644 index 000000000..65b981f9f --- /dev/null +++ b/spec/cmdx/i18n_proxy_spec.rb @@ -0,0 +1,134 @@ +# frozen_string_literal: true + +require "spec_helper" +require "tmpdir" + +RSpec.describe CMDx::I18nProxy do + let(:proxy) { described_class.new } + + describe "#translate" do + context "when I18n is defined" do + it "delegates to I18n.translate" do + fake_i18n = Module.new do + def self.translate(key, **opts) = [key, opts] + end + stub_const("I18n", fake_i18n) + + expect(proxy.translate(:hello, name: "world")).to eq([:hello, { name: "world" }]) + end + end + + context "when I18n is not defined" do + before { hide_const("I18n") } + + it "interpolates a string default with options" do + result = proxy.translate(:custom, default: "hi %{name}", name: "Ada") + expect(result).to eq("hi Ada") + end + + it "returns a missing-translation message when the key is absent" do + allow(proxy).to receive(:translation_default).and_return(nil) + expect(proxy.translate("nope.nothing")).to eq("Translation missing: nope.nothing") + end + + it "returns a non-string, non-nil default verbatim" do + expect(proxy.translate(:x, default: %w[a b])).to eq(%w[a b]) + end + end + + it "is aliased as #t" do + expect(proxy.method(:t)).to eq(proxy.method(:translate)) + end + end + + describe ".translate" do + before { hide_const("I18n") } + + it "memoizes an internal proxy and delegates to it" do + expect(described_class.translate("cmdx.faults.unspecified")).to be_a(String) + end + + it "is aliased as .t" do + expect(described_class.method(:t)).to eq(described_class.method(:translate)) + end + end + + describe "translation_default" do + before { hide_const("I18n") } + + it "loads the en locale file and returns the nested value" do + value = proxy.translate("cmdx.faults.unspecified") + expect(value).to be_a(String) + expect(value).not_to be_empty + end + + it "caches successive lookups of the same key" do + proxy.translate("cmdx.faults.unspecified") + defaults = proxy.instance_variable_get(:@defaults) + expect(defaults.keys).to include("en.cmdx.faults.unspecified") + end + end + + describe ".locale_paths / .register" do + let(:default_paths) { [File.expand_path("../../lib/cmdx/../locales", __dir__)] } + + around do |example| + original = described_class.instance_variable_get(:@locale_paths) + described_class.instance_variable_set(:@locale_paths, nil) + described_class.instance_variable_set(:@proxy, nil) + example.run + ensure + described_class.instance_variable_set(:@locale_paths, original) + described_class.instance_variable_set(:@proxy, nil) + end + + it "seeds locale_paths with cmdx's own locales directory" do + expect(described_class.locale_paths.size).to eq(1) + expect(described_class.locale_paths.first).to match(%r{/lib/locales\z}) + end + + it "appends a registered path and is idempotent" do + Dir.mktmpdir do |dir| + described_class.register(dir) + described_class.register(dir) + expect(described_class.locale_paths.last).to eq(dir) + expect(described_class.locale_paths.count(dir)).to eq(1) + end + end + + it "resets the memoized proxy when a path is registered" do + described_class.instance_variable_set(:@proxy, :sentinel) + Dir.mktmpdir do |dir| + described_class.register(dir) + end + expect(described_class.instance_variable_get(:@proxy)).to be_nil + end + + context "when resolving a locale only present in an external path" do + before { hide_const("I18n") } + + it "finds the translation via the registered path" do + Dir.mktmpdir do |dir| + File.write(File.join(dir, "fr.yml"), { "fr" => { "greeting" => "bonjour %{name}" } }.to_yaml) + described_class.register(dir) + + CMDx.configuration.default_locale = "fr" + expect(proxy.translate("greeting", name: "Ada")).to eq("bonjour Ada") + end + end + end + + context "when a locale key exists in multiple paths" do + before { hide_const("I18n") } + + it "prefers the most-recently-registered path" do + Dir.mktmpdir do |dir| + File.write(File.join(dir, "en.yml"), { "en" => { "custom" => { "key" => "override" } } }.to_yaml) + described_class.register(dir) + + expect(proxy.translate("custom.key")).to eq("override") + end + end + end + end +end diff --git a/spec/cmdx/identifier_spec.rb b/spec/cmdx/identifier_spec.rb deleted file mode 100644 index eae822cb4..000000000 --- a/spec/cmdx/identifier_spec.rb +++ /dev/null @@ -1,49 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe CMDx::Identifier, type: :unit do - subject(:identifier) { described_class } - - describe ".generate" do - context "when SecureRandom responds to uuid_v7" do - let(:uuid_v7) { "018f1234-5678-7000-8000-123456789abc" } - - before do - allow(SecureRandom).to receive(:respond_to?).with(:uuid_v7).and_return(true) - end - - it "returns a UUID v7" do - allow(SecureRandom).to receive(:uuid_v7).and_return(uuid_v7) - - expect(identifier.generate).to eq(uuid_v7) - end - - it "does not call the fallback uuid method" do - skip("Not supported on Ruby versions prior to 3.4") unless RubyVersion.min?(3.4) - - expect(SecureRandom).not_to receive(:uuid) - - identifier.generate - end - end - - context "when called multiple times" do - before do - allow(SecureRandom).to receive(:respond_to?).with(:uuid_v7).and_return(true) - end - - it "generates different UUIDs" do - allow(SecureRandom).to receive(:uuid_v7).and_return( - "018f1234-5678-7000-8000-123456789abc", - "018f1234-5678-7000-8000-123456789def" - ) - - first_id = described_class.generate - second_id = described_class.generate - - expect(first_id).not_to eq(second_id) - end - end - end -end diff --git a/spec/cmdx/input_spec.rb b/spec/cmdx/input_spec.rb new file mode 100644 index 000000000..f5f36c5f2 --- /dev/null +++ b/spec/cmdx/input_spec.rb @@ -0,0 +1,281 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe CMDx::Input do + describe "#initialize" do + it "coerces name to a symbol and freezes children/options" do + child = described_class.new(:child) + input = described_class.new("user", children: [child], required: true) + + expect(input.name).to eq(:user) + expect(input.children).to eq([child]).and be_frozen + expect(input.instance_variable_get(:@options)).to be_frozen + end + + it "defaults children to an empty array" do + expect(described_class.new(:x).children).to eq([]) + end + end + + describe "simple option accessors" do + it "returns configured options" do + input = described_class.new( + :user, + description: "desc", + as: :member, + prefix: "p_", + suffix: "_s", + source: :params, + default: 1, + transform: :upcase, + if: :if?, + unless: :unless? + ) + + expect(input).to have_attributes( + description: "desc", + as: :member, + prefix: "p_", + suffix: "_s", + source: :params, + default: 1, + transform: :upcase, + condition_if: :if?, + condition_unless: :unless?, + required: false + ) + end + + it "description falls back to :desc" do + expect(described_class.new(:user, desc: "short").description).to eq("short") + end + + it "source defaults to :context" do + expect(described_class.new(:user).source).to eq(:context) + end + end + + describe "#accessor_name" do + it "returns :as when explicitly given" do + expect(described_class.new(:user, as: :member).accessor_name).to eq(:member) + end + + it "applies string prefix and suffix" do + input = described_class.new(:id, prefix: "user_", suffix: "_val") + expect(input.accessor_name).to eq(:user_id_val) + end + + it "uses source_ prefix when prefix is true" do + input = described_class.new(:id, source: :params, prefix: true) + expect(input.accessor_name).to eq(:params_id) + end + + it "uses _source suffix when suffix is true" do + input = described_class.new(:id, source: :params, suffix: true) + expect(input.accessor_name).to eq(:id_params) + end + + it "returns the bare name by default" do + expect(described_class.new(:id).accessor_name).to eq(:id) + end + end + + describe "#ivar_name" do + it "is built from the accessor name" do + expect(described_class.new(:user).ivar_name).to eq(:@_input_user) + expect(described_class.new(:user, as: :member).ivar_name).to eq(:@_input_member) + end + end + + describe "#required?" do + let(:task) do + Class.new do + def active? = true + def inactive? = false + end.new + end + + it "is false when not required" do + expect(described_class.new(:a).required?).to be(false) + end + + it "is true without a task when required" do + expect(described_class.new(:a, required: true).required?).to be(true) + end + + it "evaluates if/unless guards against the task" do + expect(described_class.new(:a, required: true, if: :active?).required?(task)).to be(true) + expect(described_class.new(:a, required: true, if: :inactive?).required?(task)).to be(false) + expect(described_class.new(:a, required: true, unless: :active?).required?(task)).to be(false) + end + end + + describe "#to_h" do + it "exposes a serializable view" do + child = described_class.new(:inner) + input = described_class.new(:user, description: "d", required: true, children: [child]) + + expect(input.to_h).to eq( + name: :user, + description: "d", + required: true, + options: { description: "d", required: true }, + children: [child.to_h] + ) + end + end + + describe "#resolve" do + context "when the value is present in context" do + it "returns the coerced value" do + task_class = create_task_class(name: "ResolveTask") do + required :age, coerce: :integer + end + task = task_class.new + task.context.age = "42" + + input = described_class.new(:age, coerce: :integer, required: true) + expect(input.resolve(task)).to eq(42) + end + end + + context "when the value is absent and required" do + it "adds a missing error on the task" do + task_class = create_task_class(name: "MissingInputTask") + task = task_class.new + + input = described_class.new(:age, required: true) + input.resolve(task) + + expect(task.errors[:age]).to include(CMDx::I18nProxy.t("cmdx.attributes.required")) + end + end + + context "when the value is absent but has a default" do + it "applies a literal default" do + task_class = create_task_class(name: "DefaultLiteralTask") + task = task_class.new + + input = described_class.new(:level, default: 7) + expect(input.resolve(task)).to eq(7) + end + + it "applies a Symbol default by sending to the task" do + task_class = create_task_class(name: "DefaultSymTask") do + define_method(:default_level) { 99 } + end + task = task_class.new + + input = described_class.new(:level, default: :default_level) + expect(input.resolve(task)).to eq(99) + end + + it "applies a Proc default via instance_exec" do + task_class = create_task_class(name: "DefaultProcTask") do + define_method(:boost) { 5 } + end + task = task_class.new + + input = described_class.new(:level, default: proc { boost }) + expect(input.resolve(task)).to eq(5) + end + end + + context "with a transform" do + it "applies a Symbol transform on the value" do + task_class = create_task_class(name: "TransformTask") + task = task_class.new + task.context.name = "alice" + + input = described_class.new(:name, transform: :upcase) + expect(input.resolve(task)).to eq("ALICE") + end + + it "applies a Proc transform" do + task_class = create_task_class(name: "TransformProcTask") + task = task_class.new + task.context.name = "alice" + + input = described_class.new(:name, transform: proc { |v| "#{v}!" }) + expect(input.resolve(task)).to eq("alice!") + end + + it "applies a callable transform with the value and task" do + task_class = create_task_class(name: "TransformCallableTask") + task = task_class.new + task.context.name = "alice" + + received = nil + callable = Class.new do + define_method(:call) do |value, t| + received = t + value.upcase + end + end.new + + input = described_class.new(:name, transform: callable) + expect(input.resolve(task)).to eq("ALICE") + expect(received).to be(task) + end + end + + context "with a Proc source" do + it "calls the proc via instance_exec and treats the result as present" do + task_class = create_task_class(name: "ProcSourceTask") + task = task_class.new + + input = described_class.new(:computed, source: proc { 123 }) + expect(input.resolve(task)).to eq(123) + end + end + + context "with a Symbol source pointing to a hash method" do + it "fetches by the input name" do + task_class = create_task_class(name: "SymSourceTask") do + define_method(:params) { { name: "alice" } } + end + task = task_class.new + + input = described_class.new(:name, source: :params) + expect(input.resolve(task)).to eq("alice") + end + + it "treats missing key as not provided" do + task_class = create_task_class(name: "SymSourceMissingTask") do + define_method(:params) { {} } + end + task = task_class.new + + input = described_class.new(:name, source: :params, default: "fallback") + expect(input.resolve(task)).to eq("fallback") + end + end + end + + describe "#resolve_from_parent" do + let(:task_class) { create_task_class(name: "ParentTask") } + let(:task) { task_class.new } + + it "fetches by name from a hash-like parent" do + input = described_class.new(:age) + expect(input.resolve_from_parent({ age: 30 }, task)).to eq(30) + end + + it "falls back to string key lookup" do + input = described_class.new(:age) + expect(input.resolve_from_parent({ "age" => 30 }, task)).to eq(30) + end + + it "returns nil for non-indexable parent values" do + input = described_class.new(:age) + expect(input.resolve_from_parent(Object.new, task)).to be_nil + end + + it "treats nil parent as absent (no required error when not required)" do + input = described_class.new(:age) + expect(input.resolve_from_parent(nil, task)).to be_nil + expect(task.errors).to be_empty + end + end +end diff --git a/spec/cmdx/inputs_spec.rb b/spec/cmdx/inputs_spec.rb new file mode 100644 index 000000000..3c185bb10 --- /dev/null +++ b/spec/cmdx/inputs_spec.rb @@ -0,0 +1,158 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe CMDx::Inputs do + subject(:inputs) { described_class.new } + + let(:task_class) { create_task_class(name: "InputsTask") } + + describe "#initialize" do + it "starts with an empty registry" do + expect(inputs).to be_empty + expect(inputs.registry).to eq({}) + end + end + + describe "#initialize_copy" do + it "dups the registry" do + inputs.register(task_class, :a) + copy = inputs.dup + + copy.register(task_class, :b) + + expect(inputs.size).to eq(1) + expect(copy.size).to eq(2) + end + end + + describe "#register" do + it "adds an Input per name and defines an accessor on the task class" do + inputs.register(task_class, :user, :token, required: true) + + expect(inputs.size).to eq(2) + expect(inputs.registry[:user]).to be_a(CMDx::Input) + expect(inputs.registry[:user].required).to be(true) + expect(task_class.instance_method(:user)).to be_a(UnboundMethod) + expect(task_class.instance_method(:token)).to be_a(UnboundMethod) + end + + it "returns self" do + expect(inputs.register(task_class, :user)).to be(inputs) + end + + it "attaches nested children built via the DSL" do + inputs.register(task_class, :profile) do + required :email + optional :name + end + + profile = inputs.registry[:profile] + expect(profile.children.map(&:name)).to eq(%i[email name]) + expect(profile.children.map(&:required)).to eq([true, false]) + end + + it "raises when the accessor name collides with an existing instance method" do + expect { inputs.register(task_class, :context) } + .to raise_error(CMDx::DefinitionError, /:context.*already defined/) + end + + it "raises when re-registering an input with the same accessor name" do + inputs.register(task_class, :user) + + expect { inputs.register(task_class, :user) } + .to raise_error(CMDx::DefinitionError, /:user.*already defined/) + end + + it "raises when a nested child collides with an existing method" do + expect do + inputs.register(task_class, :profile) do + required :context + end + end.to raise_error(CMDx::DefinitionError, /:context.*already defined/) + end + end + + describe "#deregister" do + it "removes the Input and undefines the accessor" do + inputs.register(task_class, :user) + inputs.deregister(task_class, :user) + + expect(inputs.registry).to be_empty + expect(task_class.method_defined?(:user)).to be(false) + end + end + + describe "#resolve" do + it "assigns ivars from the context based on each Input" do + inputs.register(task_class, :age, coerce: :integer) + + task = task_class.new + task.context.age = "99" + inputs.resolve(task) + + expect(task.instance_variable_get(:@_input_age)).to eq(99) + end + + it "records required errors when values are missing" do + inputs.register(task_class, :age, required: true) + + task = task_class.new + inputs.resolve(task) + + expect(task.errors[:age]).not_to be_empty + end + + it "resolves nested children from the parent value" do + inputs.register(task_class, :profile) do + required :email + end + + task = task_class.new + task.context.profile = { email: "x@y.com" } + inputs.resolve(task) + + expect(task.instance_variable_get(:@_input_email)).to eq("x@y.com") + end + + it "skips nested resolution when the parent value is nil" do + inputs.register(task_class, :profile) do + required :email + end + + task = task_class.new + inputs.resolve(task) + + expect(task.instance_variable_defined?(:@_input_email)).to be(false) + end + end + + describe "ChildBuilder DSL" do + let(:children) do + CMDx::Inputs::ChildBuilder.build do + required :a + optional :b + input :c + end + end + + it "flags required and optional entries" do + expect(children.map(&:name)).to eq(%i[a b c]) + expect(children.map(&:required)).to eq([true, false, false]) + end + + it "supports nesting" do + children = CMDx::Inputs::ChildBuilder.build do + required :outer do + required :inner + end + end + + expect(children.first.children.map(&:name)).to eq([:inner]) + end + + it "freezes the built children list" do + expect(children).to be_frozen + end + end +end diff --git a/spec/cmdx/locale_spec.rb b/spec/cmdx/locale_spec.rb deleted file mode 100644 index 2ee0ee0ef..000000000 --- a/spec/cmdx/locale_spec.rb +++ /dev/null @@ -1,147 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe CMDx::Locale, type: :unit do - subject(:translate_result) { described_class.translate(key, **options) } - - describe "#translate" do - context "when I18n is not defined" do - before { hide_const("I18n") } - - context "with a valid key" do - let(:key) { "cmdx.validators.presence" } - let(:options) { {} } - - it "returns the default translation from EN" do - expect(translate_result).to eq("cannot be empty") - end - end - - context "with a nested key" do - let(:key) { "cmdx.validators.length.min" } - let(:options) { { min: 5 } } - - it "returns the interpolated message" do - expect(translate_result).to eq("length must be at least 5") - end - end - - context "with a key that has multiple interpolations" do - let(:key) { "cmdx.validators.length.within" } - let(:options) { { min: 3, max: 10 } } - - it "interpolates all variables" do - expect(translate_result).to eq("length must be within 3 and 10") - end - end - - context "with a non-existent key" do - let(:key) { "cmdx.non.existent.key" } - let(:options) { {} } - - it "returns a missing translation message" do - expect(translate_result).to eq("Translation missing: cmdx.non.existent.key") - end - end - - context "with an explicit default option" do - let(:key) { "cmdx.non.existent.key" } - let(:options) { { default: "Custom default message" } } - - it "uses the provided default" do - expect(translate_result).to eq("Custom default message") - end - end - - context "with an explicit default option and interpolation" do - let(:key) { "cmdx.non.existent.key" } - let(:options) { { default: "Custom %<value>s message", value: "test" } } - - it "interpolates the custom default" do - expect(translate_result).to eq("Custom test message") - end - end - - context "with a non-string default" do - let(:key) { "cmdx.non.existent.key" } - let(:options) { { default: [:array, "value"] } } - - it "returns the default as-is" do - expect(translate_result).to eq([:array, "value"]) - end - end - - context "with a symbol key" do - let(:key) { :"cmdx.validators.presence" } - let(:options) { {} } - - it "converts the symbol to string and translates" do - expect(translate_result).to eq("cannot be empty") - end - end - - context "with a key containing dots" do - let(:key) { "cmdx.types.big_decimal" } - let(:options) { {} } - - it "properly navigates nested hash structure" do - expect(translate_result).to eq("big decimal") - end - end - - context "with empty options" do - let(:key) { "cmdx.validators.format" } - let(:options) { {} } - - it "returns the translation without interpolation" do - expect(translate_result).to eq("is an invalid format") - end - end - - context "with extra options that are not in the template" do - let(:key) { "cmdx.validators.format" } - let(:options) { { extra: "value", another: "option" } } - - it "ignores extra options and returns the translation" do - expect(translate_result).to eq("is an invalid format") - end - end - end - - context "when I18n is defined" do - let(:i18n_double) { class_double("I18n") } - let(:key) { "cmdx.validators.presence" } - let(:options) { { locale: :es } } - - before do - stub_const("I18n", i18n_double) - end - - it "delegates to I18n.t with the key and options" do - expect(i18n_double).to receive(:t).with(key, **options, default: "cannot be empty").and_return("no puede estar vacío") - expect(translate_result).to eq("no puede estar vacío") - end - - context "when the key is not found in EN" do - let(:key) { "some.unknown.key" } - - it "sets default to nil and delegates to I18n" do - expect(i18n_double).to receive(:t).with(key, **options, default: nil).and_return("some translation") - - translate_result - end - end - - context "with an explicit default option" do - let(:options) { { default: "Custom default", locale: :es } } - - it "preserves the provided default" do - expect(i18n_double).to receive(:t).with(key, **options).and_return("some translation") - - translate_result - end - end - end - end -end diff --git a/spec/cmdx/log_formatters/json_spec.rb b/spec/cmdx/log_formatters/json_spec.rb index 2eadc06fa..c1e7ac278 100644 --- a/spec/cmdx/log_formatters/json_spec.rb +++ b/spec/cmdx/log_formatters/json_spec.rb @@ -1,243 +1,32 @@ # frozen_string_literal: true require "spec_helper" +require "json" -RSpec.describe CMDx::LogFormatters::JSON, type: :unit do +RSpec.describe CMDx::LogFormatters::JSON do subject(:formatter) { described_class.new } - let(:severity) { "INFO" } - let(:time) { Time.new(2023, 12, 15, 10, 30, 45.123454) } - let(:progname) { "TestApp" } - let(:message) { "Test message" } + let(:time) { Time.utc(2024, 1, 2, 3, 4, 5) } - describe "#call" do - context "with typical log parameters" do - it "returns properly formatted JSON with newline" do - result = formatter.call(severity, time, progname, message) - parsed = JSON.parse(result.chomp) + it "emits a JSON line with core fields" do + line = formatter.call("INFO", time, "cmdx", "hello") - expect(parsed).to eq( - { - "severity" => "INFO", - "timestamp" => "2023-12-15T10:30:45.123454Z", - "progname" => "TestApp", - "pid" => Process.pid, - "message" => "Test message" - } - ) - expect(result).to end_with("\n") - end + expect(line).to end_with("\n") - it "includes current process PID" do - result = formatter.call(severity, time, progname, message) - parsed = JSON.parse(result.chomp) - - expect(parsed["pid"]).to eq(Process.pid) - end - - it "formats timestamp in UTC ISO8601 with microseconds" do - result = formatter.call(severity, time, progname, message) - parsed = JSON.parse(result.chomp) - - expect(parsed["timestamp"]).to eq("2023-12-15T10:30:45.123454Z") - end - end - - context "with different severity levels" do - %w[DEBUG INFO WARN ERROR FATAL].each do |level| - it "handles #{level} severity" do - result = formatter.call(level, time, progname, message) - parsed = JSON.parse(result.chomp) - - expect(parsed["severity"]).to eq(level) - end - end - end - - context "with different time zones" do - it "converts time to UTC" do - local_time = Time.new(2023, 12, 15, 15, 30, 45.123454, "-05:00") - - result = formatter.call(severity, local_time, progname, message) - parsed = JSON.parse(result.chomp) - - expect(parsed["timestamp"]).to eq("2023-12-15T20:30:45.123454Z") - end - end - - context "with nil values" do - it "handles nil severity" do - result = formatter.call(nil, time, progname, message) - parsed = JSON.parse(result.chomp) - - expect(parsed["severity"]).to be_nil - end - - it "handles nil progname" do - result = formatter.call(severity, time, nil, message) - parsed = JSON.parse(result.chomp) - - expect(parsed["progname"]).to be_nil - end - - it "handles nil message" do - allow(CMDx::Utils::Format).to receive(:to_log).with(nil).and_return(nil) - - result = formatter.call(severity, time, progname, nil) - parsed = JSON.parse(result.chomp) - - expect(parsed["message"]).to be_nil - end - end - - context "with special characters in strings" do - it "properly escapes quotes in severity" do - severity_with_quotes = 'INFO "quoted"' - - result = formatter.call(severity_with_quotes, time, progname, message) - parsed = JSON.parse(result.chomp) - - expect(parsed["severity"]).to eq('INFO "quoted"') - end - - it "properly escapes newlines in progname" do - progname_with_newline = "TestApp\nSecondLine" - - result = formatter.call(severity, time, progname_with_newline, message) - parsed = JSON.parse(result.chomp) - - expect(parsed["progname"]).to eq("TestApp\nSecondLine") - end - - it "handles unicode characters" do - unicode_message = "Test message with émojis 🚀" - - allow(CMDx::Utils::Format).to receive(:to_log).with(unicode_message).and_return(unicode_message) - - result = formatter.call(severity, time, progname, unicode_message) - parsed = JSON.parse(result.chomp) - - expect(parsed["message"]).to eq("Test message with émojis 🚀") - end - end - - context "with CMDx objects as message" do - let(:cmdx_hash) { { "state" => "complete", "status" => "success" } } - - it "uses Utils::Format.to_log for message processing" do - allow(CMDx::Utils::Format).to receive(:to_log).with(message).and_return(cmdx_hash) - - expect(CMDx::Utils::Format).to receive(:to_log).with(message) - - result = formatter.call(severity, time, progname, message) - parsed = JSON.parse(result.chomp) - - expect(parsed["message"]).to eq(cmdx_hash) - end - - it "handles complex nested structures" do - complex_hash = { - "task" => "ProcessData", - "context" => { "user_id" => 123, "session" => "abc123" }, - "metadata" => { "attempts" => 1, "duration" => 0.45 } - } - - allow(CMDx::Utils::Format).to receive(:to_log).with(message).and_return(complex_hash) - - result = formatter.call(severity, time, progname, message) - parsed = JSON.parse(result.chomp) - - expect(parsed["message"]).to eq(complex_hash) - end - end - - context "with numeric and boolean messages" do - it "handles integer messages" do - integer_message = 42 - allow(CMDx::Utils::Format).to receive(:to_log).with(integer_message).and_return(integer_message) - - result = formatter.call(severity, time, progname, integer_message) - parsed = JSON.parse(result.chomp) - - expect(parsed["message"]).to eq(42) - end - - it "handles boolean messages" do - boolean_message = true - - allow(CMDx::Utils::Format).to receive(:to_log).with(boolean_message).and_return(boolean_message) - - result = formatter.call(severity, time, progname, boolean_message) - parsed = JSON.parse(result.chomp) - - expect(parsed["message"]).to be(true) - end - - it "handles float messages" do - float_message = 3.14159 - - allow(CMDx::Utils::Format).to receive(:to_log).with(float_message).and_return(float_message) - - result = formatter.call(severity, time, progname, float_message) - parsed = JSON.parse(result.chomp) - - expect(parsed["message"]).to eq(3.14159) - end - end - - context "with array messages" do - it "handles array messages" do - array_message = %w[item1 item2 item3] - - result = formatter.call(severity, time, progname, array_message) - parsed = JSON.parse(result.chomp) - - expect(parsed["message"]).to eq(%w[item1 item2 item3]) - end - end - - context "with extreme time values" do - it "handles very old timestamps" do - old_time = Time.new(1970, 1, 1, 0, 0, 0.0) - - result = formatter.call(severity, old_time, progname, message) - parsed = JSON.parse(result.chomp) - - expect(parsed["timestamp"]).to eq("1970-01-01T00:00:00.000000Z") - end - - it "handles future timestamps" do - future_time = Time.new(2099, 12, 31, 23, 59, 59.999999) - - result = formatter.call(severity, future_time, progname, message) - parsed = JSON.parse(result.chomp) - - expect(parsed["timestamp"]).to eq("2099-12-31T23:59:59.999999Z") - end - end - - context "when Utils::Format.to_log raises an error" do - it "allows the error to propagate" do - allow(CMDx::Utils::Format).to receive(:to_log).and_raise(StandardError, "Format error") - - expect do - formatter.call(severity, time, progname, message) - end.to raise_error(StandardError, "Format error") - end - end - - context "with very long strings" do - it "handles large messages without truncation" do - large_message = "x" * 10_000 - - allow(CMDx::Utils::Format).to receive(:to_log).with(large_message).and_return(large_message) + hash = JSON.parse(line) + expect(hash).to include( + "severity" => "INFO", + "progname" => "cmdx", + "message" => "hello", + "timestamp" => time.iso8601(6), + "pid" => Process.pid + ) + end - result = formatter.call(severity, time, progname, large_message) - parsed = JSON.parse(result.chomp) + it "serializes objects that respond to to_h via their hash form" do + message = Struct.new(:name).new("alice") - expect(parsed["message"]).to eq(large_message) - expect(parsed["message"].length).to eq(10_000) - end - end + hash = JSON.parse(formatter.call("INFO", time, nil, message)) + expect(hash["message"]).to eq("name" => "alice") end end diff --git a/spec/cmdx/log_formatters/key_value_spec.rb b/spec/cmdx/log_formatters/key_value_spec.rb index 52e859084..dc1c086f2 100644 --- a/spec/cmdx/log_formatters/key_value_spec.rb +++ b/spec/cmdx/log_formatters/key_value_spec.rb @@ -2,384 +2,22 @@ require "spec_helper" -RSpec.describe CMDx::LogFormatters::KeyValue, type: :unit do +RSpec.describe CMDx::LogFormatters::KeyValue do subject(:formatter) { described_class.new } - let(:severity) { "INFO" } - let(:time) { Time.new(2023, 12, 15, 10, 30, 45.123454) } - let(:progname) { "TestApp" } - let(:message) { "Test message" } + let(:time) { Time.utc(2024, 1, 2, 3, 4, 5) } - describe "#call" do - context "with typical log parameters" do - it "returns properly formatted key-value string with newline" do - result = formatter.call(severity, time, progname, message) + it "emits key=value pairs" do + line = formatter.call("INFO", time, "cmdx", "hello") - expect(result).to eq( - 'severity="INFO" timestamp="2023-12-15T10:30:45.123454Z" progname="TestApp" ' \ - "pid=#{Process.pid} message=\"Test message\"\n" - ) - expect(result).to end_with("\n") - end - - it "includes current process PID" do - result = formatter.call(severity, time, progname, message) - - expect(result).to include("pid=#{Process.pid}") - end - - it "formats timestamp in UTC ISO8601 with microseconds" do - result = formatter.call(severity, time, progname, message) - - expect(result).to include('timestamp="2023-12-15T10:30:45.123454Z"') - end - - it "uses Utils::Format.to_log for message processing" do - allow(CMDx::Utils::Format).to receive(:to_log).with(message).and_return("processed message") - - expect(CMDx::Utils::Format).to receive(:to_log).with(message) - - result = formatter.call(severity, time, progname, message) - - expect(result).to include('message="processed message"') - end - - it "uses Utils::Format.to_str to format the hash" do - expected_hash = { - severity: severity, - timestamp: time.utc.iso8601(6), - progname: progname, - pid: Process.pid, - message: message - } - - allow(CMDx::Utils::Format).to receive(:to_str).with(expected_hash).and_return(+"formatted output") - - expect(CMDx::Utils::Format).to receive(:to_str).with(expected_hash) - - result = formatter.call(severity, time, progname, message) - - expect(result).to eq("formatted output\n") - end - end - - context "with different severity levels" do - %w[DEBUG INFO WARN ERROR FATAL].each do |level| - it "handles #{level} severity" do - result = formatter.call(level, time, progname, message) - - expect(result).to include("severity=\"#{level}\"") - end - end - end - - context "with different time zones" do - it "converts time to UTC" do - local_time = Time.new(2023, 12, 15, 15, 30, 45.123454, "-05:00") - - result = formatter.call(severity, local_time, progname, message) - - expect(result).to include('timestamp="2023-12-15T20:30:45.123454Z"') - end - - it "handles UTC time correctly" do - utc_time = Time.new(2023, 12, 15, 10, 30, 45.123454, "+00:00") - - result = formatter.call(severity, utc_time, progname, message) - - expect(result).to include('timestamp="2023-12-15T10:30:45.123454Z"') - end - end - - context "with nil values" do - it "handles nil severity" do - result = formatter.call(nil, time, progname, message) - - expect(result).to include("severity=nil") - end - - it "handles nil progname" do - result = formatter.call(severity, time, nil, message) - - expect(result).to include("progname=nil") - end - - it "handles nil message" do - allow(CMDx::Utils::Format).to receive(:to_log).with(nil).and_return(nil) - - result = formatter.call(severity, time, progname, nil) - - expect(result).to include("message=nil") - end - end - - context "with special characters in strings" do - it "properly escapes quotes in severity" do - severity_with_quotes = 'INFO "quoted"' - - result = formatter.call(severity_with_quotes, time, progname, message) - - expect(result).to include('severity="INFO \"quoted\""') - end - - it "properly escapes newlines in progname" do - progname_with_newline = "TestApp\nSecondLine" - - result = formatter.call(severity, time, progname_with_newline, message) - - expect(result).to include("progname=\"TestApp\\nSecondLine\"") - end - - it "handles unicode characters" do - unicode_message = "Test message with émojis 🚀" - - allow(CMDx::Utils::Format).to receive(:to_log).with(unicode_message).and_return(unicode_message) - - result = formatter.call(severity, time, progname, unicode_message) - - expect(result).to include("message=\"Test message with émojis 🚀\"") - end - - it "handles backslashes in strings" do - message_with_backslash = "C:\\Windows\\Path" - - allow(CMDx::Utils::Format).to receive(:to_log).with(message_with_backslash).and_return(message_with_backslash) - - result = formatter.call(severity, time, progname, message_with_backslash) - - expect(result).to include("message=\"C:\\\\Windows\\\\Path\"") - end - end - - context "with CMDx objects as message" do - let(:cmdx_hash) { { "state" => "complete", "status" => "success" } } - - it "processes CMDx objects through Utils::Format.to_log" do - allow(CMDx::Utils::Format).to receive(:to_log).with(message).and_return(cmdx_hash) - - expect(CMDx::Utils::Format).to receive(:to_log).with(message) - - result = formatter.call(severity, time, progname, message) - - if RubyVersion.min?(3.4) - expect(result).to include('message={"state" => "complete", "status" => "success"}') - else - expect(result).to include('message={"state"=>"complete", "status"=>"success"}') - end - end - - it "handles complex nested structures" do - complex_hash = { - "task" => "ProcessData", - "context" => { "user_id" => 123, "session" => "abc123" }, - "metadata" => { "attempts" => 1, "duration" => 0.45 } - } - - allow(CMDx::Utils::Format).to receive(:to_log).with(message).and_return(complex_hash) - - result = formatter.call(severity, time, progname, message) - - expect(result).to include("message=#{complex_hash.inspect}") - end - end - - context "with numeric and boolean messages" do - it "handles integer messages" do - integer_message = 42 - - allow(CMDx::Utils::Format).to receive(:to_log).with(integer_message).and_return(integer_message) - - result = formatter.call(severity, time, progname, integer_message) - - expect(result).to include("message=42") - end - - it "handles boolean messages" do - boolean_message = true - - allow(CMDx::Utils::Format).to receive(:to_log).with(boolean_message).and_return(boolean_message) - - result = formatter.call(severity, time, progname, boolean_message) - - expect(result).to include("message=true") - end - - it "handles float messages" do - float_message = 3.14159 - - allow(CMDx::Utils::Format).to receive(:to_log).with(float_message).and_return(float_message) - - result = formatter.call(severity, time, progname, float_message) - - expect(result).to include("message=3.14159") - end - - it "handles false boolean message" do - false_message = false - - allow(CMDx::Utils::Format).to receive(:to_log).with(false_message).and_return(false_message) - - result = formatter.call(severity, time, progname, false_message) - - expect(result).to include("message=false") - end - end - - context "with array messages" do - it "handles array messages" do - array_message = %w[item1 item2 item3] - - allow(CMDx::Utils::Format).to receive(:to_log).with(array_message).and_return(array_message) - - result = formatter.call(severity, time, progname, array_message) - - expect(result).to include('message=["item1", "item2", "item3"]') - end - - it "handles empty array messages" do - empty_array = [] - - allow(CMDx::Utils::Format).to receive(:to_log).with(empty_array).and_return(empty_array) - - result = formatter.call(severity, time, progname, empty_array) - - expect(result).to include("message=[]") - end - end - - context "with extreme time values" do - it "handles very old timestamps" do - old_time = Time.new(1970, 1, 1, 0, 0, 0.0) - - result = formatter.call(severity, old_time, progname, message) - - expect(result).to include('timestamp="1970-01-01T00:00:00.000000Z"') - end - - it "handles future timestamps" do - future_time = Time.new(2099, 12, 31, 23, 59, 59.999999) - - result = formatter.call(severity, future_time, progname, message) - - expect(result).to include('timestamp="2099-12-31T23:59:59.999999Z"') - end - - it "handles time with no microseconds" do - time_no_microseconds = Time.new(2023, 1, 1, 12, 0, 0) - - result = formatter.call(severity, time_no_microseconds, progname, message) - - expect(result).to include('timestamp="2023-01-01T12:00:00.000000Z"') - end - end - - context "when Utils::Format.to_log raises an error" do - it "allows the error to propagate" do - allow(CMDx::Utils::Format).to receive(:to_log).and_raise(StandardError, "Format error") - - expect do - formatter.call(severity, time, progname, message) - end.to raise_error(StandardError, "Format error") - end - end - - context "when Utils::Format.to_str raises an error" do - it "allows the error to propagate" do - allow(CMDx::Utils::Format).to receive(:to_str).and_raise(StandardError, "String format error") - - expect do - formatter.call(severity, time, progname, message) - end.to raise_error(StandardError, "String format error") - end - end - - context "with very long strings" do - it "handles large messages without truncation" do - large_message = "x" * 10_000 - - allow(CMDx::Utils::Format).to receive(:to_log).with(large_message).and_return(large_message) - - result = formatter.call(severity, time, progname, large_message) - - expect(result).to include("message=\"#{'x' * 10_000}\"") - expect(result.length).to be > 10_000 - end - - it "handles large progname" do - large_progname = "LongApplicationName" * 100 - - result = formatter.call(severity, time, large_progname, message) - - expect(result).to include("progname=\"#{large_progname}\"") - end - end - - context "with symbol values" do - it "handles symbol severity" do - symbol_severity = :warning - - result = formatter.call(symbol_severity, time, progname, message) - - expect(result).to include("severity=:warning") - end - - it "handles symbol message" do - symbol_message = :success - - allow(CMDx::Utils::Format).to receive(:to_log).with(symbol_message).and_return(symbol_message) - - result = formatter.call(severity, time, progname, symbol_message) - - expect(result).to include("message=:success") - end - end - - context "with hash message" do - it "handles hash messages" do - hash_message = { key: "value", count: 5 } - - allow(CMDx::Utils::Format).to receive(:to_log).with(hash_message).and_return(hash_message) - - result = formatter.call(severity, time, progname, hash_message) - - if RubyVersion.min?(3.4) - expect(result).to include('message={key: "value", count: 5}') - else - expect(result).to include('message={:key=>"value", :count=>5}') - end - end - - it "handles empty hash messages" do - empty_hash = {} - - allow(CMDx::Utils::Format).to receive(:to_log).with(empty_hash).and_return(empty_hash) - - result = formatter.call(severity, time, progname, empty_hash) - - expect(result).to include("message={}") - end - end - - context "with empty string values" do - it "handles empty string severity" do - result = formatter.call("", time, progname, message) - - expect(result).to include('severity=""') - end - - it "handles empty string progname" do - result = formatter.call(severity, time, "", message) - - expect(result).to include('progname=""') - end - - it "handles empty string message" do - allow(CMDx::Utils::Format).to receive(:to_log).with("").and_return("") + expect(line).to end_with("\n") + expect(line).to include('severity="INFO"', 'progname="cmdx"', 'message="hello"') + expect(line).to include("pid=#{Process.pid}") + end - result = formatter.call(severity, time, progname, "") + it "uses to_h for message objects" do + message = Struct.new(:name).new("alice") - expect(result).to include('message=""') - end - end + expect(formatter.call("INFO", time, nil, message)).to include('message={name: "alice"}') end end diff --git a/spec/cmdx/log_formatters/line_spec.rb b/spec/cmdx/log_formatters/line_spec.rb index d8c5179d8..8d01bede7 100644 --- a/spec/cmdx/log_formatters/line_spec.rb +++ b/spec/cmdx/log_formatters/line_spec.rb @@ -2,284 +2,17 @@ require "spec_helper" -RSpec.describe CMDx::LogFormatters::Line, type: :unit do +RSpec.describe CMDx::LogFormatters::Line do subject(:formatter) { described_class.new } - let(:severity) { "INFO" } - let(:time) { Time.new(2023, 12, 15, 10, 30, 45.123454) } - let(:progname) { "TestApp" } - let(:message) { "Test message" } + let(:time) { Time.utc(2024, 1, 2, 3, 4, 5) } - describe "#call" do - context "with typical log parameters" do - it "returns properly formatted line string with newline" do - result = formatter.call(severity, time, progname, message) + it "emits a classic Logger-style line" do + line = formatter.call("INFO", time, "cmdx", "hello") - expect(result).to eq("I, [2023-12-15T10:30:45.123454Z ##{Process.pid}] INFO -- TestApp: Test message\n") - expect(result).to end_with("\n") - end - - it "includes severity prefix as first character" do - result = formatter.call(severity, time, progname, message) - - expect(result).to start_with("I,") - end - - it "includes current process PID" do - result = formatter.call(severity, time, progname, message) - - expect(result).to include("##{Process.pid}") - end - - it "formats timestamp in UTC ISO8601 with microseconds" do - result = formatter.call(severity, time, progname, message) - - expect(result).to include("[2023-12-15T10:30:45.123454Z") - end - - it "includes full severity name" do - result = formatter.call(severity, time, progname, message) - - expect(result).to include("] INFO --") - end - - it "includes progname and message" do - result = formatter.call(severity, time, progname, message) - - expect(result).to include("TestApp: Test message") - end - end - - context "with different severity levels" do - %w[DEBUG INFO WARN ERROR FATAL].each do |level| - it "handles #{level} severity with correct prefix" do - result = formatter.call(level, time, progname, message) - expected_prefix = level[0] - - expect(result).to start_with("#{expected_prefix},") - expect(result).to include("] #{level} --") - end - end - - it "handles custom severity levels" do - custom_severity = "TRACE" - - result = formatter.call(custom_severity, time, progname, message) - - expect(result).to start_with("T,") - expect(result).to include("] TRACE --") - end - end - - context "with different time zones" do - it "converts time to UTC" do - local_time = Time.new(2023, 12, 15, 15, 30, 45.123454, "-05:00") - - result = formatter.call(severity, local_time, progname, message) - - expect(result).to include("[2023-12-15T20:30:45.123454Z") - end - - it "handles UTC time correctly" do - utc_time = Time.new(2023, 12, 15, 10, 30, 45.123454, "+00:00") - - result = formatter.call(severity, utc_time, progname, message) - - expect(result).to include("[2023-12-15T10:30:45.123454Z") - end - end - - context "with nil values" do - it "handles nil severity" do - expect do - formatter.call(nil, time, progname, message) - end.to raise_error(NoMethodError) - end - - it "handles nil progname" do - result = formatter.call(severity, time, nil, message) - - expect(result).to include("INFO -- : Test message") - end - - it "handles nil message" do - result = formatter.call(severity, time, progname, nil) - - expect(result).to include("TestApp: \n") - end - end - - context "with special characters in strings" do - it "handles quotes in severity" do - severity_with_quotes = 'INFO"quoted"' - - result = formatter.call(severity_with_quotes, time, progname, message) - - expect(result).to include('] INFO"quoted" --') - end - - it "handles newlines in progname" do - progname_with_newline = "TestApp\nSecondLine" - - result = formatter.call(severity, time, progname_with_newline, message) - - expect(result).to include("TestApp\nSecondLine: Test message") - end - - it "handles unicode characters in message" do - unicode_message = "Test message with émojis 🚀" - - result = formatter.call(severity, time, progname, unicode_message) - - expect(result).to include("TestApp: Test message with émojis 🚀") - end - - it "handles backslashes in message" do - message_with_backslash = "C:\\Windows\\Path" - - result = formatter.call(severity, time, progname, message_with_backslash) - - expect(result).to include("TestApp: C:\\Windows\\Path") - end - end - - context "with complex objects as message" do - it "handles hash messages" do - hash_message = { "state" => "complete", "status" => "success" } - - result = formatter.call(severity, time, progname, hash_message) - - if RubyVersion.min?(3.4) - expect(result).to include('TestApp: {"state" => "complete", "status" => "success"}') - else - expect(result).to include('TestApp: {"state"=>"complete", "status"=>"success"}') - end - end - - it "handles array messages" do - array_message = %w[item1 item2 item3] - - result = formatter.call(severity, time, progname, array_message) - - expect(result).to include('TestApp: ["item1", "item2", "item3"]') - end - - it "handles numeric messages" do - numeric_message = 42 - - result = formatter.call(severity, time, progname, numeric_message) - - expect(result).to include("TestApp: 42") - end - - it "handles boolean messages" do - boolean_message = true - - result = formatter.call(severity, time, progname, boolean_message) - - expect(result).to include("TestApp: true") - end - end - - context "with extreme time values" do - it "handles very old timestamps" do - old_time = Time.new(1970, 1, 1, 0, 0, 0.0) - - result = formatter.call(severity, old_time, progname, message) - - expect(result).to include("[1970-01-01T00:00:00.000000Z") - end - - it "handles future timestamps" do - future_time = Time.new(2099, 12, 31, 23, 59, 59.999999) - - result = formatter.call(severity, future_time, progname, message) - - expect(result).to include("[2099-12-31T23:59:59.999999Z") - end - - it "handles time with no microseconds" do - time_no_microseconds = Time.new(2023, 1, 1, 12, 0, 0) - - result = formatter.call(severity, time_no_microseconds, progname, message) - - expect(result).to include("[2023-01-01T12:00:00.000000Z") - end - end - - context "with empty string values" do - it "handles empty string severity" do - result = formatter.call("", time, progname, message) - - expect(result).to start_with(", ") - end - - it "handles empty string progname" do - result = formatter.call(severity, time, "", message) - - expect(result).to include("INFO -- : Test message") - end - - it "handles empty string message" do - result = formatter.call(severity, time, progname, "") - - expect(result).to include("TestApp: \n") - end - end - - context "with very long strings" do - it "handles large messages without truncation" do - large_message = "x" * 10_000 - - result = formatter.call(severity, time, progname, large_message) - - expect(result).to include("TestApp: #{'x' * 10_000}") - expect(result.length).to be > 10_000 - end - - it "handles large progname" do - large_progname = "LongApplicationName" * 100 - - result = formatter.call(severity, time, large_progname, message) - - expect(result).to include("#{large_progname}: Test message") - end - - it "handles large severity" do - large_severity = "VERYLONGSEVERITYLEVEL" * 10 - - result = formatter.call(large_severity, time, progname, message) - - expect(result).to start_with("V,") - expect(result).to include("] #{large_severity} --") - end - end - - context "with symbol values" do - it "handles symbol severity" do - symbol_severity = :warning - - result = formatter.call(symbol_severity, time, progname, message) - - expect(result).to start_with("w,") - expect(result).to include("] warning --") - end - - it "handles symbol progname" do - symbol_progname = :my_app - - result = formatter.call(severity, time, symbol_progname, message) - - expect(result).to include("my_app: Test message") - end - - it "handles symbol message" do - symbol_message = :success - - result = formatter.call(severity, time, progname, symbol_message) - - expect(result).to include("TestApp: success") - end - end + expect(line).to start_with("I, ") + expect(line).to include("INFO -- cmdx: hello") + expect(line).to include("##{Process.pid}") + expect(line).to end_with("\n") end end diff --git a/spec/cmdx/log_formatters/logstash_spec.rb b/spec/cmdx/log_formatters/logstash_spec.rb index 49601c020..8033c294b 100644 --- a/spec/cmdx/log_formatters/logstash_spec.rb +++ b/spec/cmdx/log_formatters/logstash_spec.rb @@ -1,254 +1,32 @@ # frozen_string_literal: true require "spec_helper" +require "json" -RSpec.describe CMDx::LogFormatters::Logstash, type: :unit do +RSpec.describe CMDx::LogFormatters::Logstash do subject(:formatter) { described_class.new } - let(:severity) { "INFO" } - let(:time) { Time.new(2023, 12, 15, 10, 30, 45.123454) } - let(:progname) { "TestApp" } - let(:message) { "Test message" } + let(:time) { Time.utc(2024, 1, 2, 3, 4, 5) } - describe "#call" do - context "with typical log parameters" do - it "returns properly formatted JSON with newline" do - result = formatter.call(severity, time, progname, message) - parsed = JSON.parse(result.chomp) + it "emits a Logstash-shaped JSON line" do + line = formatter.call("INFO", time, "cmdx", "hello") - expect(parsed).to eq( - { - "@version" => "1", - "@timestamp" => "2023-12-15T10:30:45.123454Z", - "severity" => "INFO", - "progname" => "TestApp", - "pid" => Process.pid, - "message" => "Test message" - } - ) - expect(result).to end_with("\n") - end + expect(line).to end_with("\n") - it "includes current process PID" do - result = formatter.call(severity, time, progname, message) - parsed = JSON.parse(result.chomp) - - expect(parsed["pid"]).to eq(Process.pid) - end - - it "formats timestamp in UTC ISO8601 with microseconds" do - result = formatter.call(severity, time, progname, message) - parsed = JSON.parse(result.chomp) - - expect(parsed["@timestamp"]).to eq("2023-12-15T10:30:45.123454Z") - end - - it "includes Logstash version field" do - result = formatter.call(severity, time, progname, message) - parsed = JSON.parse(result.chomp) - - expect(parsed["@version"]).to eq("1") - end - end - - context "with different severity levels" do - %w[DEBUG INFO WARN ERROR FATAL].each do |level| - it "handles #{level} severity" do - result = formatter.call(level, time, progname, message) - parsed = JSON.parse(result.chomp) - - expect(parsed["severity"]).to eq(level) - end - end - end - - context "with different time zones" do - it "converts time to UTC" do - local_time = Time.new(2023, 12, 15, 15, 30, 45.123454, "-05:00") - - result = formatter.call(severity, local_time, progname, message) - parsed = JSON.parse(result.chomp) - - expect(parsed["@timestamp"]).to eq("2023-12-15T20:30:45.123454Z") - end - end - - context "with nil values" do - it "handles nil severity" do - result = formatter.call(nil, time, progname, message) - parsed = JSON.parse(result.chomp) - - expect(parsed["severity"]).to be_nil - end - - it "handles nil progname" do - result = formatter.call(severity, time, nil, message) - parsed = JSON.parse(result.chomp) - - expect(parsed["progname"]).to be_nil - end - - it "handles nil message" do - allow(CMDx::Utils::Format).to receive(:to_log).with(nil).and_return(nil) - - result = formatter.call(severity, time, progname, nil) - parsed = JSON.parse(result.chomp) - - expect(parsed["message"]).to be_nil - end - end - - context "with special characters in strings" do - it "properly escapes quotes in severity" do - severity_with_quotes = 'INFO "quoted"' - - result = formatter.call(severity_with_quotes, time, progname, message) - parsed = JSON.parse(result.chomp) - - expect(parsed["severity"]).to eq('INFO "quoted"') - end - - it "properly escapes newlines in progname" do - progname_with_newline = "TestApp\nSecondLine" - - result = formatter.call(severity, time, progname_with_newline, message) - parsed = JSON.parse(result.chomp) - - expect(parsed["progname"]).to eq("TestApp\nSecondLine") - end - - it "handles unicode characters" do - unicode_message = "Test message with émojis 🚀" - - allow(CMDx::Utils::Format).to receive(:to_log).with(unicode_message).and_return(unicode_message) - - result = formatter.call(severity, time, progname, unicode_message) - parsed = JSON.parse(result.chomp) - - expect(parsed["message"]).to eq("Test message with émojis 🚀") - end - end - - context "with CMDx objects as message" do - let(:cmdx_hash) { { "state" => "complete", "status" => "success" } } - - it "uses Utils::Format.to_log for message processing" do - allow(CMDx::Utils::Format).to receive(:to_log).with(message).and_return(cmdx_hash) - - expect(CMDx::Utils::Format).to receive(:to_log).with(message) - - result = formatter.call(severity, time, progname, message) - parsed = JSON.parse(result.chomp) - - expect(parsed["message"]).to eq(cmdx_hash) - end - - it "handles complex nested structures" do - complex_hash = { - "task" => "ProcessData", - "context" => { "user_id" => 123, "session" => "abc123" }, - "metadata" => { "attempts" => 1, "duration" => 0.45 } - } - - allow(CMDx::Utils::Format).to receive(:to_log).with(message).and_return(complex_hash) - - result = formatter.call(severity, time, progname, message) - parsed = JSON.parse(result.chomp) - - expect(parsed["message"]).to eq(complex_hash) - end - end - - context "with numeric and boolean messages" do - it "handles integer messages" do - integer_message = 42 - - allow(CMDx::Utils::Format).to receive(:to_log).with(integer_message).and_return(integer_message) - - result = formatter.call(severity, time, progname, integer_message) - parsed = JSON.parse(result.chomp) - - expect(parsed["message"]).to eq(42) - end - - it "handles boolean messages" do - boolean_message = true - - allow(CMDx::Utils::Format).to receive(:to_log).with(boolean_message).and_return(boolean_message) - - result = formatter.call(severity, time, progname, boolean_message) - parsed = JSON.parse(result.chomp) - - expect(parsed["message"]).to be(true) - end - - it "handles float messages" do - float_message = 3.14159 - - allow(CMDx::Utils::Format).to receive(:to_log).with(float_message).and_return(float_message) - - result = formatter.call(severity, time, progname, float_message) - parsed = JSON.parse(result.chomp) - - expect(parsed["message"]).to eq(3.14159) - end - end - - context "with array messages" do - it "handles array messages" do - array_message = %w[item1 item2 item3] - - allow(CMDx::Utils::Format).to receive(:to_log).with(array_message).and_return(array_message) - - result = formatter.call(severity, time, progname, array_message) - parsed = JSON.parse(result.chomp) - - expect(parsed["message"]).to eq(%w[item1 item2 item3]) - end - end - - context "with extreme time values" do - it "handles very old timestamps" do - old_time = Time.new(1970, 1, 1, 0, 0, 0.0) - - result = formatter.call(severity, old_time, progname, message) - parsed = JSON.parse(result.chomp) - - expect(parsed["@timestamp"]).to eq("1970-01-01T00:00:00.000000Z") - end - - it "handles future timestamps" do - future_time = Time.new(2099, 12, 31, 23, 59, 59.999999) - - result = formatter.call(severity, future_time, progname, message) - parsed = JSON.parse(result.chomp) - - expect(parsed["@timestamp"]).to eq("2099-12-31T23:59:59.999999Z") - end - end - - context "when Utils::Format.to_log raises an error" do - it "allows the error to propagate" do - allow(CMDx::Utils::Format).to receive(:to_log).and_raise(StandardError, "Format error") - - expect do - formatter.call(severity, time, progname, message) - end.to raise_error(StandardError, "Format error") - end - end - - context "with very long strings" do - it "handles large messages without truncation" do - large_message = "x" * 10_000 - - allow(CMDx::Utils::Format).to receive(:to_log).with(large_message).and_return(large_message) - - result = formatter.call(severity, time, progname, large_message) - parsed = JSON.parse(result.chomp) + hash = JSON.parse(line) + expect(hash).to include( + "severity" => "INFO", + "progname" => "cmdx", + "message" => "hello", + "@version" => "1", + "@timestamp" => time.iso8601(6), + "pid" => Process.pid + ) + end - expect(parsed["message"]).to eq(large_message) - expect(parsed["message"].length).to eq(10_000) - end - end + it "serializes to_h-capable messages" do + message = Struct.new(:event).new("ping") + hash = JSON.parse(formatter.call("INFO", time, nil, message)) + expect(hash["message"]).to eq("event" => "ping") end end diff --git a/spec/cmdx/log_formatters/raw_spec.rb b/spec/cmdx/log_formatters/raw_spec.rb index e729326c8..16e18d33a 100644 --- a/spec/cmdx/log_formatters/raw_spec.rb +++ b/spec/cmdx/log_formatters/raw_spec.rb @@ -2,202 +2,17 @@ require "spec_helper" -RSpec.describe CMDx::LogFormatters::Raw, type: :unit do +RSpec.describe CMDx::LogFormatters::Raw do subject(:formatter) { described_class.new } - let(:severity) { "INFO" } - let(:time) { Time.new(2023, 12, 15, 10, 30, 45.123454) } - let(:progname) { "TestApp" } - let(:message) { "Test message" } - - describe "#call" do - context "with typical log parameters" do - it "returns message with newline" do - result = formatter.call(severity, time, progname, message) - - expect(result).to eq("Test message\n") - end - - it "ends with newline" do - result = formatter.call(severity, time, progname, message) - - expect(result).to end_with("\n") - end - - it "ignores severity parameter" do - result1 = formatter.call("DEBUG", time, progname, message) - result2 = formatter.call("FATAL", time, progname, message) - - expect(result1).to eq(result2) - expect(result1).to eq("Test message\n") - end - - it "ignores time parameter" do - time1 = Time.new(2020, 1, 1) - time2 = Time.new(2030, 12, 31) - result1 = formatter.call(severity, time1, progname, message) - result2 = formatter.call(severity, time2, progname, message) - - expect(result1).to eq(result2) - expect(result1).to eq("Test message\n") - end - - it "ignores progname parameter" do - result1 = formatter.call(severity, time, "App1", message) - result2 = formatter.call(severity, time, "App2", message) - - expect(result1).to eq(result2) - expect(result1).to eq("Test message\n") - end - end - - context "with nil values" do - it "handles nil severity" do - result = formatter.call(nil, time, progname, message) - - expect(result).to eq("Test message\n") - end - - it "handles nil time" do - result = formatter.call(severity, nil, progname, message) - - expect(result).to eq("Test message\n") - end - - it "handles nil progname" do - result = formatter.call(severity, time, nil, message) - - expect(result).to eq("Test message\n") - end - - it "handles nil message" do - result = formatter.call(severity, time, progname, nil) - - expect(result).to eq("\n") - end - end - - context "with special characters in message" do - it "handles newlines in message" do - message_with_newline = "Line 1\nLine 2" - - result = formatter.call(severity, time, progname, message_with_newline) - - expect(result).to eq("Line 1\nLine 2\n") - end - - it "handles unicode characters in message" do - unicode_message = "Test message with émojis 🚀" - - result = formatter.call(severity, time, progname, unicode_message) - - expect(result).to eq("Test message with émojis 🚀\n") - end - - it "handles backslashes in message" do - message_with_backslash = "C:\\Windows\\Path" - - result = formatter.call(severity, time, progname, message_with_backslash) - - expect(result).to eq("C:\\Windows\\Path\n") - end - - it "handles quotes in message" do - message_with_quotes = 'Message with "quotes" and \'apostrophes\'' - - result = formatter.call(severity, time, progname, message_with_quotes) - - expect(result).to eq("Message with \"quotes\" and 'apostrophes'\n") - end - end - - context "with complex objects as message" do - it "handles hash messages" do - hash_message = { "state" => "complete", "status" => "success" } - - result = formatter.call(severity, time, progname, hash_message) - - if RubyVersion.min?(3.4) - expect(result).to eq("{\"state\" => \"complete\", \"status\" => \"success\"}\n") - else - expect(result).to eq("{\"state\"=>\"complete\", \"status\"=>\"success\"}\n") - end - end - - it "handles array messages" do - array_message = %w[item1 item2 item3] - - result = formatter.call(severity, time, progname, array_message) - - expect(result).to eq("[\"item1\", \"item2\", \"item3\"]\n") - end - - it "handles numeric messages" do - numeric_message = 42 - - result = formatter.call(severity, time, progname, numeric_message) - - expect(result).to eq("42\n") - end - - it "handles boolean messages" do - boolean_message = true - - result = formatter.call(severity, time, progname, boolean_message) - - expect(result).to eq("true\n") - end - - it "handles symbol messages" do - symbol_message = :success - - result = formatter.call(severity, time, progname, symbol_message) - - expect(result).to eq("success\n") - end - end - - context "with empty string values" do - it "handles empty string message" do - result = formatter.call(severity, time, progname, "") - - expect(result).to eq("\n") - end - - it "handles empty string for other parameters" do - result = formatter.call("", nil, "", message) - - expect(result).to eq("Test message\n") - end - end - - context "with very long strings" do - it "handles large messages without truncation" do - large_message = "x" * 10_000 - - result = formatter.call(severity, time, progname, large_message) - - expect(result).to eq("#{'x' * 10_000}\n") - expect(result.length).to eq(10_001) - end - end - - context "with different message types" do - it "handles string interpolation correctly" do - interpolated_message = "Value is 42" - - result = formatter.call(severity, time, progname, interpolated_message) - - expect(result).to eq("Value is 42\n") - end - - it "handles frozen strings" do - frozen_message = "Frozen message" + it "emits just the message with a newline" do + expect(formatter.call("INFO", Time.now, "cmdx", "hello")).to eq("hello\n") + end - result = formatter.call(severity, time, progname, frozen_message) + it "calls #to_s implicitly via interpolation" do + obj = Object.new + def obj.to_s = "custom" - expect(result).to eq("Frozen message\n") - end - end + expect(formatter.call("INFO", Time.now, nil, obj)).to eq("custom\n") end end diff --git a/spec/cmdx/logger_proxy_spec.rb b/spec/cmdx/logger_proxy_spec.rb new file mode 100644 index 000000000..305075ae7 --- /dev/null +++ b/spec/cmdx/logger_proxy_spec.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe CMDx::LoggerProxy do + let(:task_class) { create_task_class } + let(:task) { task_class.new } + let(:base_logger) { task_class.settings.logger } + + describe ".logger" do + it "returns the settings logger when level and formatter already match" do + base_logger.level = Logger::INFO + base_logger.formatter = proc { |*| "x" } + task_class.settings(log_level: base_logger.level, log_formatter: base_logger.formatter) + + expect(described_class.logger(task)).to be(base_logger) + end + + it "returns a duped logger with an adjusted level" do + task_class.settings(log_level: Logger::FATAL) + + logger = described_class.logger(task) + + expect(logger).not_to be(base_logger) + expect(logger.level).to eq(Logger::FATAL) + end + + it "returns a duped logger with an adjusted formatter" do + custom_formatter = proc { |_, _, _, msg| "[X] #{msg}\n" } + task_class.settings(log_formatter: custom_formatter) + + logger = described_class.logger(task) + + expect(logger).not_to be(base_logger) + expect(logger.formatter).to be(custom_formatter) + end + + it "returns a duped logger when both level and formatter differ" do + custom_formatter = proc { |_, _, _, msg| "[Y] #{msg}\n" } + task_class.settings(log_level: Logger::DEBUG, log_formatter: custom_formatter) + + logger = described_class.logger(task) + + expect(logger.level).to eq(Logger::DEBUG) + expect(logger.formatter).to be(custom_formatter) + end + + it "does not mutate the settings logger" do + original_level = base_logger.level + original_formatter = base_logger.formatter + task_class.settings(log_level: Logger::DEBUG, log_formatter: proc { |*| "x" }) + + described_class.logger(task) + + expect(base_logger.level).to eq(original_level) + expect(base_logger.formatter).to eq(original_formatter) + end + end +end diff --git a/spec/cmdx/middleware_registry_spec.rb b/spec/cmdx/middleware_registry_spec.rb deleted file mode 100644 index df5662468..000000000 --- a/spec/cmdx/middleware_registry_spec.rb +++ /dev/null @@ -1,453 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe CMDx::MiddlewareRegistry, type: :unit do - subject(:registry) { described_class.new(initial_registry) } - - let(:initial_registry) { [] } - let(:mock_task) { instance_double(CMDx::Task) } - let(:mock_middleware1) { instance_double("MockMiddleware1", call: nil) } - let(:mock_middleware2) { instance_double("MockMiddleware2", call: nil) } - - describe "#initialize" do - context "when no registry is provided" do - subject(:registry) { described_class.new } - - it "initializes with an empty array" do - expect(registry.registry).to eq([]) - end - end - - context "when a registry is provided" do - let(:initial_registry) { [[mock_middleware1, {}]] } - - it "initializes with the provided registry" do - expect(registry.registry).to eq([[mock_middleware1, {}]]) - end - end - end - - describe "#registry" do - let(:initial_registry) { [[mock_middleware1, {}]] } - - it "returns the internal registry array" do - expect(registry.registry).to eq([[mock_middleware1, {}]]) - end - end - - describe "#to_a" do - let(:initial_registry) { [[mock_middleware1, {}]] } - - it "returns the registry array" do - expect(registry.to_a).to eq([[mock_middleware1, {}]]) - end - - it "is an alias for registry" do - expect(registry.method(:to_a)).to eq(registry.method(:registry)) - end - end - - describe "#dup" do - let(:initial_registry) { [[mock_middleware1, { option: "value" }]] } - - it "returns a new MiddlewareRegistry instance" do - duplicated = registry.dup - - expect(duplicated).to be_a(described_class) - expect(duplicated).not_to be(registry) - end - - it "shares the parent registry until first write" do - duplicated = registry.dup - - expect(duplicated.registry).to eq(registry.registry) - expect(duplicated.registry).to be(registry.registry) - end - - it "materializes on write and does not affect the parent" do - duplicated = registry.dup - - duplicated.register(mock_middleware2) - - expect(duplicated.registry.size).to eq(2) - expect(registry.registry.size).to eq(1) - expect(duplicated.registry).not_to be(registry.registry) - end - - it "materializes on deregister and does not affect the parent" do - duplicated = registry.dup - - duplicated.deregister(mock_middleware1) - - expect(duplicated.registry).to be_empty - expect(registry.registry.size).to eq(1) - end - - context "when registry is empty" do - let(:initial_registry) { [] } - - it "returns a new empty MiddlewareRegistry" do - duplicated = registry.dup - - expect(duplicated.registry).to eq([]) - expect(duplicated).not_to be(registry) - end - end - end - - describe "#register" do - context "when registering middleware without options" do - it "adds the middleware to the end of the registry" do - registry.register(mock_middleware1) - - expect(registry.registry).to eq([[mock_middleware1, {}]]) - end - - it "returns self for method chaining" do - result = registry.register(mock_middleware1) - - expect(result).to be(registry) - end - end - - context "when registering middleware with options" do - let(:options) { { timeout: 30, retry: true } } - - it "stores the middleware with its options" do - registry.register(mock_middleware1, **options) - - expect(registry.registry).to eq([[mock_middleware1, options]]) - end - end - - context "when registering middleware at a specific position" do - let(:initial_registry) { [[mock_middleware1, {}]] } - - it "inserts at the beginning when at: 0" do - registry.register(mock_middleware2, at: 0) - - expect(registry.registry).to eq( - [ - [mock_middleware2, {}], - [mock_middleware1, {}] - ] - ) - end - - it "inserts at a specific index" do - registry.register(mock_middleware2, at: 1) - - expect(registry.registry).to eq( - [ - [mock_middleware1, {}], - [mock_middleware2, {}] - ] - ) - end - - it "inserts at the end when at: -1 (default)" do - registry.register(mock_middleware2, at: -1) - - expect(registry.registry).to eq( - [ - [mock_middleware1, {}], - [mock_middleware2, {}] - ] - ) - end - end - - context "when registering multiple middlewares" do - it "maintains insertion order" do - registry.register(mock_middleware1) - registry.register(mock_middleware2) - - expect(registry.registry).to eq( - [ - [mock_middleware1, {}], - [mock_middleware2, {}] - ] - ) - end - end - - context "when registering middleware with position and options" do - let(:initial_registry) { [[mock_middleware1, {}]] } - let(:options) { { timeout: 30 } } - - it "inserts at specified position with options" do - registry.register(mock_middleware2, at: 0, **options) - - expect(registry.registry).to eq( - [ - [mock_middleware2, options], - [mock_middleware1, {}] - ] - ) - end - end - end - - describe "#deregister" do - context "when deregistering middleware without options" do - before do - registry.register(mock_middleware1) - registry.register(mock_middleware2) - end - - it "removes the middleware from the registry" do - registry.deregister(mock_middleware1) - - expect(registry.registry).to eq([[mock_middleware2, {}]]) - end - - it "returns self for method chaining" do - result = registry.deregister(mock_middleware1) - - expect(result).to be(registry) - end - end - - context "when deregistering middleware with options" do - let(:options) { { timeout: 30, retry: true } } - - before do - registry.register(mock_middleware1, **options) - registry.register(mock_middleware2) - end - - it "removes all instances of the middleware regardless of options" do - registry.deregister(mock_middleware1) - - expect(registry.registry).to eq([[mock_middleware2, {}]]) - end - - it "removes middleware with any options" do - registry.register(mock_middleware1, timeout: 60) - registry.deregister(mock_middleware1) - - expect(registry.registry).to eq([[mock_middleware2, {}]]) - expect(registry.registry).not_to include([mock_middleware1, { timeout: 60 }]) - expect(registry.registry).not_to include([mock_middleware1, options]) - end - end - - context "when deregistering from empty registry" do - it "does not raise an error" do - expect { registry.deregister(mock_middleware1) }.not_to raise_error - end - - it "returns self" do - result = registry.deregister(mock_middleware1) - - expect(result).to be(registry) - end - end - - context "when deregistering non-existent middleware" do - before do - registry.register(mock_middleware1) - end - - it "does not affect existing middleware" do - registry.deregister(mock_middleware2) - - expect(registry.registry).to include([mock_middleware1, {}]) - end - - it "returns self" do - result = registry.deregister(mock_middleware2) - - expect(result).to be(registry) - end - end - - context "when deregistering middleware registered multiple times" do - let(:options1) { { timeout: 30 } } - let(:options2) { { timeout: 60 } } - - before do - registry.register(mock_middleware1, **options1) - registry.register(mock_middleware1, **options2) - end - - it "removes all instances of the middleware" do - registry.deregister(mock_middleware1) - - expect(registry.registry).to be_empty - end - end - - context "when deregistering one of multiple middleware" do - before do - registry.register(mock_middleware1) - registry.register(mock_middleware2) - end - - it "removes only the specified middleware" do - registry.deregister(mock_middleware1) - - expect(registry.registry).not_to include([mock_middleware1, {}]) - expect(registry.registry).to include([mock_middleware2, {}]) - end - - it "maintains order of remaining middleware" do - registry.register(mock_middleware1) # Now we have [mw1, mw2, mw1] - registry.deregister(mock_middleware2) - - expect(registry.registry).to eq([ - [mock_middleware1, {}], - [mock_middleware1, {}] - ]) - end - end - end - - describe "#call!" do - let(:block_result) { "block_executed" } - let(:test_block) { proc { |_task| block_result } } - - context "when no block is given" do - it "raises ArgumentError" do - expect { registry.call!(mock_task) }.to raise_error(ArgumentError, "block required") - end - end - - context "when registry is empty" do - it "yields to the block immediately" do - result = registry.call!(mock_task, &test_block) - - expect(result).to eq(block_result) - end - - it "passes the task to the block" do - yielded_task = nil - registry.call!(mock_task) { |task| yielded_task = task } - - expect(yielded_task).to be(mock_task) - end - end - - context "when registry has one middleware" do - before do - registry.register(mock_middleware1) - end - - it "calls the middleware with empty options by default" do - expect(mock_middleware1).to receive(:call).with(mock_task).and_yield - - registry.call!(mock_task, &test_block) - end - - it "yields the result when middleware calls the block" do - allow(mock_middleware1).to receive(:call).and_yield - - result = registry.call!(mock_task, &test_block) - - expect(result).to eq(block_result) - end - end - - context "when middleware is registered with options" do - let(:options) { { timeout: 30, retry: true } } - - before do - registry.register(mock_middleware1, **options) - end - - it "passes options to the middleware" do - expect(mock_middleware1).to receive(:call).with(mock_task, **options).and_yield - - registry.call!(mock_task, &test_block) - end - end - - context "when registry has multiple middlewares" do - before do - registry.register(mock_middleware1) - registry.register(mock_middleware2) - end - - it "calls middlewares in order" do - call_order = [] - - allow(mock_middleware1).to receive(:call) do |_task, &block| - call_order << :middleware1 - block.call - end - allow(mock_middleware2).to receive(:call) do |_task, &block| - call_order << :middleware2 - block.call - end - - registry.call!(mock_task) { call_order << :block } - - expect(call_order).to eq(%i[middleware1 middleware2 block]) - end - - it "passes the task through the middleware chain" do - expect(mock_middleware1).to receive(:call).with(mock_task).and_yield - expect(mock_middleware2).to receive(:call).with(mock_task).and_yield - - registry.call!(mock_task, &test_block) - end - - it "returns the final result from the block" do - allow(mock_middleware1).to receive(:call).and_yield - allow(mock_middleware2).to receive(:call).and_yield - - result = registry.call!(mock_task, &test_block) - - expect(result).to eq(block_result) - end - end - - context "when middleware modifies the result" do - let(:middleware_result) { "modified_result" } - - before do - registry.register(mock_middleware1) - - allow(mock_middleware1).to receive(:call) do |_task, &block| - block.call - middleware_result - end - end - - it "returns the middleware's result" do - result = registry.call!(mock_task, &test_block) - - expect(result).to eq(middleware_result) - end - end - - context "when middleware doesn't yield" do - before do - registry.register(mock_middleware1) - - allow(mock_middleware1).to receive(:call).and_return("middleware_stopped") - end - - it "stops execution and returns middleware result" do - block_called = false - result = registry.call!(mock_task) { block_called = true } - - expect(block_called).to be(false) - expect(result).to eq("middleware_stopped") - end - end - - context "when middleware raises an error" do - before do - registry.register(mock_middleware1) - - allow(mock_middleware1).to receive(:call).and_raise(StandardError, "middleware error") - end - - it "propagates the error" do - expect { registry.call!(mock_task, &test_block) }.to raise_error(StandardError, "middleware error") - end - end - end -end diff --git a/spec/cmdx/middlewares/correlate_spec.rb b/spec/cmdx/middlewares/correlate_spec.rb deleted file mode 100644 index 024185e3e..000000000 --- a/spec/cmdx/middlewares/correlate_spec.rb +++ /dev/null @@ -1,444 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe CMDx::Middlewares::Correlate, type: :unit do - subject(:correlate) { described_class } - - let(:task) { double("CMDx::Task", id: "task-123", result: result) } # rubocop:disable RSpec/VerifiedDoubles - let(:result) { instance_double(CMDx::Result, metadata: metadata) } - let(:metadata) { {} } - - before do - described_class.clear - end - - describe ".id" do - context "when no correlation ID is set" do - it "returns nil" do - expect(correlate.id).to be_nil - end - end - - context "when correlation ID is set" do - before { correlate.id = "test-correlation-id" } - - it "returns the correlation ID" do - expect(correlate.id).to eq("test-correlation-id") - end - end - end - - describe ".id=" do - it "sets the correlation ID in fiber or thread local storage" do - correlate.id = "new-correlation-id" - - expect(correlate.id).to eq("new-correlation-id") - end - - it "overwrites existing correlation ID" do - correlate.id = "first-id" - correlate.id = "second-id" - - expect(correlate.id).to eq("second-id") - end - - it "accepts nil values" do - correlate.id = "some-id" - correlate.id = nil - - expect(correlate.id).to be_nil - end - end - - describe ".clear" do - it "sets correlation ID to nil" do - correlate.id = "test-id" - - correlate.clear - - expect(correlate.id).to be_nil - end - - context "when no ID was set" do - it "remains nil" do - correlate.clear - - expect(correlate.id).to be_nil - end - end - end - - describe ".use" do - let(:new_id) { "temporary-id" } - let(:block_result) { "block executed" } - - context "when no existing ID is set" do - it "sets the new ID during block execution" do - called_with_id = nil - - correlate.use(new_id) do - called_with_id = correlate.id - end - - expect(called_with_id).to eq(new_id) - end - - it "clears the ID after block execution" do - correlate.use(new_id) { nil } - - expect(correlate.id).to be_nil - end - - it "returns the block result" do - result = correlate.use(new_id) { block_result } - - expect(result).to eq(block_result) - end - end - - context "when existing ID is set" do - let(:original_id) { "original-id" } - - before { correlate.id = original_id } - - it "temporarily replaces the ID during block execution" do - called_with_id = nil - - correlate.use(new_id) do - called_with_id = correlate.id - end - - expect(called_with_id).to eq(new_id) - end - - it "restores the original ID after block execution" do - correlate.use(new_id) { nil } - - expect(correlate.id).to eq(original_id) - end - - it "restores original ID even when block raises error" do - expect do - correlate.use(new_id) { raise StandardError, "test error" } - end.to raise_error(StandardError, "test error") - - expect(correlate.id).to eq(original_id) - end - end - - context "when block raises an error" do - it "ensures cleanup and re-raises the error" do - expect do - correlate.use(new_id) { raise ArgumentError, "block error" } - end.to raise_error(ArgumentError, "block error") - end - end - end - - describe ".call" do - let(:block_result) { "task executed" } - let(:test_block) { proc { block_result } } - - before do - allow(CMDx::Identifier).to receive(:generate).and_return("generated-uuid") - end - - context "when no id option is provided" do - context "with no existing correlation ID" do - it "generates a new correlation ID using Identifier.generate" do - expect(CMDx::Identifier).to receive(:generate) - - correlate.call(task, &test_block) - end - - it "sets the generated ID in metadata" do - correlate.call(task, &test_block) - - expect(metadata[:correlation_id]).to eq("generated-uuid") - end - end - - context "with existing correlation ID" do - before { correlate.id = "existing-id" } - - it "uses the existing correlation ID" do - expect(CMDx::Identifier).not_to receive(:generate) - - correlate.call(task, &test_block) - - expect(metadata[:correlation_id]).to eq("existing-id") - end - end - end - - context "when id option is a Symbol" do - let(:method_name) { :id } - - it "calls the method on the task" do - expect(task).to receive(method_name) - - correlate.call(task, id: method_name, &test_block) - end - - it "uses the method result as correlation ID" do - correlate.call(task, id: method_name, &test_block) - - expect(metadata[:correlation_id]).to eq("task-123") - end - end - - context "when id option is a Proc" do - let(:id_proc) { proc { "proc-generated-id" } } - - it "uses the proc result as correlation ID" do - correlate.call(task, id: id_proc, &test_block) - - expect(metadata[:correlation_id]).to eq("proc-generated-id") - end - end - - context "when id option responds to call" do - let(:callable) { instance_double("MockCallable", call: "callable-id") } - - it "calls the callable with the task" do - expect(callable).to receive(:call).with(task) - - correlate.call(task, id: callable, &test_block) - end - - it "uses the callable result as correlation ID" do - correlate.call(task, id: callable, &test_block) - - expect(metadata[:correlation_id]).to eq("callable-id") - end - end - - context "when id option is a string value" do - let(:static_id) { "static-correlation-id" } - - it "uses the static value as correlation ID" do - expect(CMDx::Identifier).not_to receive(:generate) - - correlate.call(task, id: static_id, &test_block) - - expect(metadata[:correlation_id]).to eq(static_id) - end - end - - context "when id option is nil" do - context "with no existing correlation ID" do - it "generates a new correlation ID" do - expect(CMDx::Identifier).to receive(:generate) - - correlate.call(task, id: nil, &test_block) - - expect(metadata[:correlation_id]).to eq("generated-uuid") - end - end - - context "with existing correlation ID" do - before { correlate.id = "existing-id" } - - it "uses the existing correlation ID" do - expect(CMDx::Identifier).not_to receive(:generate) - - correlate.call(task, id: nil, &test_block) - - expect(metadata[:correlation_id]).to eq("existing-id") - end - end - end - - context "when id option is false" do - it "generates a new correlation ID when falsy value provided" do - expect(CMDx::Identifier).to receive(:generate) - - correlate.call(task, id: false, &test_block) - - expect(metadata[:correlation_id]).to eq("generated-uuid") - end - end - - it "executes the block with the correlation ID set" do - called_with_id = nil - - correlate.call(task, id: "test-id") do - called_with_id = correlate.id - end - - expect(called_with_id).to eq("test-id") - end - - it "returns the block result" do - result = correlate.call(task, id: "test-id", &test_block) - - expect(result).to eq(block_result) - end - - it "restores the original correlation ID after execution" do - original_id = "original-id" - correlate.id = original_id - - correlate.call(task, id: "temporary-id", &test_block) - - expect(correlate.id).to eq(original_id) - end - - context "when block raises an error" do - let(:error_block) { proc { raise StandardError, "execution error" } } - - it "ensures cleanup and re-raises the error" do - original_id = "original-id" - correlate.id = original_id - - expect do - correlate.call(task, id: "temp-id", &error_block) - end.to raise_error(StandardError, "execution error") - - expect(correlate.id).to eq(original_id) - end - - it "still sets the correlation ID in metadata before cleanup" do - correlate.call(task, id: "error-id") do - task.result.metadata[:correlation_id] = "error-id" - raise StandardError, "execution error" - end - rescue StandardError - expect(metadata[:correlation_id]).to eq("error-id") - end - end - - context "with conditional execution using 'if'" do - before do - allow(task).to receive(:should_correlate?).and_return(true) - end - - it "applies correlation when 'if' condition is true" do - correlate.call(task, id: "test-id", if: :should_correlate?, &test_block) - - expect(metadata[:correlation_id]).to eq("test-id") - end - - it "skips correlation when 'if' condition is false" do - allow(task).to receive(:should_correlate?).and_return(false) - - result = correlate.call(task, id: "test-id", if: :should_correlate?, &test_block) - - expect(metadata[:correlation_id]).to be_nil - - expect(result).to eq(block_result) - end - end - - context "with conditional execution using 'unless'" do - before do - allow(task).to receive(:skip_correlation?).and_return(false) - end - - it "applies correlation when 'unless' condition is false" do - correlate.call(task, id: "test-id", unless: :skip_correlation?, &test_block) - - expect(metadata[:correlation_id]).to eq("test-id") - end - - it "skips correlation when 'unless' condition is true" do - allow(task).to receive(:skip_correlation?).and_return(true) - - result = correlate.call(task, id: "test-id", unless: :skip_correlation?, &test_block) - - expect(metadata[:correlation_id]).to be_nil - - expect(result).to eq(block_result) - end - end - - context "with additional options" do - it "ignores unknown options" do - expect do - correlate.call(task, id: "test-id", unknown_option: "value", &test_block) - end.not_to raise_error - end - end - end - - describe "thread safety" do - after { described_class.clear } - - it "maintains separate correlation IDs per thread" do - thread1_id = nil - thread2_id = nil - - thread1 = Thread.new do - described_class.id = "thread-1-id" - thread1_id = described_class.id - end - - thread2 = Thread.new do - described_class.id = "thread-2-id" - thread2_id = described_class.id - end - - thread1.join - thread2.join - - expect(thread1_id).to eq("thread-1-id") - expect(thread2_id).to eq("thread-2-id") - expect(thread1_id).not_to eq(thread2_id) - end - - it "does not interfere with main thread correlation ID" do - described_class.id = "main-thread-id" - - thread_id = nil - thread = Thread.new do - described_class.id = "child-thread-id" - thread_id = described_class.id - end - thread.join - - expect(described_class.id).to eq("main-thread-id") - expect(thread_id).to eq("child-thread-id") - end - end - - describe "fiber safety" do - after { described_class.clear } - - it "maintains separate correlation IDs per fiber" do - fiber1_id = nil - fiber2_id = nil - - fiber1 = Fiber.new do - described_class.id = "fiber-1-id" - fiber1_id = described_class.id - end - - fiber2 = Fiber.new do - described_class.id = "fiber-2-id" - fiber2_id = described_class.id - end - - fiber1.resume - fiber2.resume - - expect(fiber1_id).to eq("fiber-1-id") - expect(fiber2_id).to eq("fiber-2-id") - expect(fiber1_id).not_to eq(fiber2_id) - end - - it "does not interfere with main fiber correlation ID" do - described_class.id = "main-fiber-id" - - fiber_id = nil - fiber = Fiber.new do - described_class.id = "child-fiber-id" - fiber_id = described_class.id - end - fiber.resume - - expect(described_class.id).to eq("main-fiber-id") - expect(fiber_id).to eq("child-fiber-id") - end - end -end diff --git a/spec/cmdx/middlewares/runtime_spec.rb b/spec/cmdx/middlewares/runtime_spec.rb deleted file mode 100644 index 05de432f3..000000000 --- a/spec/cmdx/middlewares/runtime_spec.rb +++ /dev/null @@ -1,180 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe CMDx::Middlewares::Runtime, type: :unit do - subject(:runtime) { described_class } - - let(:task) { double("CMDx::Task", result: result) } # rubocop:disable RSpec/VerifiedDoubles - let(:result) { instance_double(CMDx::Result, metadata: metadata) } - let(:metadata) { {} } - let(:block_result) { "task executed" } - let(:test_block) { proc { block_result } } - - describe ".call" do - before do - allow(Process).to receive(:clock_gettime) - .with(Process::CLOCK_MONOTONIC, :millisecond) - .and_return(100, 150) # Start time: 100ms, end time: 150ms - end - - it "measures runtime and stores it in metadata" do - result = runtime.call(task, &test_block) - - expect(metadata[:runtime]).to eq(50) # 150 - 100 = 50ms - expect(result).to eq(block_result) - end - - it "calls monotonic_time twice to measure duration" do - expect(Process).to receive(:clock_gettime) - .with(Process::CLOCK_MONOTONIC, :millisecond) - .twice - - runtime.call(task, &test_block) - end - - it "returns the block result" do - result = runtime.call(task, &test_block) - - expect(result).to eq(block_result) - end - - context "when block execution takes no measurable time" do - before do - allow(Process).to receive(:clock_gettime) - .with(Process::CLOCK_MONOTONIC, :millisecond) - .and_return(100, 100) # Same time - end - - it "stores zero runtime" do - runtime.call(task, &test_block) - - expect(metadata[:runtime]).to eq(0) - end - end - - context "when block raises an error" do - let(:error_block) { proc { raise StandardError, "test error" } } - - before do - allow(Process).to receive(:clock_gettime) - .with(Process::CLOCK_MONOTONIC, :millisecond) - .and_return(100) - end - - it "re-raises the error without storing runtime" do - expect do - runtime.call(task, &error_block) - end.to raise_error(StandardError, "test error") - - expect(metadata[:runtime]).to be_nil - end - - it "only calls monotonic_time once before the error" do - expect(Process).to receive(:clock_gettime) - .with(Process::CLOCK_MONOTONIC, :millisecond) - .once - - begin - runtime.call(task, &error_block) - rescue StandardError - # Expected to raise - end - end - end - - context "with conditional execution using 'if'" do - before do - allow(task).to receive(:should_measure_runtime?).and_return(true) - end - - it "measures runtime when 'if' condition is true" do - result = runtime.call(task, if: :should_measure_runtime?, &test_block) - - expect(metadata[:runtime]).to eq(50) - - expect(result).to eq(block_result) - end - - it "skips runtime measurement when 'if' condition is false" do - allow(task).to receive(:should_measure_runtime?).and_return(false) - - result = runtime.call(task, if: :should_measure_runtime?, &test_block) - - expect(metadata[:runtime]).to be_nil - - expect(result).to eq(block_result) - end - end - - context "with conditional execution using 'unless'" do - before do - allow(task).to receive(:skip_runtime_measurement?).and_return(false) - end - - it "measures runtime when 'unless' condition is false" do - result = runtime.call(task, unless: :skip_runtime_measurement?, &test_block) - - expect(metadata[:runtime]).to eq(50) - - expect(result).to eq(block_result) - end - - it "skips runtime measurement when 'unless' condition is true" do - allow(task).to receive(:skip_runtime_measurement?).and_return(true) - - result = runtime.call(task, unless: :skip_runtime_measurement?, &test_block) - - expect(metadata[:runtime]).to be_nil - - expect(result).to eq(block_result) - end - end - - context "with additional options" do - it "ignores unknown options" do - expect do - runtime.call(task, unknown_option: "value", &test_block) - end.not_to raise_error - - expect(metadata[:runtime]).to eq(50) - end - end - - context "when block returns nil" do - let(:nil_block) { proc {} } - - it "still measures runtime correctly" do - result = runtime.call(task, &nil_block) - - expect(metadata[:runtime]).to eq(50) - - expect(result).to be_nil - end - end - - context "when block returns false" do - let(:false_block) { proc { false } } - - it "still measures runtime correctly" do - result = runtime.call(task, &false_block) - - expect(metadata[:runtime]).to eq(50) - - expect(result).to be(false) - end - end - end - - describe "#monotonic_time" do - it "uses Process.clock_gettime with CLOCK_MONOTONIC in milliseconds" do - allow(Process).to receive(:clock_gettime) - .with(Process::CLOCK_MONOTONIC, :millisecond) - .and_return(123_456) - - time = runtime.send(:monotonic_time) - - expect(time).to eq(123_456) - end - end -end diff --git a/spec/cmdx/middlewares/timeout_spec.rb b/spec/cmdx/middlewares/timeout_spec.rb deleted file mode 100644 index 2f5543479..000000000 --- a/spec/cmdx/middlewares/timeout_spec.rb +++ /dev/null @@ -1,272 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe CMDx::Middlewares::Timeout, type: :unit do - subject(:timeout_middleware) { described_class } - - let(:task) { double("CMDx::Task", result: result, resolver: resolver) } # rubocop:disable RSpec/VerifiedDoubles - let(:result) { instance_double(CMDx::Result) } - let(:resolver) { instance_double(CMDx::Resolver) } - let(:block_result) { "block executed" } - let(:test_block) { proc { block_result } } - - before do - allow(resolver).to receive(:fail!) - end - - describe ".call" do - context "when seconds option is a Numeric" do - it "uses the numeric value as timeout limit coerced to float" do - expect(Timeout).to receive(:timeout).with(5.0, CMDx::TimeoutError, "execution exceeded 5.0 seconds").and_yield.and_return(block_result) - - result = timeout_middleware.call(task, seconds: 5, &test_block) - - expect(result).to eq(block_result) - end - - it "handles float values" do - expect(Timeout).to receive(:timeout).with(2.5, CMDx::TimeoutError, "execution exceeded 2.5 seconds").and_yield.and_return(block_result) - - timeout_middleware.call(task, seconds: 2.5, &test_block) - end - - it "yields directly when timeout is zero" do - expect(Timeout).not_to receive(:timeout) - - result = timeout_middleware.call(task, seconds: 0, &test_block) - - expect(result).to eq(block_result) - end - - it "yields directly when timeout is negative" do - expect(Timeout).not_to receive(:timeout) - - result = timeout_middleware.call(task, seconds: -1, &test_block) - - expect(result).to eq(block_result) - end - end - - context "when seconds option is a Symbol" do - let(:method_name) { :timeout_value } - - before do - allow(task).to receive(:send).with(method_name).and_return(10) - end - - it "calls the method on the task and uses the result as float" do - allow(task).to receive(:send).with(method_name).and_return(10) - - expect(Timeout).to receive(:timeout).with(10.0, CMDx::TimeoutError, "execution exceeded 10.0 seconds").and_yield.and_return(block_result) - - timeout_middleware.call(task, seconds: method_name, &test_block) - end - - it "raises ArgumentError when method returns non-numeric value" do - allow(task).to receive(:send).with(method_name).and_return("invalid") - - expect do - timeout_middleware.call(task, seconds: method_name, &test_block) - end.to raise_error(ArgumentError) - end - end - - context "when seconds option is a Proc" do - let(:timeout_proc) { proc { 15 } } - - before do - allow(task).to receive(:instance_eval).and_yield.and_return(15) - end - - it "evaluates the proc in task context and uses the result as float" do - expect(task).to receive(:instance_eval).and_yield.and_return(15) - - expect(Timeout).to receive(:timeout).with(15.0, CMDx::TimeoutError, "execution exceeded 15.0 seconds").and_yield.and_return(block_result) - - timeout_middleware.call(task, seconds: timeout_proc, &test_block) - end - - it "raises TypeError when proc returns nil" do - expect(task).to receive(:instance_eval).and_yield.and_return(nil) - - expect do - timeout_middleware.call(task, seconds: timeout_proc, &test_block) - end.to raise_error(TypeError) - end - end - - context "when seconds option responds to call" do - let(:callable) { instance_double("MockCallable", call: 20) } - - it "calls the callable with the task and uses the result as float" do - allow(callable).to receive(:call).with(task).and_return(20) - - expect(Timeout).to receive(:timeout).with(20.0, CMDx::TimeoutError, "execution exceeded 20.0 seconds").and_yield.and_return(block_result) - - timeout_middleware.call(task, seconds: callable, &test_block) - end - - it "yields directly when callable returns zero" do - allow(callable).to receive(:call).with(task).and_return(0) - - expect(Timeout).not_to receive(:timeout) - - result = timeout_middleware.call(task, seconds: callable, &test_block) - - expect(result).to eq(block_result) - end - end - - context "when seconds option is nil" do - it "uses the default timeout limit as float" do - expect(Timeout).to receive(:timeout).with(3.0, CMDx::TimeoutError, "execution exceeded 3.0 seconds").and_yield.and_return(block_result) - - timeout_middleware.call(task, seconds: nil, &test_block) - end - end - - context "when seconds option is false" do - it "uses the default timeout limit as float" do - expect(Timeout).to receive(:timeout).with(3.0, CMDx::TimeoutError, "execution exceeded 3.0 seconds").and_yield.and_return(block_result) - - timeout_middleware.call(task, seconds: false, &test_block) - end - end - - context "when no seconds option is provided" do - it "uses the default timeout limit as float" do - expect(Timeout).to receive(:timeout).with(3.0, CMDx::TimeoutError, "execution exceeded 3.0 seconds").and_yield.and_return(block_result) - - timeout_middleware.call(task, &test_block) - end - end - - context "when seconds option is an unsupported type" do - it "uses the default timeout limit for string values" do - expect(Timeout).to receive(:timeout).with(3.0, CMDx::TimeoutError, "execution exceeded 3.0 seconds").and_yield.and_return(block_result) - - timeout_middleware.call(task, seconds: "invalid", &test_block) - end - - it "uses the default timeout limit for array values" do - expect(Timeout).to receive(:timeout).with(3.0, CMDx::TimeoutError, "execution exceeded 3.0 seconds").and_yield.and_return(block_result) - - timeout_middleware.call(task, seconds: [1, 2, 3], &test_block) - end - end - - context "when block execution succeeds" do - it "returns the block result" do - allow(Timeout).to receive(:timeout).and_yield.and_return(block_result) - - result = timeout_middleware.call(task, seconds: 5, &test_block) - - expect(result).to eq(block_result) - end - - it "returns nil when block returns nil" do - nil_block = proc {} - allow(Timeout).to receive(:timeout).and_yield.and_return(nil) - - result = timeout_middleware.call(task, seconds: 5, &nil_block) - - expect(result).to be_nil - end - - it "returns false when block returns false" do - false_block = proc { false } - allow(Timeout).to receive(:timeout).and_yield.and_return(false) - - result = timeout_middleware.call(task, seconds: 5, &false_block) - - expect(result).to be(false) - end - end - - context "when block raises other errors" do - let(:standard_error) { StandardError.new("unexpected error") } - let(:error_block) { proc { raise standard_error } } - - it "re-raises non-timeout errors without calling fail!" do - expect(Timeout).to receive(:timeout).and_yield.and_raise(standard_error) - - expect(resolver).not_to receive(:fail!) - - expect do - timeout_middleware.call(task, seconds: 5, &error_block) - end.to raise_error(StandardError, "unexpected error") - end - end - - context "with conditional execution using 'if'" do - before do - allow(task).to receive(:should_timeout?).and_return(true) - allow(Timeout).to receive(:timeout) - end - - it "executes timeout when 'if' condition is true" do - expect(Timeout).to receive(:timeout).with(5.0, CMDx::TimeoutError, "execution exceeded 5.0 seconds").and_yield.and_return(block_result) - - result = timeout_middleware.call(task, seconds: 5, if: :should_timeout?, &test_block) - - expect(result).to eq(block_result) - end - - it "skips timeout when 'if' condition is false" do - allow(task).to receive(:should_timeout?).and_return(false) - expect(Timeout).not_to receive(:timeout) - - result = timeout_middleware.call(task, seconds: 5, if: :should_timeout?, &test_block) - - expect(result).to eq(block_result) - end - end - - context "with conditional execution using 'unless'" do - before do - allow(task).to receive(:skip_timeout?).and_return(false) - allow(Timeout).to receive(:timeout) - end - - it "executes timeout when 'unless' condition is false" do - expect(Timeout).to receive(:timeout).with(5.0, CMDx::TimeoutError, "execution exceeded 5.0 seconds").and_yield.and_return(block_result) - - result = timeout_middleware.call(task, seconds: 5, unless: :skip_timeout?, &test_block) - - expect(result).to eq(block_result) - end - - it "skips timeout when 'unless' condition is true" do - allow(task).to receive(:skip_timeout?).and_return(true) - - expect(Timeout).not_to receive(:timeout) - - result = timeout_middleware.call(task, seconds: 5, unless: :skip_timeout?, &test_block) - - expect(result).to eq(block_result) - end - end - - context "with additional options" do - it "ignores unknown options" do - expect(Timeout).to receive(:timeout).with(5.0, CMDx::TimeoutError, "execution exceeded 5.0 seconds").and_yield.and_return(block_result) - - expect do - timeout_middleware.call(task, seconds: 5, unknown_option: "value", &test_block) - end.not_to raise_error - end - end - end - - describe "CMDx::TimeoutError" do - it "is a subclass of Interrupt" do - expect(CMDx::TimeoutError.superclass).to eq(Interrupt) - end - - it "can be instantiated with a message" do - error = CMDx::TimeoutError.new("test timeout") - expect(error.message).to eq("test timeout") - end - end -end diff --git a/spec/cmdx/middlewares_spec.rb b/spec/cmdx/middlewares_spec.rb new file mode 100644 index 000000000..e86986cbb --- /dev/null +++ b/spec/cmdx/middlewares_spec.rb @@ -0,0 +1,178 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe CMDx::Middlewares do + subject(:middlewares) { described_class.new } + + let(:task) { Object.new } + + describe "#initialize" do + it "starts with an empty registry" do + expect(middlewares).to be_empty + expect(middlewares.registry).to eq([]) + end + end + + describe "#initialize_copy" do + it "dups the registry so new registrations don't leak" do + mw = ->(_t, &blk) { blk.call } + middlewares.register(mw) + + copy = middlewares.dup + copy.register(->(_t, &blk) { blk.call }) + + expect(middlewares.size).to eq(1) + expect(copy.size).to eq(2) + end + end + + describe "#register" do + let(:mw) { ->(_t, &blk) { blk.call } } + + it "appends the callable and returns self" do + expect(middlewares.register(mw)).to be(middlewares) + expect(middlewares.registry).to eq([mw]) + end + + it "accepts a block" do + middlewares.register { |_t, &blk| blk.call } + expect(middlewares.size).to eq(1) + end + + it "raises when both callable and block are given" do + expect { middlewares.register(mw) { |_t, &blk| blk.call } } + .to raise_error(ArgumentError, "provide either a callable or a block, not both") + end + + it "raises when the middleware does not respond to #call" do + expect { middlewares.register("not callable") } + .to raise_error(ArgumentError, "middleware must respond to #call") + end + + it "raises when at is non-integer" do + expect { middlewares.register(mw, at: "1") } + .to raise_error(ArgumentError, "at must be an Integer") + end + + it "inserts at the given positive index" do + a = ->(_t, &blk) { blk.call } + b = ->(_t, &blk) { blk.call } + c = ->(_t, &blk) { blk.call } + + middlewares.register(a) + middlewares.register(b) + middlewares.register(c, at: 1) + + expect(middlewares.registry).to eq([a, c, b]) + end + + it "clamps out-of-bounds indices to the valid range" do + a = ->(_t, &blk) { blk.call } + b = ->(_t, &blk) { blk.call } + c = ->(_t, &blk) { blk.call } + + middlewares.register(a) + middlewares.register(b, at: 100) + middlewares.register(c, at: -100) + + expect(middlewares.registry.first).to be(c) + expect(middlewares.registry.last).to be(b) + end + end + + describe "#deregister" do + let(:mw) { ->(_t, &blk) { blk.call } } + + before { middlewares.register(mw) } + + it "removes a specific middleware and returns self" do + expect(middlewares.deregister(mw)).to be(middlewares) + expect(middlewares).to be_empty + end + + it "removes by index" do + middlewares.deregister(at: 0) + expect(middlewares).to be_empty + end + + it "raises when neither a middleware nor an index is provided" do + expect { middlewares.deregister } + .to raise_error(ArgumentError, "provide either a middleware or an at: index") + end + + it "raises when both a middleware and an index are provided" do + expect { middlewares.deregister(mw, at: 0) } + .to raise_error(ArgumentError, "provide either a middleware or an at: index, not both") + end + + it "raises when at is non-integer" do + expect { middlewares.deregister(at: "1") } + .to raise_error(ArgumentError, "at must be an Integer") + end + end + + describe "#process" do + it "yields once when there are no middlewares" do + yields = 0 + middlewares.process(task) { yields += 1 } + expect(yields).to eq(1) + end + + it "runs each middleware in registration order around the inner block" do + trace = [] + middlewares.register(lambda { |_t, &blk| + trace << :a_before + blk.call + trace << :a_after + }) + middlewares.register(lambda { |_t, &blk| + trace << :b_before + blk.call + trace << :b_after + }) + + middlewares.process(task) { trace << :inner } + + expect(trace).to eq(%i[a_before b_before inner b_after a_after]) + end + + it "passes the task to each middleware" do + seen = [] + middlewares.register(lambda do |t, &blk| + seen << t + blk.call + end) + + middlewares.process(task) { :ok } + expect(seen).to eq([task]) + end + + it "invokes the inner block through the chain" do + middlewares.register(->(_t, &blk) { blk.call }) + + called = false + middlewares.process(task) { called = true } + expect(called).to be(true) + end + + it "raises MiddlewareError when a middleware fails to yield" do + middlewares.register(->(_t, &_blk) { :no_yield }) + + expect { middlewares.process(task) { :inner } } + .to raise_error(CMDx::MiddlewareError, "middleware did not yield the next_link") + end + end + + describe "#size / #empty?" do + it "tracks registry state" do + expect(middlewares).to be_empty + expect(middlewares.size).to eq(0) + + middlewares.register(->(_t, &blk) { blk.call }) + + expect(middlewares).not_to be_empty + expect(middlewares.size).to eq(1) + end + end +end diff --git a/spec/cmdx/output_spec.rb b/spec/cmdx/output_spec.rb new file mode 100644 index 000000000..e8f4f13ed --- /dev/null +++ b/spec/cmdx/output_spec.rb @@ -0,0 +1,305 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe CMDx::Output do + describe "#initialize" do + it "coerces the name to a symbol" do + expect(described_class.new("user").name).to eq(:user) + end + + it "freezes the options hash" do + output = described_class.new(:user, required: true) + expect(output.instance_variable_get(:@options)).to be_frozen + end + end + + describe "#description" do + it "prefers :description over :desc" do + output = described_class.new(:user, description: "full", desc: "short") + expect(output.description).to eq("full") + end + + it "falls back to :desc" do + expect(described_class.new(:user, desc: "fallback").description).to eq("fallback") + end + + it "is nil when neither is set" do + expect(described_class.new(:user).description).to be_nil + end + end + + describe "#condition_if / #condition_unless" do + it "returns the guard options" do + output = described_class.new(:user, if: :active?, unless: :inactive?) + expect(output.condition_if).to eq(:active?) + expect(output.condition_unless).to eq(:inactive?) + end + end + + describe "#required" do + it "defaults to false" do + expect(described_class.new(:user).required).to be(false) + end + + it "reflects the :required option" do + expect(described_class.new(:user, required: true).required).to be(true) + end + end + + describe "simple option accessors" do + it "exposes :default and :transform" do + output = described_class.new(:level, default: 7, transform: :upcase) + expect(output).to have_attributes(default: 7, transform: :upcase) + end + + it "returns nil when unset" do + output = described_class.new(:level) + expect(output).to have_attributes(default: nil, transform: nil) + end + end + + describe "#required?" do + it "is false when not required" do + expect(described_class.new(:user).required?).to be(false) + end + + it "is true when required and no task is given" do + expect(described_class.new(:user, required: true).required?).to be(true) + end + + context "with a task" do + let(:task_class) do + Class.new do + def active? = true + def inactive? = false + end + end + let(:task) { task_class.new } + + it "is false when the if-guard is false" do + output = described_class.new(:user, required: true, if: :inactive?) + expect(output.required?(task)).to be(false) + end + + it "is false when the unless-guard is true" do + output = described_class.new(:user, required: true, unless: :active?) + expect(output.required?(task)).to be(false) + end + + it "is true when guards pass" do + output = described_class.new(:user, required: true, if: :active?) + expect(output.required?(task)).to be(true) + end + end + end + + describe "#to_h" do + it "returns name, description, required, and the raw options" do + output = described_class.new(:user, description: "d", required: true, type: :string) + + expect(output.to_h).to eq( + name: :user, + description: "d", + required: true, + options: { description: "d", required: true, type: :string } + ) + end + end + + describe "#verify" do + let(:task) do + create_task_class(name: "OutVerifyTask") do + output :user, required: true + end.new + end + + it "adds a missing error when required and the context key is absent" do + task.class.outputs.registry[:user].verify(task) + + expect(task.errors[:user]).to include(CMDx::I18nProxy.t("cmdx.outputs.missing")) + end + + it "coerces and stores the value when the key is present" do + task_class = create_task_class(name: "CoerceTask") do + output :count, coerce: :integer + end + task = task_class.new + task.context[:count] = "42" + + task_class.outputs.registry[:count].verify(task) + + expect(task.context[:count]).to eq(42) + end + + it "does not modify context when the key is absent and not required" do + task_class = create_task_class(name: "OptionalTask") do + output :note + end + task = task_class.new + + task_class.outputs.registry[:note].verify(task) + + expect(task.context).to be_empty + expect(task.errors).to be_empty + end + + context "with :default" do + it "applies a literal default when the key is absent" do + task_class = create_task_class(name: "OutDefaultLiteral") do + output :version, default: "v2" + end + task = task_class.new + + task_class.outputs.registry[:version].verify(task) + + expect(task.context[:version]).to eq("v2") + end + + it "applies a Symbol default by sending to the task" do + task_class = create_task_class(name: "OutDefaultSym") do + output :version, default: :default_version + define_method(:default_version) { "v3" } + end + task = task_class.new + + task_class.outputs.registry[:version].verify(task) + + expect(task.context[:version]).to eq("v3") + end + + it "applies a Proc default via instance_exec" do + task_class = create_task_class(name: "OutDefaultProc") do + output :version, default: proc { "v#{algo_version}" } + define_method(:algo_version) { 4 } + end + task = task_class.new + + task_class.outputs.registry[:version].verify(task) + + expect(task.context[:version]).to eq("v4") + end + + it "applies a callable default with the task" do + callable = Class.new do + def call(task) + "v-#{task.object_id}" + end + end.new + task_class = create_task_class(name: "OutDefaultCallable") do + output :version, default: callable + end + task = task_class.new + + task_class.outputs.registry[:version].verify(task) + + expect(task.context[:version]).to eq("v-#{task.object_id}") + end + + it "applies default when the task wrote nil" do + task_class = create_task_class(name: "OutDefaultNilWrite") do + output :version, default: "v2" + end + task = task_class.new + task.context[:version] = nil + + task_class.outputs.registry[:version].verify(task) + + expect(task.context[:version]).to eq("v2") + end + + it "satisfies :required when the default produces a value" do + task_class = create_task_class(name: "OutDefaultRequired") do + output :version, required: true, default: "v2" + end + task = task_class.new + + task_class.outputs.registry[:version].verify(task) + + expect(task.errors).to be_empty + expect(task.context[:version]).to eq("v2") + end + + it "flows through coercion and validation" do + task_class = create_task_class(name: "OutDefaultCoerced") do + output :retention_days, default: "7", coerce: :integer + end + task = task_class.new + + task_class.outputs.registry[:retention_days].verify(task) + + expect(task.context[:retention_days]).to eq(7) + end + end + + context "with :transform" do + it "applies a Symbol transform on the value" do + task_class = create_task_class(name: "OutTransformSym") do + output :email, transform: :downcase + end + task = task_class.new + task.context[:email] = "Alice@Example.COM" + + task_class.outputs.registry[:email].verify(task) + + expect(task.context[:email]).to eq("alice@example.com") + end + + it "falls back to the task when value doesn't respond" do + task_class = create_task_class(name: "OutTransformTaskMethod") do + output :name, transform: :shout + define_method(:shout) { |v| "#{v}!!!" } + end + task = task_class.new + task.context[:name] = 42 + + task_class.outputs.registry[:name].verify(task) + + expect(task.context[:name]).to eq("42!!!") + end + + it "applies a Proc transform via instance_exec" do + task_class = create_task_class(name: "OutTransformProc") do + output :tags, transform: proc { |v| v.uniq.sort } + end + task = task_class.new + task.context[:tags] = %w[b a a c] + + task_class.outputs.registry[:tags].verify(task) + + expect(task.context[:tags]).to eq(%w[a b c]) + end + + it "applies a callable transform with (value, task)" do + callable = Class.new do + def call(value, _task) + value.to_s.upcase + end + end.new + task_class = create_task_class(name: "OutTransformCallable") do + output :code, transform: callable + end + task = task_class.new + task.context[:code] = "abc" + + task_class.outputs.registry[:code].verify(task) + + expect(task.context[:code]).to eq("ABC") + end + + it "runs after coerce and before validation" do + task_class = create_task_class(name: "OutTransformPipeline") do + output :days, coerce: :integer, transform: proc { |v| v.clamp(1, 5) }, + numeric: { min: 1, max: 5 } + end + task = task_class.new + task.context[:days] = "42" + + task_class.outputs.registry[:days].verify(task) + + expect(task.context[:days]).to eq(5) + expect(task.errors).to be_empty + end + end + end +end diff --git a/spec/cmdx/outputs_spec.rb b/spec/cmdx/outputs_spec.rb new file mode 100644 index 000000000..ccb009e83 --- /dev/null +++ b/spec/cmdx/outputs_spec.rb @@ -0,0 +1,88 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe CMDx::Outputs do + subject(:outputs) { described_class.new } + + describe "#initialize" do + it "starts with an empty registry" do + expect(outputs).to be_empty + expect(outputs.registry).to eq({}) + end + end + + describe "#initialize_copy" do + it "dups the registry" do + outputs.register(:user) + copy = outputs.dup + + copy.register(:extra) + + expect(outputs.size).to eq(1) + expect(copy.size).to eq(2) + end + end + + describe "#register" do + it "adds each key as an Output entry and returns self" do + expect(outputs.register(:user, :token, required: true)).to be(outputs) + + expect(outputs.size).to eq(2) + expect(outputs.registry[:user]).to be_a(CMDx::Output) + expect(outputs.registry[:user].required).to be(true) + end + + it "overwrites an existing entry with the same name" do + outputs.register(:user, required: false) + outputs.register(:user, required: true) + + expect(outputs.registry[:user].required).to be(true) + expect(outputs.size).to eq(1) + end + end + + describe "#deregister" do + it "removes the given keys and returns self" do + outputs.register(:user, :token) + expect(outputs.deregister(:user)).to be(outputs) + + expect(outputs.registry).to have_key(:token) + expect(outputs.registry).not_to have_key(:user) + end + + it "accepts string keys" do + outputs.register(:user) + outputs.deregister("user") + expect(outputs).to be_empty + end + + it "is a no-op for unknown keys" do + expect { outputs.deregister(:missing) }.not_to raise_error + end + end + + describe "#empty? / #size" do + it "track registry state" do + expect(outputs).to be_empty + + outputs.register(:a, :b) + expect(outputs).not_to be_empty + expect(outputs.size).to eq(2) + end + end + + describe "#verify" do + it "invokes verify on each registered Output" do + task_class = create_task_class(name: "VerifyAllTask") do + output :user, required: true + output :token, required: true + end + task = task_class.new + + task_class.outputs.verify(task) + + expect(task.errors.keys).to contain_exactly(:user, :token) + end + end +end diff --git a/spec/cmdx/parallelizer_spec.rb b/spec/cmdx/parallelizer_spec.rb deleted file mode 100644 index 7c4799e64..000000000 --- a/spec/cmdx/parallelizer_spec.rb +++ /dev/null @@ -1,65 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe CMDx::Parallelizer, type: :unit do - describe ".call" do - it "processes all items and returns results in order" do - results = described_class.call([3, 1, 2]) { |n| n * 10 } - - expect(results).to eq([30, 10, 20]) - end - - it "runs items concurrently across threads" do - mutex = Mutex.new - thread_ids = [] - - described_class.call(%w[a b c]) do |_item| - mutex.synchronize { thread_ids << Thread.current.object_id } - end - - expect(thread_ids.size).to eq(3) - end - - context "with pool_size smaller than item count" do - it "limits the number of concurrent threads" do - mutex = Mutex.new - thread_ids = [] - - described_class.call([1, 2, 3, 4], pool_size: 2) do |item| - mutex.synchronize { thread_ids << Thread.current.object_id } - item - end - - expect(thread_ids.uniq.size).to be <= 2 - end - - it "still processes all items" do - results = described_class.call([1, 2, 3, 4], pool_size: 2) { |n| n + 1 } - - expect(results).to eq([2, 3, 4, 5]) - end - end - - context "with an empty items array" do - it "returns an empty array" do - results = described_class.call([]) { |n| n } - - expect(results).to eq([]) - end - end - end - - describe "#call" do - it "yields each item to the block" do - yielded = [] - mutex = Mutex.new - - described_class.new(%w[x y z]).call do |item| - mutex.synchronize { yielded << item } - end - - expect(yielded).to match_array(%w[x y z]) - end - end -end diff --git a/spec/cmdx/pipeline_spec.rb b/spec/cmdx/pipeline_spec.rb index 15e5c8e87..c2492a90a 100644 --- a/spec/cmdx/pipeline_spec.rb +++ b/spec/cmdx/pipeline_spec.rb @@ -2,387 +2,135 @@ require "spec_helper" -RSpec.describe CMDx::Pipeline, type: :unit do - let(:pipeline) { described_class.new(workflow) } - let(:workflow_class) { class_double("WorkflowClass") } - let(:workflow) { instance_double("WorkflowInstance", class: workflow_class) } +RSpec.describe CMDx::Pipeline do + after { CMDx::Chain.clear } describe ".execute" do - subject(:execute) { described_class.execute(workflow) } - - it "creates a new instance and executes it" do - expect(described_class).to receive(:new).with(workflow).and_return(pipeline) + it "delegates to a new Pipeline instance" do + workflow_instance = create_workflow_class.new + pipeline = instance_double(described_class) + expect(described_class).to receive(:new).with(workflow_instance).and_return(pipeline) expect(pipeline).to receive(:execute) - execute - end - end - describe "#initialize" do - it "sets the workflow" do - expect(pipeline.workflow).to eq(workflow) + described_class.execute(workflow_instance) end end describe "#execute" do - let(:execution_group) { instance_double("ExecutionGroup") } - let(:group_options) { {} } - let(:breakpoints) { [] } - - before do - allow(execution_group).to receive_messages(options: group_options, tasks: []) - allow(CMDx::Utils::Condition).to receive(:evaluate).and_return(true) - allow(workflow_class).to receive_messages(pipeline: [execution_group], settings: mock_settings(workflow_breakpoints: [])) - end - - it "iterates through workflow pipeline groups" do - expect(workflow_class).to receive(:pipeline).and_return([execution_group]) - pipeline.execute - end - - context "when condition evaluates to true" do - it "executes the group tasks" do - expect(pipeline).to receive(:execute_group_tasks).with(execution_group, []) - pipeline.execute - end - - context "with breakpoints in group options" do - let(:group_options) { { breakpoints: %w[step1 step2] } } - - it "uses group breakpoints" do - expect(pipeline).to receive(:execute_group_tasks).with(execution_group, %w[step1 step2]) - pipeline.execute - end - end + context "with an empty pipeline" do + it "is a no-op" do + workflow_class = create_workflow_class + result = workflow_class.execute - context "with breakpoints in workflow settings" do - before do - allow(workflow_class).to receive(:settings).and_return(mock_settings(breakpoints: ["workflow_step"])) - end - - it "uses workflow breakpoints" do - expect(pipeline).to receive(:execute_group_tasks).with(execution_group, ["workflow_step"]) - pipeline.execute - end - end - - context "with breakpoints in workflow_breakpoints setting" do - before do - allow(workflow_class).to receive(:settings).and_return(mock_settings(workflow_breakpoints: ["wf_step"])) - end - - it "uses workflow_breakpoints" do - expect(pipeline).to receive(:execute_group_tasks).with(execution_group, ["wf_step"]) - pipeline.execute - end - end - - context "with multiple breakpoint sources" do - let(:group_options) { { breakpoints: ["group_step"] } } - - before do - allow(workflow_class).to receive(:settings).and_return( - mock_settings(breakpoints: ["workflow_step"], workflow_breakpoints: ["wf_step"]) - ) - end - - it "prioritizes group breakpoints" do - expect(pipeline).to receive(:execute_group_tasks).with(execution_group, ["group_step"]) - pipeline.execute - end - end - - context "with string and symbol breakpoints" do - let(:group_options) { { breakpoints: ["step1", :step2, "step3"] } } - - it "converts all breakpoints to strings and removes duplicates" do - expect(pipeline).to receive(:execute_group_tasks).with(execution_group, %w[step1 step2 step3]) - pipeline.execute - end + expect(result).to be_success + expect(result.chain.size).to eq(1) end end - context "when condition evaluates to false" do - before do - allow(CMDx::Utils::Condition).to receive(:evaluate).and_return(false) - end + context "when a group has no tasks" do + it "fails the workflow with the error surfaced via the fault" do + workflow_class = create_workflow_class + workflow_class.pipeline << CMDx::Workflow::ExecutionGroup.new(tasks: [], options: {}) - it "skips the group tasks" do - expect(pipeline).not_to receive(:execute_group_tasks) - pipeline.execute + expect { workflow_class.execute! }.to raise_error(ArgumentError, "no tasks in group") end end - context "with multiple execution groups" do - let(:execution_group2) { instance_double("ExecutionGroup2") } - let(:group_options2) { {} } - - before do - allow(workflow_class).to receive(:pipeline).and_return([execution_group, execution_group2]) - allow(execution_group2).to receive_messages(options: group_options2, tasks: []) - allow(CMDx::Utils::Condition).to receive(:evaluate).with(workflow, group_options, workflow).and_return(true) - allow(CMDx::Utils::Condition).to receive(:evaluate).with(workflow, group_options2, workflow).and_return(true) - end - - it "processes each group independently" do - expect(pipeline).to receive(:execute_group_tasks).with(execution_group, []) - expect(pipeline).to receive(:execute_group_tasks).with(execution_group2, []) - pipeline.execute - end - end - end - - describe "#execute_group_tasks" do - let(:execution_group) { instance_double("ExecutionGroup") } - let(:breakpoints) { [] } - - context "when strategy is nil" do - before do - allow(execution_group).to receive(:options).and_return({}) - end - - it "calls execute_tasks_in_sequence" do - expect(pipeline).to receive(:execute_tasks_in_sequence).with(execution_group, breakpoints) - pipeline.send(:execute_group_tasks, execution_group, breakpoints) - end - end - - context "when strategy is sequential" do - before do - allow(execution_group).to receive(:options).and_return({ strategy: :sequential }) - end - - it "calls execute_tasks_in_sequence" do - expect(pipeline).to receive(:execute_tasks_in_sequence).with(execution_group, breakpoints) - pipeline.send(:execute_group_tasks, execution_group, breakpoints) - end - end - - context "when strategy is parallel" do - before do - allow(execution_group).to receive(:options).and_return({ strategy: :parallel }) - end - - it "calls execute_tasks_in_parallel" do - expect(pipeline).to receive(:execute_tasks_in_parallel).with(execution_group, breakpoints) - pipeline.send(:execute_group_tasks, execution_group, breakpoints) - end - end - - context "when strategy is unknown" do - before do - allow(execution_group).to receive(:options).and_return({ strategy: :unknown }) - end - - it "raises an error" do - expect { pipeline.send(:execute_group_tasks, execution_group, breakpoints) } - .to raise_error("unknown execution strategy :unknown") - end - end - end - - describe "#execute_tasks_in_sequence" do - let(:execution_group) { instance_double("ExecutionGroup") } - let(:tasks) { [task1, task2, task3] } - let(:task1) { instance_double("Task1") } - let(:task2) { instance_double("Task2") } - let(:task3) { instance_double("Task3") } - let(:breakpoints) { [] } - let(:context) { instance_double("Context") } - let(:result1) { instance_double("Result1") } - let(:result2) { instance_double("Result2") } - let(:result3) { instance_double("Result3") } - - before do - allow(workflow).to receive(:context).and_return(context) - allow(execution_group).to receive(:tasks).and_return(tasks) - allow(task1).to receive(:execute).with(context).and_return(result1) - allow(task2).to receive(:execute).with(context).and_return(result2) - allow(task3).to receive(:execute).with(context).and_return(result3) - allow(result1).to receive(:status).and_return("success") - allow(result2).to receive(:status).and_return("success") - allow(result3).to receive(:status).and_return("success") - allow(workflow).to receive(:throw!) - end - - it "executes all tasks in sequence" do - expect(task1).to receive(:execute).with(context).and_return(result1) - expect(task2).to receive(:execute).with(context).and_return(result2) - expect(task3).to receive(:execute).with(context).and_return(result3) - pipeline.send(:execute_tasks_in_sequence, execution_group, breakpoints) - end - - context "when breakpoint is triggered on first task" do - let(:breakpoints) { ["success"] } - - before do - allow(result1).to receive(:status).and_return("success") - end + context "with an invalid strategy" do + it "propagates ArgumentError via execute!" do + task = create_successful_task + workflow_class = create_workflow_class do + tasks task, strategy: :bogus + end - it "throws on first task and continues executing remaining tasks" do - expect(workflow).to receive(:throw!).with(result1) - expect(task1).to receive(:execute).with(context).and_return(result1) - expect(task2).to receive(:execute).with(context).and_return(result2) - expect(task3).to receive(:execute).with(context).and_return(result3) - pipeline.send(:execute_tasks_in_sequence, execution_group, breakpoints) + expect { workflow_class.execute! }.to raise_error(ArgumentError, /invalid strategy: :bogus/) end end - context "when breakpoint is triggered on second task" do - let(:breakpoints) { ["success"] } - - before do - allow(result1).to receive(:status).and_return("failure") - allow(result2).to receive(:status).and_return("success") - end + context "with :if guard" do + it "skips the group when the guard is false" do + task = create_failing_task(reason: "should not run") + workflow_class = create_workflow_class do + tasks task, if: proc { false } + end - it "executes first task, then throws on second and continues with remaining tasks" do - expect(workflow).to receive(:throw!).with(result2) - expect(task1).to receive(:execute).with(context).and_return(result1) - expect(task2).to receive(:execute).with(context).and_return(result2) - expect(task3).to receive(:execute).with(context).and_return(result3) - pipeline.send(:execute_tasks_in_sequence, execution_group, breakpoints) + expect(workflow_class.execute).to be_success end - end - context "with string breakpoints" do - let(:breakpoints) { ["halt"] } - - before do - allow(result1).to receive(:status).and_return("halt") - end + it "runs the group when the guard is true" do + task = create_successful_task + workflow_class = create_workflow_class do + tasks task, if: proc { true } + end - it "matches string breakpoints correctly" do - expect(workflow).to receive(:throw!).with(result1) - expect(task1).to receive(:execute).with(context).and_return(result1) - pipeline.send(:execute_tasks_in_sequence, execution_group, breakpoints) + expect(workflow_class.execute).to be_success end end - context "with symbol breakpoints" do - let(:breakpoints) { ["halt"] } - - before do - allow(result1).to receive(:status).and_return("halt") - end + context "with :unless guard" do + it "skips the group when the guard is true" do + task = create_failing_task(reason: "should not run") + workflow_class = create_workflow_class do + tasks task, unless: proc { true } + end - it "matches symbol breakpoints correctly" do - expect(workflow).to receive(:throw!).with(result1) - expect(task1).to receive(:execute).with(context).and_return(result1) - pipeline.send(:execute_tasks_in_sequence, execution_group, breakpoints) + expect(workflow_class.execute).to be_success end end - context "with mixed breakpoint types" do - let(:breakpoints) { %w[success failure] } + describe "sequential strategy" do + it "runs each task in order" do + task1 = create_successful_task(name: "T1") + task2 = create_successful_task(name: "T2") + workflow_class = create_workflow_class do + tasks task1, task2 + end - before do - allow(result1).to receive(:status).and_return("success") + result = workflow_class.execute + expect(result.chain.map { |r| r.task.name }).to match([/AnonymousWorkflow/, /T1/, /T2/]) end - it "matches both string breakpoints" do - expect(workflow).to receive(:throw!).with(result1) - expect(task1).to receive(:execute).with(context).and_return(result1) - pipeline.send(:execute_tasks_in_sequence, execution_group, breakpoints) - end - end - end - - describe "#execute_tasks_in_parallel" do - let(:execution_group) { instance_double("ExecutionGroup") } - let(:tasks) { [task1, task2, task3] } - let(:task1) { instance_double("Task1") } - let(:task2) { instance_double("Task2") } - let(:task3) { instance_double("Task3") } - let(:breakpoints) { [] } - let(:context) { CMDx::Context.new(user_id: 1) } - let(:result1) { instance_double("Result1") } - let(:result2) { instance_double("Result2") } - let(:result3) { instance_double("Result3") } - let(:chain) { instance_double("Chain") } - let(:result_hash) { { status: "failed" } } - - before do - allow(workflow).to receive_messages(context: context, chain: chain) - allow(execution_group).to receive_messages(tasks: tasks, options: {}) - allow(task1).to receive(:execute).and_return(result1) - allow(task2).to receive(:execute).and_return(result2) - allow(task3).to receive(:execute).and_return(result3) - allow(result1).to receive_messages(status: "success", to_h: result_hash) - allow(result2).to receive_messages(status: "success", to_h: result_hash) - allow(result3).to receive_messages(status: "success", to_h: result_hash) - allow(CMDx::Chain).to receive(:current=) - end + it "halts the group when a task fails" do + task1 = create_failing_task(reason: "stop") + task2 = create_successful_task(name: "NeverRun") + workflow_class = create_workflow_class do + tasks task1, task2 + end - it "creates context snapshots for each task" do - expect(task1).to receive(:execute) do |ctx| - expect(ctx).to be_a(CMDx::Context) - expect(ctx.to_h).to eq(user_id: 1) - expect(ctx).not_to equal(context) - result1 + result = workflow_class.execute + expect(result).to be_failed + task_names = result.chain.map { |r| r.task.name } + expect(task_names.any? { |n| n.include?("NeverRun") }).to be(false) end - pipeline.send(:execute_tasks_in_parallel, execution_group, breakpoints) end - it "executes all tasks via Parallelizer" do - expect(task1).to receive(:execute).and_return(result1) - expect(task2).to receive(:execute).and_return(result2) - expect(task3).to receive(:execute).and_return(result3) - pipeline.send(:execute_tasks_in_parallel, execution_group, breakpoints) - end + describe "parallel strategy" do + it "runs every task regardless of failure" do + task1 = create_failing_task(name: "Failing1", reason: "f1") + task2 = create_successful_task(name: "Succ2") + task3 = create_successful_task(name: "Succ3") - it "merges context snapshots back into workflow context" do - expect(workflow.context).to receive(:merge!).exactly(3).times - pipeline.send(:execute_tasks_in_parallel, execution_group, breakpoints) - end - - it "sets Chain.current for each thread" do - expect(CMDx::Chain).to receive(:current=).with(chain).at_least(3).times - pipeline.send(:execute_tasks_in_parallel, execution_group, breakpoints) - end - - context "when breakpoint is triggered" do - let(:breakpoints) { ["failed"] } - - before do - allow(result2).to receive(:status).and_return("failed") - allow(workflow).to receive(:failed!) - end - - it "calls the faulted status method on the workflow" do - expect(workflow).to receive(:failed!).with( - CMDx::Locale.t("cmdx.reasons.unspecified"), - source: :parallel, - faults: [result_hash] - ) - pipeline.send(:execute_tasks_in_parallel, execution_group, breakpoints) - end - end + workflow_class = create_workflow_class do + tasks task1, task2, task3, strategy: :parallel + end - context "when multiple breakpoints are triggered" do - let(:breakpoints) { %w[failed skipped] } + result = workflow_class.execute - before do - allow(result1).to receive(:status).and_return("skipped") - allow(result3).to receive(:status).and_return("failed") - allow(workflow).to receive(:failed!) + expect(result).to be_failed + task_names = result.chain.map { |r| r.task.name } + expect(task_names.count { |n| n.include?("Succ2") }).to eq(1) + expect(task_names.count { |n| n.include?("Succ3") }).to eq(1) end - it "uses the last faulted result status and includes all faulted results" do - expect(workflow).to receive(:failed!).with( - CMDx::Locale.t("cmdx.reasons.unspecified"), - source: :parallel, - faults: [result_hash, result_hash] - ) - pipeline.send(:execute_tasks_in_parallel, execution_group, breakpoints) - end - end + it "respects :pool_size" do + task1 = create_successful_task(name: "Par1") + task2 = create_successful_task(name: "Par2") - context "when no breakpoints are triggered" do - let(:breakpoints) { ["failed"] } + workflow_class = create_workflow_class do + tasks task1, task2, strategy: :parallel, pool_size: 1 + end - it "does not call any fault method on the workflow" do - expect(workflow).not_to receive(:failed!) - pipeline.send(:execute_tasks_in_parallel, execution_group, breakpoints) + expect(workflow_class.execute).to be_success end end end diff --git a/spec/cmdx/resolver_spec.rb b/spec/cmdx/resolver_spec.rb deleted file mode 100644 index bcaf7bdc1..000000000 --- a/spec/cmdx/resolver_spec.rb +++ /dev/null @@ -1,316 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe CMDx::Resolver, type: :unit do - let(:task_class) { create_successful_task(name: "TestTask") } - let(:task) { task_class.new } - let(:result) { task.result } - let(:resolver) { task.resolver } - - describe "#initialize" do - context "with valid result" do - it "initializes with correct defaults" do - expect(resolver.result).to eq(result) - expect(result.strict?).to be(true) - end - end - - context "with invalid result" do - it "raises TypeError when result is not a CMDx::Result" do - expect { described_class.new("not a result") }.to raise_error(TypeError, "must be a CMDx::Result") - end - end - end - - describe "#success!" do - context "when successful" do - it "sets the reason" do - catch(:cmdx_halt) { resolver.success!("Created 42 records") } - - expect(result.status).to eq(CMDx::Result::SUCCESS) - expect(result.reason).to eq("Created 42 records") - expect(result.metadata).to eq({}) - end - - it "accepts metadata" do - catch(:cmdx_halt) { resolver.success!("Imported", rows: 100) } - - expect(result.reason).to eq("Imported") - expect(result.metadata).to eq({ rows: 100 }) - end - - it "allows nil reason" do - catch(:cmdx_halt) { resolver.success! } - - expect(result.reason).to be_nil - end - - it "does not change state or status" do - original_state = result.state - original_status = result.status - catch(:cmdx_halt) { resolver.success!("note") } - - expect(result.state).to eq(original_state) - expect(result.status).to eq(original_status) - end - - it "throws :cmdx_halt by default" do - expect { resolver.success!("done") }.to throw_symbol(:cmdx_halt) - end - - it "does not throw when halt is false" do - expect { resolver.success!("done", halt: false) }.not_to throw_symbol(:cmdx_halt) - end - end - - context "when not successful" do - it "raises error when skipped" do - resolver.skip!("test", halt: false) - - expect { resolver.success!("reason") }.to raise_error(/can only be used while success/) - end - - it "raises error when failed" do - resolver.fail!("test", halt: false) - - expect { resolver.success!("reason") }.to raise_error(/can only be used while success/) - end - end - end - - describe "#skip!" do - context "when successful" do - it "transitions to skipped status" do - resolver.skip!("test reason", halt: false) - - expect(result.status).to eq(CMDx::Result::SKIPPED) - expect(result.state).to eq(CMDx::Result::INTERRUPTED) - expect(result.reason).to eq("test reason") - expect(result.cause).to be_nil - expect(result.metadata).to eq({}) - end - - it "accepts metadata" do - resolver.skip!("test reason", halt: false, foo: "bar") - - expect(result.metadata).to eq({ foo: "bar" }) - end - - it "accepts cause" do - cause = StandardError.new("cause") - resolver.skip!("test reason", halt: false, cause: cause) - - expect(result.cause).to eq(cause) - end - - it "uses default reason when none provided" do - allow(CMDx::Locale).to receive(:t).with("cmdx.reasons.unspecified").and_return("Unspecified") - - resolver.skip!(halt: false) - - expect(result.reason).to eq("Unspecified") - end - - it "calls halt! by default" do - expect { resolver.skip!("test reason") }.to raise_error(CMDx::SkipFault) - end - - it "does not call halt! when halt: false" do - expect { resolver.skip!("test reason", halt: false) }.not_to raise_error - end - end - - context "when already skipped" do - it "returns early without changes" do - resolver.skip!("first reason", halt: false) - original_reason = result.reason - resolver.skip!("second reason", halt: false) - - expect(result.reason).to eq(original_reason) - end - end - - context "when not successful" do - it "raises error when trying to skip from failed" do - resolver.fail!("test reason", halt: false) - - expect { resolver.skip!("another reason", halt: false) }.to raise_error(/can only transition to skipped from success/) - end - end - end - - describe "#fail!" do - context "when successful" do - it "transitions to failed status" do - resolver.fail!("test reason", halt: false) - - expect(result.status).to eq(CMDx::Result::FAILED) - expect(result.state).to eq(CMDx::Result::INTERRUPTED) - expect(result.reason).to eq("test reason") - expect(result.cause).to be_nil - expect(result.metadata).to eq({}) - end - - it "accepts metadata" do - resolver.fail!("test reason", halt: false, foo: "bar") - - expect(result.metadata).to eq({ foo: "bar" }) - end - - it "accepts cause" do - cause = StandardError.new("cause") - resolver.fail!("test reason", halt: false, cause: cause) - - expect(result.cause).to eq(cause) - end - - it "uses default reason when none provided" do - allow(CMDx::Locale).to receive(:t).with("cmdx.reasons.unspecified").and_return("Unspecified") - - resolver.fail!(halt: false) - - expect(result.reason).to eq("Unspecified") - end - - it "calls halt! by default" do - expect { resolver.fail!("test reason") }.to raise_error(CMDx::FailFault) - end - - it "does not call halt! when halt: false" do - expect { resolver.fail!("test reason", halt: false) }.not_to raise_error - end - end - - context "when already failed" do - it "returns early without changes" do - resolver.fail!("first reason", halt: false) - original_reason = result.reason - resolver.fail!("second reason", halt: false) - - expect(result.reason).to eq(original_reason) - end - end - - context "when not successful" do - it "raises error when trying to fail from skipped" do - resolver.skip!("test reason", halt: false) - - expect { resolver.fail!("another reason", halt: false) }.to raise_error(/can only transition to failed from success/) - end - end - end - - describe "#halt!" do - context "when successful" do - it "returns early without raising" do - expect { resolver.halt! }.not_to raise_error - end - end - - context "when skipped" do - it "raises SkipFault" do - resolver.skip!("test reason", halt: false) - - expect { resolver.halt! }.to raise_error(CMDx::SkipFault) do |fault| - expect(fault.result).to eq(result) - expect(fault.message).to eq("test reason") - end - end - - it "sets proper backtrace" do - resolver.skip!("test reason", halt: false) - - begin - resolver.halt! - rescue CMDx::SkipFault => e - expect(e.backtrace).to be_an(Array) - expect(e.backtrace).not_to be_empty - end - end - end - - context "when failed" do - it "raises FailFault" do - resolver.fail!("test reason", halt: false) - - expect { resolver.halt! }.to raise_error(CMDx::FailFault) do |fault| - expect(fault.result).to eq(result) - expect(fault.message).to eq("test reason") - end - end - end - end - - describe "#throw!" do - let(:other_task) { create_failing_task.new } - let(:other_result) { other_task.result } - - before do - other_task.resolver.fail!("source failure", halt: false, foo: "bar") - end - - context "with valid result" do - it "copies state and status from other result" do - resolver.throw!(other_result, halt: false) - - expect(result.state).to eq(other_result.state) - expect(result.status).to eq(other_result.status) - expect(result.reason).to eq(other_result.reason) - end - - it "merges metadata" do - resolver.throw!(other_result, halt: false, baz: "qux") - - expect(result.metadata).to eq({ foo: "bar", baz: "qux" }) - end - - it "uses provided cause over other result's cause" do - custom_cause = StandardError.new("custom") - - resolver.throw!(other_result, halt: false, cause: custom_cause) - expect(result.cause).to eq(custom_cause) - end - - it "uses other result's cause when none provided" do - other_cause = StandardError.new("other") - other_result.instance_variable_set(:@cause, other_cause) - resolver.throw!(other_result, halt: false) - - expect(result.cause).to eq(other_cause) - end - - it "calls halt! by default" do - expect { resolver.throw!(other_result) }.to raise_error(CMDx::FailFault) - end - - it "does not call halt! when halt: false" do - expect { resolver.throw!(other_result, halt: false) }.not_to raise_error - end - end - - context "with invalid result" do - it "raises TypeError when not a CMDx::Result" do - expect { resolver.throw!("not a result", halt: false) }.to raise_error(TypeError, "must be a CMDx::Result") - end - end - end - - describe "#strict?" do - it "returns true by default" do - expect(result.strict?).to be(true) - end - - it "returns false when strict is false via fail!" do - resolver.fail!("test reason", halt: false, strict: false) - - expect(result.strict?).to be(false) - end - - it "returns false when strict is false via skip!" do - resolver.skip!("test reason", halt: false, strict: false) - - expect(result.strict?).to be(false) - end - end -end diff --git a/spec/cmdx/result_spec.rb b/spec/cmdx/result_spec.rb index e06438848..4130f60d7 100644 --- a/spec/cmdx/result_spec.rb +++ b/spec/cmdx/result_spec.rb @@ -2,849 +2,287 @@ require "spec_helper" -RSpec.describe CMDx::Result, type: :unit do - let(:task_class) { create_successful_task(name: "TestTask") } +RSpec.describe CMDx::Result do + let(:chain) { CMDx::Chain.new } + let(:task_class) { create_task_class(name: "SampleTask") } let(:task) { task_class.new } - let(:result) { task.result } - let(:resolver) { task.resolver } - describe "#initialize" do - context "with valid task" do - it "initializes with correct defaults" do - expect(result.task).to eq(task) - expect(result.state).to eq(CMDx::Result::INITIALIZED) - expect(result.status).to eq(CMDx::Result::SUCCESS) - expect(result.metadata).to eq({}) - expect(result.reason).to be_nil - expect(result.cause).to be_nil - end - - it "delegates context and chain to task" do - expect(result.context).to eq(task.context) - expect(result.chain).to eq(task.chain) - end - end - - context "with invalid task" do - it "raises TypeError when task is not a CMDx::Task" do - expect { described_class.new("not a task") }.to raise_error(TypeError, "must be a CMDx::Task") - end - end + def build(signal, **opts) + described_class.new(chain, task, signal, **opts) end - describe "state predicates" do - describe "#initialized?" do - it "returns true when state is initialized" do - expect(result.initialized?).to be(true) - end - - it "returns false when state is not initialized" do - resolver.executing! - - expect(result.initialized?).to be(false) - end - end - - describe "#executing?" do - it "returns false when state is initialized" do - expect(result.executing?).to be(false) - end - - it "returns true when state is executing" do - resolver.executing! - - expect(result.executing?).to be(true) - end + describe "#initialize" do + it "freezes the options hash" do + result = build(CMDx::Signal::Success, id: "abc") + expect(result.instance_variable_get(:@options)).to be_frozen end + end - describe "#complete?" do - it "returns false when state is not complete" do - expect(result.complete?).to be(false) - end - - it "returns true when state is complete" do - resolver.executing! - resolver.complete! + describe "simple delegators" do + let(:result) { build(CMDx::Signal::Success, id: "rid-1", duration: 0.01) } - expect(result.complete?).to be(true) - end + it "returns id, duration, task class, type" do + expect(result).to have_attributes( + id: "rid-1", + duration: 0.01, + task: task_class, + type: "Task" + ) end - describe "#interrupted?" do - it "returns false when state is not interrupted" do - expect(result.interrupted?).to be(false) - end - - it "returns true when state is interrupted" do - resolver.executing! - resolver.interrupt! - - expect(result.interrupted?).to be(true) - end + it "delegates context/errors to the task" do + expect(result.context).to be(task.context) + expect(result.ctx).to be(task.context) + expect(result.errors).to be(task.errors) end - end - describe "state transitions" do - describe "#executing!" do - context "when initialized" do - it "transitions to executing state" do - resolver.executing! - - expect(result.state).to eq(CMDx::Result::EXECUTING) - end - - it "returns early if already executing" do - resolver.executing! - initial_state = result.state - resolver.executing! - - expect(result.state).to eq(initial_state) - end - end - - context "when not initialized" do - it "raises error when trying to transition from complete" do - resolver.executing! - resolver.complete! - - expect { resolver.executing! }.to raise_error(/can only transition to executing from initialized/) - end - - it "raises error when trying to transition from interrupted" do - resolver.executing! - resolver.interrupt! - - expect { resolver.executing! }.to raise_error(/can only transition to executing from initialized/) - end - end + it "exposes the chain id" do + expect(result.chain.id).to eq(chain.id) end - describe "#complete!" do - context "when executing" do - before { resolver.executing! } - - it "transitions to complete state" do - resolver.complete! - - expect(result.state).to eq(CMDx::Result::COMPLETE) - end - - it "returns early if already complete" do - resolver.complete! - initial_state = result.state - resolver.complete! + it "reports the full chain results array" do + other = build(CMDx::Signal::Success) + chain << result + chain << other - expect(result.state).to eq(initial_state) - end - end - - context "when not executing" do - it "raises error when trying to transition from initialized" do - expect { resolver.complete! }.to raise_error(/can only transition to complete from executing/) - end - end + expect(result.chain.to_a).to eq([result, other]) + expect(other.chain.to_a).to eq([result, other]) end - describe "#interrupt!" do - context "when not complete" do - it "transitions to interrupted from initialized" do - resolver.interrupt! - - expect(result.state).to eq(CMDx::Result::INTERRUPTED) - end - - it "transitions to interrupted from executing" do - resolver.executing! - resolver.interrupt! - - expect(result.state).to eq(CMDx::Result::INTERRUPTED) - end - - it "returns early if already interrupted" do - resolver.interrupt! - initial_state = result.state - resolver.interrupt! - - expect(result.state).to eq(initial_state) - end - end - - context "when complete" do - it "raises error when trying to transition from complete" do - resolver.executing! - resolver.complete! - - expect { resolver.interrupt! }.to raise_error(/cannot transition to interrupted from complete/) - end - end + it "chain_index reports the index of self" do + chain << result + expect(result.chain_index).to eq(0) end end - describe "status predicates" do - describe "#success?" do - it "returns true by default" do - expect(result.success?).to be(true) - end - - it "returns false after skip!" do - resolver.skip!("test reason", halt: false) - - expect(result.success?).to be(false) - end - - it "returns false after fail!" do - resolver.fail!("test reason", halt: false) - - expect(result.success?).to be(false) - end + describe "signal delegators" do + it "exposes state/status predicates" do + result = build(CMDx::Signal::Success) + expect(result).to have_attributes( + state: "complete", status: "success", + complete?: true, interrupted?: false, + success?: true, skipped?: false, failed?: false, + ok?: true, ko?: false + ) end - describe "#skipped?" do - it "returns false by default" do - expect(result.skipped?).to be(false) - end - - it "returns true after skip!" do - resolver.skip!("test reason", halt: false) + it "exposes reason/metadata/cause" do + sig = CMDx::Signal.failed("boom", metadata: { k: 1 }, cause: StandardError.new("x")) + result = build(sig) - expect(result.skipped?).to be(true) - end + expect(result.reason).to eq("boom") + expect(result.metadata).to eq(k: 1) + expect(result.cause).to be_a(StandardError) end + end - describe "#failed?" do - it "returns false by default" do - expect(result.failed?).to be(false) - end - - it "returns true after fail!" do - resolver.fail!("test reason", halt: false) + describe "#on" do + let(:success) { build(CMDx::Signal::Success) } + let(:failed) { build(CMDx::Signal::Failed) } - expect(result.failed?).to be(true) - end + it "yields when any of the listed events match" do + yielded = [] + success.on(:success) { |r| yielded << r } + expect(yielded).to eq([success]) end - describe "#good?" do - it "returns true when not failed" do - expect(result.good?).to be(true) - end - - it "returns true when skipped" do - resolver.skip!("test reason", halt: false) - - expect(result.good?).to be(true) - end - - it "returns false when failed" do - resolver.fail!("test reason", halt: false) - - expect(result.good?).to be(false) - end + it "does not yield when no events match" do + yielded = [] + success.on(:failed, :ko) { |r| yielded << r } + expect(yielded).to be_empty end - describe "#bad?" do - it "returns false when successful" do - expect(result.bad?).to be(false) - end - - it "returns true when skipped" do - resolver.skip!("test reason", halt: false) - - expect(result.bad?).to be(true) - end - - it "returns true when failed" do - resolver.fail!("test reason", halt: false) - - expect(result.bad?).to be(true) - end + it "returns self" do + expect(failed.on(:failed) { |_| nil }).to be(failed) end - describe "#rolled_back?" do - it "returns false by default" do - expect(result.rolled_back?).to be(false) - end - - it "returns true when rolled back" do - result.rolled_back = true - - expect(result.rolled_back?).to be(true) - end + it "raises without a block" do + expect { success.on(:success) }.to raise_error(ArgumentError, "block required") end - describe "#retried?" do - it "returns false when retries is zero" do - expect(result.retried?).to be(false) - end - - it "returns true when retries is positive" do - result.retries = 1 - - expect(result.retried?).to be(true) - end + it "raises on an unknown event" do + expect { success.on(:bogus) { |_| nil } }.to raise_error(ArgumentError, /unknown event :bogus/) end end - describe "execution methods" do - describe "#executed!" do - context "when successful" do - it "calls complete!" do - resolver.executing! - resolver.executed! - - expect(result.complete?).to be(true) - end - end - - context "when not successful" do - it "calls interrupt! when skipped" do - resolver.skip!("test reason", halt: false) - resolver.executed! - - expect(result.interrupted?).to be(true) - end + describe "failure helpers" do + context "when the result is not failed" do + let(:result) { build(CMDx::Signal::Success) } - it "calls interrupt! when failed" do - resolver.fail!("test reason", halt: false) - resolver.executed! - - expect(result.interrupted?).to be(true) - end + it "caused_failure/threw_failure are nil and predicates are false" do + expect(result.caused_failure).to be_nil + expect(result.threw_failure).to be_nil + expect(result.caused_failure?).to be(false) + expect(result.thrown_failure?).to be(false) end end - describe "#executed?" do - it "returns false when not executed" do - expect(result.executed?).to be(false) - end - - it "returns true when complete" do - resolver.executing! - resolver.complete! + context "when the result is failed with no Fault cause" do + let(:result) { build(CMDx::Signal.failed("boom")) } - expect(result.executed?).to be(true) - end - - it "returns true when interrupted" do - resolver.interrupt! - - expect(result.executed?).to be(true) + it "self is both the caused and threw failure" do + expect(result.caused_failure).to be(result) + expect(result.threw_failure).to be(result) + expect(result.caused_failure?).to be(true) + expect(result.thrown_failure?).to be(false) end end - describe "#on(:executed)" do - it "raises ArgumentError without block" do - expect { result.on(:executed) }.to raise_error(ArgumentError, "block required") - end - - it "calls block when executed" do - resolver.interrupt! - called = false - result.on(:executed) { |_r| called = true } - - expect(called).to be(true) + context "when the result was echoed from an upstream failure" do + let(:original_failure) do + build(CMDx::Signal.failed("origin")).tap { |r| chain << r } end - - it "does not call block when not executed" do - called = false - result.on(:executed) { |_r| called = true } - - expect(called).to be(false) + let(:rethrown) do + sig = CMDx::Signal.echoed(original_failure, cause: CMDx::Fault.new(original_failure)) + build(sig).tap { |r| chain << r } end - it "returns self" do - expect(result.on(:executed) { "test" }).to eq(result) + before do + original_failure + rethrown end - end - end - - describe "failure tracking methods" do - let(:chain) { CMDx::Chain.new } - let(:first_task) { create_successful_task.new } - let(:second_task) { create_failing_task.new } - let(:third_task) { create_successful_task.new } - let(:first_result) { first_task.result } - let(:second_result) { second_task.result } - let(:third_result) { third_task.result } - before do - chain.results.push(first_result, second_result, third_result) - - [first_result, second_result, third_result].each do |r| - r.instance_variable_set(:@chain, chain) + it "exposes the upstream as origin" do + expect(rethrown.origin).to be(original_failure) end - second_task.resolver.fail!("test failure", halt: false) - end - - describe "#caused_failure" do - context "when failed" do - it "returns the first failed result in chain" do - expect(second_result.caused_failure).to eq(second_result) - end + it "threw_failure points at the immediate upstream failure" do + expect(rethrown.threw_failure).to be(original_failure) + expect(rethrown.thrown_failure?).to be(true) end - context "when not failed" do - it "returns nil" do - expect(first_result.caused_failure).to be_nil - end - end - end - - describe "#caused_failure?" do - it "returns true for the causing failure" do - expect(second_result.caused_failure?).to be(true) - end - - it "returns false for non-failing results" do - expect(first_result.caused_failure?).to be(false) - end - - it "returns false for non-failed results" do - expect(third_result.caused_failure?).to be(false) - end - end - - describe "#threw_failure" do - context "when failed" do - let(:fourth_task) { create_failing_task.new } - let(:fourth_result) { fourth_task.result } - - before do - chain.results << fourth_result - - fourth_result.instance_variable_set(:@chain, chain) - fourth_task.resolver.fail!("another failure", halt: false) - end - - it "returns the next failed result after current" do - expect(second_result.threw_failure).to eq(fourth_result) - end - - it "returns the last failed result when no failures after current" do - expect(fourth_result.threw_failure).to eq(fourth_result) - end - end - - context "when not failed" do - it "returns nil" do - expect(first_result.threw_failure).to be_nil - end - end - end - - describe "#threw_failure?" do - it "returns true when the result is the last failure" do - expect(second_result.threw_failure?).to be(true) - end - - it "returns false for non-failing results" do - expect(first_result.threw_failure?).to be(false) - end - end - - describe "#thrown_failure?" do - it "returns false when result caused the failure" do - expect(second_result.thrown_failure?).to be(false) - end - - it "returns false for non-failed results" do - expect(first_result.thrown_failure?).to be(false) + it "caused_failure walks back to the originator" do + expect(rethrown.caused_failure).to be(original_failure) + expect(rethrown.caused_failure?).to be(false) end end end - describe "#index" do - it "returns the cached chain index set during push" do - expect(result.index).to eq(0) - end - - it "falls back to chain.index when cache is not set" do - result.remove_instance_variable(:@chain_index) if result.instance_variable_defined?(:@chain_index) - allow(result.chain).to receive(:index).with(result).and_return(42) + describe "option-backed predicates" do + it "retries/retried?/strict?/deprecated?/rolled_back?" do + r1 = build(CMDx::Signal::Success, retries: 2) + r2 = build(CMDx::Signal::Success, strict: true, deprecated: true, rolled_back: true) + r3 = build(CMDx::Signal::Success) - expect(result.index).to eq(42) + expect(r1.retries).to eq(2) + expect(r1.retried?).to be(true) + expect(r3.retried?).to be(false) + expect(r2).to have_attributes(strict?: true, deprecated?: true, rolled_back?: true) + expect(r3).to have_attributes(strict?: false, deprecated?: false, rolled_back?: false) end end - describe "#outcome" do - context "when initialized" do - it "returns state" do - expect(result.outcome).to eq(result.state) - end - end - - context "when thrown failure" do - it "returns state" do - allow(result).to receive(:thrown_failure?).and_return(true) - - expect(result.outcome).to eq(result.state) - end - end - - context "when not initialized and not thrown failure" do - it "returns status" do - resolver.executing! - - expect(result.outcome).to eq(result.status) - end + describe "#tags" do + it "returns the task's settings tags" do + task_class.settings(tags: %w[billing]) + expect(build(CMDx::Signal::Success).tags).to eq(%w[billing]) end end describe "#to_h" do - it "includes basic task and result information" do + it "includes core fields for a success result" do + chain << (result = build(CMDx::Signal::Success, id: "rid", duration: 0.1)) hash = result.to_h - task_hash = task.to_h expect(hash).to include( - state: result.state, - status: result.status, - outcome: result.outcome, - metadata: result.metadata, - index: task_hash[:index], - chain_id: task_hash[:chain_id], - type: task_hash[:type], - tags: task_hash[:tags], - id: task_hash[:id], - class: start_with("TestTask") + chain_id: chain.id, + chain_index: 0, + chain_root: false, + type: "Task", + task: task_class, + id: "rid", + state: "complete", + status: "success", + duration: 0.1 ) + expect(hash).not_to have_key(:cause) end - context "when successful with reason" do - it "includes reason without cause or rolled_back" do - catch(:cmdx_halt) { resolver.success!("Created 42 records") } - - hash = result.to_h - - expect(hash[:reason]).to eq("Created 42 records") - expect(hash).not_to include(:cause, :rolled_back) - end - end - - context "when interrupted" do - it "includes reason, cause and rolled_back status" do - resolver.skip!("test reason", halt: false, cause: StandardError.new("test")) - - hash = result.to_h - - expect(hash).to include(:reason, :cause, :rolled_back) - expect(hash[:reason]).to eq("test reason") - expect(hash[:rolled_back]).to be(false) - end - end - - context "when failed" do - it "includes failure information" do - resolver.fail!("test failure", halt: false) - - # Create mock objects that avoid calling to_h to prevent infinite recursion - threw_failure_mock = instance_double(described_class, to_h: { index: 1, class: "Test", id: "123" }) - caused_failure_mock = instance_double(described_class, to_h: { index: 0, class: "Test", id: "456" }) - - allow(result).to receive_messages(threw_failure?: false, caused_failure?: false, threw_failure: threw_failure_mock, caused_failure: caused_failure_mock) - - hash = result.to_h + it "includes failure-specific fields for failed results" do + sig = CMDx::Signal.failed("boom", cause: StandardError.new("x")) + chain << (result = build(sig, rolled_back: true)) + hash = result.to_h - expect(hash).to include(:threw_failure, :caused_failure) - expect(hash[:threw_failure]).to eq({ index: 1, class: "Test", id: "123" }) - expect(hash[:caused_failure]).to eq({ index: 0, class: "Test", id: "456" }) - end + expect(hash[:cause]).to be_a(StandardError) + expect(hash).to have_key(:threw_failure) + expect(hash).to have_key(:caused_failure) + expect(hash[:rolled_back]).to be(true) end end describe "#to_s" do - it "formats hash using Utils::Format.to_str" do - expect(CMDx::Utils::Format).to receive(:to_str).and_return("formatted string") - - expect(result.to_s).to eq("formatted string") - end - - it "handles failure formatting in block" do - expect(CMDx::Utils::Format).to receive(:to_str).and_return("formatted string") - - result.to_s - end - end - - describe "#deconstruct" do - it "returns state and status as array" do - expect(result.deconstruct).to eq( - [ - result.state, - result.status, - result.reason, - result.cause, - result.metadata - ] - ) - end - - it "ignores arguments" do - expect(result.deconstruct(:anything, :here)).to eq( - [ - result.state, - result.status, - result.reason, - result.cause, - result.metadata - ] - ) - end - end - - describe "#deconstruct_keys" do - it "returns hash with key attributes" do - expected = { - state: result.state, - status: result.status, - reason: result.reason, - cause: result.cause, - metadata: result.metadata, - outcome: result.outcome, - executed: result.executed?, - good: result.good?, - bad: result.bad? - } - - expect(result.deconstruct_keys).to eq(expected) - end - - it "ignores arguments" do - expected = result.deconstruct_keys - - expect(result.deconstruct_keys(:anything)).to eq(expected) + it "renders a space-separated key=value summary" do + chain << (result = build(CMDx::Signal::Success, id: "rid")) + expect(result.to_s).to include("id=\"rid\"", "state=\"complete\"", "status=\"success\"") end end - describe "handle methods" do - describe "state handle methods" do - CMDx::Result::STATES.each do |state| - describe "#on(#{state})" do - it "raises ArgumentError without block" do - expect { result.on(state) }.to raise_error(ArgumentError, "block required") - end - - context "when in #{state} state" do - before do - case state - when CMDx::Result::INITIALIZED - # Already in initialized state - when CMDx::Result::EXECUTING - resolver.executing! - when CMDx::Result::COMPLETE - resolver.executing! - resolver.complete! - when CMDx::Result::INTERRUPTED - resolver.interrupt! - end - end - - it "calls the block" do - called = false - result.on(state) { |_r| called = true } - - expect(called).to be(true) - end - - it "passes result to block" do - block_result = nil - result.on(state) { |r| block_result = r } - - expect(block_result).to eq(result) - end - end - - context "when not in #{state} state" do - before do - case state - when CMDx::Result::INITIALIZED - resolver.executing! - when CMDx::Result::EXECUTING - # Stay in initialized state - when CMDx::Result::COMPLETE - # Stay in initialized state - when CMDx::Result::INTERRUPTED - # Stay in initialized state - end - end - - it "does not call the block" do - called = false - result.on(state) { |_r| called = true } - - expect(called).to be(false) - end - end - - it "returns self" do - expect(result.on(state) { "test" }).to eq(result) - end - end - end - end - - describe "status handle methods" do - CMDx::Result::STATUSES.each do |status| - describe "#on(#{status})" do - it "raises ArgumentError without block" do - expect { result.on(status) }.to raise_error(ArgumentError, "block required") - end - - context "when in #{status} status" do - before do - case status - when CMDx::Result::SUCCESS - # Already in success status - when CMDx::Result::SKIPPED - resolver.skip!("test", halt: false) - when CMDx::Result::FAILED - resolver.fail!("test", halt: false) - end - end - - it "calls the block" do - called = false - result.on(status) { |_r| called = true } - - expect(called).to be(true) - end - - it "passes result to block" do - block_result = nil - result.on(status) { |r| block_result = r } - - expect(block_result).to eq(result) - end - end - - context "when not in #{status} status" do - before do - case status - when CMDx::Result::SUCCESS - resolver.skip!("test", halt: false) - when CMDx::Result::SKIPPED - # Stay in success status - when CMDx::Result::FAILED - # Stay in success status - end - end - - it "does not call the block" do - called = false - result.on(status) { |_r| called = true } - - expect(called).to be(false) - end - end - - it "returns self" do - expect(result.on(status) { "test" }).to eq(result) - end - end - end - end - - describe "#on(:good)" do - it "raises ArgumentError without block" do - expect { result.on(:good) }.to raise_error(ArgumentError, "block required") - end + describe "pattern matching support" do + let(:result) { build(CMDx::Signal.failed("boom", metadata: { k: 1 })) } - context "when good" do - it "calls the block for success" do - called = false - result.on(:good) { |_r| called = true } - - expect(called).to be(true) - end - - it "calls the block for skipped" do - resolver.skip!("test", halt: false) - called = false - result.on(:good) { |_r| called = true } - - expect(called).to be(true) - end - end - - context "when not good" do - it "does not call the block for failed" do - resolver.fail!("test", halt: false) - called = false - result.on(:good) { |_r| called = true } - - expect(called).to be(false) - end - end - - it "returns self" do - expect(result.on(:good) { "test" }).to eq(result) - end - end - - describe "#on(:bad)" do - it "raises ArgumentError without block" do - expect { result.on(:bad) }.to raise_error(ArgumentError, "block required") - end - - context "when bad" do - it "calls the block for skipped" do - resolver.skip!("test", halt: false) - called = false - result.on(:bad) { |_r| called = true } - - expect(called).to be(true) - end - - it "calls the block for failed" do - resolver.fail!("test", halt: false) - called = false - result.on(:bad) { |_r| called = true } - - expect(called).to be(true) - end - end - - context "when not bad" do - it "does not call the block for success" do - called = false - result.on(:bad) { |_r| called = true } - - expect(called).to be(false) - end - end - - it "returns self" do - expect(result.on(:bad) { "test" }).to eq(result) + describe "#deconstruct" do + it "deconstructs to [type, task, state, status, reason, metadata, cause, origin]" do + expect(result.deconstruct).to eq( + ["Task", task_class, "interrupted", "failed", "boom", { k: 1 }, nil, nil] + ) end end - end - describe "constants" do - describe "STATES" do - it "defines all expected states" do - expect(CMDx::Result::STATES).to contain_exactly( - "initialized", - "executing", - "complete", - "interrupted" + describe "#deconstruct_keys" do + let(:result) do + build( + CMDx::Signal.failed("boom", metadata: { k: 1 }), + strict: true, + deprecated: true, + retries: 3, + rolled_back: true, + duration: 0.25 ) end - it "freezes the array" do - expect(CMDx::Result::STATES).to be_frozen - end - end + it "returns the full hash regardless of the keys argument" do + expected = { + chain_root: false, + type: "Task", + task: task_class, + state: "interrupted", + status: "failed", + reason: "boom", + metadata: { k: 1 }, + cause: nil, + origin: nil, + strict: true, + deprecated: true, + retries: 3, + rolled_back: true, + duration: 0.25 + } + + expect(result.deconstruct_keys(nil)).to eq(expected) + expect(result.deconstruct_keys(%i[status reason])).to eq(expected) + expect(result.deconstruct_keys([])).to eq(expected) + end + + it "supports hash pattern matching" do + matched = + case result + in { status: "failed", reason: String => r, retries: Integer => n } + [r, n] + end - describe "STATUSES" do - it "defines all expected statuses" do - expect(CMDx::Result::STATUSES).to contain_exactly( - "success", - "skipped", - "failed" - ) + expect(matched).to eq(["boom", 3]) end - it "freezes the array" do - expect(CMDx::Result::STATUSES).to be_frozen + it "reflects default option-backed values" do + plain = build(CMDx::Signal::Success) + expect(plain.deconstruct_keys(nil)).to include( + strict: false, + deprecated: false, + retries: 0, + rolled_back: false, + duration: nil, + cause: nil + ) end end end diff --git a/spec/cmdx/retry_spec.rb b/spec/cmdx/retry_spec.rb index e35a57c75..8208590fa 100644 --- a/spec/cmdx/retry_spec.rb +++ b/spec/cmdx/retry_spec.rb @@ -2,359 +2,208 @@ require "spec_helper" -RSpec.describe CMDx::Retry, type: :unit do - subject(:retry_instance) { described_class.new(task) } - - let(:task_class) { create_successful_task(name: "RetryTask") } - let(:task) { task_class.new } +RSpec.describe CMDx::Retry do + let(:error_class) { Class.new(StandardError) } describe "#initialize" do - it "assigns the task" do - expect(retry_instance.task).to eq(task) - end - - it "provides read access to task attribute" do - expect(described_class.instance_methods).to include(:task) - expect(described_class.private_instance_methods).not_to include(:task) + it "flattens the exceptions argument" do + retry_ = described_class.new([[error_class]]) + expect(retry_.exceptions).to eq([error_class]) end end - describe "#available" do - context "when retries is configured" do - before do - allow(task.class).to receive(:settings).and_return(mock_settings(retries: 3)) - end - - it "returns the configured retry count" do - expect(retry_instance.available).to eq(3) - end + describe "#build" do + it "returns self when no additional exceptions are given" do + retry_ = described_class.new([error_class]) + expect(retry_.build([], {})).to be(retry_) end - context "when retries is nil" do - before do - allow(task.class).to receive(:settings).and_return(mock_settings(retries: nil)) - end + it "unions exceptions and merges options" do + other_error = Class.new(StandardError) + retry_ = described_class.new([error_class], limit: 1) + rebuilt = retry_.build([other_error], limit: 5) - it "returns 0" do - expect(retry_instance.available).to eq(0) - end + expect(rebuilt.exceptions).to contain_exactly(error_class, other_error) + expect(rebuilt.limit).to eq(5) end - context "when retries is not configured" do - before do - allow(task.class).to receive(:settings).and_return(mock_settings) - end + it "preserves the original block when no new block is given" do + original_block = proc { :orig } + retry_ = described_class.new([error_class], {}, &original_block) + rebuilt = retry_.build([Class.new(StandardError)], {}) - it "returns 0" do - expect(retry_instance.available).to eq(0) - end + expect(rebuilt.jitter).to be(original_block) end - end - describe "#available?" do - context "when retries are configured" do - before do - allow(task.class).to receive(:settings).and_return(mock_settings(retries: 2)) - end + it "replaces the block when a new one is given" do + new_block = proc { :new } + retry_ = described_class.new([error_class], {}) { :orig } + rebuilt = retry_.build([Class.new(StandardError)], {}, &new_block) - it "returns true" do - expect(retry_instance.available?).to be(true) - end + expect(rebuilt.jitter).to be(new_block) end + end - context "when retries is 0" do - before do - allow(task.class).to receive(:settings).and_return(mock_settings(retries: 0)) - end + describe "default options" do + subject(:retry_) { described_class.new([error_class]) } - it "returns false" do - expect(retry_instance.available?).to be(false) - end + it "limit defaults to 3" do + expect(retry_.limit).to eq(3) end - context "when retries is nil" do - before do - allow(task.class).to receive(:settings).and_return(mock_settings(retries: nil)) - end - - it "returns false" do - expect(retry_instance.available?).to be(false) - end + it "delay defaults to 0.5" do + expect(retry_.delay).to eq(0.5) end - end - describe "#attempts" do - context "when no retries have occurred" do - it "returns 0" do - expect(retry_instance.attempts).to eq(0) - end + it "max_delay defaults to nil" do + expect(retry_.max_delay).to be_nil end - context "when retries have occurred" do - before do - task.result.retries = 2 - end - - it "returns the number of attempts" do - expect(retry_instance.attempts).to eq(2) - end + it "jitter defaults to nil" do + expect(retry_.jitter).to be_nil end end - describe "#retried?" do - context "when no retries have occurred" do - it "returns false" do - expect(retry_instance.retried?).to be(false) - end + describe "#jitter" do + it "returns the block when no :jitter option is set" do + block = proc { :j } + retry_ = described_class.new([error_class], {}, &block) + expect(retry_.jitter).to be(block) end - context "when at least one retry has occurred" do - before do - task.result.retries = 1 - end - - it "returns true" do - expect(retry_instance.retried?).to be(true) - end + it "prefers the :jitter option over the block" do + block = proc { :b } + retry_ = described_class.new([error_class], { jitter: :exponential }, &block) + expect(retry_.jitter).to eq(:exponential) end end - describe "#remaining" do - before do - allow(task.class).to receive(:settings).and_return(mock_settings(retries: 5)) - end + describe "#wait" do + let(:sleeps) { [] } - context "when no retries have occurred" do - it "returns the full retry count" do - expect(retry_instance.remaining).to eq(5) - end + before do + s = sleeps + allow(Kernel).to receive(:sleep) { |d| s << d } end - context "when some retries have occurred" do - before do - task.result.retries = 2 - end - - it "returns the difference between available and attempts" do - expect(retry_instance.remaining).to eq(3) - end + it "does nothing when delay is zero" do + described_class.new([error_class], delay: 0).wait(1) + expect(sleeps).to be_empty end - context "when all retries are exhausted" do - before do - task.result.retries = 5 - end - - it "returns 0" do - expect(retry_instance.remaining).to eq(0) - end + it "sleeps for the configured delay with no jitter" do + described_class.new([error_class], delay: 0.25).wait(2) + expect(sleeps).to eq([0.25]) end - end - describe "#remaining?" do - before do - allow(task.class).to receive(:settings).and_return(mock_settings(retries: 3)) + it "computes exponential backoff" do + described_class.new([error_class], delay: 0.25, jitter: :exponential).wait(3) + expect(sleeps).to eq([0.25 * (2**3)]) end - context "when retries remain" do - it "returns true" do - expect(retry_instance.remaining?).to be(true) - end + it "clamps to max_delay" do + described_class.new([error_class], delay: 0.25, jitter: :exponential, max_delay: 1.0).wait(5) + expect(sleeps).to eq([1.0]) end - context "when all retries are exhausted" do - before do - task.result.retries = 3 - end + it "half_random produces delays in [delay/2, delay]" do + retry_ = described_class.new([error_class], delay: 1.0, jitter: :half_random) + allow(retry_).to receive(:rand).and_return(0.0, 1.0) - it "returns false" do - expect(retry_instance.remaining?).to be(false) - end + retry_.wait(0) + retry_.wait(0) + expect(sleeps).to eq([0.5, 1.0]) end - end - describe "#exceptions" do - context "when retry_on is configured with specific exceptions" do - before do - allow(task.class).to receive(:settings).and_return(mock_settings(retry_on: [ArgumentError, RuntimeError])) - end + it "full_random produces a delay in [0, delay]" do + retry_ = described_class.new([error_class], delay: 2.0, jitter: :full_random) + allow(retry_).to receive(:rand).and_return(0.25) - it "returns the configured exception classes" do - expect(retry_instance.exceptions).to eq([ArgumentError, RuntimeError]) - end + retry_.wait(0) + expect(sleeps).to eq([0.5]) end - context "when retry_on is a single exception class" do - before do - allow(task.class).to receive(:settings).and_return(mock_settings(retry_on: ArgumentError)) - end + it "bounded_random produces a delay in [delay, 2*delay]" do + retry_ = described_class.new([error_class], delay: 2.0, jitter: :bounded_random) + allow(retry_).to receive(:rand).and_return(0.5) - it "wraps it in an array" do - expect(retry_instance.exceptions).to eq([ArgumentError]) - end + retry_.wait(0) + expect(sleeps).to eq([3.0]) end - context "when retry_on is nil" do - before do - allow(task.class).to receive(:settings).and_return(mock_settings(retry_on: nil)) - end + it "calls a Symbol jitter on the task" do + task = Class.new { def jitter_calc(attempt, delay) = delay * attempt }.new + described_class.new([error_class], delay: 1.0, jitter: :jitter_calc).wait(4, task) - it "defaults to StandardError" do - expect(retry_instance.exceptions).to eq([StandardError, CMDx::TimeoutError]) - end + expect(sleeps).to eq([4.0]) end - context "when retry_on is not configured" do - before do - allow(task.class).to receive(:settings).and_return(mock_settings) - end + it "evaluates a Proc jitter via instance_exec on the task" do + retry_ = described_class.new([error_class], delay: 1.0) { |attempt, delay| attempt + delay } + retry_.wait(2, Object.new) - it "defaults to StandardError" do - expect(retry_instance.exceptions).to eq([StandardError, CMDx::TimeoutError]) - end + expect(sleeps).to eq([3.0]) end - it "memoizes the result" do - allow(task.class).to receive(:settings).and_return(mock_settings(retry_on: [ArgumentError])) - - first_call = retry_instance.exceptions - second_call = retry_instance.exceptions + it "calls a callable jitter" do + callable = ->(attempt, delay) { attempt + delay + 1 } + described_class.new([error_class], delay: 1.0, jitter: callable).wait(1) - expect(first_call).to equal(second_call) + expect(sleeps).to eq([3.0]) end end - describe "#exception?" do - context "with default retry_on (StandardError)" do - before do - allow(task.class).to receive(:settings).and_return(mock_settings(retry_on: nil)) - end - - it "returns true for StandardError" do - expect(retry_instance.exception?(StandardError.new)).to be(true) - end - - it "returns true for subclass of StandardError" do - expect(retry_instance.exception?(ArgumentError.new)).to be(true) - end - - it "returns false for non-matching exception" do - expect(retry_instance.exception?(Exception.new)).to be(false) - end - end - - context "with specific retry_on" do - before do - allow(task.class).to receive(:settings).and_return(mock_settings(retry_on: [ArgumentError])) - end - - it "returns true for exact match" do - expect(retry_instance.exception?(ArgumentError.new)).to be(true) - end - - it "returns false for non-matching exception" do - expect(retry_instance.exception?(RuntimeError.new)).to be(false) - end - - it "returns false for parent class of configured exception" do - expect(retry_instance.exception?(StandardError.new)).to be(false) - end + describe "#process" do + it "yields once with attempt 0 when there are no exceptions" do + retry_ = described_class.new([]) + attempts = [] + retry_.process { |attempt| attempts << attempt } + expect(attempts).to eq([0]) end - context "with multiple retry_on exceptions" do - before do - allow(task.class).to receive(:settings).and_return(mock_settings(retry_on: [ArgumentError, RuntimeError])) - end - - it "returns true when matching any configured exception" do - expect(retry_instance.exception?(ArgumentError.new)).to be(true) - expect(retry_instance.exception?(RuntimeError.new)).to be(true) - end + it "yields once when limit is zero" do + retry_ = described_class.new([error_class], limit: 0) + attempts = [] + retry_.process { |a| attempts << a } + expect(attempts).to eq([0]) end - end - - describe "#wait" do - context "with numeric jitter" do - before do - allow(task.class).to receive(:settings).and_return(mock_settings(retries: 3, retry_jitter: 0.5)) - end - it "returns jitter multiplied by attempts" do - task.result.retries = 2 + it "retries up to the limit on matching exceptions" do + retry_ = described_class.new([error_class], limit: 2, delay: 0) + count = 0 - expect(retry_instance.wait).to eq(1.0) + retry_.process do |_attempt| + count += 1 + raise error_class, "boom" if count < 3 end - it "returns 0.0 when no attempts have been made" do - expect(retry_instance.wait).to eq(0.0) - end + expect(count).to eq(3) end - context "with symbol jitter" do - before do - allow(task.class).to receive(:settings).and_return(mock_settings(retries: 3, retry_jitter: :custom_wait)) - allow(task).to receive(:custom_wait).with(1).and_return(2.5) - task.result.retries = 1 - end - - it "calls the named method on the task with attempts" do - expect(task).to receive(:custom_wait).with(1) - - retry_instance.wait - end + it "re-raises after the limit is exceeded" do + retry_ = described_class.new([error_class], limit: 1, delay: 0) - it "returns the method result as a float" do - expect(retry_instance.wait).to eq(2.5) - end + expect { retry_.process { raise error_class, "boom" } } + .to raise_error(error_class, "boom") end - context "with proc jitter" do - let(:jitter_proc) { ->(retries) { retries * 0.75 } } - - before do - allow(task.class).to receive(:settings).and_return(mock_settings(retries: 3, retry_jitter: jitter_proc)) - task.result.retries = 2 - end + it "does not rescue non-matching exceptions" do + retry_ = described_class.new([error_class], limit: 3, delay: 0) - it "instance_execs the proc with attempts" do - expect(retry_instance.wait).to eq(1.5) - end + expect { retry_.process { raise "other" } } + .to raise_error(RuntimeError, "other") end - context "with callable object jitter" do - let(:jitter_callable) do - Class.new do - def call(_task, retries) - retries * 1.25 - end - end.new - end - - before do - allow(task.class).to receive(:settings).and_return(mock_settings(retries: 3, retry_jitter: jitter_callable)) - task.result.retries = 2 - end - - it "calls the object with task and attempts" do - expect(jitter_callable).to receive(:call).with(task, 2).and_call_original + it "passes the attempt number to the block" do + retry_ = described_class.new([error_class], limit: 2, delay: 0) + attempts = [] - retry_instance.wait + retry_.process do |attempt| + attempts << attempt + raise error_class if attempts.size < 3 end - it "returns the callable result as a float" do - expect(retry_instance.wait).to eq(2.5) - end - end - - context "with nil jitter" do - before do - allow(task.class).to receive(:settings).and_return(mock_settings(retries: 3, retry_jitter: nil)) - task.result.retries = 2 - end - - it "returns 0.0" do - expect(retry_instance.wait).to eq(0.0) - end + expect(attempts).to eq([0, 1, 2]) end end end diff --git a/spec/cmdx/runtime_spec.rb b/spec/cmdx/runtime_spec.rb new file mode 100644 index 000000000..c616db376 --- /dev/null +++ b/spec/cmdx/runtime_spec.rb @@ -0,0 +1,138 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe CMDx::Runtime do + describe ".execute" do + it "delegates to a new Runtime instance" do + task = create_successful_task.new + runtime = instance_double(described_class) + + expect(described_class).to receive(:new).with(task, strict: true).and_return(runtime) + expect(runtime).to receive(:execute) + + described_class.execute(task, strict: true) + end + end + + describe "#execute" do + context "with a successful task" do + it "returns a success result" do + result = described_class.execute(create_successful_task.new) + + expect(result).to have_attributes( + state: "complete", + status: "success", + success?: true + ) + end + + it "records a monotonic duration" do + result = described_class.execute(create_successful_task.new) + expect(result.duration).to be_positive + end + + it "adds the result to the chain" do + result = described_class.execute(create_successful_task.new) + expect(result.chain.to_a).to eq([result]) + end + end + + context "with a failing task" do + it "returns a failed result" do + result = described_class.execute(create_failing_task(reason: "nope").new) + expect(result).to have_attributes(status: "failed", reason: "nope") + end + end + + context "with a task that raises" do + it "wraps the error in a failed result with the cause" do + task = create_erroring_task(reason: "broken").new + result = described_class.execute(task) + + expect(result.status).to eq("failed") + expect(result.cause).to be_a(StandardError) + expect(result.cause.message).to eq("broken") + end + end + + context "when strict and the task fails" do + it "re-raises the fault" do + task = create_failing_task(reason: "nope").new + expect { described_class.execute(task, strict: true) } + .to raise_error(CMDx::Fault, "nope") + end + + it "does not raise for a successful task" do + task = create_successful_task.new + expect { described_class.execute(task, strict: true) }.not_to raise_error + end + end + + describe "chain lifecycle" do + it "creates a new chain when none is active and clears it afterwards" do + expect(CMDx::Chain.current).to be_nil + described_class.execute(create_successful_task.new) + expect(CMDx::Chain.current).to be_nil + end + + it "reuses the current chain when nested" do + outer = create_task_class(name: "OuterTask") do + define_method(:work) { context.inner_id = CMDx::Chain.current.id } + end + + result = described_class.execute(outer.new) + expect(result.chain.id).to eq(result.context.inner_id) + end + end + + describe "freezing on teardown" do + it "freezes the task, its errors, and (at the root) the context" do + task = create_successful_task.new + described_class.execute(task) + + expect(task).to be_frozen + expect(task.errors).to be_frozen + expect(task.context).to be_frozen + end + end + + describe "telemetry" do + it "emits task_started and task_executed events" do + events = [] + task_class = create_successful_task + task_class.telemetry.subscribe(:task_started) { |e| events << e.name } + task_class.telemetry.subscribe(:task_executed) { |e| events << e.name } + + described_class.execute(task_class.new) + expect(events).to eq(%i[task_started task_executed]) + end + end + + describe "rollback" do + it "invokes #rollback when the task fails" do + task_class = create_task_class(name: "RollbackTask") do + define_method(:work) { fail!("bad") } + define_method(:rollback) { context.rolled_back = true } + end + task = task_class.new + + result = described_class.execute(task) + + expect(result.status).to eq("failed") + expect(result.rolled_back?).to be(true) + expect(task.context.rolled_back).to be(true) + end + + it "does not invoke rollback on success" do + task_class = create_task_class(name: "NoRollbackTask") do + define_method(:work) { nil } + define_method(:rollback) { context.rolled_back = true } + end + + result = described_class.execute(task_class.new) + expect(result.rolled_back?).to be(false) + end + end + end +end diff --git a/spec/cmdx/settings_spec.rb b/spec/cmdx/settings_spec.rb index 02bb98162..80d03b60b 100644 --- a/spec/cmdx/settings_spec.rb +++ b/spec/cmdx/settings_spec.rb @@ -2,303 +2,87 @@ require "spec_helper" -RSpec.describe CMDx::Settings, type: :unit do - describe "#initialize" do - context "without parent" do - subject(:settings) { described_class.new } - - it "creates fresh registries from configuration" do - expect(settings.attributes).to be_a(CMDx::AttributeRegistry) - expect(settings.attributes.registry).to be_empty - expect(settings.middlewares).to be_a(CMDx::MiddlewareRegistry) - expect(settings.callbacks).to be_a(CMDx::CallbackRegistry) - expect(settings.coercions).to be_a(CMDx::CoercionRegistry) - expect(settings.validators).to be_a(CMDx::ValidatorRegistry) - end - - it "dups registries so they are independent from configuration" do - expect(settings.middlewares).not_to equal(CMDx.configuration.middlewares) - expect(settings.callbacks).not_to equal(CMDx.configuration.callbacks) - expect(settings.coercions).not_to equal(CMDx.configuration.coercions) - expect(settings.validators).not_to equal(CMDx.configuration.validators) - end - - it "inherits scalar values from configuration" do - expect(settings.task_breakpoints).to eq(CMDx.configuration.task_breakpoints) - expect(settings.workflow_breakpoints).to eq(CMDx.configuration.workflow_breakpoints) - expect(settings.rollback_on).to eq(CMDx.configuration.rollback_on) - expect(settings.backtrace).to eq(CMDx.configuration.backtrace) - expect(settings.dump_context).to eq(CMDx.configuration.dump_context) - expect(settings.logger).to eq(CMDx.configuration.logger) - end - - it "initializes nullable fields from configuration" do - expect(settings.backtrace_cleaner).to eq(CMDx.configuration.backtrace_cleaner) - expect(settings.exception_handler).to eq(CMDx.configuration.exception_handler) - end - - it "sets arrays to frozen empty arrays" do - expect(settings.returns).to eq([]) - expect(settings.returns).to be_frozen - expect(settings.tags).to eq([]) - expect(settings.tags).to be_frozen - end - - it "defaults task-level settings to nil" do - expect(settings.breakpoints).to be_nil - expect(settings.log_level).to be_nil - expect(settings.log_formatter).to be_nil - expect(settings.retries).to be_nil - expect(settings.retry_on).to be_nil - expect(settings.retry_jitter).to be_nil - expect(settings.deprecate).to be_nil - end +RSpec.describe CMDx::Settings do + describe "#build" do + it "returns self when new_options is empty" do + settings = described_class.new(a: 1) + expect(settings.build({})).to be(settings) end - context "with parent" do - subject(:settings) { described_class.new(parent: parent) } - - let(:parent) do - mock_settings( - attributes: CMDx::AttributeRegistry.new, - middlewares: CMDx::MiddlewareRegistry.new, - callbacks: CMDx::CallbackRegistry.new, - coercions: CMDx::CoercionRegistry.new, - validators: CMDx::ValidatorRegistry.new, - task_breakpoints: %w[failed skipped], - workflow_breakpoints: %w[failed], - rollback_on: %w[failed], - breakpoints: %w[failed], - backtrace: true, - backtrace_cleaner: ->(bt) { bt.first(3) }, - exception_handler: ->(_task, _err) {}, - logger: Logger.new(nil), - log_level: Logger::WARN, - log_formatter: proc { |*| "fmt" }, - retries: 3, - retry_on: [StandardError, CMDx::TimeoutError], - retry_jitter: :exponential, - deprecate: :warn, - returns: %i[user token], - tags: %i[auth critical] - ) - end - - it "deep-dups registries from parent" do - expect(settings.attributes.registry).to eq(parent.attributes.registry) - expect(settings.attributes).not_to equal(parent.attributes) + it "returns a new instance with merged options" do + settings = described_class.new(a: 1, b: 2) + merged = settings.build(b: 99, c: 3) - expect(settings.middlewares).not_to equal(parent.middlewares) - expect(settings.callbacks).not_to equal(parent.callbacks) - expect(settings.coercions).not_to equal(parent.coercions) - expect(settings.validators).not_to equal(parent.validators) - end - - it "inherits scalar/callable values from parent" do - expect(settings.task_breakpoints).to eq(%w[failed skipped]) - expect(settings.workflow_breakpoints).to eq(%w[failed]) - expect(settings.rollback_on).to eq(%w[failed]) - expect(settings.breakpoints).to eq(%w[failed]) - expect(settings.backtrace).to be true - expect(settings.backtrace_cleaner).to eq(parent.backtrace_cleaner) - expect(settings.exception_handler).to eq(parent.exception_handler) - expect(settings.logger).to eq(parent.logger) - expect(settings.log_level).to eq(Logger::WARN) - expect(settings.log_formatter).to eq(parent.log_formatter) - end - - it "inherits task-level values from parent" do - expect(settings.retries).to eq(3) - expect(settings.retry_on).to eq([StandardError, CMDx::TimeoutError]) - expect(settings.retry_jitter).to eq(:exponential) - expect(settings.deprecate).to eq(:warn) - end - - it "dups returns and tags arrays" do - expect(settings.returns).to eq(%i[user token]) - expect(settings.returns).not_to equal(parent.returns) - expect(settings.tags).to eq(%i[auth critical]) - expect(settings.tags).not_to equal(parent.tags) - end - - context "when parent has nil returns and tags" do - let(:parent) do - mock_settings( - attributes: CMDx::AttributeRegistry.new, - middlewares: CMDx::MiddlewareRegistry.new, - callbacks: CMDx::CallbackRegistry.new, - coercions: CMDx::CoercionRegistry.new, - validators: CMDx::ValidatorRegistry.new, - task_breakpoints: %w[failed], - workflow_breakpoints: %w[failed], - rollback_on: %w[failed], - breakpoints: nil, - backtrace: false, - backtrace_cleaner: nil, - exception_handler: nil, - logger: nil, - log_level: nil, - log_formatter: nil, - retries: nil, - retry_on: nil, - retry_jitter: nil, - deprecate: nil, - returns: nil, - tags: nil - ) - end - - it "falls back to frozen empty arrays" do - expect(settings.returns).to eq([]) - expect(settings.returns).to be_frozen - expect(settings.tags).to eq([]) - expect(settings.tags).to be_frozen - end - end + expect(merged).not_to be(settings) + expect(merged.instance_variable_get(:@options)).to eq(a: 1, b: 99, c: 3) + end + end - context "when parent has nil backtrace_cleaner, exception_handler, and logger" do - let(:parent) do - mock_settings( - attributes: CMDx::AttributeRegistry.new, - middlewares: CMDx::MiddlewareRegistry.new, - callbacks: CMDx::CallbackRegistry.new, - coercions: CMDx::CoercionRegistry.new, - validators: CMDx::ValidatorRegistry.new, - task_breakpoints: %w[failed], - workflow_breakpoints: %w[failed], - rollback_on: %w[failed], - breakpoints: nil, - backtrace: false, - backtrace_cleaner: nil, - exception_handler: nil, - logger: nil, - log_level: nil, - log_formatter: nil, - retries: nil, - retry_on: nil, - retry_jitter: nil, - deprecate: nil, - returns: nil, - tags: nil - ) - end + describe "configuration fallbacks" do + let(:custom_logger) { Logger.new(nil) } + let(:custom_formatter) { ->(sev, _time, _prog, msg) { "#{sev} #{msg}" } } + let(:custom_cleaner) { ->(bt) { bt } } - it "falls back to configuration values" do - expect(settings.backtrace_cleaner).to eq(CMDx.configuration.backtrace_cleaner) - expect(settings.exception_handler).to eq(CMDx.configuration.exception_handler) - expect(settings.logger).to eq(CMDx.configuration.logger) - end - end + before do + CMDx.configuration.logger = custom_logger + CMDx.configuration.log_formatter = custom_formatter + CMDx.configuration.log_level = Logger::DEBUG + CMDx.configuration.backtrace_cleaner = custom_cleaner end - context "with overrides" do - subject(:settings) { described_class.new(retries: 5, backtrace: true) } - - it "applies overrides after inheriting from configuration" do - expect(settings.retries).to eq(5) - expect(settings.backtrace).to be true - end + it "logger falls back to the global configuration" do + expect(described_class.new.logger).to be(custom_logger) end - context "with parent and overrides" do - subject(:settings) { described_class.new(parent: parent, retries: 10, deprecate: :warn) } + it "log_formatter falls back to the global configuration" do + expect(described_class.new.log_formatter).to be(custom_formatter) + end - let(:parent) do - mock_settings( - attributes: CMDx::AttributeRegistry.new, - middlewares: CMDx::MiddlewareRegistry.new, - callbacks: CMDx::CallbackRegistry.new, - coercions: CMDx::CoercionRegistry.new, - validators: CMDx::ValidatorRegistry.new, - task_breakpoints: %w[failed], - workflow_breakpoints: %w[failed], - rollback_on: %w[failed], - breakpoints: nil, - backtrace: false, - backtrace_cleaner: nil, - exception_handler: nil, - logger: nil, - log_level: nil, - log_formatter: nil, - retries: 2, - retry_on: nil, - retry_jitter: nil, - deprecate: nil, - returns: nil, - tags: nil - ) - end + it "log_level falls back to the global configuration" do + expect(described_class.new.log_level).to eq(Logger::DEBUG) + end - it "overrides parent values" do - expect(settings.retries).to eq(10) - expect(settings.deprecate).to eq(:warn) - end + it "backtrace_cleaner falls back to the global configuration" do + expect(described_class.new.backtrace_cleaner).to be(custom_cleaner) end end - describe "delegation" do - context "with delegate_to_configuration settings" do - it "reflects configuration changes without re-creating settings" do - settings = described_class.new - - CMDx.configuration.task_breakpoints = %w[failed skipped] - expect(settings.task_breakpoints).to eq(%w[failed skipped]) - - CMDx.configuration.rollback_on = %w[skipped] - expect(settings.rollback_on).to eq(%w[skipped]) - end + describe "option overrides" do + let(:local_logger) { Logger.new(nil) } - it "stops delegating once locally overridden" do - settings = described_class.new - settings.task_breakpoints = %w[custom] - - CMDx.configuration.task_breakpoints = %w[failed skipped] - expect(settings.task_breakpoints).to eq(%w[custom]) - end + it "logger prefers the local option over the global default" do + expect(described_class.new(logger: local_logger).logger).to be(local_logger) end - context "with delegate_with_fallback settings" do - it "reflects configuration logger changes" do - settings = described_class.new - new_logger = Logger.new(nil) - - CMDx.configuration.logger = new_logger - expect(settings.logger).to equal(new_logger) - end - - it "stops delegating once locally overridden" do - settings = described_class.new - local_logger = Logger.new(nil) - settings.logger = local_logger - - CMDx.configuration.logger = Logger.new(nil) - expect(settings.logger).to equal(local_logger) - end + it "log_formatter prefers the local option" do + fmt = ->(*_) { "" } + expect(described_class.new(log_formatter: fmt).log_formatter).to be(fmt) end - context "with parent chain delegation" do - it "walks the parent chain to resolve values" do - grandparent = described_class.new(retries: 5) - parent = described_class.new(parent: grandparent) - child = described_class.new(parent: parent) - - expect(child.retries).to eq(5) - end + it "log_level prefers the local option" do + expect(described_class.new(log_level: Logger::ERROR).log_level).to eq(Logger::ERROR) + end - it "prefers the closest override in the chain" do - grandparent = described_class.new(retries: 5) - parent = described_class.new(parent: grandparent, retries: 10) - child = described_class.new(parent: parent) + it "backtrace_cleaner prefers the local option" do + cleaner = ->(bt) { bt } + expect(described_class.new(backtrace_cleaner: cleaner).backtrace_cleaner).to be(cleaner) + end + end - expect(child.retries).to eq(10) - end + describe "#tags" do + it "returns an empty array by default" do + expect(described_class.new.tags).to eq([]) + end - it "local override wins over parent chain" do - parent = described_class.new(retries: 5) - child = described_class.new(parent: parent, retries: 20) + it "returns the configured tags" do + expect(described_class.new(tags: %w[a b]).tags).to eq(%w[a b]) + end + end - expect(child.retries).to eq(20) - end + describe "immutability" do + it "freezes the internal options hash" do + settings = described_class.new(a: 1) + expect(settings.instance_variable_get(:@options)).to be_frozen end end end diff --git a/spec/cmdx/signal_spec.rb b/spec/cmdx/signal_spec.rb new file mode 100644 index 000000000..ac7964b37 --- /dev/null +++ b/spec/cmdx/signal_spec.rb @@ -0,0 +1,182 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe CMDx::Signal do + describe ".success" do + context "without arguments" do + it "returns the shared Success singleton" do + expect(described_class.success).to be(described_class::Success) + end + end + + context "with a reason" do + it "builds a new complete/success signal with the reason" do + signal = described_class.success("ok now") + + expect(signal).to have_attributes( + state: described_class::COMPLETE, + status: described_class::SUCCESS, + reason: "ok now" + ) + expect(signal).not_to be(described_class::Success) + end + end + + context "with options" do + it "stores metadata, cause, and backtrace" do + cause = StandardError.new("inner") + signal = described_class.success(metadata: { code: 1 }, cause: cause, backtrace: %w[a b]) + + expect(signal).to have_attributes( + metadata: { code: 1 }, + cause: cause, + backtrace: %w[a b] + ) + end + end + end + + describe ".skipped" do + it "returns the Skipped singleton when no args" do + expect(described_class.skipped).to be(described_class::Skipped) + end + + it "builds an interrupted/skipped signal when given a reason" do + signal = described_class.skipped("nope") + + expect(signal).to have_attributes( + state: described_class::INTERRUPTED, + status: described_class::SKIPPED, + reason: "nope" + ) + end + end + + describe ".failed" do + it "returns the Failed singleton when no args" do + expect(described_class.failed).to be(described_class::Failed) + end + + it "builds an interrupted/failed signal when given a reason" do + signal = described_class.failed("broken") + + expect(signal).to have_attributes( + state: described_class::INTERRUPTED, + status: described_class::FAILED, + reason: "broken" + ) + end + end + + describe ".echoed" do + context "when given a Signal" do + it "copies state, status, and reason and applies new options" do + source = described_class.failed("boom", metadata: { x: 1 }) + echoed = described_class.echoed(source, metadata: { y: 2 }) + + expect(echoed).to have_attributes( + state: described_class::INTERRUPTED, + status: described_class::FAILED, + reason: "boom", + metadata: { y: 2 } + ) + end + end + + context "when given a non-Signal/non-Result" do + it "raises ArgumentError" do + expect { described_class.echoed(Object.new) } + .to raise_error(ArgumentError, "must be a Result or Signal") + end + end + end + + describe "singletons" do + it "expose the canonical state/status pairs" do + expect(described_class::Success).to have_attributes( + state: described_class::COMPLETE, status: described_class::SUCCESS + ) + expect(described_class::Skipped).to have_attributes( + state: described_class::INTERRUPTED, status: described_class::SKIPPED + ) + expect(described_class::Failed).to have_attributes( + state: described_class::INTERRUPTED, status: described_class::FAILED + ) + end + end + + describe "state predicates" do + it "complete? is true only for COMPLETE state" do + expect(described_class::Success.complete?).to be(true) + expect(described_class::Failed.complete?).to be(false) + end + + it "interrupted? is true only for INTERRUPTED state" do + expect(described_class::Success.interrupted?).to be(false) + expect(described_class::Failed.interrupted?).to be(true) + expect(described_class::Skipped.interrupted?).to be(true) + end + end + + describe "status predicates" do + it "success? / skipped? / failed? match status" do + expect(described_class::Success.success?).to be(true) + expect(described_class::Skipped.skipped?).to be(true) + expect(described_class::Failed.failed?).to be(true) + + expect(described_class::Success.failed?).to be(false) + expect(described_class::Failed.success?).to be(false) + end + + it "ok? is true when not failed" do + expect(described_class::Success.ok?).to be(true) + expect(described_class::Skipped.ok?).to be(true) + expect(described_class::Failed.ok?).to be(false) + end + + it "ko? is true when not success" do + expect(described_class::Success.ko?).to be(false) + expect(described_class::Skipped.ko?).to be(true) + expect(described_class::Failed.ko?).to be(true) + end + end + + describe "#reason / #metadata / #cause / #backtrace" do + subject(:signal) do + described_class.new( + described_class::INTERRUPTED, + described_class::FAILED, + reason: "r", + metadata: { k: :v }, + cause: cause, + backtrace: %w[line1 line2] + ) + end + + let(:cause) { StandardError.new("inner") } + + it "exposes each option" do + expect(signal).to have_attributes( + reason: "r", + metadata: { k: :v }, + cause: cause, + backtrace: %w[line1 line2] + ) + end + + it "defaults metadata to an empty hash when omitted" do + signal = described_class.new(described_class::COMPLETE, described_class::SUCCESS) + expect(signal.metadata).to eq({}) + expect(signal.metadata).to be_frozen + end + + it "returns nil for unset reason/cause/backtrace" do + signal = described_class.new(described_class::COMPLETE, described_class::SUCCESS) + + expect(signal.reason).to be_nil + expect(signal.cause).to be_nil + expect(signal.backtrace).to be_nil + end + end +end diff --git a/spec/cmdx/task_spec.rb b/spec/cmdx/task_spec.rb index 0b01f311c..768fb23b7 100644 --- a/spec/cmdx/task_spec.rb +++ b/spec/cmdx/task_spec.rb @@ -2,609 +2,234 @@ require "spec_helper" -RSpec.describe CMDx::Task, type: :unit do - let(:task_class) { create_task_class(name: "TestTask") } - let(:task) { task_class.new } - let(:context_hash) { { foo: "bar", baz: 42 } } +RSpec.describe CMDx::Task do + describe "class-level registries" do + let(:base) { create_task_class(name: "BaseTask") } + let(:child) { create_task_class(base: base, name: "ChildTask") } - describe "#initialize" do - context "with no arguments" do - it "initializes with default values" do - expect(task.attributes).to eq({}) - expect(task.errors).to be_a(CMDx::Errors) - expect(task.errors).to be_empty - expect(task.id).to be_a(String) - expect(task.context).to be_a(CMDx::Context) - expect(task.context.to_h).to eq({}) - expect(task.result).to be_a(CMDx::Result) - expect(task.result.task).to eq(task) - expect(task.chain).to be_a(CMDx::Chain) - expect(task.dry_run?).to be(false) - end - - it "generates unique IDs for different instances" do - task1 = task_class.new - task2 = task_class.new - - expect(task1.id).not_to eq(task2.id) - end - end - - context "with context hash" do - let(:task) { task_class.new(context_hash) } - - it "initializes context with provided hash" do - expect(task.context.to_h).to eq(context_hash) - end - end + it "inherits settings from the superclass" do + base.settings(tags: %w[root]) - context "with context object" do - let(:context_obj) { CMDx::Context.new(context_hash) } - let(:task) { task_class.new(context_obj) } - - it "uses the provided context object" do - expect(task.context).to eq(context_obj) - expect(task.context.to_h).to eq(context_hash) - end + expect(child.settings.tags).to eq(%w[root]) end - context "with object that responds to context" do - let(:context_wrapper) { instance_double("MockContextWrapper", context: context_hash) } - let(:task) { task_class.new(context_wrapper) } - - it "extracts context from the wrapper object" do - expect(task.context.to_h).to eq(context_hash) - end + it "settings with options builds a new Settings layer" do + base.settings(tags: %w[root]) + child.settings(tags: %w[extra]) + expect(child.settings.tags).to eq(%w[extra]) + expect(base.settings.tags).to eq(%w[root]) end - it "calls Deprecator.restrict" do - expect(CMDx::Deprecator).to receive(:restrict).with(an_instance_of(task_class)) - - task_class.new - end - end + it "middlewares inherits from the superclass" do + mw = ->(_t, &blk) { blk.call } + base.register(:middleware, mw) - describe "aliases" do - it "aliases ctx to context" do - expect(task.ctx).to eq(task.context) + expect(child.middlewares.registry).to include(mw) end - it "aliases res to result" do - expect(task.res).to eq(task.result) + it "callbacks/telemetry/coercions/validators are dup-isolated per subclass" do + base.callbacks + child.callbacks + expect(child.callbacks).not_to be(base.callbacks) end end - describe "delegated methods" do - it "delegates success! to resolver" do - expect(task.resolver).to receive(:success!).with("reason", metadata: "data") - - task.success!("reason", metadata: "data") - end - - it "delegates skip! to resolver" do - expect(task.resolver).to receive(:skip!).with("reason", metadata: "data") - - task.skip!("reason", metadata: "data") + describe "retry_on" do + it "defaults to a new Retry with no exceptions" do + klass = create_task_class + expect(klass.retry_on).to be_a(CMDx::Retry) end - it "delegates fail! to resolver" do - expect(task.resolver).to receive(:fail!).with("reason", metadata: "data") + it "adds exceptions when called with them" do + klass = create_task_class + err = Class.new(StandardError) + klass.retry_on(err, limit: 2) - task.fail!("reason", metadata: "data") - end - - it "delegates throw! to resolver" do - expect(task.resolver).to receive(:throw!).with("reason", metadata: "data") - - task.throw!("reason", metadata: "data") + expect(klass.retry_on.exceptions).to include(err) + expect(klass.retry_on.limit).to eq(2) end end - describe ".settings" do - context "when called for the first time" do - it "returns default settings with required keys" do - settings = task_class.settings - - expect(settings).to be_a(CMDx::Settings) - expect(settings.attributes).to be_a(CMDx::AttributeRegistry) - expect(settings.tags).to eq([]) - end - end - - context "when superclass has settings" do - let(:parent_class) { create_task_class(name: "ParentTask") } - let(:child_class) { Class.new(parent_class) } - - before do - parent_class.settings(deprecate: true) - end - - it "inherits from superclass settings" do - child_settings = child_class.settings - - expect(child_settings.deprecate).to be(true) - end - - it "can override inherited settings" do - child_settings = child_class.settings(deprecate: false) - - expect(child_settings.deprecate).to be(false) - end - end - - context "with custom options" do - it "merges custom options with defaults" do - settings = task_class.settings(deprecate: :warn, tags: ["tag1"]) + describe "#register / #deregister" do + it "dispatches to the right registry" do + klass = create_task_class + mw = ->(_t, &blk) { blk.call } + klass.register(:middleware, mw) + expect(klass.middlewares.registry).to include(mw) - expect(settings.deprecate).to eq(:warn) - expect(settings.tags).to eq(["tag1"]) - end + klass.deregister(:middleware, mw) + expect(klass.middlewares.registry).not_to include(mw) end - it "memoizes settings" do - settings1 = task_class.settings - settings2 = task_class.settings - - expect(settings1).to be(settings2) + it "raises on an unknown registry type" do + klass = create_task_class + expect { klass.register(:bogus) }.to raise_error(ArgumentError, "unknown registry type: :bogus") + expect { klass.deregister(:bogus) }.to raise_error(ArgumentError, "unknown registry type: :bogus") end end - describe ".register" do - let(:mock_registry) { instance_double("MockRegistry") } - - before do - allow(task_class.settings).to receive_messages( - attributes: mock_registry, - callbacks: mock_registry, - coercions: mock_registry, - middlewares: mock_registry, - validators: mock_registry - ) + describe "#deprecation" do + it "returns nil without a setting" do + expect(create_task_class.deprecation).to be_nil end - context "with :attribute type" do - it "registers with attribute registry and defines readers" do - expect(mock_registry).to receive(:register).with("object") - expect(mock_registry).to receive(:define_readers_on!).with(task_class, ["object"]) - - task_class.register(:attribute, "object") - end - end - - context "with :callback type" do - it "registers with callback registry" do - expect(mock_registry).to receive(:register).with("object", "arg1", "arg2") - - task_class.register(:callback, "object", "arg1", "arg2") - end - end - - context "with :coercion type" do - it "registers with coercion registry" do - expect(mock_registry).to receive(:register).with("object", "arg1", "arg2") - - task_class.register(:coercion, "object", "arg1", "arg2") - end + it "stores a new Deprecation when given a value" do + klass = create_task_class + klass.deprecation(:log) + expect(klass.deprecation).to be_a(CMDx::Deprecation) end - context "with :middleware type" do - it "registers with middleware registry" do - expect(mock_registry).to receive(:register).with("object", "arg1", "arg2") - - task_class.register(:middleware, "object", "arg1", "arg2") - end - end - - context "with :validator type" do - it "registers with validator registry" do - expect(mock_registry).to receive(:register).with("object", "arg1", "arg2") - - task_class.register(:validator, "object", "arg1", "arg2") - end - end - - context "with unknown type" do - it "raises an error" do - expect { task_class.register(:unknown, "object") }.to raise_error("unknown registry type :unknown") - end - end - end - - describe ".attributes" do - it "defines and registers multiple attributes" do - mock_attributes = instance_double(CMDx::Attribute) - expect(CMDx::Attribute).to receive(:build).with("arg1", "arg2").and_return(mock_attributes) - expect(task_class).to receive(:register).with(:attribute, mock_attributes) - - task_class.attributes("arg1", "arg2") - end - end - - describe ".optional" do - it "defines and registers optional attributes" do - mock_attribute = instance_double(CMDx::Attribute) - expect(CMDx::Attribute).to receive(:optional).with("arg1", "arg2").and_return(mock_attribute) - expect(task_class).to receive(:register).with(:attribute, mock_attribute) - - task_class.optional("arg1", "arg2") - end - end - - describe ".required" do - it "defines and registers required attributes" do - mock_attribute = instance_double(CMDx::Attribute) - expect(CMDx::Attribute).to receive(:required).with("arg1", "arg2").and_return(mock_attribute) - expect(task_class).to receive(:register).with(:attribute, mock_attribute) - - task_class.required("arg1", "arg2") - end - end - - describe ".remove_attributes" do - it "removes multiple attributes from the registry" do - mock_registry = instance_double(CMDx::AttributeRegistry) - allow(task_class.settings).to receive(:attributes).and_return(mock_registry) - - expect(mock_registry).to receive(:undefine_readers_on!).with(task_class, %w[attr1 attr2 attr3]) - expect(mock_registry).to receive(:deregister).with(%w[attr1 attr2 attr3]) - - task_class.remove_attributes("attr1", "attr2", "attr3") - end - - it "handles single attribute removal" do - mock_registry = instance_double(CMDx::AttributeRegistry) - allow(task_class.settings).to receive(:attributes).and_return(mock_registry) - - expect(mock_registry).to receive(:undefine_readers_on!).with(task_class, ["single_attr"]) - expect(mock_registry).to receive(:deregister).with(["single_attr"]) + it "inherits from the superclass when unset on the subclass" do + parent = create_task_class(name: "DeprecatedParent") + parent.deprecation(:log) + child = create_task_class(base: parent, name: "DeprecatedChild") - task_class.remove_attributes("single_attr") + expect(child.deprecation).to be(parent.deprecation) end end - describe ".attributes_schema" do - let(:task_with_attrs) do - Class.new(CMDx::Task) do - required :user_id, type: :integer - optional :email, type: :string, default: "test@example.com" - optional :profile, type: :hash do - optional :bio, type: :string - required :name, type: :string - end - - def work; end + describe "#inputs / #outputs and their schemas" do + let(:klass) do + create_task_class(name: "SchemaTask") do + required :name + optional :age + output :id, required: true end end - it "returns a hash keyed by attribute method names" do - schema = task_with_attrs.attributes_schema - - expect(schema).to be_a(Hash) - expect(schema.keys).to contain_exactly(:user_id, :email, :profile) - end - - it "includes attribute metadata from to_h" do - schema = task_with_attrs.attributes_schema - - expect(schema[:user_id]).to include( - name: :user_id, - method_name: :user_id, - required: true, - types: [:integer] - ) - - expect(schema[:email]).to include( - name: :email, - method_name: :email, - required: false, - types: [:string] - ) - expect(schema[:email][:options]).to include(default: "test@example.com") - end - - it "includes nested children" do - schema = task_with_attrs.attributes_schema - - expect(schema[:profile][:children]).to be_an(Array) - expect(schema[:profile][:children].size).to eq(2) - - child_names = schema[:profile][:children].map { |c| c[:name] } - expect(child_names).to contain_exactly(:bio, :name) + it "inputs_schema returns a flat hash of input descriptors" do + expect(klass.inputs_schema.keys).to eq(%i[name age]) + expect(klass.inputs_schema[:name][:required]).to be(true) + expect(klass.inputs_schema[:age][:required]).to be(false) end - it "returns empty hash when no attributes defined" do - empty_task = Class.new(CMDx::Task) { def work; end } - - expect(empty_task.attributes_schema).to eq({}) + it "outputs_schema returns a flat hash of output descriptors" do + expect(klass.outputs_schema.keys).to eq([:id]) + expect(klass.outputs_schema[:id][:required]).to be(true) end end - describe "callback methods" do - CMDx::CallbackRegistry::TYPES.each do |callback_type| - describe ".#{callback_type}" do - it "registers the callback" do - expect(task_class).to receive(:register).with(:callback, callback_type, "callable1", "callable2", option: "value") - - task_class.public_send(callback_type, "callable1", "callable2", option: "value") - end - - it "accepts a block" do - block = proc { "test" } - expect(task_class).to receive(:register).with(:callback, callback_type, option: "value") do |*_args, &passed_block| - expect(passed_block).to eq(block) - end + describe "#type" do + it "is 'Task' by default" do + expect(create_task_class.type).to eq("Task") + end - task_class.public_send(callback_type, option: "value", &block) - end - end + it "is 'Workflow' when the class includes Workflow" do + klass = create_workflow_class + expect(klass.type).to eq("Workflow") end end - describe "#dry_run?" do - it "returns false by default" do - expect(task.dry_run?).to be(false) + describe ".execute / .execute!" do + it "execute returns a result for successful tasks" do + expect(create_successful_task.execute).to be_success end - context "when initialized with dry_run: true" do - let(:task) { task_class.new(dry_run: true) } - - it "returns true" do - expect(task.dry_run?).to be(true) - end + it "execute returns a failed result when the task fails" do + expect(create_failing_task(reason: "x").execute).to be_failed end - context "when executed with dry_run: true" do - let(:dry_run_class) do - create_task_class do - def work; end - end - end - - it "returns true via execute" do - result = dry_run_class.execute(dry_run: true) - - expect(result).to be_dry_run - end - - it "returns true via execute!" do - result = dry_run_class.execute!(dry_run: true) - - expect(result).to be_dry_run - end + it "execute! raises on failure" do + expect { create_failing_task(reason: "x").execute! }.to raise_error(CMDx::Fault, "x") end - end - - describe ".execute" do - let(:mock_task) { instance_double(described_class, result: "result") } - - it "creates new task instance and executes with raise: false" do - expect(task_class).to receive(:new).with("arg1", "arg2").and_return(mock_task) - expect(mock_task).to receive(:execute).with(raise: false) - - result = task_class.execute("arg1", "arg2") - expect(result).to eq("result") + it "yields the result when given a block" do + received = nil + create_successful_task.execute { |r| received = r } + expect(received).to be_success end - end - - describe ".execute!" do - let(:mock_task) { instance_double(described_class, result: "result") } - - it "creates new task instance and executes with raise: true" do - expect(task_class).to receive(:new).with("arg1", "arg2").and_return(mock_task) - expect(mock_task).to receive(:execute).with(raise: true) - result = task_class.execute!("arg1", "arg2") - - expect(result).to eq("result") + it "is aliased as .call" do + expect(create_successful_task.call).to be_success end end - describe "#execute" do - context "with raise: false" do - it "delegates to Executor.execute with raise: false" do - expect(CMDx::Executor).to receive(:execute).with(task, raise: false) - - task.execute(raise: false) - end - end - - context "with raise: true" do - it "delegates to Executor.execute with raise: true" do - expect(CMDx::Executor).to receive(:execute).with(task, raise: true) + describe "#initialize" do + it "builds a Context and an empty Errors" do + task = create_task_class.new(a: 1) - task.execute(raise: true) - end + expect(task.context).to be_a(CMDx::Context) + expect(task.context[:a]).to eq(1) + expect(task.errors).to be_a(CMDx::Errors) + expect(task.errors).to be_empty end - context "with no arguments" do - it "defaults to raise: false" do - expect(CMDx::Executor).to receive(:execute).with(task, raise: false) - - task.execute - end + it "wraps a hash as Context" do + task = create_task_class.new(b: 2) + expect(task.context.to_h).to include(b: 2) end end describe "#work" do - it "raises UndefinedMethodError" do - expect { task.work }.to raise_error( - CMDx::UndefinedMethodError, - /undefined method.*#work/ - ) - end - - it "includes the class name in the error message" do - expect { task.work }.to raise_error( - CMDx::UndefinedMethodError, - /TestTask\d+#work/ - ) + it "raises ImplementationError for the base definition" do + task = create_task_class.new + expect { task.work }.to raise_error(CMDx::ImplementationError, /undefined method/) end end - describe "#logger" do - context "when class settings has logger" do - let(:class_logger) { Logger.new(IO::NULL) } + describe "signal throwers" do + let(:klass) { create_task_class } - before do - allow(task.class).to receive(:settings).and_return(mock_settings(logger: class_logger)) - end - - it "returns the class logger" do - expect(task.logger).to equal(class_logger) - end - end - - context "when class settings has no logger" do - let(:config_logger) { Logger.new(IO::NULL) } + it "success! throws a success signal" do + task = klass.new + signal = catch(CMDx::Signal::TAG) { task.send(:success!, "ok", foo: 1) } - before do - allow(task.class).to receive(:settings).and_return(mock_settings) - allow(CMDx.configuration).to receive(:logger).and_return(config_logger) - end - - it "returns the configuration logger" do - expect(task.logger).to equal(config_logger) - end + expect(signal).to have_attributes(status: "success", reason: "ok", metadata: { foo: 1 }) end - context "when log_level is customized" do - let(:shared_logger) { Logger.new(IO::NULL).tap { |l| l.level = Logger::INFO } } - - before do - allow(task.class).to receive(:settings).and_return(mock_settings(logger: shared_logger, log_level: Logger::DEBUG)) - end - - it "does not mutate the shared logger" do - task.logger - expect(shared_logger.level).to eq(Logger::INFO) - end + it "skip! throws a skipped signal" do + task = klass.new + signal = catch(CMDx::Signal::TAG) { task.send(:skip!, "later") } - it "returns a different logger instance" do - expect(task.logger).not_to equal(shared_logger) - expect(task.logger.level).to eq(Logger::DEBUG) - end + expect(signal.status).to eq("skipped") end - context "when log_formatter is customized" do - let(:shared_logger) { Logger.new(IO::NULL) } - let(:original_formatter) { shared_logger.formatter } - let(:custom_formatter) { proc { |_s, _d, _p, msg| "#{msg}\n" } } - - before do - allow(task.class).to receive(:settings).and_return(mock_settings(logger: shared_logger, log_formatter: custom_formatter)) - end - - it "does not mutate the shared logger" do - task.logger - expect(shared_logger.formatter).to eq(original_formatter) - end + it "fail! throws a failed signal with backtrace" do + task = klass.new + signal = catch(CMDx::Signal::TAG) { task.send(:fail!, "bad") } - it "returns a different logger instance" do - expect(task.logger).not_to equal(shared_logger) - expect(task.logger.formatter).to eq(custom_formatter) - end + expect(signal.status).to eq("failed") + expect(signal.backtrace).to be_an(Array) + expect(signal.backtrace).not_to be_empty end - end - describe "#to_h" do - let(:workflow_class) { Class.new(task_class) { include CMDx::Workflow } } - let(:workflow_task) { workflow_class.new } + it "throw! echoes another failed signal" do + source = CMDx::Signal.failed("upstream") + task = klass.new + signal = catch(CMDx::Signal::TAG) { task.send(:throw!, source) } - before do - allow(task.result).to receive(:index).and_return(5) - allow(task.chain).to receive(:id).and_return("chain-123") - allow(task.class).to receive(:settings).and_return(mock_settings(tags: %w[tag1 tag2])) + expect(signal.reason).to eq("upstream") + expect(signal.status).to eq("failed") end - context "when task is regular task" do - it "returns hash representation" do - result_hash = task.to_h - - expect(result_hash[:index]).to eq(5) - expect(result_hash[:chain_id]).to eq("chain-123") - expect(result_hash[:type]).to eq("Task") - expect(result_hash[:tags]).to eq(%w[tag1 tag2]) - expect(result_hash[:class]).to be_a(String) - expect(result_hash[:class]).to match(/TestTask\d+/) - expect(result_hash[:dry_run]).to be(false) - expect(result_hash[:id]).to eq(task.id) - expect(result_hash).not_to have_key(:context) + it "throw! is a no-op for non-failed signals" do + source = CMDx::Signal::Success + task = klass.new + result = catch(CMDx::Signal::TAG) do + task.send(:throw!, source) + :not_thrown end - end - context "when dump_context is enabled globally" do - let(:task) { task_class.new(foo: "bar") } - - before do - allow(task.result).to receive(:index).and_return(5) - allow(task.chain).to receive(:id).and_return("chain-123") - CMDx.configuration.dump_context = true - end - - after { CMDx.configuration.dump_context = false } - - it "includes context in hash" do - expect(task.to_h[:context]).to eq(foo: "bar") - end + expect(result).to eq(:not_thrown) end - context "when dump_context is enabled via task settings" do - let(:ctx_task_class) do - create_task_class(name: "CtxTask") do - settings dump_context: true - end - end - let(:task) { ctx_task_class.new(baz: 42) } - - before do - allow(task.result).to receive(:index).and_return(0) - allow(task.chain).to receive(:id).and_return("chain-456") - allow(task.class).to receive(:settings).and_return( - mock_settings(tags: [], dump_context: true) - ) - end + it "raises FrozenError when the task is frozen" do + task = klass.new + task.freeze - it "includes context in hash" do - expect(task.to_h[:context]).to eq(baz: 42) - end - end - - context "when task is workflow task" do - before do - allow(workflow_task.result).to receive(:index).and_return(3) - allow(workflow_task.chain).to receive(:id).and_return("workflow-chain-456") - allow(workflow_task.class).to receive(:settings).and_return(mock_settings(tags: ["workflow"])) - end - - it "returns hash with type 'Workflow'" do - result_hash = workflow_task.to_h - - expect(result_hash[:index]).to eq(3) - expect(result_hash[:chain_id]).to eq("workflow-chain-456") - expect(result_hash[:type]).to eq("Workflow") - expect(result_hash[:tags]).to eq(["workflow"]) - expect(result_hash[:class]).to be_a(String) - expect(result_hash[:class]).to match(/TestTask\d+/) - expect(result_hash[:dry_run]).to be(false) - expect(result_hash[:id]).to eq(workflow_task.id) - end + expect { task.send(:success!) }.to raise_error(FrozenError, "cannot throw signals") + expect { task.send(:fail!) }.to raise_error(FrozenError) + expect { task.send(:skip!) }.to raise_error(FrozenError) + expect { task.send(:throw!, CMDx::Signal.failed) }.to raise_error(FrozenError) end end - describe "#to_s" do - let(:hash_representation) { { key: "value", number: 42 } } - - before do - allow(task).to receive(:to_h).and_return(hash_representation) - end - - it "formats the hash using Utils::Format.to_str" do - expect(CMDx::Utils::Format).to receive(:to_str).with(hash_representation).and_return("formatted_string") - - result = task.to_s - - expect(result).to eq("formatted_string") + describe "#logger" do + it "memoizes a LoggerProxy-backed logger" do + task = create_task_class.new + first = task.logger + second = task.logger + expect(first).to be(second) end end end diff --git a/spec/cmdx/telemetry_spec.rb b/spec/cmdx/telemetry_spec.rb new file mode 100644 index 000000000..54b00e59d --- /dev/null +++ b/spec/cmdx/telemetry_spec.rb @@ -0,0 +1,135 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe CMDx::Telemetry do + subject(:telemetry) { described_class.new } + + describe "#initialize" do + it "starts with an empty registry" do + expect(telemetry).to be_empty + expect(telemetry.registry).to eq({}) + end + end + + describe "#initialize_copy" do + it "deep-dups each event's subscriber list" do + sub = ->(_p) {} + telemetry.subscribe(:task_started, sub) + + copy = telemetry.dup + copy.subscribe(:task_started, ->(_p) {}) + + expect(telemetry.registry[:task_started]).to eq([sub]) + expect(copy.registry[:task_started].size).to eq(2) + end + end + + describe "#subscribe" do + let(:sub) { ->(_p) {} } + + it "appends the subscriber and returns self" do + expect(telemetry.subscribe(:task_started, sub)).to be(telemetry) + expect(telemetry.registry[:task_started]).to eq([sub]) + end + + it "accepts a block" do + telemetry.subscribe(:task_started) { |_p| :ok } + expect(telemetry.registry[:task_started].size).to eq(1) + end + + it "raises when both callable and block are given" do + expect { telemetry.subscribe(:task_started, sub) { |_p| :ok } } + .to raise_error(ArgumentError, "provide either a callable or a block, not both") + end + + it "raises when the subscriber does not respond to call" do + expect { telemetry.subscribe(:task_started, "not callable") } + .to raise_error(ArgumentError, "subscriber must respond to #call") + end + + it "raises when the event is unknown" do + expect { telemetry.subscribe(:bogus, sub) } + .to raise_error(ArgumentError, /unknown event :bogus, must be one of/) + end + end + + describe "#unsubscribe" do + let(:sub1) { ->(_p) {} } + let(:sub2) { ->(_p) {} } + + it "removes the callable and returns self" do + telemetry.subscribe(:task_started, sub1) + telemetry.subscribe(:task_started, sub2) + + expect(telemetry.unsubscribe(:task_started, sub1)).to be(telemetry) + expect(telemetry.registry[:task_started]).to eq([sub2]) + end + + it "removes the event key when the last subscriber goes away" do + telemetry.subscribe(:task_started, sub1) + telemetry.unsubscribe(:task_started, sub1) + + expect(telemetry.registry).not_to have_key(:task_started) + expect(telemetry).to be_empty + end + + it "is a no-op when the event has no subscribers" do + expect(telemetry.unsubscribe(:task_started, sub1)).to be(telemetry) + end + + it "raises for an unknown event" do + expect { telemetry.unsubscribe(:bogus, sub1) } + .to raise_error(ArgumentError, /unknown event :bogus/) + end + end + + describe "#subscribed?" do + it "is true when at least one subscriber exists" do + telemetry.subscribe(:task_started, ->(_p) {}) + expect(telemetry.subscribed?(:task_started)).to be(true) + expect(telemetry.subscribed?(:task_executed)).to be(false) + end + end + + describe "#size and #count" do + before do + telemetry.subscribe(:task_started, ->(_p) {}) + telemetry.subscribe(:task_started, ->(_p) {}) + telemetry.subscribe(:task_executed, ->(_p) {}) + end + + it "size returns distinct event count" do + expect(telemetry.size).to eq(2) + end + + it "count returns total subscribers across events" do + expect(telemetry.count).to eq(3) + end + end + + describe "#emit" do + it "is a no-op when the event has no subscribers" do + expect { telemetry.emit(:task_started, :payload) }.not_to raise_error + end + + it "calls each subscriber with the payload, in order" do + received = [] + telemetry.subscribe(:task_started, ->(p) { received << [:a, p] }) + telemetry.subscribe(:task_started, ->(p) { received << [:b, p] }) + + telemetry.emit(:task_started, :payload) + expect(received).to eq([%i[a payload], %i[b payload]]) + end + + it "only fires subscribers for the given event" do + received = [] + telemetry.subscribe(:task_started, ->(p) { received << p }) + telemetry.subscribe(:task_executed, ->(p) { received << [:exec, p] }) + + telemetry.emit(:task_started, 42) + + expect(received).to eq([42]) + end + end +end diff --git a/spec/cmdx/util_spec.rb b/spec/cmdx/util_spec.rb new file mode 100644 index 000000000..a566e387d --- /dev/null +++ b/spec/cmdx/util_spec.rb @@ -0,0 +1,110 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe CMDx::Util do + let(:receiver) do + Class.new do + def truthy = true # rubocop:disable Naming/PredicateMethod + def falsy = false # rubocop:disable Naming/PredicateMethod + def shout(word) = word.upcase + end.new + end + + describe ".evaluate" do + context "with booleans and nil" do + it "returns false for nil" do + expect(described_class.evaluate(nil, receiver)).to be(false) + end + + it "returns false for false" do + expect(described_class.evaluate(false, receiver)).to be(false) + end + + it "returns true for true" do + expect(described_class.evaluate(true, receiver)).to be(true) + end + end + + context "with a Symbol" do + it "sends the method on the receiver" do + expect(described_class.evaluate(:truthy, receiver)).to be(true) + end + + it "forwards additional arguments" do + expect(described_class.evaluate(:shout, receiver, "hi")).to eq("HI") + end + end + + context "with a Proc" do + it "runs the proc via instance_exec on the receiver" do + probe = proc { truthy } + expect(described_class.evaluate(probe, receiver)).to be(true) + end + + it "forwards args and sets self to the receiver" do + probe = proc { |word| shout(word) } + expect(described_class.evaluate(probe, receiver, "hi")).to eq("HI") + end + end + + context "with a callable object" do + it "invokes #call with the receiver and args" do + callable = Class.new do + def self.call(receiver, word) + receiver.shout(word) + end + end + + expect(described_class.evaluate(callable, receiver, "hi")).to eq("HI") + end + end + + context "with an unsupported condition" do + it "raises ArgumentError" do + expect { described_class.evaluate(123, receiver) } + .to raise_error(ArgumentError, "condition must be a Symbol, Proc, or respond to #call") + end + end + end + + describe ".if?" do + it "returns true when condition is nil" do + expect(described_class.if?(nil, receiver)).to be(true) + end + + it "delegates to evaluate otherwise" do + expect(described_class.if?(:truthy, receiver)).to be(true) + expect(described_class.if?(:falsy, receiver)).to be(false) + end + end + + describe ".unless?" do + it "returns true when condition is nil" do + expect(described_class.unless?(nil, receiver)).to be(true) + end + + it "returns the negation of evaluate" do + expect(described_class.unless?(:truthy, receiver)).to be(false) + expect(described_class.unless?(:falsy, receiver)).to be(true) + end + end + + describe ".satisfied?" do + it "is true when if? is true and unless? is true" do + expect(described_class.satisfied?(:truthy, :falsy, receiver)).to be(true) + end + + it "is false when the if condition fails" do + expect(described_class.satisfied?(:falsy, :falsy, receiver)).to be(false) + end + + it "is false when the unless condition is truthy" do + expect(described_class.satisfied?(:truthy, :truthy, receiver)).to be(false) + end + + it "is true when both conditions are nil" do + expect(described_class.satisfied?(nil, nil, receiver)).to be(true) + end + end +end diff --git a/spec/cmdx/utils/call_spec.rb b/spec/cmdx/utils/call_spec.rb deleted file mode 100644 index cef60a7ef..000000000 --- a/spec/cmdx/utils/call_spec.rb +++ /dev/null @@ -1,424 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe CMDx::Utils::Call, type: :unit do - subject(:call_module) { described_class } - - let(:target_object) do - Class.new do - def test_method(*args, **kwargs, &block) - result = { args: args, kwargs: kwargs } - result[:block_result] = yield if block - result - end - - def no_args_method - "no_args_result" - end - - def method_with_return_value(value) - "returned_#{value}" - end - - def method_with_side_effect - @side_effect_called = true - "side_effect_result" - end - - def side_effect_called? - @side_effect_called == true - end - end.new - end - - describe ".invoke" do - context "when callable is a Symbol" do - it "calls the method on target with no arguments" do - result = call_module.invoke(target_object, :no_args_method) - - expect(result).to eq("no_args_result") - end - - it "calls the method on target with positional arguments" do - result = call_module.invoke(target_object, :test_method, "arg1", "arg2") - - expect(result).to eq({ - args: %w[arg1 arg2], - kwargs: {} - }) - end - - it "calls the method on target with keyword arguments" do - result = call_module.invoke(target_object, :test_method, key1: "value1", key2: "value2") - - expect(result).to eq({ - args: [], - kwargs: { key1: "value1", key2: "value2" } - }) - end - - it "calls the method on target with both positional and keyword arguments" do - result = call_module.invoke(target_object, :test_method, "arg1", key: "value") - - expect(result).to eq({ - args: ["arg1"], - kwargs: { key: "value" } - }) - end - - it "calls the method on target with a block" do - result = call_module.invoke(target_object, :test_method) { "block_result" } - - expect(result).to eq({ - args: [], - kwargs: {}, - block_result: "block_result" - }) - end - - it "calls the method on target with arguments and block" do - result = call_module.invoke(target_object, :test_method, "arg1", key: "value") { "block_result" } - - expect(result).to eq({ - args: ["arg1"], - kwargs: { key: "value" }, - block_result: "block_result" - }) - end - - it "returns the method's return value" do - result = call_module.invoke(target_object, :method_with_return_value, "test") - - expect(result).to eq("returned_test") - end - - it "executes method with side effects" do - expect(target_object.side_effect_called?).to be(false) - - call_module.invoke(target_object, :method_with_side_effect) - - expect(target_object.side_effect_called?).to be(true) - end - end - - context "when callable is a Proc" do - it "executes proc in target context with no arguments" do - proc_callable = proc { no_args_method } - - result = call_module.invoke(target_object, proc_callable) - - expect(result).to eq("no_args_result") - end - - it "executes proc in target context with positional arguments" do - proc_callable = proc { |arg1, arg2| test_method(arg1, arg2) } - - result = call_module.invoke(target_object, proc_callable, "arg1", "arg2") - - expect(result).to eq({ - args: %w[arg1 arg2], - kwargs: {} - }) - end - - it "executes proc in target context with keyword arguments" do - proc_callable = proc { |key1:, key2:| test_method(key1: key1, key2: key2) } - - result = call_module.invoke(target_object, proc_callable, key1: "value1", key2: "value2") - - expect(result).to eq({ - args: [], - kwargs: { key1: "value1", key2: "value2" } - }) - end - - it "executes proc in target context with mixed arguments" do - proc_callable = proc { |arg, key:| test_method(arg, key: key) } - - result = call_module.invoke(target_object, proc_callable, "arg1", key: "value") - - expect(result).to eq({ - args: ["arg1"], - kwargs: { key: "value" } - }) - end - - it "executes proc with access to target instance variables" do - proc_callable = proc do - @test_var = "set_by_proc" - @test_var # rubocop:disable RSpec/InstanceVariable - end - - result = call_module.invoke(target_object, proc_callable) - - expect(result).to eq("set_by_proc") - expect(target_object.instance_variable_get(:@test_var)).to eq("set_by_proc") - end - - it "executes proc with access to target methods" do - proc_callable = proc { method_with_return_value("from_proc") } - - result = call_module.invoke(target_object, proc_callable) - - expect(result).to eq("returned_from_proc") - end - - it "executes proc with block" do - proc_callable = proc { test_method { "block_result" } } - - result = call_module.invoke(target_object, proc_callable) - - expect(result).to eq({ - args: [], - kwargs: {}, - block_result: "block_result" - }) - end - - it "executes proc that modifies target state" do - proc_callable = proc { method_with_side_effect } - - expect(target_object.side_effect_called?).to be(false) - - call_module.invoke(target_object, proc_callable) - - expect(target_object.side_effect_called?).to be(true) - end - - it "handles proc that returns nil" do - proc_callable = proc {} - - result = call_module.invoke(target_object, proc_callable) - - expect(result).to be_nil - end - - it "handles proc that raises an exception" do - proc_callable = proc { raise StandardError, "proc error" } - - expect do - call_module.invoke(target_object, proc_callable) - end.to raise_error(StandardError, "proc error") - end - end - - context "when callable responds to :call" do - let(:callable_object) do - Class.new do - def call(*args, **kwargs, &block) - result = { args: args, kwargs: kwargs } - result[:block_result] = yield if block - result - end - end.new - end - - it "calls the callable object with no arguments" do - result = call_module.invoke(target_object, callable_object) - - expect(result).to eq({ - args: [], - kwargs: {} - }) - end - - it "calls the callable object with positional arguments" do - result = call_module.invoke(target_object, callable_object, "arg1", "arg2") - - expect(result).to eq({ - args: %w[arg1 arg2], - kwargs: {} - }) - end - - it "calls the callable object with keyword arguments" do - result = call_module.invoke(target_object, callable_object, key1: "value1", key2: "value2") - - expect(result).to eq({ - args: [], - kwargs: { key1: "value1", key2: "value2" } - }) - end - - it "calls the callable object with mixed arguments" do - result = call_module.invoke(target_object, callable_object, "arg1", key: "value") - - expect(result).to eq({ - args: ["arg1"], - kwargs: { key: "value" } - }) - end - - it "calls the callable object with a block" do - result = call_module.invoke(target_object, callable_object) { "block_result" } - - expect(result).to eq({ - args: [], - kwargs: {}, - block_result: "block_result" - }) - end - - it "returns the callable object's return value" do - return_value_callable = Class.new do - def call - "callable_result" - end - end.new - - result = call_module.invoke(target_object, return_value_callable) - - expect(result).to eq("callable_result") - end - - it "handles callable that returns nil" do - nil_callable = Class.new do - def call - nil - end - end.new - - result = call_module.invoke(target_object, nil_callable) - - expect(result).to be_nil - end - - it "handles callable that raises an exception" do - error_callable = Class.new do - def call - raise StandardError, "callable error" - end - end.new - - expect do - call_module.invoke(target_object, error_callable) - end.to raise_error(StandardError, "callable error") - end - end - - context "when callable is lambda" do - it "executes lambda in target context" do - lambda_callable = -> { no_args_method } - - result = call_module.invoke(target_object, lambda_callable) - - expect(result).to eq("no_args_result") - end - - it "executes lambda with strict argument checking" do - lambda_callable = ->(arg) { method_with_return_value(arg) } - - result = call_module.invoke(target_object, lambda_callable, "test") - - expect(result).to eq("returned_test") - end - - it "raises error when lambda argument count doesn't match" do - lambda_callable = ->(arg) { method_with_return_value(arg) } - - expect do - call_module.invoke(target_object, lambda_callable) - end.to raise_error(ArgumentError) - end - end - - context "when callable is invalid" do - it "raises error for string" do - expect do - call_module.invoke(target_object, "invalid_string") - end.to raise_error(/cannot invoke invalid_string/) - end - - it "raises error for integer" do - expect do - call_module.invoke(target_object, 42) - end.to raise_error(/cannot invoke 42/) - end - - it "raises error for array" do - expect do - call_module.invoke(target_object, [1, 2, 3]) - end.to raise_error(/cannot invoke \[1, 2, 3\]/) - end - - it "raises error for hash" do - if RubyVersion.min?(3.4) - expect do - call_module.invoke(target_object, { key: "value" }) - end.to raise_error(/cannot invoke {key: "value"}/) - else - expect do - call_module.invoke(target_object, { key: "value" }) - end.to raise_error(/cannot invoke {:key=>"value"}/) - end - end - - it "raises error for nil" do - expect do - call_module.invoke(target_object, nil) - end.to raise_error(/cannot invoke /) - end - - it "raises error for object that doesn't respond to call" do - non_callable = Class.new.new - - expect do - call_module.invoke(target_object, non_callable) - end.to raise_error(/cannot invoke/) - end - end - - context "when target raises NoMethodError for symbol callable" do - it "raises NoMethodError for undefined method" do - expect do - call_module.invoke(target_object, :undefined_method) - end.to raise_error(NoMethodError) - end - end - - context "with edge cases" do - it "handles empty symbol" do - empty_symbol = :"" - - expect do - call_module.invoke(target_object, empty_symbol) - end.to raise_error(NoMethodError) - end - - it "handles proc with default parameters" do - proc_with_defaults = proc { |arg = "default"| method_with_return_value(arg) } - - result = call_module.invoke(target_object, proc_with_defaults) - - expect(result).to eq("returned_default") - end - - it "handles proc with variable arguments" do - proc_with_splat = proc { |*args| test_method(*args) } - - result = call_module.invoke(target_object, proc_with_splat, "arg1", "arg2", "arg3") - - expect(result).to eq({ - args: %w[arg1 arg2 arg3], - kwargs: {} - }) - end - - it "handles callable with variable arguments" do - splat_callable = Class.new do - def call(*args, **kwargs) - { received_args: args, received_kwargs: kwargs } - end - end.new - - result = call_module.invoke(target_object, splat_callable, "a", "b", x: 1, y: 2) - - expect(result).to eq({ - received_args: %w[a b], - received_kwargs: { x: 1, y: 2 } - }) - end - end - end -end diff --git a/spec/cmdx/utils/condition_spec.rb b/spec/cmdx/utils/condition_spec.rb deleted file mode 100644 index cf21d20ae..000000000 --- a/spec/cmdx/utils/condition_spec.rb +++ /dev/null @@ -1,444 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe CMDx::Utils::Condition, type: :unit do - subject(:condition_module) { described_class } - - let(:target_object) do - Class.new do - def test_method(*args, **kwargs, &block) - result = { args: args, kwargs: kwargs } - result[:block_result] = yield if block - result - end - - def no_args_method - "no_args_result" - end - - def method_with_args(arg1, arg2) - "#{arg1}_#{arg2}" - end - - def method_with_kwargs(name:, value:) - "#{name}: #{value}" - end - - def truthy_method? - true - end - - def falsy_method? - false - end - - def instance_variable_check - @instance_variable_check ||= "instance_value" - end - - attr_accessor :accessible_value - end.new - end - - describe ".evaluate" do - context "when options contain if condition" do - context "with Symbol if condition" do - it "returns true when if condition evaluates to truthy" do - result = condition_module.evaluate(target_object, { if: :truthy_method? }) - - expect(result).to be(true) - end - - it "returns false when if condition evaluates to falsy" do - result = condition_module.evaluate(target_object, { if: :falsy_method? }) - - expect(result).to be(false) - end - - it "passes arguments to the if condition method" do - result = condition_module.evaluate(target_object, { if: :method_with_args }, "hello", "world") - - expect(result).to be_truthy - end - - it "passes keyword arguments to the if condition method" do - result = condition_module.evaluate(target_object, { if: :method_with_kwargs }, name: "test", value: "data") - - expect(result).to be_truthy - end - - it "passes block to the if condition method" do - expect(target_object).to receive(:test_method).and_return(true) - - condition_module.evaluate(target_object, { if: :test_method }) { "block_value" } - end - end - - context "with Proc if condition" do - it "returns true when Proc evaluates to truthy" do - truthy_proc = proc { true } - result = condition_module.evaluate(target_object, { if: truthy_proc }) - - expect(result).to be(true) - end - - it "returns false when Proc evaluates to falsy" do - falsy_proc = proc { false } - result = condition_module.evaluate(target_object, { if: falsy_proc }) - - expect(result).to be(false) - end - - it "executes Proc in the context of target object" do - context_proc = proc { instance_variable_check } - result = condition_module.evaluate(target_object, { if: context_proc }) - - expect(result).to be_truthy - end - - it "passes arguments to the Proc" do - arg_proc = proc { |arg1, arg2| arg1 == "hello" && arg2 == "world" } - result = condition_module.evaluate(target_object, { if: arg_proc }, "hello", "world") - - expect(result).to be(true) - end - - it "passes keyword arguments to the Proc" do - kwarg_proc = proc { |name:, value:| name == "test" && value == "data" } - result = condition_module.evaluate(target_object, { if: kwarg_proc }, name: "test", value: "data") - - expect(result).to be(true) - end - end - - context "with callable object if condition" do - let(:callable_object) do - Class.new do - def call(*_args, **_kwargs, &) - true - end - end.new - end - - let(:falsy_callable_object) do - Class.new do - def call(*_args, **_kwargs, &) - false - end - end.new - end - - it "returns true when callable returns truthy" do - result = condition_module.evaluate(target_object, { if: callable_object }) - - expect(result).to be(true) - end - - it "returns false when callable returns falsy" do - result = condition_module.evaluate(target_object, { if: falsy_callable_object }) - - expect(result).to be(false) - end - - it "passes arguments to the callable" do - arg_callable = Class.new do - def call(arg1, arg2) - arg1 == "hello" && arg2 == "world" - end - end.new - - result = condition_module.evaluate(target_object, { if: arg_callable }, "hello", "world") - - expect(result).to be(true) - end - end - - context "with boolean if condition" do - it "returns true when if condition is true" do - result = condition_module.evaluate(target_object, { if: true }) - - expect(result).to be(true) - end - - it "returns false when if condition is false" do - result = condition_module.evaluate(target_object, { if: false }) - - expect(result).to be(false) - end - - it "returns false when if condition is nil" do - result = condition_module.evaluate(target_object, { if: nil }) - - expect(result).to be(false) - end - end - end - - context "when options contain unless condition" do - context "with Symbol unless condition" do - it "returns false when unless condition evaluates to truthy" do - result = condition_module.evaluate(target_object, { unless: :truthy_method? }) - - expect(result).to be(false) - end - - it "returns true when unless condition evaluates to falsy" do - result = condition_module.evaluate(target_object, { unless: :falsy_method? }) - - expect(result).to be(true) - end - - it "passes arguments to the unless condition method" do - result = condition_module.evaluate(target_object, { unless: :method_with_args }, "hello", "world") - - expect(result).to be_falsy - end - end - - context "with Proc unless condition" do - it "returns false when Proc evaluates to truthy" do - truthy_proc = proc { true } - result = condition_module.evaluate(target_object, { unless: truthy_proc }) - - expect(result).to be(false) - end - - it "returns true when Proc evaluates to falsy" do - falsy_proc = proc { false } - result = condition_module.evaluate(target_object, { unless: falsy_proc }) - - expect(result).to be(true) - end - end - - context "with boolean unless condition" do - it "returns false when unless condition is true" do - result = condition_module.evaluate(target_object, { unless: true }) - - expect(result).to be(false) - end - - it "returns true when unless condition is false" do - result = condition_module.evaluate(target_object, { unless: false }) - - expect(result).to be(true) - end - - it "returns true when unless condition is nil" do - result = condition_module.evaluate(target_object, { unless: nil }) - - expect(result).to be(true) - end - end - end - - context "when options contain both if and unless conditions" do - it "returns true when if is truthy and unless is falsy" do - result = condition_module.evaluate(target_object, { if: :truthy_method?, unless: :falsy_method? }) - - expect(result).to be(true) - end - - it "returns false when if is truthy and unless is truthy" do - result = condition_module.evaluate(target_object, { if: :truthy_method?, unless: :truthy_method? }) - - expect(result).to be(false) - end - - it "returns false when if is falsy and unless is falsy" do - result = condition_module.evaluate(target_object, { if: :falsy_method?, unless: :falsy_method? }) - - expect(result).to be(false) - end - - it "returns false when if is falsy and unless is truthy" do - result = condition_module.evaluate(target_object, { if: :falsy_method?, unless: :truthy_method? }) - - expect(result).to be(false) - end - - it "passes arguments to both conditions" do - if_proc = proc { |arg| arg == "test" } - unless_proc = proc { |arg| arg == "fail" } - - result = condition_module.evaluate(target_object, { if: if_proc, unless: unless_proc }, "test") - - expect(result).to be(true) - end - end - - context "when options contain neither if nor unless" do - it "returns true for empty options" do - result = condition_module.evaluate(target_object, {}) - - expect(result).to be(true) - end - - it "returns true for options with other keys" do - result = condition_module.evaluate(target_object, { other_key: "value" }) - - expect(result).to be(true) - end - end - - context "with invalid callable objects" do - let(:invalid_callable) do - Object.new - end - - it "raises an error when if condition is not callable" do - expect do - condition_module.evaluate(target_object, { if: invalid_callable }) - end.to raise_error(RuntimeError, /cannot evaluate/) - end - - it "raises an error when unless condition is not callable" do - expect do - condition_module.evaluate(target_object, { unless: invalid_callable }) - end.to raise_error(RuntimeError, /cannot evaluate/) - end - - it "includes the invalid object in the error message" do - expect do - condition_module.evaluate(target_object, { if: invalid_callable }) - end.to raise_error(RuntimeError, /#{Regexp.escape(invalid_callable.inspect)}/) - end - end - - context "when target object doesn't respond to method" do - it "raises NoMethodError for Symbol condition" do - expect do - condition_module.evaluate(target_object, { if: :nonexistent_method }) - end.to raise_error(NoMethodError) - end - end - end - - describe "EVAL constant" do - let(:eval_proc) { described_class.const_get(:EVAL) } - - context "when callable is NilClass" do - it "returns false" do - result = eval_proc.call(target_object, nil) - - expect(result).to be(false) - end - end - - context "when callable is FalseClass" do - it "returns false" do - result = eval_proc.call(target_object, false) - - expect(result).to be(false) - end - end - - context "when callable is TrueClass" do - it "returns true" do - result = eval_proc.call(target_object, true) - - expect(result).to be(true) - end - end - - context "when callable is Symbol" do - it "calls the method on target" do - result = eval_proc.call(target_object, :no_args_method) - - expect(result).to eq("no_args_result") - end - - it "passes arguments to the method" do - result = eval_proc.call(target_object, :method_with_args, "hello", "world") - - expect(result).to eq("hello_world") - end - - it "passes keyword arguments to the method" do - result = eval_proc.call(target_object, :method_with_kwargs, name: "test", value: "data") - - expect(result).to eq("test: data") - end - - it "passes block to the method" do - result = eval_proc.call(target_object, :test_method) { "block_value" } - - expect(result[:block_result]).to eq("block_value") - end - end - - context "when callable is Proc" do - it "executes the Proc in target context" do - test_proc = proc { instance_variable_check } - result = eval_proc.call(target_object, test_proc) - - expect(result).to eq("instance_value") - end - - it "passes arguments to the Proc" do - arg_proc = proc { |arg1, arg2| "#{arg1}_#{arg2}" } - result = eval_proc.call(target_object, arg_proc, "hello", "world") - - expect(result).to eq("hello_world") - end - - it "passes keyword arguments to the Proc" do - kwarg_proc = proc { |name:, value:| "#{name}: #{value}" } - result = eval_proc.call(target_object, kwarg_proc, name: "test", value: "data") - - expect(result).to eq("test: data") - end - end - - context "when callable has call method" do - let(:callable_object) do - Class.new do - def call(*args, **kwargs, &block) - { args: args, kwargs: kwargs, block_called: block&.call } - end - end.new - end - - it "calls the call method" do - result = eval_proc.call(target_object, callable_object) - - expect(result).to eq({ args: [], kwargs: {}, block_called: nil }) - end - - it "passes arguments to the call method" do - result = eval_proc.call(target_object, callable_object, "arg1", "arg2") - - expect(result).to eq({ args: %w[arg1 arg2], kwargs: {}, block_called: nil }) - end - - it "passes keyword arguments to the call method" do - result = eval_proc.call(target_object, callable_object, name: "test", value: "data") - - expect(result).to eq({ args: [], kwargs: { name: "test", value: "data" }, block_called: nil }) - end - - it "passes block to the call method" do - result = eval_proc.call(target_object, callable_object) { "block_value" } - - expect(result).to eq({ args: [], kwargs: {}, block_called: "block_value" }) - end - end - - context "when callable doesn't respond to call" do - let(:invalid_object) { Object.new } - - it "raises an error" do - expect do - eval_proc.call(target_object, invalid_object) - end.to raise_error(RuntimeError, /cannot evaluate/) - end - - it "includes the object in the error message" do - expect do - eval_proc.call(target_object, invalid_object) - end.to raise_error(RuntimeError, /#{Regexp.escape(invalid_object.inspect)}/) - end - end - end -end diff --git a/spec/cmdx/utils/format_spec.rb b/spec/cmdx/utils/format_spec.rb deleted file mode 100644 index 0d43e1587..000000000 --- a/spec/cmdx/utils/format_spec.rb +++ /dev/null @@ -1,289 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe CMDx::Utils::Format, type: :unit do - subject(:format_module) { described_class } - - describe ".to_log" do - context "when message is a CMDx object with to_h method" do - let(:cmdx_context) { CMDx::Context.new(user_id: 123, name: "test") } - let(:task_class) do - Class.new(CMDx::Task) do - def work - # no-op - end - end - end - let(:task) { task_class.new } - - it "returns the hash representation of CMDx::Context" do - result = format_module.to_log(cmdx_context) - - expect(result).to eq({ user_id: 123, name: "test" }) - end - - it "returns the hash representation of CMDx::Task" do - result = format_module.to_log(task) - - expect(result).to be_a(Hash) - expect(result).to include(:class, :index, :chain_id, :type, :tags, :id) - end - - it "returns the hash representation of CMDx::Chain" do - chain = CMDx::Chain.new - - result = format_module.to_log(chain) - - expect(result).to be_a(Hash) - expect(result).to include(:id, :results) - end - - it "returns the hash representation of CMDx::Result" do - task = task_class.new - result_obj = CMDx::Result.new(task) - - result = format_module.to_log(result_obj) - - expect(result).to be_a(Hash) - expect(result).to include(:state, :status, :outcome, :metadata) - end - - it "returns the hash representation of CMDx::Errors" do - errors = CMDx::Errors.new - errors.add(:name, "can't be blank") - - result = format_module.to_log(errors) - - expect(result).to eq({ name: ["can't be blank"] }) - end - end - - context "when message responds to to_h but is not a CMDx object" do - let(:non_cmdx_object) do - Class.new do - def to_h - { key: "value" } - end - - def class - Class.new do - def ancestors - [Object, BasicObject] - end - end.new - end - end.new - end - - it "returns the original message unchanged" do - result = format_module.to_log(non_cmdx_object) - - expect(result).to eq(non_cmdx_object) - end - end - - context "when message does not respond to to_h" do - it "returns string message unchanged" do - message = "Simple log message" - - result = format_module.to_log(message) - - expect(result).to eq("Simple log message") - end - - it "returns integer message unchanged" do - message = 42 - - result = format_module.to_log(message) - - expect(result).to eq(42) - end - - it "returns array message unchanged" do - message = [1, 2, 3] - - result = format_module.to_log(message) - - expect(result).to eq([1, 2, 3]) - end - - it "returns hash message unchanged" do - message = { key: "value" } - - result = format_module.to_log(message) - - expect(result).to eq({ key: "value" }) - end - - it "returns nil message unchanged" do - result = format_module.to_log(nil) - - expect(result).to be_nil - end - end - - context "when message responds to to_h but ancestors check returns false" do - let(:object_with_to_h) do - obj = Object.new - def obj.to_h - { custom: "hash" } - end - obj - end - - it "returns the original message unchanged" do - result = format_module.to_log(object_with_to_h) - - expect(result).to eq(object_with_to_h) - end - end - end - - describe ".to_str" do - context "when using default formatter" do - let(:hash) { { name: "John", age: 30, active: true } } - - it "formats hash using default key=value.inspect format" do - result = format_module.to_str(hash) - - expect(result).to eq('name="John" age=30 active=true') - end - - it "handles empty hash" do - result = format_module.to_str({}) - - expect(result).to eq("") - end - - it "handles hash with symbol keys" do - hash = { user_id: 123, email: "test@example.com" } - - result = format_module.to_str(hash) - - expect(result).to eq('user_id=123 email="test@example.com"') - end - - it "handles hash with string keys" do - hash = { "name" => "Alice", "role" => "admin" } - - result = format_module.to_str(hash) - - expect(result).to eq('name="Alice" role="admin"') - end - - it "handles hash with mixed value types" do - hash = { id: 1, name: "Test", data: [1, 2, 3], meta: { nested: true } } - - result = format_module.to_str(hash) - - if RubyVersion.min?(3.4) - expect(result).to eq('id=1 name="Test" data=[1, 2, 3] meta={nested: true}') - else - expect(result).to eq('id=1 name="Test" data=[1, 2, 3] meta={:nested=>true}') - end - end - - it "handles hash with nil values" do - hash = { name: "John", email: nil, age: 25 } - - result = format_module.to_str(hash) - - expect(result).to eq('name="John" email=nil age=25') - end - end - - context "when using custom formatter block" do - let(:hash) { { name: "John", age: 30, city: "NYC" } } - - it "uses the provided block for formatting" do - result = format_module.to_str(hash) { |key, value| "#{key}: #{value}" } - - expect(result).to eq("name: John age: 30 city: NYC") - end - - it "allows custom separator in block" do - result = format_module.to_str(hash) { |key, value| "#{key}=#{value}" } - - expect(result).to eq("name=John age=30 city=NYC") - end - - it "handles complex formatting logic in block" do - result = format_module.to_str(hash) do |key, value| - case key - when :name then "USER: #{value.upcase}" - when :age then "AGE: #{value} years" - else "#{key.upcase}: #{value}" - end - end - - expect(result).to eq("USER: JOHN AGE: 30 years CITY: NYC") - end - - it "handles block that returns nil" do - result = format_module.to_str(hash) { |_key, _value| nil } - - expect(result).to eq(" ") - end - - it "handles block that returns empty string" do - result = format_module.to_str(hash) { |_key, _value| "" } - - expect(result).to eq(" ") - end - end - - context "with edge cases" do - it "handles hash with special characters in values" do - hash = { message: "Hello\nWorld", path: "/tmp/test file.txt" } - - result = format_module.to_str(hash) - - expect(result).to eq('message="Hello\nWorld" path="/tmp/test file.txt"') - end - - it "handles hash with unicode characters" do - hash = { name: "José", emoji: "🚀", chinese: "测试" } - - result = format_module.to_str(hash) - - expect(result).to eq('name="José" emoji="🚀" chinese="测试"') - end - - it "handles large hash efficiently" do - large_hash = (1..100).to_h { |i| ["key#{i}", "value#{i}"] } - - expect { format_module.to_str(large_hash) }.not_to raise_error - end - end - end - - describe "FORMATTER constant" do - it "is private" do - expect { described_class::FORMATTER }.to raise_error(NameError) - end - - it "is frozen" do - formatter = described_class.send(:const_get, :FORMATTER) - - expect(formatter).to be_frozen - end - - it "formats key-value pairs correctly" do - formatter = described_class.send(:const_get, :FORMATTER) - - result = formatter.call(:name, "John") - - expect(result).to eq('name="John"') - end - - it "handles different value types" do - formatter = described_class.send(:const_get, :FORMATTER) - - expect(formatter.call(:id, 123)).to eq("id=123") - expect(formatter.call(:active, true)).to eq("active=true") - expect(formatter.call(:data, nil)).to eq("data=nil") - expect(formatter.call(:items, [1, 2])).to eq("items=[1, 2]") - end - end -end diff --git a/spec/cmdx/utils/normalize_spec.rb b/spec/cmdx/utils/normalize_spec.rb deleted file mode 100644 index 5e27b6c8e..000000000 --- a/spec/cmdx/utils/normalize_spec.rb +++ /dev/null @@ -1,94 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe CMDx::Utils::Normalize, type: :unit do - subject(:normalize_module) { described_class } - - describe ".exception" do - context "when given a standard error" do - it "returns class and message in bracket format" do - error = StandardError.new("something went wrong") - result = normalize_module.exception(error) - - expect(result).to eq("[StandardError] something went wrong") - end - end - - context "when given a custom error class" do - it "includes the full class name" do - error = ArgumentError.new("bad argument") - result = normalize_module.exception(error) - - expect(result).to eq("[ArgumentError] bad argument") - end - end - - context "when given an error with an empty message" do - it "returns the class with an empty message" do - error = RuntimeError.new("") - result = normalize_module.exception(error) - - expect(result).to eq("[RuntimeError] ") - end - end - end - - describe ".statuses" do - context "when object is an array of symbols" do - it "returns unique string representations" do - result = normalize_module.statuses(%i[success pending success]) - - expect(result).to eq(%w[success pending]) - end - end - - context "when object is an array of strings" do - it "returns unique strings" do - result = normalize_module.statuses(%w[success pending success]) - - expect(result).to eq(%w[success pending]) - end - end - - context "when object is an array of mixed types" do - it "converts all to strings and deduplicates" do - result = normalize_module.statuses([:success, "success"]) - - expect(result).to eq(["success"]) - end - end - - context "when object is a single symbol" do - it "returns a single-element string array" do - result = normalize_module.statuses(:success) - - expect(result).to eq(["success"]) - end - end - - context "when object is a single string" do - it "returns a single-element string array" do - result = normalize_module.statuses("pending") - - expect(result).to eq(["pending"]) - end - end - - context "when object is nil" do - it "returns an empty array" do - result = normalize_module.statuses(nil) - - expect(result).to eq([]) - end - end - - context "when object is an empty array" do - it "returns an empty array" do - result = normalize_module.statuses([]) - - expect(result).to eq([]) - end - end - end -end diff --git a/spec/cmdx/utils/wrap_spec.rb b/spec/cmdx/utils/wrap_spec.rb deleted file mode 100644 index a7129ee49..000000000 --- a/spec/cmdx/utils/wrap_spec.rb +++ /dev/null @@ -1,60 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe CMDx::Utils::Wrap, type: :unit do - subject(:wrap_module) { described_class } - - describe ".array" do - context "when object is already an array" do - it "returns the same array" do - array = [1, 2, 3] - result = wrap_module.array(array) - - expect(result).to be(array) - end - - it "returns an empty array as-is" do - result = wrap_module.array([]) - - expect(result).to eq([]) - end - end - - context "when object is nil" do - it "returns an empty array" do - result = wrap_module.array(nil) - - expect(result).to eq([]) - end - end - - context "when object is a single value" do - it "wraps an integer" do - result = wrap_module.array(1) - - expect(result).to eq([1]) - end - - it "wraps a string" do - result = wrap_module.array("hello") - - expect(result).to eq(["hello"]) - end - - it "wraps a symbol" do - result = wrap_module.array(:foo) - - expect(result).to eq([:foo]) - end - end - - context "when object is a hash" do - it "wraps the hash in an array" do - result = wrap_module.array({ a: 1, b: 2 }) - - expect(result).to eq([[:a, 1], [:b, 2]]) - end - end - end -end diff --git a/spec/cmdx/validator_registry_spec.rb b/spec/cmdx/validator_registry_spec.rb deleted file mode 100644 index cf0d308da..000000000 --- a/spec/cmdx/validator_registry_spec.rb +++ /dev/null @@ -1,433 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe CMDx::ValidatorRegistry, type: :unit do - subject(:registry) { described_class.new(initial_registry) } - - let(:initial_registry) { nil } - let(:mock_validator) { instance_double("MockValidator") } - let(:mock_task) { instance_double(CMDx::Task) } - - describe "#initialize" do - context "when no registry is provided" do - subject(:registry) { described_class.new } - - it "initializes with default coercions" do - expect(registry.registry).to include( - exclusion: CMDx::Validators::Exclusion, - format: CMDx::Validators::Format, - inclusion: CMDx::Validators::Inclusion, - length: CMDx::Validators::Length, - numeric: CMDx::Validators::Numeric, - presence: CMDx::Validators::Presence - ) - end - end - - context "when a registry is provided" do - let(:initial_registry) { { custom: mock_validator } } - - it "initializes with the provided registry" do - expect(registry.registry).to eq({ custom: mock_validator }) - end - end - end - - describe "#registry" do - let(:initial_registry) { { custom: mock_validator } } - - it "returns the internal registry hash" do - expect(registry.registry).to eq({ custom: mock_validator }) - end - end - - describe "#to_h" do - let(:initial_registry) { { custom: mock_validator } } - - it "returns the registry hash" do - expect(registry.to_h).to eq({ custom: mock_validator }) - end - - it "is an alias for registry" do - expect(registry.method(:to_h)).to eq(registry.method(:registry)) - end - end - - describe "#keys" do - let(:initial_registry) { { custom: mock_validator, another: mock_validator } } - - it "returns the keys from the registry" do - expect(registry.keys).to match_array(%i[custom another]) - end - - it "delegates to the registry hash" do - allow(registry.registry).to receive(:keys).and_return([:delegated]) - - expect(registry.keys).to eq([:delegated]) - end - end - - describe "#dup" do - it "returns a new ValidatorRegistry instance" do - duplicated = registry.dup - - expect(duplicated).to be_a(described_class) - expect(duplicated).not_to be(registry) - end - - it "shares the parent registry until first write" do - duplicated = registry.dup - - expect(duplicated.registry).to eq(registry.registry) - expect(duplicated.registry).to be(registry.registry) - end - - it "materializes on write and does not affect the parent" do - duplicated = registry.dup - - duplicated.register(:new_type, mock_validator) - - expect(duplicated.registry).to have_key(:new_type) - expect(registry.registry).not_to have_key(:new_type) - expect(duplicated.registry).not_to be(registry.registry) - end - end - - describe "#register" do - context "when registering a validator with string name" do - it "adds the validator to the registry with symbol key" do - registry.register("custom", mock_validator) - - expect(registry.registry[:custom]).to eq(mock_validator) - end - - it "returns self for method chaining" do - result = registry.register("custom", mock_validator) - - expect(result).to be(registry) - end - end - - context "when registering a validator with symbol name" do - it "adds the validator to the registry" do - registry.register(:custom, mock_validator) - - expect(registry.registry[:custom]).to eq(mock_validator) - end - end - - context "when registering to an existing registry" do - let(:initial_registry) { { existing: "existing_validator" } } - - it "adds new validator to existing ones" do - registry.register(:new_type, mock_validator) - - expect(registry.registry).to include(existing: "existing_validator", new_type: mock_validator) - end - end - - context "when registering over an existing validator" do - let(:initial_registry) { { existing: "old_validator" } } - - it "overwrites the existing validator" do - registry.register(:existing, mock_validator) - - expect(registry.registry[:existing]).to eq(mock_validator) - end - end - end - - describe "#deregister" do - context "when deregistering with string name" do - before do - registry.register("custom", mock_validator) - end - - it "removes the validator from the registry" do - registry.deregister("custom") - - expect(registry.registry).not_to have_key(:custom) - end - - it "returns self for method chaining" do - result = registry.deregister("custom") - - expect(result).to be(registry) - end - end - - context "when deregistering with symbol name" do - before do - registry.register(:custom, mock_validator) - end - - it "removes the validator from the registry" do - registry.deregister(:custom) - - expect(registry.registry).not_to have_key(:custom) - end - end - - context "when deregistering from existing registry" do - let(:initial_registry) { { existing: "existing_validator", custom: mock_validator } } - - it "removes only the specified validator" do - registry.deregister(:custom) - - expect(registry.registry).to include(existing: "existing_validator") - expect(registry.registry).not_to have_key(:custom) - end - end - - context "when deregistering non-existent validator" do - it "does not raise an error" do - expect { registry.deregister(:nonexistent) }.not_to raise_error - end - - it "returns self" do - result = registry.deregister(:nonexistent) - - expect(result).to be(registry) - end - - it "does not affect existing validators" do - registry.register(:existing, mock_validator) - registry.deregister(:nonexistent) - - expect(registry.registry[:existing]).to eq(mock_validator) - end - end - - context "when deregistering from empty registry" do - let(:initial_registry) { {} } - - it "does not raise an error" do - expect { registry.deregister(:custom) }.not_to raise_error - end - - it "returns self" do - result = registry.deregister(:custom) - - expect(result).to be(registry) - end - end - - context "when deregistering default validators" do - subject(:registry) { described_class.new } - - it "removes the default validator" do - registry.deregister(:presence) - - expect(registry.registry).not_to have_key(:presence) - end - - it "does not affect other default validators" do - registry.deregister(:presence) - - expect(registry.registry).to have_key(:format) - expect(registry.registry).to have_key(:length) - end - end - end - - describe "#validate" do - let(:initial_registry) { { custom: mock_validator } } - let(:value) { "test_value" } - let(:options) { { option1: "value1" } } - - before do - allow(CMDx::Utils::Call).to receive(:invoke).and_return("validation_result") - allow(CMDx::Utils::Condition).to receive(:evaluate).and_return(true) - end - - context "when validator type exists" do - context "with hash options" do - it "evaluates condition and calls Utils::Call.invoke when condition is true" do - expect(CMDx::Utils::Condition).to receive(:evaluate).with(mock_task, options, value) - expect(CMDx::Utils::Call).to receive(:invoke).with( - mock_task, mock_validator, value, options - ) - - registry.validate(:custom, mock_task, value, options) - end - - it "returns the result from Utils::Call.invoke" do - result = registry.validate(:custom, mock_task, value, options) - - expect(result).to eq("validation_result") - end - - context "when condition evaluates to false" do - before do - allow(CMDx::Utils::Condition).to receive(:evaluate).and_return(false) - end - - it "does not call the validator" do - expect(CMDx::Utils::Call).not_to receive(:invoke) - - registry.validate(:custom, mock_task, value, options) - end - - it "returns nil" do - result = registry.validate(:custom, mock_task, value, options) - - expect(result).to be_nil - end - end - - context "with allow_nil: true" do - context "when value is nil" do - let(:value) { nil } - let(:options) { { allow_nil: true } } - - it "skips the validator" do - expect(CMDx::Utils::Condition).not_to receive(:evaluate) - expect(CMDx::Utils::Call).not_to receive(:invoke) - - registry.validate(:custom, mock_task, value, options) - end - - it "returns nil" do - result = registry.validate(:custom, mock_task, value, options) - - expect(result).to be_nil - end - end - - context "when value is not nil" do - let(:options) { { allow_nil: true } } - - it "calls the validator" do - expect(CMDx::Utils::Condition).not_to receive(:evaluate) - expect(CMDx::Utils::Call).to receive(:invoke).with( - mock_task, mock_validator, value, options - ) - - registry.validate(:custom, mock_task, value, options) - end - - it "returns the result from Utils::Call.invoke" do - result = registry.validate(:custom, mock_task, value, options) - - expect(result).to eq("validation_result") - end - end - end - - context "with allow_nil: false" do - context "when value is nil" do - let(:value) { nil } - let(:options) { { allow_nil: false } } - - it "calls the validator" do - expect(CMDx::Utils::Condition).not_to receive(:evaluate) - expect(CMDx::Utils::Call).to receive(:invoke).with( - mock_task, mock_validator, value, options - ) - - registry.validate(:custom, mock_task, value, options) - end - - it "returns the result from Utils::Call.invoke" do - result = registry.validate(:custom, mock_task, value, options) - - expect(result).to eq("validation_result") - end - end - - context "when value is not nil" do - let(:options) { { allow_nil: false } } - - it "calls the validator" do - expect(CMDx::Utils::Condition).not_to receive(:evaluate) - expect(CMDx::Utils::Call).to receive(:invoke).with( - mock_task, mock_validator, value, options - ) - - registry.validate(:custom, mock_task, value, options) - end - - it "returns the result from Utils::Call.invoke" do - result = registry.validate(:custom, mock_task, value, options) - - expect(result).to eq("validation_result") - end - end - end - end - - context "with non-hash options" do - let(:options) { true } - - it "uses options directly as condition" do - expect(CMDx::Utils::Condition).not_to receive(:evaluate) - expect(CMDx::Utils::Call).to receive(:invoke).with( - mock_task, mock_validator, value, options - ) - - registry.validate(:custom, mock_task, value, options) - end - - context "when options is false" do - let(:options) { false } - - it "does not call the validator" do - expect(CMDx::Utils::Call).not_to receive(:invoke) - - registry.validate(:custom, mock_task, value, options) - end - - it "returns nil" do - result = registry.validate(:custom, mock_task, value, options) - - expect(result).to be_nil - end - end - end - - context "with string type name" do - let(:initial_registry) { { "custom" => mock_validator } } - - it "works with string type names" do - expect(CMDx::Utils::Condition).to receive(:evaluate).with(mock_task, options, value) - expect(CMDx::Utils::Call).to receive(:invoke).with( - mock_task, mock_validator, value, options - ) - - registry.validate("custom", mock_task, value, options) - end - end - end - - context "when options are not provided" do - it "passes empty hash as options and evaluates condition" do - expect(CMDx::Utils::Condition).to receive(:evaluate).with(mock_task, {}, value) - expect(CMDx::Utils::Call).to receive(:invoke).with( - mock_task, mock_validator, value, {} - ) - - registry.validate(:custom, mock_task, value) - end - end - - context "when validator type does not exist" do - it "raises TypeError with descriptive message" do - expect { registry.validate(:nonexistent, mock_task, value) } - .to raise_error(TypeError, "unknown validator type :nonexistent") - end - - it "raises TypeError for string type names" do - expect { registry.validate("string", mock_task, value) } - .to raise_error(TypeError, 'unknown validator type "string"') - end - end - - context "when type is nil" do - it "raises TypeError" do - expect { registry.validate(nil, mock_task, value) } - .to raise_error(TypeError, "unknown validator type nil") - end - end - end -end diff --git a/spec/cmdx/validators/absence_spec.rb b/spec/cmdx/validators/absence_spec.rb index 052b8bbbb..f6cf0b4da 100644 --- a/spec/cmdx/validators/absence_spec.rb +++ b/spec/cmdx/validators/absence_spec.rb @@ -2,153 +2,35 @@ require "spec_helper" -RSpec.describe CMDx::Validators::Absence, type: :unit do - subject(:validator) { described_class } - +RSpec.describe CMDx::Validators::Absence do describe ".call" do - context "when value is absent" do - context "with string values" do - it "does not raise error for empty strings" do - expect { validator.call("") }.not_to raise_error - end - - it "does not raise error for whitespace-only strings" do - expect { validator.call(" ") }.not_to raise_error - expect { validator.call("\t\n\r ") }.not_to raise_error - end - end - - context "with objects responding to empty?" do - it "does not raise error for empty arrays" do - expect { validator.call([]) }.not_to raise_error - end - - it "does not raise error for empty hashes" do - expect { validator.call({}) }.not_to raise_error - end - - it "does not raise error for empty string-like objects" do - empty_obj = Object.new - def empty_obj.empty? = true - expect { validator.call(empty_obj) }.not_to raise_error - end - end - - context "with nil values" do - it "does not raise error for nil" do - expect { validator.call(nil) }.not_to raise_error - end - end + it "passes for nil" do + expect(described_class.call(nil)).to be_nil end - context "when value is present" do - context "with string values" do - it "raises ValidationError for non-whitespace strings" do - expect { validator.call("hello") } - .to raise_error(CMDx::ValidationError, "must be empty") - expect { validator.call("a") } - .to raise_error(CMDx::ValidationError, "must be empty") - expect { validator.call("123") } - .to raise_error(CMDx::ValidationError, "must be empty") - end - - it "raises ValidationError for strings with mixed whitespace and content" do - expect { validator.call(" hello ") } - .to raise_error(CMDx::ValidationError, "must be empty") - expect { validator.call("\thello\n") } - .to raise_error(CMDx::ValidationError, "must be empty") - expect { validator.call(" a ") } - .to raise_error(CMDx::ValidationError, "must be empty") - end - end - - context "with objects responding to empty?" do - it "raises ValidationError for non-empty arrays" do - expect { validator.call([1, 2, 3]) } - .to raise_error(CMDx::ValidationError, "must be empty") - expect { validator.call(["a"]) } - .to raise_error(CMDx::ValidationError, "must be empty") - end - - it "raises ValidationError for non-empty hashes" do - expect { validator.call({ a: 1 }) } - .to raise_error(CMDx::ValidationError, "must be empty") - expect { validator.call({ "key" => "value" }) } - .to raise_error(CMDx::ValidationError, "must be empty") - end - - it "raises ValidationError for non-empty string-like objects" do - string_obj = Object.new - def string_obj.empty? = false - expect { validator.call(string_obj) } - .to raise_error(CMDx::ValidationError, "must be empty") - end - end - - context "with objects not responding to empty?" do - it "raises ValidationError for non-nil values" do - expect { validator.call(42) } - .to raise_error(CMDx::ValidationError, "must be empty") - expect { validator.call(true) } - .to raise_error(CMDx::ValidationError, "must be empty") - expect { validator.call(false) } - .to raise_error(CMDx::ValidationError, "must be empty") - expect { validator.call(0) } - .to raise_error(CMDx::ValidationError, "must be empty") - end - - it "raises ValidationError for objects" do - expect { validator.call(Object.new) } - .to raise_error(CMDx::ValidationError, "must be empty") - expect { validator.call(Date.today) } - .to raise_error(CMDx::ValidationError, "must be empty") - end - end + it "passes for a blank string" do + expect(described_class.call(" ")).to be_nil end - context "with custom message option" do - let(:custom_message) { "must be blank" } - let(:options) { { message: custom_message } } - - it "uses custom message for present strings" do - expect { validator.call("hello", options) } - .to raise_error(CMDx::ValidationError, custom_message) - end - - it "uses custom message for present arrays" do - expect { validator.call([1], options) } - .to raise_error(CMDx::ValidationError, custom_message) - end - - it "uses custom message for present objects" do - expect { validator.call(true, options) } - .to raise_error(CMDx::ValidationError, custom_message) - end + it "passes for an empty array" do + expect(described_class.call([])).to be_nil + end - it "does not raise error for absent values" do - expect { validator.call(nil, options) }.not_to raise_error - expect { validator.call("", options) }.not_to raise_error - end + it "fails for non-blank strings" do + expect(described_class.call("x")).to be_a(CMDx::Validators::Failure) end - context "without options" do - it "does not raise error when no options provided for absent value" do - expect { validator.call("") }.not_to raise_error - end + it "fails for non-empty arrays" do + expect(described_class.call([1])).to be_a(CMDx::Validators::Failure) + end - it "raises error with default message when no options provided for present value" do - expect { validator.call("hello") } - .to raise_error(CMDx::ValidationError, "must be empty") - end + it "fails for scalars" do + expect(described_class.call(1)).to be_a(CMDx::Validators::Failure) end - context "with non-hash options" do - it "ignores non-hash options and uses default message" do - expect { validator.call("hello", "invalid_options") } - .to raise_error(CMDx::ValidationError, "must be empty") - expect { validator.call("hello", 123) } - .to raise_error(CMDx::ValidationError, "must be empty") - end + it "uses :message when supplied" do + f = described_class.call("hi", message: "must be blank") + expect(f.message).to eq("must be blank") end end end diff --git a/spec/cmdx/validators/exclusion_spec.rb b/spec/cmdx/validators/exclusion_spec.rb index db983f124..b84388afc 100644 --- a/spec/cmdx/validators/exclusion_spec.rb +++ b/spec/cmdx/validators/exclusion_spec.rb @@ -2,348 +2,39 @@ require "spec_helper" -RSpec.describe CMDx::Validators::Exclusion, type: :unit do - subject(:validator) { described_class } - +RSpec.describe CMDx::Validators::Exclusion do describe ".call" do - context "with array exclusions" do - context "when using :in option" do - let(:options) { { in: %w[admin root system] } } - - context "when value is excluded" do - it "raises ValidationError with default message" do - expect { validator.call("admin", options) } - .to raise_error(CMDx::ValidationError, 'must not be one of: "admin", "root", "system"') - end - - it "raises ValidationError for any excluded value" do - %w[admin root system].each do |excluded_value| - expect { validator.call(excluded_value, options) } - .to raise_error(CMDx::ValidationError, 'must not be one of: "admin", "root", "system"') - end - end - end - - context "when value is not excluded" do - it "does not raise error for allowed values" do - expect { validator.call("user", options) }.not_to raise_error - expect { validator.call("guest", options) }.not_to raise_error - expect { validator.call("", options) }.not_to raise_error - end - end - - context "with custom :of_message" do - let(:options) { { in: %w[reserved blocked], of_message: "is a reserved username" } } - - it "uses custom message" do - expect { validator.call("reserved", options) } - .to raise_error(CMDx::ValidationError, "is a reserved username") - end - end - - context "with custom :message" do - let(:options) { { in: %w[forbidden], message: "cannot be used" } } - - it "uses custom message" do - expect { validator.call("forbidden", options) } - .to raise_error(CMDx::ValidationError, "cannot be used") - end - end - - context "with message interpolation" do - let(:options) { { in: %w[bad evil], of_message: "value %<values>s not allowed" } } - - it "interpolates values into custom message" do - expect { validator.call("bad", options) } - .to raise_error(CMDx::ValidationError, 'value "bad", "evil" not allowed') - end - end - end - - context "when using :within option" do - let(:options) { { within: %w[admin root] } } - - context "when value is excluded" do - it "raises ValidationError with default message" do - expect { validator.call("admin", options) } - .to raise_error(CMDx::ValidationError, 'must not be one of: "admin", "root"') - end - end - - context "when value is not excluded" do - it "does not raise error" do - expect { validator.call("user", options) }.not_to raise_error - end - end - end - - context "with different data types" do - context "with integers" do - let(:options) { { in: [1, 2, 3] } } - - it "excludes integer values" do - expect { validator.call(2, options) } - .to raise_error(CMDx::ValidationError, "must not be one of: 1, 2, 3") - end - - it "allows non-excluded integers" do - expect { validator.call(4, options) }.not_to raise_error - end - end - - context "with symbols" do - let(:options) { { in: %i[pending cancelled] } } - - it "excludes symbol values" do - expect { validator.call(:pending, options) } - .to raise_error(CMDx::ValidationError, "must not be one of: :pending, :cancelled") - end - - it "allows non-excluded symbols" do - expect { validator.call(:active, options) }.not_to raise_error - end - end - - context "with mixed types" do - let(:options) { { in: ["string", 42, :symbol] } } - - it "excludes string value" do - expect { validator.call("string", options) } - .to raise_error(CMDx::ValidationError, 'must not be one of: "string", 42, :symbol') - end - - it "excludes integer value" do - expect { validator.call(42, options) } - .to raise_error(CMDx::ValidationError, 'must not be one of: "string", 42, :symbol') - end - - it "excludes symbol value" do - expect { validator.call(:symbol, options) } - .to raise_error(CMDx::ValidationError, 'must not be one of: "string", 42, :symbol') - end - end - end - - context "with case-sensitive comparison" do - let(:options) { { in: %w[Admin ROOT] } } - - it "is case sensitive" do - expect { validator.call("admin", options) }.not_to raise_error - expect { validator.call("root", options) }.not_to raise_error - expect { validator.call("Admin", options) } - .to raise_error(CMDx::ValidationError, 'must not be one of: "Admin", "ROOT"') - end - end + it "raises without :in or :within" do + expect { described_class.call(1) }.to raise_error(ArgumentError, /:in or :within/) end - context "with range exclusions" do - context "with integer range" do - let(:options) { { in: (1..10) } } - - context "when value is within range" do - it "raises ValidationError with default message" do - expect { validator.call(5, options) } - .to raise_error(CMDx::ValidationError, "must not be within 1 and 10") - end - - it "raises error for boundary values" do - expect { validator.call(1, options) } - .to raise_error(CMDx::ValidationError, "must not be within 1 and 10") - expect { validator.call(10, options) } - .to raise_error(CMDx::ValidationError, "must not be within 1 and 10") - end - end - - context "when value is outside range" do - it "does not raise error" do - expect { validator.call(0, options) }.not_to raise_error - expect { validator.call(11, options) }.not_to raise_error - expect { validator.call(-5, options) }.not_to raise_error - end - end - - context "with custom :in_message" do - let(:options) { { in: (18..65), in_message: "age restricted" } } - - it "uses custom message" do - expect { validator.call(25, options) } - .to raise_error(CMDx::ValidationError, "age restricted") - end - end - - context "with custom :within_message" do - let(:options) { { within: (1..5), within_message: "not in allowed range" } } - - it "uses custom message" do - expect { validator.call(3, options) } - .to raise_error(CMDx::ValidationError, "not in allowed range") - end - end - - context "with message interpolation" do - let(:options) { { in: (1..10), in_message: "between %<min>s and %<max>s not allowed" } } - - it "interpolates min and max into custom message" do - expect { validator.call(5, options) } - .to raise_error(CMDx::ValidationError, "between 1 and 10 not allowed") - end - end + context "with an array" do + it "passes when the value is not in the list" do + expect(described_class.call(:c, in: %i[a b])).to be_nil end - context "with exclusive range" do - let(:options) { { in: (1...10) } } - - it "excludes values within range but not the end" do - expect { validator.call(9, options) } - .to raise_error(CMDx::ValidationError, "must not be within 1 and 10") - expect { validator.call(10, options) }.not_to raise_error - end + it "fails when the value is in the list" do + expect(described_class.call(:a, in: %i[a b])).to be_a(CMDx::Validators::Failure) end - context "with string range" do - let(:options) { { in: ("a".."z") } } - - it "excludes values within string range" do - expect { validator.call("m", options) } - .to raise_error(CMDx::ValidationError, "must not be within a and z") - expect { validator.call("A", options) }.not_to raise_error - end - end - - context "with date range" do - let(:start_date) { Date.new(2023, 1, 1) } - let(:end_date) { Date.new(2023, 12, 31) } - let(:options) { { in: (start_date..end_date) } } - - it "excludes dates within range" do - test_date = Date.new(2023, 6, 15) - expect { validator.call(test_date, options) } - .to raise_error(CMDx::ValidationError, "must not be within #{start_date} and #{end_date}") - end - - it "allows dates outside range" do - expect { validator.call(Date.new(2022, 12, 31), options) }.not_to raise_error - expect { validator.call(Date.new(2024, 1, 1), options) }.not_to raise_error - end + it "interpolates :values into a custom message" do + f = described_class.call(:a, in: %i[a b], message: "not %{values}") + expect(f.message).to include(":a") end end - context "with edge cases" do - context "when exclusion list is empty" do - let(:options) { { in: [] } } - - it "does not raise error for any value" do - expect { validator.call("anything", options) }.not_to raise_error - expect { validator.call(123, options) }.not_to raise_error - expect { validator.call(nil, options) }.not_to raise_error - end + context "with a range" do + it "passes when outside the range" do + expect(described_class.call(5, within: 1..3)).to be_nil end - context "when exclusion list is nil" do - let(:options) { { in: nil } } - - it "does not raise error for any value" do - expect { validator.call("anything", options) }.not_to raise_error - expect { validator.call(123, options) }.not_to raise_error - end - end - - context "when testing nil value" do - let(:options) { { in: [nil, "null"] } } - - it "excludes nil when explicitly included" do - expect { validator.call(nil, options) } - .to raise_error(CMDx::ValidationError, 'must not be one of: nil, "null"') - end - - it "allows nil when not in exclusion list" do - expect { validator.call(nil, { in: ["other"] }) }.not_to raise_error - end + it "fails when inside the range" do + expect(described_class.call(2, within: 1..3)).to be_a(CMDx::Validators::Failure) end - context "with object comparison using ===" do - let(:regex_pattern) { /admin/ } - let(:options) { { in: [regex_pattern] } } - - it "uses === for comparison with regex" do - expect { validator.call("admin_user", options) } - .to raise_error(CMDx::ValidationError) - expect { validator.call("user", options) }.not_to raise_error - end - end - - context "when no exclusion option provided" do - let(:options) { {} } - - it "does not raise error" do - expect { validator.call("anything", options) }.not_to raise_error - end - end - - context "with both :in and :within options" do - let(:options) { { in: %w[admin], within: %w[root] } } - - it "uses :in option when both are present" do - expect { validator.call("admin", options) } - .to raise_error(CMDx::ValidationError, 'must not be one of: "admin"') - expect { validator.call("root", options) }.not_to raise_error - end - end - end - - context "with custom message priority" do - context "when multiple message options are provided" do - let(:options) do - { - in: %w[test], - message: "generic message", - of_message: "specific of message" - } - end - - it "prioritizes of_message over message for arrays" do - expect { validator.call("test", options) } - .to raise_error(CMDx::ValidationError, "specific of message") - end - end - - context "with range and multiple message options" do - let(:options) do - { - in: (1..5), - message: "generic message", - in_message: "specific in message", - within_message: "specific within message" - } - end - - it "prioritizes in_message over within_message and message for ranges" do - expect { validator.call(3, options) } - .to raise_error(CMDx::ValidationError, "specific in message") - end - end - end - - context "with internationalization" do - it "calls Locale.t for default array exclusion message" do - expect(CMDx::Locale).to receive(:t).with( - "cmdx.validators.exclusion.of", - values: '"admin"' - ).and_return("localized message") - - expect { validator.call("admin", { in: %w[admin] }) } - .to raise_error(CMDx::ValidationError, "localized message") - end - - it "calls Locale.t for default range exclusion message" do - expect(CMDx::Locale).to receive(:t).with( - "cmdx.validators.exclusion.within", - min: 1, - max: 10 - ).and_return("localized range message") - - expect { validator.call(5, { in: (1..10) }) } - .to raise_error(CMDx::ValidationError, "localized range message") + it "interpolates :min/:max into a custom within_message" do + f = described_class.call(2, within: 1..3, within_message: "%{min}..%{max}") + expect(f.message).to eq("1..3") end end end diff --git a/spec/cmdx/validators/format_spec.rb b/spec/cmdx/validators/format_spec.rb index 61f154199..ae26f070d 100644 --- a/spec/cmdx/validators/format_spec.rb +++ b/spec/cmdx/validators/format_spec.rb @@ -2,278 +2,40 @@ require "spec_helper" -RSpec.describe CMDx::Validators::Format, type: :unit do - subject(:validator) { described_class } - +RSpec.describe CMDx::Validators::Format do describe ".call" do - context "with direct Regexp argument" do - it "validates value against the regex pattern" do - expect { validator.call("hello", /\A[a-z]+\z/) }.not_to raise_error - expect { validator.call("123", /\A\d+\z/) }.not_to raise_error - expect { validator.call("test@example.com", /\A[\w+\-.]+@[a-z\d-]+(\.[a-z\d-]+)*\.[a-z]+\z/i) }.not_to raise_error - end - - it "raises ValidationError when value doesn't match pattern" do - expect { validator.call("Hello", /\A[a-z]+\z/) } - .to raise_error(CMDx::ValidationError, "is an invalid format") - expect { validator.call("abc", /\A\d+\z/) } - .to raise_error(CMDx::ValidationError, "is an invalid format") - expect { validator.call("invalid-email", /\A[\w+\-.]+@[a-z\d-]+(\.[a-z\d-]+)*\.[a-z]+\z/i) } - .to raise_error(CMDx::ValidationError, "is an invalid format") - end - - it "handles complex regex patterns" do - phone_regex = /\A\+?1?[-.\s]?\(?[0-9]{3}\)?[-.\s]?[0-9]{3}[-.\s]?[0-9]{4}\z/ - expect { validator.call("123-456-7890", phone_regex) }.not_to raise_error - expect { validator.call("(123) 456-7890", phone_regex) }.not_to raise_error - expect { validator.call("123-45-6789", phone_regex) } - .to raise_error(CMDx::ValidationError, "is an invalid format") - end - - it "works with edge cases" do - expect { validator.call("", /\A.*\z/) }.not_to raise_error - expect { validator.call("", /\A.+\z/) } - .to raise_error(CMDx::ValidationError, "is an invalid format") - expect { validator.call(nil, /\A.+\z/) } - .to raise_error(CMDx::ValidationError, "is an invalid format") - end + it "passes when :with matches" do + expect(described_class.call("abc", with: /^a/)).to be_nil end - context "with :with option" do - let(:options) { { with: /\A[a-z]+\z/ } } - - context "when value matches pattern" do - it "does not raise error for matching values" do - expect { validator.call("hello", options) }.not_to raise_error - expect { validator.call("world", options) }.not_to raise_error - expect { validator.call("test", options) }.not_to raise_error - end - end - - context "when value does not match pattern" do - it "raises ValidationError with default message" do - expect { validator.call("Hello", options) } - .to raise_error(CMDx::ValidationError, "is an invalid format") - end - - it "raises ValidationError for various invalid formats" do - ["Hello", "123", "test_case", ""].each do |invalid_value| - expect { validator.call(invalid_value, options) } - .to raise_error(CMDx::ValidationError, "is an invalid format") - end - end - end - - context "with custom message" do - let(:options) { { with: /\A\d+\z/, message: "must contain only digits" } } - - it "uses custom message when validation fails" do - expect { validator.call("abc", options) } - .to raise_error(CMDx::ValidationError, "must contain only digits") - end - end - - context "with email pattern" do - let(:options) { { with: /\A[\w+\-.]+@[a-z\d-]+(\.[a-z\d-]+)*\.[a-z]+\z/i } } - - it "validates email format correctly" do - expect { validator.call("user@example.com", options) }.not_to raise_error - expect { validator.call("test.email+tag@domain.co.uk", options) }.not_to raise_error - end - - it "rejects invalid email formats" do - expect { validator.call("invalid-email", options) } - .to raise_error(CMDx::ValidationError) - expect { validator.call("@example.com", options) } - .to raise_error(CMDx::ValidationError) - end - end + it "fails when :with does not match" do + expect(described_class.call("xyz", with: /^a/)).to be_a(CMDx::Validators::Failure) end - context "with :without option" do - let(:options) { { without: /[^a-zA-Z]/ } } - - context "when value does not match forbidden pattern" do - it "does not raise error for valid values" do - expect { validator.call("Hello", options) }.not_to raise_error - expect { validator.call("World", options) }.not_to raise_error - expect { validator.call("", options) }.not_to raise_error - end - end - - context "when value matches forbidden pattern" do - it "raises ValidationError with default message" do - expect { validator.call("hello123", options) } - .to raise_error(CMDx::ValidationError, "is an invalid format") - end - - it "raises ValidationError for various invalid formats" do - ["test_case", "hello!", "123", "hello world"].each do |invalid_value| - expect { validator.call(invalid_value, options) } - .to raise_error(CMDx::ValidationError, "is an invalid format") - end - end - end - - context "with custom message" do - let(:options) { { without: /\d/, message: "cannot contain numbers" } } - - it "uses custom message when validation fails" do - expect { validator.call("test123", options) } - .to raise_error(CMDx::ValidationError, "cannot contain numbers") - end - end + it "passes when :without does not match" do + expect(described_class.call("abc", without: /z/)).to be_nil end - context "with both :with and :without options" do - let(:options) { { with: /\A[a-zA-Z]+\z/, without: /[A-Z]{2,}/ } } - - context "when value matches :with and does not match :without" do - it "does not raise error for valid values" do - expect { validator.call("Hello", options) }.not_to raise_error - expect { validator.call("world", options) }.not_to raise_error - expect { validator.call("Test", options) }.not_to raise_error - end - end - - context "when value does not match :with pattern" do - it "raises ValidationError" do - expect { validator.call("hello123", options) } - .to raise_error(CMDx::ValidationError, "is an invalid format") - expect { validator.call("test_case", options) } - .to raise_error(CMDx::ValidationError, "is an invalid format") - end - end - - context "when value matches :without pattern" do - it "raises ValidationError for forbidden patterns" do - expect { validator.call("HELLO", options) } - .to raise_error(CMDx::ValidationError, "is an invalid format") - expect { validator.call("TEST", options) } - .to raise_error(CMDx::ValidationError, "is an invalid format") - end - end - - context "when value fails both conditions" do - it "raises ValidationError" do - expect { validator.call("HELLO123", options) } - .to raise_error(CMDx::ValidationError, "is an invalid format") - end - end - - context "with custom message" do - let(:options) do - { - with: /\A[a-z]+\z/, - without: /test/, - message: "must be lowercase letters without 'test'" - } - end - - it "uses custom message when validation fails" do - expect { validator.call("testing", options) } - .to raise_error(CMDx::ValidationError, "must be lowercase letters without 'test'") - end - end + it "fails when :without matches" do + expect(described_class.call("xyz", without: /z/)).to be_a(CMDx::Validators::Failure) end - context "without any pattern options" do - let(:options) { {} } - - it "always raises ValidationError" do - expect { validator.call("anything", options) } - .to raise_error(CMDx::ValidationError, "is an invalid format") - expect { validator.call("", options) } - .to raise_error(CMDx::ValidationError, "is an invalid format") - end - - context "with custom message" do - let(:options) { { message: "no pattern specified" } } - - it "uses custom message" do - expect { validator.call("test", options) } - .to raise_error(CMDx::ValidationError, "no pattern specified") - end - end + it "combines :with and :without" do + expect(described_class.call("abc", with: /^a/, without: /z/)).to be_nil + expect(described_class.call("abz", with: /^a/, without: /z/)).to be_a(CMDx::Validators::Failure) end - context "with complex regex patterns" do - context "when validating phone numbers" do - let(:options) { { with: /\A\+?1?[-.\s]?\(?[0-9]{3}\)?[-.\s]?[0-9]{3}[-.\s]?[0-9]{4}\z/ } } - - it "validates various phone number formats" do - valid_numbers = [ - "123-456-7890", - "(123) 456-7890", - "123.456.7890", - "1234567890", - "+1-123-456-7890" - ] - - valid_numbers.each do |number| - expect { validator.call(number, options) }.not_to raise_error - end - end - - it "rejects invalid phone number formats" do - invalid_numbers = %w[ - 123-45-6789 - abc-def-ghij - 123-456-78901 - ] - - invalid_numbers.each do |number| - expect { validator.call(number, options) } - .to raise_error(CMDx::ValidationError) - end - end - end - - context "when validating hexadecimal colors" do - let(:options) { { with: /\A#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})\z/ } } - - it "validates hex color codes" do - expect { validator.call("#FF0000", options) }.not_to raise_error - expect { validator.call("#fff", options) }.not_to raise_error - expect { validator.call("#123abc", options) }.not_to raise_error - end - - it "rejects invalid hex color codes" do - expect { validator.call("FF0000", options) } - .to raise_error(CMDx::ValidationError) - expect { validator.call("#gg0000", options) } - .to raise_error(CMDx::ValidationError) - end - end + it "handles nil values gracefully" do + expect(described_class.call(nil, with: /a/)).to be_a(CMDx::Validators::Failure) end - context "with string patterns" do - let(:options) { { with: "test" } } - - it "treats string patterns as regex" do - expect { validator.call("testing", options) }.not_to raise_error - expect { validator.call("retest", options) }.not_to raise_error - end - - it "raises error when string pattern not found" do - expect { validator.call("hello", options) } - .to raise_error(CMDx::ValidationError) - end + it "raises without any option" do + expect { described_class.call("x") }.to raise_error(ArgumentError, %r{:with and/or :without}) end - context "with edge case values" do - let(:options) { { with: /\A.+\z/ } } - - it "handles empty strings" do - expect { validator.call("", { with: /\A.*\z/ }) }.not_to raise_error - expect { validator.call("", options) } - .to raise_error(CMDx::ValidationError) - end - - it "handles very long strings" do - long_string = "a" * 10_000 - expect { validator.call(long_string, options) }.not_to raise_error - end + it "uses :message when supplied" do + f = described_class.call("x", with: /y/, message: "bad format") + expect(f.message).to eq("bad format") end end end diff --git a/spec/cmdx/validators/inclusion_spec.rb b/spec/cmdx/validators/inclusion_spec.rb index 54350069f..3ea422e6b 100644 --- a/spec/cmdx/validators/inclusion_spec.rb +++ b/spec/cmdx/validators/inclusion_spec.rb @@ -2,357 +2,34 @@ require "spec_helper" -RSpec.describe CMDx::Validators::Inclusion, type: :unit do - subject(:validator) { described_class } - +RSpec.describe CMDx::Validators::Inclusion do describe ".call" do - context "with array inclusions" do - context "when using :in option" do - let(:options) { { in: %w[admin user guest] } } - - context "when value is included" do - it "does not raise error for included values" do - expect { validator.call("admin", options) }.not_to raise_error - expect { validator.call("user", options) }.not_to raise_error - expect { validator.call("guest", options) }.not_to raise_error - end - end - - context "when value is not included" do - it "raises ValidationError with default message" do - expect { validator.call("root", options) } - .to raise_error(CMDx::ValidationError, 'must be one of: "admin", "user", "guest"') - end - - it "raises ValidationError for any non-included value" do - %w[root system moderator].each do |excluded_value| - expect { validator.call(excluded_value, options) } - .to raise_error(CMDx::ValidationError, 'must be one of: "admin", "user", "guest"') - end - end - end - - context "with custom :of_message" do - let(:options) { { in: %w[active inactive], of_message: "must be a valid status" } } - - it "uses custom message" do - expect { validator.call("pending", options) } - .to raise_error(CMDx::ValidationError, "must be a valid status") - end - end - - context "with custom :message" do - let(:options) { { in: %w[red green blue], message: "invalid color" } } - - it "uses custom message" do - expect { validator.call("yellow", options) } - .to raise_error(CMDx::ValidationError, "invalid color") - end - end - - context "with message interpolation" do - let(:options) { { in: %w[small medium large], of_message: "size must be %<values>s" } } - - it "interpolates values into custom message" do - expect { validator.call("extra_large", options) } - .to raise_error(CMDx::ValidationError, 'size must be "small", "medium", "large"') - end - end - end - - context "when using :within option" do - let(:options) { { within: %w[draft published] } } - - context "when value is included" do - it "does not raise error" do - expect { validator.call("draft", options) }.not_to raise_error - expect { validator.call("published", options) }.not_to raise_error - end - end - - context "when value is not included" do - it "raises ValidationError with default message" do - expect { validator.call("archived", options) } - .to raise_error(CMDx::ValidationError, 'must be one of: "draft", "published"') - end - end - end - - context "with different data types" do - context "with integers" do - let(:options) { { in: [1, 2, 3] } } - - it "includes integer values" do - expect { validator.call(2, options) }.not_to raise_error - end - - it "raises error for non-included integers" do - expect { validator.call(4, options) } - .to raise_error(CMDx::ValidationError, "must be one of: 1, 2, 3") - end - end - - context "with symbols" do - let(:options) { { in: %i[pending active completed] } } - - it "includes symbol values" do - expect { validator.call(:pending, options) }.not_to raise_error - end - - it "raises error for non-included symbols" do - expect { validator.call(:cancelled, options) } - .to raise_error(CMDx::ValidationError, "must be one of: :pending, :active, :completed") - end - end - - context "with mixed types" do - let(:options) { { in: ["string", 42, :symbol] } } - - it "includes string value" do - expect { validator.call("string", options) }.not_to raise_error - end - - it "includes integer value" do - expect { validator.call(42, options) }.not_to raise_error - end - - it "includes symbol value" do - expect { validator.call(:symbol, options) }.not_to raise_error - end - - it "raises error for non-included values" do - expect { validator.call("other", options) } - .to raise_error(CMDx::ValidationError, 'must be one of: "string", 42, :symbol') - end - end - end - - context "with case-sensitive comparison" do - let(:options) { { in: %w[Admin ROOT] } } - - it "is case sensitive" do - expect { validator.call("Admin", options) }.not_to raise_error - expect { validator.call("ROOT", options) }.not_to raise_error - expect { validator.call("admin", options) } - .to raise_error(CMDx::ValidationError, 'must be one of: "Admin", "ROOT"') - expect { validator.call("root", options) } - .to raise_error(CMDx::ValidationError, 'must be one of: "Admin", "ROOT"') - end - end + it "raises without :in or :within" do + expect { described_class.call(1) }.to raise_error(ArgumentError, /:in or :within/) end - context "with range inclusions" do - context "with integer range" do - let(:options) { { in: (1..10) } } - - context "when value is within range" do - it "does not raise error for values in range" do - expect { validator.call(5, options) }.not_to raise_error - expect { validator.call(1, options) }.not_to raise_error - expect { validator.call(10, options) }.not_to raise_error - end - end - - context "when value is outside range" do - it "raises ValidationError with default message" do - expect { validator.call(0, options) } - .to raise_error(CMDx::ValidationError, "must be within 1 and 10") - expect { validator.call(11, options) } - .to raise_error(CMDx::ValidationError, "must be within 1 and 10") - expect { validator.call(-5, options) } - .to raise_error(CMDx::ValidationError, "must be within 1 and 10") - end - end - - context "with custom :in_message" do - let(:options) { { in: (18..65), in_message: "age must be valid" } } - - it "uses custom message" do - expect { validator.call(17, options) } - .to raise_error(CMDx::ValidationError, "age must be valid") - end - end - - context "with custom :within_message" do - let(:options) { { within: (1..5), within_message: "must be in allowed range" } } - - it "uses custom message" do - expect { validator.call(6, options) } - .to raise_error(CMDx::ValidationError, "must be in allowed range") - end - end - - context "with message interpolation" do - let(:options) { { in: (1..10), in_message: "must be between %<min>s and %<max>s" } } - - it "interpolates min and max into custom message" do - expect { validator.call(15, options) } - .to raise_error(CMDx::ValidationError, "must be between 1 and 10") - end - end - end - - context "with exclusive range" do - let(:options) { { in: (1...10) } } - - it "includes values within range but not the end" do - expect { validator.call(9, options) }.not_to raise_error - expect { validator.call(10, options) } - .to raise_error(CMDx::ValidationError, "must be within 1 and 10") - end - end - - context "with string range" do - let(:options) { { in: ("a".."z") } } - - it "includes values within string range" do - expect { validator.call("m", options) }.not_to raise_error - expect { validator.call("A", options) } - .to raise_error(CMDx::ValidationError, "must be within a and z") - end - end - - context "with date range" do - let(:start_date) { Date.new(2023, 1, 1) } - let(:end_date) { Date.new(2023, 12, 31) } - let(:options) { { in: (start_date..end_date) } } - - it "includes dates within range" do - test_date = Date.new(2023, 6, 15) - expect { validator.call(test_date, options) }.not_to raise_error - end - - it "raises error for dates outside range" do - expect { validator.call(Date.new(2022, 12, 31), options) } - .to raise_error(CMDx::ValidationError, "must be within #{start_date} and #{end_date}") - expect { validator.call(Date.new(2024, 1, 1), options) } - .to raise_error(CMDx::ValidationError, "must be within #{start_date} and #{end_date}") - end - end - end - - context "with edge cases" do - context "when inclusion list is empty" do - let(:options) { { in: [] } } - - it "raises error for any value" do - expect { validator.call("anything", options) } - .to raise_error(CMDx::ValidationError, "must be one of: ") - expect { validator.call(123, options) } - .to raise_error(CMDx::ValidationError, "must be one of: ") - expect { validator.call(nil, options) } - .to raise_error(CMDx::ValidationError, "must be one of: ") - end - end - - context "when inclusion list is nil" do - let(:options) { { in: nil } } - - it "raises error for any value" do - expect { validator.call("anything", options) } - .to raise_error(CMDx::ValidationError, "must be one of: ") - expect { validator.call(123, options) } - .to raise_error(CMDx::ValidationError, "must be one of: ") - end + context "with an array" do + it "passes when the value is in the list" do + expect(described_class.call(:a, in: %i[a b])).to be_nil end - context "when testing nil value" do - let(:options) { { in: [nil, "null"] } } - - it "includes nil when explicitly included" do - expect { validator.call(nil, options) }.not_to raise_error - end - - it "raises error for nil when not in inclusion list" do - expect { validator.call(nil, { in: ["other"] }) } - .to raise_error(CMDx::ValidationError, 'must be one of: "other"') - end - end - - context "with object comparison using ===" do - let(:regex_pattern) { /admin/ } - let(:options) { { in: [regex_pattern] } } - - it "uses === for comparison with regex" do - expect { validator.call("admin_user", options) }.not_to raise_error - expect { validator.call("user", options) } - .to raise_error(CMDx::ValidationError) - end - end - - context "when no inclusion option provided" do - let(:options) { {} } - - it "raises error for any value" do - expect { validator.call("anything", options) } - .to raise_error(CMDx::ValidationError, "must be one of: ") - end - end - - context "with both :in and :within options" do - let(:options) { { in: %w[admin], within: %w[root] } } - - it "uses :in option when both are present" do - expect { validator.call("admin", options) }.not_to raise_error - expect { validator.call("root", options) } - .to raise_error(CMDx::ValidationError, 'must be one of: "admin"') - end + it "fails when the value is not in the list" do + expect(described_class.call(:c, in: %i[a b])).to be_a(CMDx::Validators::Failure) end end - context "with custom message priority" do - context "when multiple message options are provided" do - let(:options) do - { - in: %w[test], - message: "generic message", - of_message: "specific of message" - } - end - - it "prioritizes of_message over message for arrays" do - expect { validator.call("invalid", options) } - .to raise_error(CMDx::ValidationError, "specific of message") - end + context "with a range" do + it "passes when inside" do + expect(described_class.call(2, within: 1..3)).to be_nil end - context "with range and multiple message options" do - let(:options) do - { - in: (1..5), - message: "generic message", - in_message: "specific in message", - within_message: "specific within message" - } - end - - it "prioritizes in_message over within_message and message for ranges" do - expect { validator.call(10, options) } - .to raise_error(CMDx::ValidationError, "specific in message") - end + it "fails when outside" do + expect(described_class.call(9, within: 1..3)).to be_a(CMDx::Validators::Failure) end - end - - context "with internationalization" do - it "calls Locale.t for default array inclusion message" do - expect(CMDx::Locale).to receive(:t).with( - "cmdx.validators.inclusion.of", - values: '"valid"' - ).and_return("localized message") - - expect { validator.call("invalid", { in: %w[valid] }) } - .to raise_error(CMDx::ValidationError, "localized message") - end - - it "calls Locale.t for default range inclusion message" do - expect(CMDx::Locale).to receive(:t).with( - "cmdx.validators.inclusion.within", - min: 1, - max: 10 - ).and_return("localized range message") - expect { validator.call(15, { in: (1..10) }) } - .to raise_error(CMDx::ValidationError, "localized range message") + it "uses within_message with interpolation" do + f = described_class.call(9, within: 1..3, within_message: "%{min}-%{max}") + expect(f.message).to eq("1-3") end end end diff --git a/spec/cmdx/validators/length_spec.rb b/spec/cmdx/validators/length_spec.rb index 8a6d296d5..0d1a95762 100644 --- a/spec/cmdx/validators/length_spec.rb +++ b/spec/cmdx/validators/length_spec.rb @@ -2,429 +2,110 @@ require "spec_helper" -RSpec.describe CMDx::Validators::Length, type: :unit do - subject(:validator) { described_class } - +RSpec.describe CMDx::Validators::Length do describe ".call" do - context "with :within option" do - let(:options) { { within: (3..10) } } - - context "when value length is within range" do - it "does not raise error for valid lengths" do - expect { validator.call("abc", options) }.not_to raise_error - expect { validator.call("test", options) }.not_to raise_error - expect { validator.call("1234567890", options) }.not_to raise_error - end - end - - context "when value length is outside range" do - it "raises ValidationError for lengths below minimum" do - expect { validator.call("ab", options) } - .to raise_error(CMDx::ValidationError, "length must be within 3 and 10") - end - - it "raises ValidationError for lengths above maximum" do - expect { validator.call("12345678901", options) } - .to raise_error(CMDx::ValidationError, "length must be within 3 and 10") - end - end - - context "with custom :within_message" do - let(:options) { { within: (5..15), within_message: "must be between %<min>s and %<max>s characters" } } - - it "uses custom message with interpolation" do - expect { validator.call("abc", options) } - .to raise_error(CMDx::ValidationError, "must be between 5 and 15 characters") - end - end - - context "with custom :message" do - let(:options) { { within: (2..5), message: "invalid length" } } - - it "uses custom message" do - expect { validator.call("toolong", options) } - .to raise_error(CMDx::ValidationError, "invalid length") - end - end - end - - context "with :not_within option" do - let(:options) { { not_within: (5..8) } } - - context "when value length is outside forbidden range" do - it "does not raise error for valid lengths" do - expect { validator.call("abc", options) }.not_to raise_error - expect { validator.call("123456789", options) }.not_to raise_error - end - end - - context "when value length is within forbidden range" do - it "raises ValidationError for forbidden lengths" do - expect { validator.call("12345", options) } - .to raise_error(CMDx::ValidationError, "length must not be within 5 and 8") - - expect { validator.call("123456", options) } - .to raise_error(CMDx::ValidationError, "length must not be within 5 and 8") - end - end - - context "with custom :not_within_message" do - let(:options) { { not_within: (3..6), not_within_message: "cannot be %<min>s-%<max>s chars" } } - - it "uses custom message with interpolation" do - expect { validator.call("test", options) } - .to raise_error(CMDx::ValidationError, "cannot be 3-6 chars") - end - end + it "fails for values without #length" do + expect(described_class.call(nil, min: 1)).to be_a(CMDx::Validators::Failure) end - context "with :in option" do - let(:options) { { in: (2..7) } } - - context "when value length is in range" do - it "does not raise error for valid lengths" do - expect { validator.call("ab", options) }.not_to raise_error - expect { validator.call("1234567", options) }.not_to raise_error - end - end - - context "when value length is outside range" do - it "raises ValidationError for invalid lengths" do - expect { validator.call("a", options) } - .to raise_error(CMDx::ValidationError, "length must be within 2 and 7") - - expect { validator.call("12345678", options) } - .to raise_error(CMDx::ValidationError, "length must be within 2 and 7") - end - end - - context "with custom :in_message" do - let(:options) { { in: (1..3), in_message: "should be %<min>s to %<max>s long" } } - - it "uses custom message with interpolation" do - expect { validator.call("toolong", options) } - .to raise_error(CMDx::ValidationError, "should be 1 to 3 long") - end - end + it "raises without any length option" do + expect { described_class.call("a") }.to raise_error(ArgumentError, /unknown length validator options/) end - context "with :not_in option" do - let(:options) { { not_in: (4..6) } } - - context "when value length is outside forbidden range" do - it "does not raise error for valid lengths" do - expect { validator.call("abc", options) }.not_to raise_error - expect { validator.call("1234567", options) }.not_to raise_error - end + context "with :within" do + it "passes when within range" do + expect(described_class.call("ab", within: 1..3)).to be_nil end - context "when value length is in forbidden range" do - it "raises ValidationError for forbidden lengths" do - expect { validator.call("1234", options) } - .to raise_error(CMDx::ValidationError, "length must not be within 4 and 6") - end - end - - context "with custom :not_in_message" do - let(:options) { { not_in: (2..4), not_in_message: "forbidden range %<min>s-%<max>s" } } - - it "uses custom message with interpolation" do - expect { validator.call("abc", options) } - .to raise_error(CMDx::ValidationError, "forbidden range 2-4") - end - end - end - - context "with :min and :max options" do - let(:options) { { min: 3, max: 8 } } - - context "when value length is within bounds" do - it "does not raise error for valid lengths" do - expect { validator.call("abc", options) }.not_to raise_error - expect { validator.call("test", options) }.not_to raise_error - expect { validator.call("12345678", options) }.not_to raise_error - end - end - - context "when value length is outside bounds" do - it "raises ValidationError for lengths below minimum" do - expect { validator.call("ab", options) } - .to raise_error(CMDx::ValidationError, "length must be within 3 and 8") - end - - it "raises ValidationError for lengths above maximum" do - expect { validator.call("123456789", options) } - .to raise_error(CMDx::ValidationError, "length must be within 3 and 8") - end + it "fails when out of range" do + expect(described_class.call("abcd", within: 1..3)).to be_a(CMDx::Validators::Failure) end end - context "with :min option only" do - let(:options) { { min: 5 } } - - context "when value length meets minimum" do - it "does not raise error for valid lengths" do - expect { validator.call("12345", options) }.not_to raise_error - expect { validator.call("123456789", options) }.not_to raise_error - end - end - - context "when value length is below minimum" do - it "raises ValidationError" do - expect { validator.call("1234", options) } - .to raise_error(CMDx::ValidationError, "length must be at least 5") - end - end - - context "with custom :min_message" do - let(:options) { { min: 3, min_message: "too short, needs %<min>s+ chars" } } - - it "uses custom message with interpolation" do - expect { validator.call("ab", options) } - .to raise_error(CMDx::ValidationError, "too short, needs 3+ chars") - end + context "with :not_within" do + it "fails when within forbidden range" do + expect(described_class.call("ab", not_within: 1..3)).to be_a(CMDx::Validators::Failure) end end - context "with :max option only" do - let(:options) { { max: 6 } } - - context "when value length is within maximum" do - it "does not raise error for valid lengths" do - expect { validator.call("abc", options) }.not_to raise_error - expect { validator.call("123456", options) }.not_to raise_error - end + context "with :min / :max" do + it "passes inside the band" do + expect(described_class.call("ab", min: 1, max: 3)).to be_nil end - context "when value length exceeds maximum" do - it "raises ValidationError" do - expect { validator.call("1234567", options) } - .to raise_error(CMDx::ValidationError, "length must be at most 6") - end + it "fails when too short" do + expect(described_class.call("", min: 1, max: 3)).to be_a(CMDx::Validators::Failure) end - context "with custom :max_message" do - let(:options) { { max: 4, max_message: "too long, max %<max>s chars" } } - - it "uses custom message with interpolation" do - expect { validator.call("12345", options) } - .to raise_error(CMDx::ValidationError, "too long, max 4 chars") - end + it "fails when too long" do + expect(described_class.call("abcd", min: 1, max: 3)).to be_a(CMDx::Validators::Failure) end end - context "with :is option" do - let(:options) { { is: 5 } } - - context "when value length matches exactly" do - it "does not raise error for exact length" do - expect { validator.call("12345", options) }.not_to raise_error - expect { validator.call("hello", options) }.not_to raise_error - end - end - - context "when value length does not match" do - it "raises ValidationError for shorter values" do - expect { validator.call("1234", options) } - .to raise_error(CMDx::ValidationError, "length must be 5") - end - - it "raises ValidationError for longer values" do - expect { validator.call("123456", options) } - .to raise_error(CMDx::ValidationError, "length must be 5") - end - end - - context "with custom :is_message" do - let(:options) { { is: 3, is_message: "must be exactly %<is>s characters" } } - - it "uses custom message with interpolation" do - expect { validator.call("ab", options) } - .to raise_error(CMDx::ValidationError, "must be exactly 3 characters") - end + context "with :min only" do + it "fails when below min" do + expect(described_class.call("", min: 1)).to be_a(CMDx::Validators::Failure) end end - context "with :is_not option" do - let(:options) { { is_not: 4 } } - - context "when value length is not the forbidden length" do - it "does not raise error for different lengths" do - expect { validator.call("abc", options) }.not_to raise_error - expect { validator.call("12345", options) }.not_to raise_error - end - end - - context "when value length matches forbidden length" do - it "raises ValidationError" do - expect { validator.call("1234", options) } - .to raise_error(CMDx::ValidationError, "length must not be 4") - end - end - - context "with custom :is_not_message" do - let(:options) { { is_not: 2, is_not_message: "cannot be %<is_not>s chars long" } } - - it "uses custom message with interpolation" do - expect { validator.call("ab", options) } - .to raise_error(CMDx::ValidationError, "cannot be 2 chars long") - end + context "with :max only" do + it "fails when above max" do + expect(described_class.call("abcd", max: 3)).to be_a(CMDx::Validators::Failure) end end - context "with custom message priority" do - context "when multiple message options are provided for :within" do - let(:options) do - { - within: (2..5), - message: "generic message", - within_message: "specific within message" - } - end - - it "prioritizes specific message over generic" do - expect { validator.call("a", options) } - .to raise_error(CMDx::ValidationError, "specific within message") - end + context "with :is / :is_not" do + it "fails when not equal" do + expect(described_class.call("abc", is: 2)).to be_a(CMDx::Validators::Failure) end - context "when multiple message options are provided for :min" do - let(:options) do - { - min: 3, - message: "generic message", - min_message: "specific min message" - } - end - - it "prioritizes specific message over generic" do - expect { validator.call("ab", options) } - .to raise_error(CMDx::ValidationError, "specific min message") - end + it "fails when equal to :is_not" do + expect(described_class.call("ab", is_not: 2)).to be_a(CMDx::Validators::Failure) end end - context "with edge cases" do - context "when value is empty string" do - it "validates length correctly" do - expect { validator.call("", { min: 1 }) } - .to raise_error(CMDx::ValidationError, "length must be at least 1") - - expect { validator.call("", { is: 0 }) }.not_to raise_error - - expect { validator.call("", { max: 5 }) }.not_to raise_error - end + context "with :eq / :not_eq (aliases of :is / :is_not)" do + it "passes when length equals :eq" do + expect(described_class.call("ab", eq: 2)).to be_nil end - context "when value is array" do - it "validates array length" do - expect { validator.call([1, 2, 3], { min: 2 }) }.not_to raise_error - expect { validator.call([1], { min: 2 }) } - .to raise_error(CMDx::ValidationError, "length must be at least 2") - end + it "fails when length differs and reuses :is translation" do + failure = described_class.call("abc", eq: 2) + expect(failure.message).to eq("length must be 2") end - context "when value is hash" do - it "validates hash length" do - expect { validator.call({ a: 1, b: 2 }, { max: 3 }) }.not_to raise_error - expect { validator.call({ a: 1, b: 2, c: 3, d: 4 }, { max: 3 }) } - .to raise_error(CMDx::ValidationError, "length must be at most 3") - end - end - - context "when range has equal bounds" do - let(:options) { { within: (5..5) } } - - it "validates single-value range correctly" do - expect { validator.call("12345", options) }.not_to raise_error - expect { validator.call("1234", options) } - .to raise_error(CMDx::ValidationError, "length must be within 5 and 5") - end + it "fails when length equals :not_eq" do + expect(described_class.call("ab", not_eq: 2)).to be_a(CMDx::Validators::Failure) end end - context "with invalid options" do - it "raises ArgumentError for unknown options" do - expect { validator.call("test", { unknown: true }) } - .to raise_error(ArgumentError, "unknown length validator options given") + context "with :gte / :lte (aliases of :min / :max)" do + it "passes when length meets :gte bound" do + expect(described_class.call("ab", gte: 2)).to be_nil end - it "raises ArgumentError for empty options" do - expect { validator.call("test", {}) } - .to raise_error(ArgumentError, "unknown length validator options given") + it "fails below :gte bound and reuses :min translation" do + failure = described_class.call("a", gte: 2) + expect(failure.message).to eq("length must be at least 2") end - end - - context "with internationalization" do - it "calls Locale.t for default within message" do - expect(CMDx::Locale).to receive(:t).with( - "cmdx.validators.length.within", - min: 3, - max: 5 - ).and_return("localized within message") - expect { validator.call("a", { within: (3..5) }) } - .to raise_error(CMDx::ValidationError, "localized within message") + it "fails above :lte bound" do + expect(described_class.call("abcd", lte: 3)).to be_a(CMDx::Validators::Failure) end - it "calls Locale.t for default not_within message" do - expect(CMDx::Locale).to receive(:t).with( - "cmdx.validators.length.not_within", - min: 3, - max: 5 - ).and_return("localized not_within message") - - expect { validator.call("test", { not_within: (3..5) }) } - .to raise_error(CMDx::ValidationError, "localized not_within message") + it "combines :gte and :lte as a range" do + expect(described_class.call("ab", gte: 1, lte: 3)).to be_nil + expect(described_class.call("abcd", gte: 1, lte: 3)).to be_a(CMDx::Validators::Failure) end + end - it "calls Locale.t for default min message" do - expect(CMDx::Locale).to receive(:t).with( - "cmdx.validators.length.min", - min: 3 - ).and_return("localized min message") - - expect { validator.call("ab", { min: 3 }) } - .to raise_error(CMDx::ValidationError, "localized min message") - end - - it "calls Locale.t for default max message" do - expect(CMDx::Locale).to receive(:t).with( - "cmdx.validators.length.max", - max: 3 - ).and_return("localized max message") - - expect { validator.call("toolong", { max: 3 }) } - .to raise_error(CMDx::ValidationError, "localized max message") - end - - it "calls Locale.t for default is message" do - expect(CMDx::Locale).to receive(:t).with( - "cmdx.validators.length.is", - is: 5 - ).and_return("localized is message") - - expect { validator.call("ab", { is: 5 }) } - .to raise_error(CMDx::ValidationError, "localized is message") - end - - it "calls Locale.t for default is_not message" do - expect(CMDx::Locale).to receive(:t).with( - "cmdx.validators.length.is_not", - is_not: 4 - ).and_return("localized is_not message") - - expect { validator.call("test", { is_not: 4 }) } - .to raise_error(CMDx::ValidationError, "localized is_not message") + context "with :gt / :lt (strict)" do + it "fails when length equals :gt bound" do + expect(described_class.call("ab", gt: 2)).to be_a(CMDx::Validators::Failure) end - context "when custom message is provided without interpolation" do - it "does not call string interpolation for custom message" do - custom_message = "fixed custom message" - - expect { validator.call("a", { min: 3, min_message: custom_message }) } - .to raise_error(CMDx::ValidationError, custom_message) - end + it "fails when length equals :lt bound" do + expect(described_class.call("abc", lt: 3)).to be_a(CMDx::Validators::Failure) end end end diff --git a/spec/cmdx/validators/numeric_spec.rb b/spec/cmdx/validators/numeric_spec.rb index 51909b0a6..d432a5706 100644 --- a/spec/cmdx/validators/numeric_spec.rb +++ b/spec/cmdx/validators/numeric_spec.rb @@ -2,377 +2,124 @@ require "spec_helper" -RSpec.describe CMDx::Validators::Numeric, type: :unit do - subject(:validator) { described_class } - +RSpec.describe CMDx::Validators::Numeric do describe ".call" do - context "with within option" do - let(:options) { { within: 1..10 } } - - context "when value is within range" do - it "does not raise error for values within range" do - expect { validator.call(1, options) }.not_to raise_error - expect { validator.call(5, options) }.not_to raise_error - expect { validator.call(10, options) }.not_to raise_error - end - end - - context "when value is not within range" do - it "raises ValidationError with default message for below range" do - expect { validator.call(0, options) } - .to raise_error(CMDx::ValidationError, "must be within 1 and 10") - end - - it "raises ValidationError with default message for above range" do - expect { validator.call(11, options) } - .to raise_error(CMDx::ValidationError, "must be within 1 and 10") - end - end - - context "with custom within_message" do - let(:options) { { within: 1..10, within_message: "value must be between %<min>s and %<max>s" } } - - it "uses custom message with interpolation" do - expect { validator.call(15, options) } - .to raise_error(CMDx::ValidationError, "value must be between 1 and 10") - end - end - - context "with custom message" do - let(:options) { { within: 1..10, message: "invalid range" } } - - it "uses custom message without interpolation" do - expect { validator.call(15, options) } - .to raise_error(CMDx::ValidationError, "invalid range") - end - end + it "fails for nil values" do + expect(described_class.call(nil, min: 1)).to be_a(CMDx::Validators::Failure) end - context "with in option" do - let(:options) { { in: 5..15 } } - - context "when value is in range" do - it "does not raise error for values in range" do - expect { validator.call(5, options) }.not_to raise_error - expect { validator.call(10, options) }.not_to raise_error - expect { validator.call(15, options) }.not_to raise_error - end - end - - context "when value is not in range" do - it "raises ValidationError with default message" do - expect { validator.call(4, options) } - .to raise_error(CMDx::ValidationError, "must be within 5 and 15") - end - end - - context "with custom in_message" do - let(:options) { { in: 5..15, in_message: "must be from %<min>s to %<max>s" } } - - it "uses custom in_message with interpolation" do - expect { validator.call(20, options) } - .to raise_error(CMDx::ValidationError, "must be from 5 to 15") - end - end + it "raises without any option" do + expect { described_class.call(1) }.to raise_error(ArgumentError, /unknown numeric validator options/) end - context "with not_within option" do - let(:options) { { not_within: 5..10 } } - - context "when value is not within excluded range" do - it "does not raise error for values outside range" do - expect { validator.call(4, options) }.not_to raise_error - expect { validator.call(11, options) }.not_to raise_error - expect { validator.call(1, options) }.not_to raise_error - expect { validator.call(100, options) }.not_to raise_error - end + context "with :within / :not_within" do + it "passes within range" do + expect(described_class.call(2, within: 1..3)).to be_nil end - context "when value is within excluded range" do - it "raises ValidationError with default message" do - expect { validator.call(5, options) } - .to raise_error(CMDx::ValidationError, "must not be within 5 and 10") - expect { validator.call(7, options) } - .to raise_error(CMDx::ValidationError, "must not be within 5 and 10") - expect { validator.call(10, options) } - .to raise_error(CMDx::ValidationError, "must not be within 5 and 10") - end + it "fails out of range" do + expect(described_class.call(5, within: 1..3)).to be_a(CMDx::Validators::Failure) end - context "with custom not_within_message" do - let(:options) { { not_within: 5..10, not_within_message: "cannot be between %<min>s and %<max>s" } } - - it "uses custom not_within_message with interpolation" do - expect { validator.call(8, options) } - .to raise_error(CMDx::ValidationError, "cannot be between 5 and 10") - end + it "fails when inside a forbidden range" do + expect(described_class.call(2, not_within: 1..3)).to be_a(CMDx::Validators::Failure) end end - context "with not_in option" do - let(:options) { { not_in: 20..30 } } - - context "when value is not in excluded range" do - it "does not raise error for values outside range" do - expect { validator.call(19, options) }.not_to raise_error - expect { validator.call(31, options) }.not_to raise_error - end - end - - context "when value is in excluded range" do - it "raises ValidationError with default message" do - expect { validator.call(25, options) } - .to raise_error(CMDx::ValidationError, "must not be within 20 and 30") - end + context "with :min / :max" do + it "fails below min" do + expect(described_class.call(0, min: 1, max: 3)).to be_a(CMDx::Validators::Failure) end - context "with custom not_in_message" do - let(:options) { { not_in: 20..30, not_in_message: "value forbidden between %<min>s and %<max>s" } } - - it "uses custom not_in_message with interpolation" do - expect { validator.call(25, options) } - .to raise_error(CMDx::ValidationError, "value forbidden between 20 and 30") - end + it "fails above max" do + expect(described_class.call(9, min: 1, max: 3)).to be_a(CMDx::Validators::Failure) end end - context "with min and max options" do - let(:options) { { min: 5, max: 15 } } - - context "when value is between min and max" do - it "does not raise error for values in range" do - expect { validator.call(5, options) }.not_to raise_error - expect { validator.call(10, options) }.not_to raise_error - expect { validator.call(15, options) }.not_to raise_error - end - end - - context "when value is below min" do - it "raises ValidationError with within message" do - expect { validator.call(4, options) } - .to raise_error(CMDx::ValidationError, "must be within 5 and 15") - end + context "with :is / :is_not" do + it "fails when not equal" do + expect(described_class.call(2, is: 3)).to be_a(CMDx::Validators::Failure) end - context "when value is above max" do - it "raises ValidationError with within message" do - expect { validator.call(16, options) } - .to raise_error(CMDx::ValidationError, "must be within 5 and 15") - end + it "fails when equal to :is_not" do + expect(described_class.call(2, is_not: 2)).to be_a(CMDx::Validators::Failure) end end - context "with min option only" do - let(:options) { { min: 10 } } - - context "when value meets minimum" do - it "does not raise error for values at or above minimum" do - expect { validator.call(10, options) }.not_to raise_error - expect { validator.call(15, options) }.not_to raise_error - expect { validator.call(100, options) }.not_to raise_error - end + context "with :eq / :not_eq (aliases of :is / :is_not)" do + it "passes when equal" do + expect(described_class.call(2, eq: 2)).to be_nil end - context "when value is below minimum" do - it "raises ValidationError with min message" do - expect { validator.call(9, options) } - .to raise_error(CMDx::ValidationError, "must be at least 10") - end + it "fails when not equal and reuses :is translation" do + failure = described_class.call(2, eq: 3) + expect(failure).to be_a(CMDx::Validators::Failure) + expect(failure.message).to eq("must be 3") end - context "with custom min_message" do - let(:options) { { min: 10, min_message: "cannot be less than %<min>s" } } - - it "uses custom min_message with interpolation" do - expect { validator.call(5, options) } - .to raise_error(CMDx::ValidationError, "cannot be less than 10") - end + it "passes when not equal to :not_eq" do + expect(described_class.call(2, not_eq: 3)).to be_nil end - end - - context "with max option only" do - let(:options) { { max: 20 } } - context "when value meets maximum" do - it "does not raise error for values at or below maximum" do - expect { validator.call(20, options) }.not_to raise_error - expect { validator.call(15, options) }.not_to raise_error - expect { validator.call(1, options) }.not_to raise_error - end + it "fails when equal to :not_eq" do + expect(described_class.call(2, not_eq: 2)).to be_a(CMDx::Validators::Failure) end - context "when value exceeds maximum" do - it "raises ValidationError with max message" do - expect { validator.call(21, options) } - .to raise_error(CMDx::ValidationError, "must be at most 20") - end - end - - context "with custom max_message" do - let(:options) { { max: 20, max_message: "cannot exceed %<max>s" } } - - it "uses custom max_message with interpolation" do - expect { validator.call(25, options) } - .to raise_error(CMDx::ValidationError, "cannot exceed 20") - end + it "honors :eq_message override" do + failure = described_class.call(2, eq: 3, eq_message: "must equal %{is}") + expect(failure.message).to eq("must equal 3") end end - context "with is option" do - let(:options) { { is: 42 } } - - context "when value equals expected value" do - it "does not raise error for exact match" do - expect { validator.call(42, options) }.not_to raise_error - end + context "with :gte / :lte (aliases of :min / :max)" do + it "passes when equal to :gte bound" do + expect(described_class.call(1, gte: 1)).to be_nil end - context "when value does not equal expected value" do - it "raises ValidationError with is message" do - expect { validator.call(41, options) } - .to raise_error(CMDx::ValidationError, "must be 42") - expect { validator.call(43, options) } - .to raise_error(CMDx::ValidationError, "must be 42") - end + it "fails below :gte bound and reuses :min translation" do + failure = described_class.call(0, gte: 1) + expect(failure.message).to eq("must be at least 1") end - context "with custom is_message" do - let(:options) { { is: 42, is_message: "value must equal %<is>s" } } - - it "uses custom is_message with interpolation" do - expect { validator.call(50, options) } - .to raise_error(CMDx::ValidationError, "value must equal 42") - end - end - end - - context "with is_not option" do - let(:options) { { is_not: 13 } } - - context "when value does not equal forbidden value" do - it "does not raise error for different values" do - expect { validator.call(12, options) }.not_to raise_error - expect { validator.call(14, options) }.not_to raise_error - expect { validator.call(100, options) }.not_to raise_error - end + it "passes when equal to :lte bound" do + expect(described_class.call(3, lte: 3)).to be_nil end - context "when value equals forbidden value" do - it "raises ValidationError with is_not message" do - expect { validator.call(13, options) } - .to raise_error(CMDx::ValidationError, "must not be 13") - end + it "fails above :lte bound" do + expect(described_class.call(4, lte: 3)).to be_a(CMDx::Validators::Failure) end - context "with custom is_not_message" do - let(:options) { { is_not: 13, is_not_message: "cannot be %<is_not>s" } } - - it "uses custom is_not_message with interpolation" do - expect { validator.call(13, options) } - .to raise_error(CMDx::ValidationError, "cannot be 13") - end + it "combines :gte and :lte as a range" do + expect(described_class.call(2, gte: 1, lte: 3)).to be_nil + expect(described_class.call(5, gte: 1, lte: 3)).to be_a(CMDx::Validators::Failure) end end - context "with decimal values" do - context "with within option" do - let(:options) { { within: 1.5..10.5 } } - - it "validates decimal values correctly" do - expect { validator.call(1.5, options) }.not_to raise_error - expect { validator.call(5.7, options) }.not_to raise_error - expect { validator.call(10.5, options) }.not_to raise_error - - expect { validator.call(1.4, options) } - .to raise_error(CMDx::ValidationError, "must be within 1.5 and 10.5") - expect { validator.call(10.6, options) } - .to raise_error(CMDx::ValidationError, "must be within 1.5 and 10.5") - end + context "with :gt" do + it "passes when strictly above bound" do + expect(described_class.call(2, gt: 1)).to be_nil end - end - - context "with negative values" do - context "with min option" do - let(:options) { { min: -10 } } - it "validates negative values correctly" do - expect { validator.call(-10, options) }.not_to raise_error - expect { validator.call(-5, options) }.not_to raise_error - expect { validator.call(0, options) }.not_to raise_error - - expect { validator.call(-11, options) } - .to raise_error(CMDx::ValidationError, "must be at least -10") - end + it "fails when equal to bound" do + expect(described_class.call(1, gt: 1)).to be_a(CMDx::Validators::Failure) end - end - context "with unknown options" do - it "raises ArgumentError for unrecognized options" do - expect { validator.call(5, { unknown: "option" }) } - .to raise_error(ArgumentError, "unknown numeric validator options given") + it "fails when below bound" do + expect(described_class.call(0, gt: 1)).to be_a(CMDx::Validators::Failure) end end - context "with empty options" do - it "raises ArgumentError for empty options hash" do - expect { validator.call(5, {}) } - .to raise_error(ArgumentError, "unknown numeric validator options given") + context "with :lt" do + it "passes when strictly below bound" do + expect(described_class.call(2, lt: 3)).to be_nil end - end - - context "with global custom message" do - context "when using within option" do - let(:options) { { within: 1..10, message: "global error message" } } - - it "uses global message when specific message not provided" do - expect { validator.call(15, options) } - .to raise_error(CMDx::ValidationError, "global error message") - end - end - - context "when using min option" do - let(:options) { { min: 10, message: "global min error" } } - - it "uses global message for min validation" do - expect { validator.call(5, options) } - .to raise_error(CMDx::ValidationError, "global min error") - end - end - - context "when using max option" do - let(:options) { { max: 10, message: "global max error" } } - it "uses global message for max validation" do - expect { validator.call(15, options) } - .to raise_error(CMDx::ValidationError, "global max error") - end + it "fails when equal to bound" do + expect(described_class.call(3, lt: 3)).to be_a(CMDx::Validators::Failure) end - context "when using is option" do - let(:options) { { is: 42, message: "global is error" } } - - it "uses global message for is validation" do - expect { validator.call(50, options) } - .to raise_error(CMDx::ValidationError, "global is error") - end - end - - context "when using is_not option" do - let(:options) { { is_not: 13, message: "global is_not error" } } - - it "uses global message for is_not validation" do - expect { validator.call(13, options) } - .to raise_error(CMDx::ValidationError, "global is_not error") - end - end - - context "when using not_within option" do - let(:options) { { not_within: 5..10, message: "global not_within error" } } - - it "uses global message for not_within validation" do - expect { validator.call(7, options) } - .to raise_error(CMDx::ValidationError, "global not_within error") - end + it "fails when above bound" do + expect(described_class.call(4, lt: 3)).to be_a(CMDx::Validators::Failure) end end end diff --git a/spec/cmdx/validators/presence_spec.rb b/spec/cmdx/validators/presence_spec.rb index 2bb389dbd..86cda96e6 100644 --- a/spec/cmdx/validators/presence_spec.rb +++ b/spec/cmdx/validators/presence_spec.rb @@ -2,164 +2,35 @@ require "spec_helper" -RSpec.describe CMDx::Validators::Presence, type: :unit do - subject(:validator) { described_class } - +RSpec.describe CMDx::Validators::Presence do describe ".call" do - context "when value is present" do - context "with string values" do - it "does not raise error for non-whitespace strings" do - expect { validator.call("hello") }.not_to raise_error - expect { validator.call("a") }.not_to raise_error - expect { validator.call("123") }.not_to raise_error - end - - it "does not raise error for strings with mixed whitespace and content" do - expect { validator.call(" hello ") }.not_to raise_error - expect { validator.call("\thello\n") }.not_to raise_error - expect { validator.call(" a ") }.not_to raise_error - end - end - - context "with objects responding to empty?" do - it "does not raise error for non-empty arrays" do - expect { validator.call([1, 2, 3]) }.not_to raise_error - expect { validator.call(["a"]) }.not_to raise_error - end - - it "does not raise error for non-empty hashes" do - expect { validator.call({ a: 1 }) }.not_to raise_error - expect { validator.call({ "key" => "value" }) }.not_to raise_error - end - - it "does not raise error for non-empty string-like objects" do - string_obj = Object.new - def string_obj.empty? = false - expect { validator.call(string_obj) }.not_to raise_error - end - end - - context "with objects not responding to empty?" do - it "does not raise error for non-nil values" do - expect { validator.call(42) }.not_to raise_error - expect { validator.call(true) }.not_to raise_error - expect { validator.call(false) }.not_to raise_error - expect { validator.call(0) }.not_to raise_error - end - - it "does not raise error for objects" do - expect { validator.call(Object.new) }.not_to raise_error - expect { validator.call(Date.today) }.not_to raise_error - end - end + it "passes for non-blank strings" do + expect(described_class.call("x")).to be_nil end - context "when value is not present" do - context "with string values" do - it "raises ValidationError for empty strings" do - expect { validator.call("") } - .to raise_error(CMDx::ValidationError, "cannot be empty") - end - - it "raises ValidationError for whitespace-only strings" do - expect { validator.call(" ") } - .to raise_error(CMDx::ValidationError, "cannot be empty") - expect { validator.call("\t\n\r ") } - .to raise_error(CMDx::ValidationError, "cannot be empty") - end - end - - context "with objects responding to empty?" do - it "raises ValidationError for empty arrays" do - expect { validator.call([]) } - .to raise_error(CMDx::ValidationError, "cannot be empty") - end - - it "raises ValidationError for empty hashes" do - expect { validator.call({}) } - .to raise_error(CMDx::ValidationError, "cannot be empty") - end - - it "raises ValidationError for empty string-like objects" do - empty_obj = Object.new - def empty_obj.empty? = true - expect { validator.call(empty_obj) } - .to raise_error(CMDx::ValidationError, "cannot be empty") - end - end - - context "with nil values" do - it "raises ValidationError for nil" do - expect { validator.call(nil) } - .to raise_error(CMDx::ValidationError, "cannot be empty") - end - end + it "passes for non-empty arrays" do + expect(described_class.call([1])).to be_nil end - context "with custom message option" do - let(:custom_message) { "is required" } - let(:options) { { message: custom_message } } - - it "uses custom message for empty strings" do - expect { validator.call("", options) } - .to raise_error(CMDx::ValidationError, custom_message) - end - - it "uses custom message for whitespace-only strings" do - expect { validator.call(" ", options) } - .to raise_error(CMDx::ValidationError, custom_message) - end - - it "uses custom message for empty arrays" do - expect { validator.call([], options) } - .to raise_error(CMDx::ValidationError, custom_message) - end - - it "uses custom message for nil values" do - expect { validator.call(nil, options) } - .to raise_error(CMDx::ValidationError, custom_message) - end - - it "does not raise error for present values" do - expect { validator.call("hello", options) }.not_to raise_error - expect { validator.call([1], options) }.not_to raise_error - end + it "passes for truthy scalars" do + expect(described_class.call(42)).to be_nil end - context "without options" do - it "does not raise error when no options provided" do - expect { validator.call("hello") }.not_to raise_error - end - - it "raises error with default message when no options provided" do - expect { validator.call("") } - .to raise_error(CMDx::ValidationError, "cannot be empty") - end + it "fails for nil" do + expect(described_class.call(nil)).to be_a(CMDx::Validators::Failure) end - context "with non-hash options" do - it "ignores non-hash options and uses default message" do - expect { validator.call("", "invalid_options") } - .to raise_error(CMDx::ValidationError, "cannot be empty") - expect { validator.call("", 123) } - .to raise_error(CMDx::ValidationError, "cannot be empty") - end + it "fails for blank strings" do + expect(described_class.call(" ")).to be_a(CMDx::Validators::Failure) end - context "with edge cases" do - it "handles zero correctly (not empty)" do - expect { validator.call(0) }.not_to raise_error - end - - it "handles false correctly (not empty)" do - expect { validator.call(false) }.not_to raise_error - end - - it "handles objects not responding to empty?" do - custom_obj = Object.new + it "fails for empty arrays" do + expect(described_class.call([])).to be_a(CMDx::Validators::Failure) + end - expect { validator.call(custom_obj) }.not_to raise_error - end + it "uses :message when supplied" do + f = described_class.call(nil, message: "required") + expect(f.message).to eq("required") end end end diff --git a/spec/cmdx/validators/validate_spec.rb b/spec/cmdx/validators/validate_spec.rb new file mode 100644 index 000000000..af5ee4bdf --- /dev/null +++ b/spec/cmdx/validators/validate_spec.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe CMDx::Validators::Validate do + let(:task_class) do + create_task_class do + def nope(v) = "no:#{v}" + end + end + let(:task) { task_class.new } + + describe ".call" do + it "sends a symbol handler to the task" do + expect(described_class.call(task, 1, :nope)).to eq("no:1") + end + + it "invokes a proc via instance_exec on the task" do + handler = proc { |v| context.object_id.positive? && v } + expect(described_class.call(task, 42, handler)).to eq(42) + end + + it "invokes a callable with the value and task" do + handler = Class.new do + class << self + + attr_reader :received_task + + def call(value, task) + @received_task = task + "x:#{value}" + end + + end + end + + expect(described_class.call(task, 1, handler)).to eq("x:1") + expect(handler.received_task).to be(task) + end + + it "raises for unsupported handlers" do + expect { described_class.call(task, 1, Object.new) } + .to raise_error(ArgumentError, /Symbol, Proc, or respond to #call/) + end + end +end diff --git a/spec/cmdx/validators_spec.rb b/spec/cmdx/validators_spec.rb new file mode 100644 index 000000000..62364da92 --- /dev/null +++ b/spec/cmdx/validators_spec.rb @@ -0,0 +1,133 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe CMDx::Validators do + subject(:validators) { described_class.new } + + let(:task) { create_task_class.new } + + describe "#initialize" do + it "registers the built-in validators" do + expect(validators.registry.keys).to contain_exactly( + :absence, :exclusion, :format, :inclusion, :length, :numeric, :presence + ) + end + end + + describe "#initialize_copy" do + it "dups the registry" do + copy = validators.dup + copy.deregister(:presence) + expect(validators.registry).to have_key(:presence) + expect(copy.registry).not_to have_key(:presence) + end + end + + describe "#register" do + it "stores a callable" do + c = ->(v, **) { v } + validators.register(:custom, c) + expect(validators.lookup(:custom)).to be(c) + end + + it "stores a block" do + validators.register(:x) { |v, **| v } + expect(validators.lookup(:x)).to be_a(Proc) + end + + it "raises when both a callable and block are given" do + expect { validators.register(:x, ->(v, **) { v }) { |v, **| v } } + .to raise_error(ArgumentError, /either a callable or a block/) + end + + it "raises when the handler does not respond to call" do + expect { validators.register(:x, Object.new) } + .to raise_error(ArgumentError, /must respond to #call/) + end + end + + describe "#deregister" do + it "removes a key" do + validators.deregister(:presence) + expect(validators.registry).not_to have_key(:presence) + end + end + + describe "#lookup" do + it "raises on unknown keys" do + expect { validators.lookup(:bogus) }.to raise_error(ArgumentError, "unknown validator: bogus") + end + end + + describe "#empty? / #size" do + it "reports the registry size" do + expect(validators.size).to eq(7) + expect(validators).not_to be_empty + end + end + + describe "#extract" do + it "returns EMPTY_HASH for empty options" do + expect(validators.extract({})).to eq({}) + end + + it "picks up registry keys from options" do + expect(validators.extract(presence: true, other: 1)).to eq(presence: true) + end + + it "includes :validate entries" do + expect(validators.extract(validate: ->(_, _) {})).to have_key(:validate) + end + end + + describe "#validate" do + it "adds errors for failing built-in validators" do + validators.validate(task, :name, nil, presence: true) + expect(task.errors[:name]).not_to be_empty + end + + it "honors :allow_nil" do + validators.validate(task, :name, nil, presence: { allow_nil: true }) + expect(task.errors).to be_empty + end + + it "honors :if guards" do + validators.validate(task, :name, nil, presence: { if: proc { false } }) + expect(task.errors).to be_empty + end + + it "accepts array shorthand for :in" do + validators.validate(task, :x, :c, inclusion: %i[a b]) + expect(task.errors[:x]).not_to be_empty + end + + it "accepts regexp shorthand for :with" do + validators.validate(task, :x, "Ada", format: /\A\d+\z/) + expect(task.errors[:x]).not_to be_empty + end + + it "skips when raw_options is false or nil" do + validators.validate(task, :x, nil, presence: false) + expect(task.errors).to be_empty + end + + it "raises for unsupported raw_options" do + expect do + validators.validate(task, :x, nil, presence: 42) + end.to raise_error(ArgumentError, /unsupported validator option format/) + end + + it "invokes :validate custom handlers and records their failure messages" do + handler = ->(_v) { CMDx::Validators::Failure.new("broken") } + validators.validate(task, :x, 1, validate: handler) + expect(task.errors[:x]).to include("broken") + end + + it "ignores :validate handlers that do not return a Failure" do + handler = ->(_v) {} + validators.validate(task, :x, 1, validate: handler) + expect(task.errors).to be_empty + end + end +end diff --git a/spec/cmdx/workflow_spec.rb b/spec/cmdx/workflow_spec.rb index a65a5abe8..42700f3a2 100644 --- a/spec/cmdx/workflow_spec.rb +++ b/spec/cmdx/workflow_spec.rb @@ -2,351 +2,95 @@ require "spec_helper" -RSpec.describe CMDx::Workflow, type: :unit do - let(:workflow_class) { create_workflow_class(name: "TestWorkflow") } - let(:workflow) { workflow_class.new } - let(:context_hash) { { executed: [] } } - let(:workflow_with_context) { workflow_class.new(context_hash) } - - describe "module inclusion" do - it "extends the class with ClassMethods" do - expect(workflow_class).to respond_to(:pipeline) - expect(workflow_class).to respond_to(:task) - expect(workflow_class).to respond_to(:tasks) - end - - it "includes workflow functionality in the instance" do - expect(workflow).to respond_to(:work) +RSpec.describe CMDx::Workflow do + describe ".pipeline" do + it "defaults to an empty array" do + workflow = create_workflow_class + expect(workflow.pipeline).to eq([]) end end - describe "ExecutionGroup" do - subject(:execution_group) { CMDx::Workflow::ExecutionGroup.new(tasks, options) } - - let(:tasks) { [create_successful_task] } - let(:options) { { if: true } } - - it "is a Struct with tasks and options" do - expect(execution_group.tasks).to eq(tasks) - expect(execution_group.options).to eq(options) + describe ".tasks" do + it "returns the pipeline when called with no tasks" do + workflow = create_workflow_class + expect(workflow.tasks).to be(workflow.pipeline) end - end - describe "ClassMethods" do - describe "#method_added" do - context "when redefining work method" do - it "raises an error" do - expect do - workflow_class.class_eval do - def work - "custom work" - end - end - end.to raise_error(RuntimeError, /cannot redefine.*#work method/) - end + it "appends an ExecutionGroup with options" do + task = create_successful_task + workflow = create_workflow_class do + tasks task, if: proc { true } end - context "when adding other methods" do - it "allows normal method definition" do - expect do - workflow_class.class_eval do - def custom_method - "allowed" - end - end - end.not_to raise_error - - expect(workflow_class.new).to respond_to(:custom_method) - end - end + group = workflow.pipeline.first + expect(group).to be_a(CMDx::Workflow::ExecutionGroup) + expect(group.tasks).to eq([task]) + expect(group.options[:if]).to be_a(Proc) end - describe "#pipeline" do - it "initializes as empty array" do - expect(workflow_class.pipeline).to eq([]) + it "supports multiple tasks in a single group" do + a = create_successful_task + b = create_successful_task + workflow = create_workflow_class do + tasks a, b end - it "memoizes the pipeline" do - groups = workflow_class.pipeline - - expect(workflow_class.pipeline).to be(groups) - end + expect(workflow.pipeline.first.tasks).to eq([a, b]) end - describe "#tasks" do - let(:task1) { create_successful_task(name: "Task1") } - let(:task2) { create_successful_task(name: "Task2") } - let(:options) { { if: true, breakpoints: [:failure] } } - - context "with valid CMDx::Task classes" do - it "adds execution group to pipeline" do - workflow_class.tasks(task1, task2, **options) - - expect(workflow_class.pipeline.size).to eq(1) - - group = workflow_class.pipeline.first - - expect(group.tasks).to eq([task1, task2]) - expect(group.options).to eq(options) - end - - it "supports multiple task declarations" do - workflow_class.tasks(task1, **options) - workflow_class.tasks(task2, if: false) - - expect(workflow_class.pipeline.size).to eq(2) - expect(workflow_class.pipeline[0].tasks).to eq([task1]) - expect(workflow_class.pipeline[1].tasks).to eq([task2]) - end - end - - context "with invalid task types" do - it "raises TypeError for non-Task classes" do - expect do - workflow_class.tasks(String, Integer) - end.to raise_error(TypeError, "must be a CMDx::Task") - end - - it "raises TypeError for regular objects" do - expect do - workflow_class.tasks("not a task") - end.to raise_error(TypeError, "must be a CMDx::Task") - end - end - - context "with mixed valid and invalid tasks" do - it "raises TypeError when any task is invalid" do - expect do - workflow_class.tasks(task1, String, task2) - end.to raise_error(TypeError, "must be a CMDx::Task") - end - end + it "raises TypeError for a non-Task" do + expect do + create_workflow_class { tasks "not a task" } + end.to raise_error(TypeError, /is not a Task/) end - describe "#subtasks" do - let(:task1) { create_successful_task(name: "SubTask1") } - let(:task2) { create_successful_task(name: "SubTask2") } - let(:task3) { create_successful_task(name: "SubTask3") } - - it "returns empty array when pipeline is empty" do - expect(workflow_class.subtasks).to eq([]) - end - - it "returns tasks from a single execution group" do - workflow_class.tasks(task1, task2) - - expect(workflow_class.subtasks).to eq([task1, task2]) - end - - it "returns tasks flattened across multiple execution groups" do - workflow_class.tasks(task1) - workflow_class.tasks(task2, task3) - - expect(workflow_class.subtasks).to eq([task1, task2, task3]) - end + it "is aliased as .task" do + t = create_successful_task + workflow = create_workflow_class { task t } + expect(workflow.pipeline.first.tasks).to eq([t]) end end - describe "#work" do - let(:task1) { create_successful_task(name: "Task1") } - let(:task2) { create_successful_task(name: "Task2") } - let(:task3) { create_successful_task(name: "Task3") } - - before do - workflow_class.class_eval do - settings workflow_breakpoints: [] - end - end - - context "with single execution group" do - before do - workflow_class.tasks(task1, task2, task3) - end - - it "executes all tasks in sequence" do - workflow_with_context.work + describe "inheritance" do + it "dups the parent's pipeline into subclasses" do + a = create_successful_task + parent = create_workflow_class(name: "ParentFlow") { tasks a } + child = Class.new(parent) - expect(workflow_with_context.context.executed).to eq(%i[success success success]) - end + expect(child.pipeline).to eq(parent.pipeline) + expect(child.pipeline).not_to be(parent.pipeline) end + end - context "with multiple execution groups" do - before do - workflow_class.tasks(task1) - workflow_class.tasks(task2, task3) - end - - it "executes all groups in sequence" do - workflow_with_context.work - - expect(workflow_with_context.context.executed).to eq(%i[success success success]) - end - end - - context "with conditional execution" do - before do - workflow_class.tasks(task1, if: true) - workflow_class.tasks(task2, if: false) - workflow_class.tasks(task3, unless: false) - end - - it "only executes tasks when conditions are met" do - workflow_with_context.work - - expect(workflow_with_context.context.executed).to eq(%i[success success]) - end - end - - context "with breakpoints in group options" do - let(:failing_task) { create_failing_task(name: "FailingTask") } - - before do - workflow_class.tasks(task1, failing_task, task3, breakpoints: [:failed]) - end - - it "stops execution when task status matches breakpoint" do - expect { workflow_with_context.work }.to raise_error(CMDx::FailFault) - - expect(workflow_with_context.context.executed).to eq([:success]) - end - end - - context "with breakpoints in class settings" do - before do - workflow_class.class_eval do - settings workflow_breakpoints: [:skipped] - end - - workflow_class.tasks(task1, task2, task3) - end - - it "executes all tasks when no task matches breakpoints" do - workflow_with_context.work - - expect(workflow_with_context.context.executed).to eq(%i[success success success]) - end - end - - context "with group breakpoints overriding class breakpoints" do - before do - workflow_class.class_eval do - settings workflow_breakpoints: [:skipped] - end - - workflow_class.tasks(task1, task2, task3, breakpoints: [:failed]) - end - - it "uses group breakpoints instead of class breakpoints" do - workflow_with_context.work - - expect(workflow_with_context.context.executed).to eq(%i[success success success]) - end - end - - context "when breakpoints is nil" do - before do - workflow_class.class_eval do - settings workflow_breakpoints: [:failed] - end - - workflow_class.tasks(task1, task2, task3, breakpoints: nil) - end - - it "uses class-level breakpoints" do - workflow_with_context.work + describe "#work" do + it "delegates to Pipeline.execute" do + a = create_successful_task + workflow = create_workflow_class { tasks a } - expect(workflow_with_context.context.executed).to eq(%i[success success success]) - end + expect(CMDx::Pipeline).to receive(:execute).with(an_instance_of(workflow)).and_return(:done) + instance = workflow.new + expect(instance.work).to eq(:done) end + end - context "with different breakpoint types" do - let(:failing_task) { create_nested_task(strategy: :throw, status: :failure) } - - context "when breakpoints is a single symbol" do - before do - workflow_class.tasks(task1, failing_task, task3, breakpoints: :failed) - end - - it "converts single breakpoint to array" do - expect(workflow_with_context).to receive(:throw!) - - workflow_with_context.work - end - end - - context "when breakpoints is a string" do - before do - workflow_class.tasks(task1, failing_task, task3, breakpoints: "failed") - end - - it "converts string breakpoint to array and compares as string" do - expect(workflow_with_context).to receive(:throw!) - - workflow_with_context.work - end - end - - context "when breakpoints contains duplicates" do - before do - workflow_class.tasks(task1, failing_task, task3, breakpoints: [:failed, :failed, "failed"]) - end - - it "removes duplicates and converts to strings" do - expect(workflow_with_context).to receive(:throw!) + describe "work definition guard" do + it "raises when a workflow subclass tries to define #work" do + expect do + Class.new(CMDx::Task) do + include CMDx::Workflow - workflow_with_context.work + def work; end end - end - end - - context "when task status does not match breakpoints" do - let(:failing_task) { create_nested_task(strategy: :throw, status: :failure) } - - before do - workflow_class.tasks(task1, failing_task, task3, breakpoints: [:skipped]) - end - - it "continues execution" do - workflow_with_context.work - - expect(workflow_with_context.context.executed).to eq(%i[success success]) - end - end - - context "when execution group condition evaluates to false" do - before do - workflow_class.tasks(task1, if: false) - workflow_class.tasks(task2, unless: true) - workflow_class.tasks(task3) - end - - it "skips groups that do not meet conditions" do - workflow_with_context.work - - expect(workflow_with_context.context.executed).to eq([:success]) - end - end - - context "with complex conditional scenarios" do - before do - workflow_class.tasks(task1, if: true) - workflow_class.tasks(task2, if: false) - workflow_class.tasks(task3, unless: false) - end - - it "evaluates conditions against workflow instance" do - workflow_with_context.work - - expect(workflow_with_context.context.executed).to eq(%i[success success]) - end + end.to raise_error(CMDx::ImplementationError, /cannot define .*#work in a workflow/) end + end - context "with empty execution groups" do - it "completes without executing any tasks" do - workflow_with_context.work + describe "integration with Task.execute" do + it "runs the pipeline and returns a successful result" do + a = create_successful_task + workflow = create_workflow_class { tasks a } - expect(workflow_with_context.context.executed).to eq([]) - end + expect(workflow.execute).to be_success end end end diff --git a/spec/cmdx_spec.rb b/spec/cmdx_spec.rb deleted file mode 100644 index 556b4372d..000000000 --- a/spec/cmdx_spec.rb +++ /dev/null @@ -1,216 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe CMDx do - after do - described_class.reset_configuration! - end - - describe ".configuration" do - it "returns a Configuration instance" do - expect(described_class.configuration).to be_a(CMDx::Configuration) - end - - it "returns the same instance on subsequent calls" do - first_call = described_class.configuration - second_call = described_class.configuration - - expect(first_call).to be(second_call) - end - - context "when @configuration is already set" do - let(:custom_config) { CMDx::Configuration.new } - - before do - described_class.instance_variable_set(:@configuration, custom_config) - end - - after do - # Skip the automatic reset for this test - described_class.instance_variable_set(:@configuration, nil) - end - - it "returns the existing configuration" do - expect(described_class.configuration).to be(custom_config) - end - - it "reuses the existing instance" do - first_config = described_class.configuration - second_config = described_class.configuration - - expect(first_config).to be(second_config) - expect(first_config).to be(custom_config) - end - end - - context "when @configuration is nil" do - before do - described_class.instance_variable_set(:@configuration, nil) - end - - it "creates and returns a new Configuration instance" do - expect(described_class.configuration).to be_a(CMDx::Configuration) - end - end - end - - describe ".configure" do - it "yields the configuration object" do - expect { |b| described_class.configure(&b) }.to yield_with_args(CMDx::Configuration) - end - - it "returns the configuration object" do - result = described_class.configure { nil } - - expect(result).to be(described_class.configuration) - end - - it "allows configuration modification within the block" do - custom_breakpoints = %w[custom_failed timeout] - - described_class.configure do |config| - config.task_breakpoints = custom_breakpoints - end - - expect(described_class.configuration.task_breakpoints).to eq(custom_breakpoints) - end - - it "passes the same configuration instance to the block" do - original_config = described_class.configuration - - described_class.configure do |config| - expect(config).to be(original_config) - end - end - - context "without a block" do - it "raises ArgumentError with descriptive message" do - expect { described_class.configure }.to raise_error(ArgumentError, "block required") - end - end - - context "with multiple configuration changes" do - let(:custom_logger) { Logger.new($stderr, progname: "test") } - let(:custom_breakpoints) { %w[error timeout] } - - it "applies all changes correctly" do - described_class.configure do |config| - config.task_breakpoints = custom_breakpoints - config.logger = custom_logger - end - - config = described_class.configuration - expect(config.task_breakpoints).to eq(custom_breakpoints) - expect(config.logger).to eq(custom_logger) - end - end - end - - describe ".reset_configuration!" do - let(:custom_logger) { Logger.new($stderr, progname: "test") } - let(:custom_breakpoints) { %w[custom_failed] } - - before do - # Modify the configuration first - described_class.configure do |config| - config.task_breakpoints = custom_breakpoints - config.workflow_breakpoints = custom_breakpoints - config.logger = custom_logger - end - end - - after do - # Skip the automatic reset for these tests since we're testing reset - described_class.instance_variable_set(:@configuration, nil) - end - - it "creates a new Configuration instance" do - original_config = described_class.configuration - described_class.reset_configuration! - expect(described_class.configuration).not_to be(original_config) - expect(described_class.configuration).to be_a(CMDx::Configuration) - end - - it "resets all breakpoints to default values" do - described_class.reset_configuration! - config = described_class.configuration - - expect(config.task_breakpoints).to eq(%w[failed]) - expect(config.workflow_breakpoints).to eq(%w[failed]) - end - - it "resets logger to default configuration" do - described_class.reset_configuration! - logger = described_class.configuration.logger - - expect(logger.progname).to eq("cmdx") - expect(logger.formatter).to be_a(CMDx::LogFormatters::Line) - expect(logger.level).to eq(Logger::INFO) - end - - it "resets all registries to their default state" do - described_class.reset_configuration! - config = described_class.configuration - - expect(config.middlewares.registry).to be_empty - expect(config.callbacks.registry).to be_empty - expect(config.coercions.registry.keys).to include( - :array, :string, :integer, :boolean, :float, :hash - ) - expect(config.validators.registry.keys).to include( - :presence, :format, :inclusion, :exclusion, :length, :numeric - ) - end - - it "clears the memoized @configuration variable" do - old_object_id = described_class.configuration.object_id - described_class.reset_configuration! - - expect(described_class.configuration.object_id).not_to eq(old_object_id) - end - - context "when called multiple times" do - it "creates a fresh instance each time" do - described_class.reset_configuration! - first_reset = described_class.configuration - - described_class.reset_configuration! - second_reset = described_class.configuration - - expect(first_reset).not_to be(second_reset) - end - end - - context "when configuration is not modified" do - it "still creates a new instance" do - original_config = described_class.configuration - described_class.reset_configuration! - - expect(described_class.configuration).not_to be(original_config) - end - end - - context "when configuration has custom registries" do - let(:custom_middleware) { CMDx::MiddlewareRegistry.new } - let(:custom_callback) { CMDx::CallbackRegistry.new } - - before do - described_class.configure do |config| - config.middlewares = custom_middleware - config.callbacks = custom_callback - end - end - - it "resets to new default registry instances" do - described_class.reset_configuration! - config = described_class.configuration - - expect(config.middlewares).not_to be(custom_middleware) - expect(config.callbacks).not_to be(custom_callback) - expect(config.middlewares).to be_a(CMDx::MiddlewareRegistry) - expect(config.callbacks).to be_a(CMDx::CallbackRegistry) - end - end - end -end diff --git a/spec/integration/tasks/attributes_spec.rb b/spec/integration/tasks/attributes_spec.rb deleted file mode 100644 index f02acf7d5..000000000 --- a/spec/integration/tasks/attributes_spec.rb +++ /dev/null @@ -1,482 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe "Task attributes", type: :feature do - context "when defining" do - context "without options" do - context "with no inputs" do - it "fails due to missing input" do - task = create_task_class do - attribute :plain_optional_attr - attributes :plain_required_attr, required: true - required :required_attr - optional :optional_attr - - def work - context.attrs = [plain_optional_attr, plain_required_attr, required_attr, optional_attr] - end - end - - result = task.execute - - expect(result).to have_failed( - reason: "Invalid", - cause: be_a(CMDx::FailFault) - ) - expect(result).to have_matching_metadata( - errors: { - full_message: "plain_required_attr must be accessible via the context source method. required_attr must be accessible via the context source method", - messages: { - plain_required_attr: ["must be accessible via the context source method"], - required_attr: ["must be accessible via the context source method"] - } - } - ) - end - end - - context "with minimum inputs" do - it "returns attributes defined as methods" do - task = create_task_class do - attribute :plain_optional_attr - attributes :plain_required_attr, required: true - required :required_attr - optional :optional_attr - - def work - context.attrs = [plain_optional_attr, plain_required_attr, required_attr, optional_attr] - end - end - - result = task.execute( - plain_required_attr: "plain_required", - required_attr: "required" - ) - - expect(result).to be_successful - expect(result).to have_matching_context(attrs: [nil, "plain_required", "required", nil]) - end - end - - context "with maximum inputs" do - it "returns attributes defined as methods" do - task = create_task_class do - attribute :plain_optional_attr - attributes :plain_required_attr, required: true - required :required_attr - optional :optional_attr - - def work - context.attrs = [plain_optional_attr, plain_required_attr, required_attr, optional_attr] - end - end - - result = task.execute( - plain_optional_attr: "plain_optional", - plain_required_attr: "plain_required", - required_attr: "required", - optional_attr: "optional" - ) - - expect(result).to be_successful - expect(result).to have_matching_context(attrs: %w[plain_optional plain_required required optional]) - end - end - end - - context "with source options" do - context "when source doesnt exist" do - it "fails with coercion error message" do - task = create_task_class do - attribute :raw_attr, source: :not_a_method - - def work = nil - end - - result = task.execute - - expect(result).to have_failed( - reason: "Invalid", - cause: be_a(CMDx::FailFault) - ) - expect(result).to have_matching_metadata( - errors: { - full_message: "raw_attr delegates to undefined method not_a_method", - messages: { raw_attr: ["delegates to undefined method not_a_method"] } - } - ) - end - end - end - - context "with type options" do - context "when attribute is optional" do - context "when cannot be coerced into type" do - it "fails with coercion error message" do - task = create_task_class do - attribute :raw_attr, type: :integer - - def work - context.attrs = [raw_attr] - end - end - - result = task.execute - - expect(result).to be_successful - expect(result).to have_matching_context(attrs: [nil]) - end - end - - context "when cannot be coerced into any type" do - it "fails with coercion error message" do - task = create_task_class do - attribute :raw_attr, types: %i[float integer] - - def work - context.attrs = [raw_attr] - end - end - - result = task.execute - - expect(result).to be_successful - expect(result).to have_matching_context(attrs: [nil]) - end - end - - context "when value can be coerced" do - it "coerces the value into the type" do - task = create_task_class do - attribute :raw_attr, type: :integer - - def work - context.attrs = [raw_attr] - end - end - - result = task.execute(raw_attr: "123") - - expect(result).to be_successful - expect(result).to have_matching_context(attrs: [123]) - end - end - end - - context "when attribute is required" do - context "when cannot be coerced into type" do - it "fails with coercion error message" do - task = create_task_class do - required :raw_attr, type: :hash do - required :raw_key, type: :integer - end - - def work = nil - end - - result = task.execute(raw_attr: { raw_key: nil }) - - expect(result).to have_failed( - reason: "Invalid", - cause: be_a(CMDx::FailFault) - ) - expect(result).to have_matching_metadata( - errors: { - full_message: "raw_key could not coerce into an integer", - messages: { raw_key: ["could not coerce into an integer"] } - } - ) - end - end - - context "when cannot be coerced into any type" do - it "fails with coercion error message" do - task = create_task_class do - required :raw_attr, type: :hash do - required :raw_key, type: %i[float integer] - end - - def work = nil - end - - result = task.execute(raw_attr: { raw_key: nil }) - - expect(result).to have_failed( - reason: "Invalid", - cause: be_a(CMDx::FailFault) - ) - expect(result).to have_matching_metadata( - errors: { - full_message: "raw_key could not coerce into one of: float, integer", - messages: { raw_key: ["could not coerce into one of: float, integer"] } - } - ) - end - end - - context "when nested attribute is missing" do - it "fails with coercion error message" do - task = create_task_class do - required :raw_attr, type: :hash do - optional :raw_key, type: :integer - end - - def work - context.attrs = [raw_key] - end - end - - result = task.execute(raw_attr: {}) - - expect(result).to be_successful - expect(result).to have_matching_context(attrs: [nil]) - end - end - - context "when value can be coerced" do - it "coerces the value into the type" do - task = create_task_class do - required :raw_attr, type: :hash do - required :raw_key, type: :integer - end - - def work - context.attrs = [raw_key] - end - end - - result = task.execute(raw_attr: { raw_key: "123" }) - - expect(result).to be_successful - expect(result).to have_matching_context(attrs: [123]) - end - end - end - end - - context "with default option" do - context "when derived value is nil" do - it "returns the default value" do - task = create_task_class do - attribute :raw_attr, default: 987 - - def work - context.attrs = [raw_attr] - end - end - - result = task.execute - - expect(result).to be_successful - expect(result).to have_matching_context(attrs: [987]) - end - end - - context "when derived value is not nil" do - it "returns the derived value" do - task = create_task_class do - attribute :raw_attr, default: 987 - - def work - context.attrs = [raw_attr] - end - end - - result = task.execute(raw_attr: "123") - - expect(result).to be_successful - expect(result).to have_matching_context(attrs: ["123"]) - end - end - - context "when the default value cannot be coerced into the type" do - it "fails with coercion error message" do - task = create_task_class do - attribute :raw_attr, type: :integer, default: "abc" - - def work = nil - end - - result = task.execute - - expect(result).to have_failed( - reason: "Invalid", - cause: be_a(CMDx::FailFault) - ) - expect(result).to have_matching_metadata( - errors: { - full_message: "raw_attr could not coerce into an integer", - messages: { raw_attr: ["could not coerce into an integer"] } - } - ) - end - end - end - - context "with transformation options" do - context "when value is not valid" do - it "succeeds but does not transform the value" do - task = create_task_class do - attribute :raw_attr, transform: :downcase - - def work - context.attr = raw_attr - end - end - - result = task.execute(raw_attr: 123) - - expect(result).to be_successful - expect(result).to have_matching_context(attr: 123) - end - end - - context "when value is valid" do - it "succeeds and transforms the value" do - task = create_task_class do - attribute :raw_attr, transform: :upcase - - def work - context.attr = raw_attr - end - end - - result = task.execute(raw_attr: "abc") - - expect(result).to be_successful - expect(result).to have_matching_context(attr: "ABC") - end - end - end - - context "with validation options" do - context "when value is not valid" do - it "fails with validation error message" do - task = create_task_class do - attribute :raw_attr, format: { with: /^\d+$/ } - - def work = nil - end - - result = task.execute - - expect(result).to have_failed( - reason: "Invalid", - cause: be_a(CMDx::FailFault) - ) - expect(result).to have_matching_metadata( - errors: { - full_message: "raw_attr is an invalid format", - messages: { raw_attr: ["is an invalid format"] } - } - ) - end - end - end - - context "with naming options" do - context "when prefix is set" do - it "prefixes the attribute name" do - task = create_task_class do - attribute :raw_attr, prefix: :prefix_ - - def work - context.attrs = [prefix_raw_attr] - end - end - - result = task.execute(raw_attr: "123") - - expect(result).to be_successful - expect(result).to have_matching_context(attrs: ["123"]) - end - end - - context "when suffix is set" do - it "suffixes the attribute name" do - task = create_task_class do - attribute :raw_attr, suffix: :_suffix - - def work - context.attrs = [raw_attr_suffix] - end - end - - result = task.execute(raw_attr: "123") - - expect(result).to be_successful - expect(result).to have_matching_context(attrs: ["123"]) - end - end - - context "when as is set" do - it "sets the attribute name" do - task = create_task_class do - attribute :raw_attr, as: :raw_attr_as - - def work - context.attrs = [raw_attr_as] - end - end - - result = task.execute(raw_attr: "123") - - expect(result).to be_successful - expect(result).to have_matching_context(attrs: ["123"]) - end - end - end - end - - context "when undefining" do - it "raises a NameError" do - task = create_task_class do - attribute :raw_attr - - remove_attribute :raw_attr - - def work - context.attrs = [raw_attr] - end - end - - result = task.execute - - expect(result).to have_failed( - reason: start_with( - if RubyVersion.min?(3.4) - "[NameError] undefined local variable or method 'raw_attr' for an instance of" - else - "[NameError] undefined local variable or method `raw_attr' for" - end - ), - cause: be_a(NameError) - ) - end - end - - context "when inheriting" do - it "assumes the parents attributes" do - parent_task = create_task_class(name: "ParentTask") do - required :parent_attr - - def work = nil - end - child_task = create_task_class(base: parent_task, name: "ChildTask") do - optional :child_attr - - def work - context.executed ||= [] - context.executed << parent_attr - context.executed << child_attr - end - end - - result = child_task.execute(parent_attr: "parent123", child_attr: "child456") - - expect(result).to be_successful - expect(result).to have_matching_context(executed: %w[parent123 child456]) - end - end -end diff --git a/spec/integration/tasks/callbacks_spec.rb b/spec/integration/tasks/callbacks_spec.rb index 13f72c117..35c48243e 100644 --- a/spec/integration/tasks/callbacks_spec.rb +++ b/spec/integration/tasks/callbacks_spec.rb @@ -3,534 +3,294 @@ require "spec_helper" RSpec.describe "Task callbacks", type: :feature do - context "when defining lifecycle callbacks" do - context "with before_validation" do - it "executes before attribute validation" do - task = create_task_class do - before_validation :track_validation - - required :value - - def work - context.executed = true - end - - private - - def track_validation - (context.callbacks ||= []) << :before_validation - end + def callback_log_task(status: :success) + create_task_class(name: "CallbackTask") do + before_execution :log_before_execution + before_validation :log_before_validation + on_complete :log_on_complete + on_interrupted :log_on_interrupted + on_success :log_on_success + on_skipped :log_on_skipped + on_failed :log_on_failed + on_ok :log_on_ok + on_ko :log_on_ko + + define_method(:work) do + case status + when :success then nil + when :skip then skip!("s") + when :fail then fail!("f") end - - result = task.execute(value: "test") - - expect(result).to be_successful - expect(result).to have_matching_context(callbacks: [:before_validation], executed: true) end - end - - context "with before_execution" do - it "executes before the work method" do - task = create_task_class do - before_execution :setup_data - - def work - (context.callbacks ||= []) << :work - end - - private - - def setup_data - (context.callbacks ||= []) << :before_execution - end - end - - result = task.execute - expect(result).to be_successful - expect(result).to have_matching_context(callbacks: %i[before_execution work]) + CMDx::Callbacks::EVENTS.each do |event| + define_method(:"log_#{event}") { (context.log ||= []) << event } end end + end - context "with on_complete" do - it "executes after successful completion" do - task = create_task_class do - on_complete :track_complete - - def work - (context.callbacks ||= []) << :work - end - - private - - def track_complete - (context.callbacks ||= []) << :on_complete - end - end - - result = task.execute + describe "event firing order" do + it "fires before_execution, before_validation, on_complete, on_success, on_ok for a successful task" do + result = callback_log_task(status: :success).execute - expect(result).to be_successful - expect(result).to have_matching_context(callbacks: %i[work on_complete]) - end + expect(result.context[:log]).to eq(%i[before_execution before_validation on_complete on_success on_ok]) end - context "with on_interrupted" do - it "executes after interruption" do - task = create_task_class do - on_interrupted :track_interrupted - - def work - fail!("Test failure") - end - - private - - def track_interrupted - (context.callbacks ||= []) << :on_interrupted - end - end - - result = task.execute + it "fires before_execution, before_validation, on_interrupted, on_skipped, on_ok for a skipped task" do + result = callback_log_task(status: :skip).execute - expect(result).to have_failed(reason: "Test failure") - expect(result).to have_matching_context(callbacks: [:on_interrupted]) - end + expect(result.context[:log]).to eq(%i[before_execution before_validation on_interrupted on_skipped on_ok]) end - context "with on_executed" do - it "executes after any outcome" do - success_task = create_task_class do - on_executed :track_executed - - def work - (context.callbacks ||= []) << :success - end - - private - - def track_executed - (context.callbacks ||= []) << :on_executed - end - end - - failed_task = create_task_class do - on_executed :track_executed - - def work - fail!("Test") - end - - private + it "fires before_execution, before_validation, on_interrupted, on_failed, on_ko for a failed task" do + result = callback_log_task(status: :fail).execute - def track_executed - (context.callbacks ||= []) << :on_executed - end - end - - success_result = success_task.execute - failed_result = failed_task.execute - - expect(success_result).to be_successful - expect(success_result).to have_matching_context(callbacks: %i[success on_executed]) - - expect(failed_result).to have_failed(reason: "Test") - expect(failed_result).to have_matching_context(callbacks: [:on_executed]) - end + expect(result.context[:log]).to eq(%i[before_execution before_validation on_interrupted on_failed on_ko]) end + end - context "with on_success" do - it "executes only on success" do - task = create_task_class do - on_success :track_success - - def work - (context.callbacks ||= []) << :work - end - - private + describe "callback forms" do + subject(:result) { task.execute } - def track_success - (context.callbacks ||= []) << :on_success - end + context "with a Symbol pointing to an instance method" do + let(:task) do + create_successful_task do + on_success :note_success + define_method(:note_success) { context.note = :symbol } end - - result = task.execute - - expect(result).to be_successful - expect(result).to have_matching_context(callbacks: %i[work on_success]) end - it "does not execute on failure" do - task = create_task_class do - on_success :track_success - - def work - fail!("Test") - end - - private - - def track_success - (context.callbacks ||= []) << :on_success - end - end - - result = task.execute - - expect(result).to have_failed(reason: "Test") - expect(result).to have_empty_context + it "invokes the method in the task instance" do + expect(result.context[:note]).to eq(:symbol) end end - context "with on_skipped" do - it "executes only when skipped" do - task = create_task_class do - on_skipped :track_skipped - - def work - skip!("Test skip") - end - - private - - def track_skipped - (context.callbacks ||= []) << :on_skipped - end + context "with a block" do + let(:task) do + create_successful_task do + on_success { context.note = :block } end + end - result = task.execute - - expect(result).to have_skipped(reason: "Test skip") - expect(result).to have_matching_context(callbacks: [:on_skipped]) + it "runs the block via instance_exec" do + expect(result.context[:note]).to eq(:block) end end - context "with on_failed" do - it "executes only on failure" do - task = create_task_class do - on_failed :track_failed - - def work - fail!("Test failure") - end - - private - - def track_failed - (context.callbacks ||= []) << :on_failed - end + context "with a Proc" do + let(:task) do + create_successful_task do + on_success proc { |t| t.context.note = :proc } end + end - result = task.execute - - expect(result).to have_failed(reason: "Test failure") - expect(result).to have_matching_context(callbacks: [:on_failed]) + it "invokes the proc with the task as argument" do + expect(result.context[:note]).to eq(:proc) end end - context "with on_good" do - it "executes on success" do - task = create_task_class do - on_good :track_good - - def work - (context.callbacks ||= []) << :work - end - - private - - def track_good - (context.callbacks ||= []) << :on_good + context "with a callable object" do + let(:task) do + handler = Class.new do + def self.call(task) + task.context.note = :callable end end - - result = task.execute - - expect(result).to be_successful - expect(result).to have_matching_context(callbacks: %i[work on_good]) - end - - it "executes when skipped" do - task = create_task_class do - on_good :track_good - - def work - skip!("Test") - end - - private - - def track_good - (context.callbacks ||= []) << :on_good - end + create_successful_task do + on_success handler end + end - result = task.execute - - expect(result).to have_skipped(reason: "Test") - expect(result).to have_matching_context(callbacks: [:on_good]) + it "delegates to the callable's #call" do + expect(result.context[:note]).to eq(:callable) end end - context "with on_bad" do - it "executes when skipped" do - task = create_task_class do - on_bad :track_bad - - def work - skip!("Test") - end - - private - - def track_bad - (context.callbacks ||= []) << :on_bad - end + context "with multiple callbacks on the same event" do + let(:task) do + create_successful_task do + on_success { (context.log ||= []) << :first } + on_success :second_handler + on_success { (context.log ||= []) << :third } + define_method(:second_handler) { (context.log ||= []) << :second } end - - result = task.execute - - expect(result).to have_skipped(reason: "Test") - expect(result).to have_matching_context(callbacks: [:on_bad]) end - it "executes on failure" do - task = create_task_class do - on_bad :track_bad - - def work - fail!("Test") - end - - private - - def track_bad - (context.callbacks ||= []) << :on_bad - end - end - - result = task.execute - - expect(result).to have_failed(reason: "Test") - expect(result).to have_matching_context(callbacks: [:on_bad]) + it "invokes them in registration order" do + expect(result.context[:log]).to eq(%i[first second third]) end end end - context "when using proc or lambda callbacks" do - it "executes proc callbacks" do - task = create_task_class do - before_execution proc { (context.callbacks ||= []) << :before_proc } - on_complete proc { (context.callbacks ||= []) << :complete_proc } - - def work = nil + describe "inheritance" do + let(:parent) do + create_successful_task(name: "Parent") do + on_success { (context.log ||= []) << :parent } end - - result = task.execute - - expect(result).to be_successful - expect(result).to have_matching_context(callbacks: %i[before_proc complete_proc]) end - it "executes lambda callbacks" do - task = create_task_class do - before_execution -> { (context.callbacks ||= []) << :before_lambda } - on_complete -> { (context.callbacks ||= []) << :complete_lambda } - - def work = nil + it "propagates parent callbacks to children while keeping their own" do + child = create_successful_task(base: parent, name: "Child") do + on_success { (context.log ||= []) << :child } end - result = task.execute - - expect(result).to be_successful - expect(result).to have_matching_context(callbacks: %i[before_lambda complete_lambda]) + expect(child.execute.context[:log]).to eq(%i[parent child]) end - it "executes class-based callbacks" do - setup_callback = Class.new do - def call(task) - (task.context.callbacks ||= []) << :before_class - end + it "does not leak child callbacks back to the parent" do + create_successful_task(base: parent, name: "Leaky") do + on_success { (context.log ||= []) << :leaky } end - complete_callback = Class.new do - def call(task) - (task.context.callbacks ||= []) << :complete_class - end - end - - task = create_task_class do - before_execution setup_callback.new - on_complete complete_callback.new + expect(parent.execute.context[:log]).to eq(%i[parent]) + end + end - def work = nil + describe "deregister" do + let(:task) do + parent = create_successful_task(name: "Parent") do + on_success { (context.log ||= []) << :parent } end - result = task.execute + create_successful_task(base: parent, name: "Child") do + deregister :callback, :on_success + end + end - expect(result).to be_successful - expect(result).to have_matching_context(callbacks: %i[before_class complete_class]) + it "removes callbacks for the given event" do + expect(task.execute.context[:log]).to be_nil end end - context "when using conditional callbacks" do - it "executes callbacks with if condition" do - task = create_task_class do - before_execution :setup_data, if: :should_setup? + describe "invalid registration" do + it "raises when the callback is neither a Symbol nor callable" do + expect do + create_task_class(name: "BadCallback") { on_success "not_callable" } + end.to raise_error(ArgumentError, /must be a Symbol or respond to #call/) + end - def work - (context.callbacks ||= []) << :work + it "raises when both a callable and a block are provided" do + expect do + create_task_class(name: "BothCallback") do + on_success(proc {}) { nil } end + end.to raise_error(ArgumentError, /either a callable or a block, not both/) + end - private - - def setup_data - (context.callbacks ||= []) << :before_execution - end + it "raises on an unknown event name" do + expect do + create_task_class(name: "UnknownCallback") { register :callback, :on_bogus, -> {} } + end.to raise_error(ArgumentError, /unknown event/) + end + end - def should_setup? - context.enable_setup == true - end + describe "conditional execution" do + let(:gate_class) do + Class.new do + def self.call(task) = task.context.gate_open == true end - - enabled_result = task.execute(enable_setup: true) - disabled_result = task.execute(enable_setup: false) - - expect(enabled_result).to be_successful - expect(enabled_result).to have_matching_context(callbacks: %i[before_execution work]) - - expect(disabled_result).to be_successful - expect(disabled_result).to have_matching_context(callbacks: [:work]) end - it "executes callbacks with unless condition" do - task = create_task_class do - before_execution :setup_data, unless: :skip_setup? - - def work - (context.callbacks ||= []) << :work - end - - private - - def setup_data - (context.callbacks ||= []) << :before_execution - end - - def skip_setup? - context.skip_setup == true - end + it "runs the callback when an :if Symbol gate evaluates truthy" do + task = create_successful_task do + on_success :note_run, if: :messaging_enabled? + define_method(:note_run) { context.note = :ran } + define_method(:messaging_enabled?) { context.messaging == :on } end - enabled_result = task.execute(skip_setup: false) - disabled_result = task.execute(skip_setup: true) - - expect(enabled_result).to be_successful - expect(enabled_result).to have_matching_context(callbacks: %i[before_execution work]) - - expect(disabled_result).to be_successful - expect(disabled_result).to have_matching_context(callbacks: [:work]) + expect(task.execute(messaging: :on).context[:note]).to eq(:ran) + expect(task.execute(messaging: :off).context[:note]).to be_nil end - it "executes callbacks with combined if and unless" do - task = create_task_class do - before_execution :setup_data, if: :should_setup?, unless: :override_setup? - - def work - (context.callbacks ||= []) << :work - end - - private - - def setup_data - (context.callbacks ||= []) << :before_execution - end - - def should_setup? - context.enable_setup == true - end - - def override_setup? - context.override == true - end + it "skips the callback when an :unless Symbol gate evaluates truthy" do + task = create_successful_task do + on_success :note_run, unless: :messaging_blocked? + define_method(:note_run) { context.note = :ran } + define_method(:messaging_blocked?) { context.blocked == true } end - both_true = task.execute(enable_setup: true, override: true) - if_true_unless_false = task.execute(enable_setup: true, override: false) + expect(task.execute(blocked: false).context[:note]).to eq(:ran) + expect(task.execute(blocked: true).context[:note]).to be_nil + end - expect(both_true).to be_successful - expect(both_true).to have_matching_context(callbacks: [:work]) + it "supports a Proc gate that runs against the task as self" do + task = create_successful_task do + on_success :note_run, if: proc { context.flag } + define_method(:note_run) { context.note = :ran } + end - expect(if_true_unless_false).to be_successful - expect(if_true_unless_false).to have_matching_context(callbacks: %i[before_execution work]) + expect(task.execute(flag: true).context[:note]).to eq(:ran) + expect(task.execute(flag: false).context[:note]).to be_nil end - end - context "when executing multiple callbacks" do - it "executes callbacks in declaration order (FIFO)" do - task = create_task_class do - before_execution :first_setup - before_execution :second_setup - on_complete :first_complete - on_complete :second_complete + it "supports a class callable gate invoked with the task" do + gate = gate_class + task = create_successful_task do + on_success :note_run, if: gate + define_method(:note_run) { context.note = :ran } + end - def work - (context.callbacks ||= []) << :work - end + expect(task.execute(gate_open: true).context[:note]).to eq(:ran) + expect(task.execute(gate_open: false).context[:note]).to be_nil + end - private + it "applies :if and :unless together (both must pass)" do + task = create_successful_task do + on_success :note_run, if: :allowed?, unless: :paused? + define_method(:note_run) { context.note = :ran } + define_method(:allowed?) { context.allowed == true } + define_method(:paused?) { context.paused == true } + end - def first_setup - (context.callbacks ||= []) << :first_setup - end + expect(task.execute(allowed: true, paused: false).context[:note]).to eq(:ran) + expect(task.execute(allowed: true, paused: true).context[:note]).to be_nil + expect(task.execute(allowed: false, paused: false).context[:note]).to be_nil + end - def second_setup - (context.callbacks ||= []) << :second_setup - end + it "evaluates each registration's gates independently" do + task = create_successful_task do + on_success :note_a, if: :a_open? + on_success :note_b, if: :b_open? + define_method(:note_a) { (context.log ||= []) << :a } + define_method(:note_b) { (context.log ||= []) << :b } + define_method(:a_open?) { context.a == true } + define_method(:b_open?) { context.b == true } + end - def first_complete - (context.callbacks ||= []) << :first_complete - end + expect(task.execute(a: true, b: false).context[:log]).to eq([:a]) + expect(task.execute(a: false, b: true).context[:log]).to eq([:b]) + expect(task.execute(a: true, b: true).context[:log]).to eq(%i[a b]) + expect(task.execute(a: false, b: false).context[:log]).to be_nil + end - def second_complete - (context.callbacks ||= []) << :second_complete - end + it "gates Proc/block callbacks the same as Symbol callbacks" do + task = create_successful_task do + on_success(if: :open?) { context.note = :proc_ran } + define_method(:open?) { context.open == true } end - result = task.execute - - expect(result).to be_successful - expect(result).to have_matching_context( - callbacks: %i[first_setup second_setup work first_complete second_complete] - ) + expect(task.execute(open: true).context[:note]).to eq(:proc_ran) + expect(task.execute(open: false).context[:note]).to be_nil end end - context "when removing callbacks" do - it "removes symbol callbacks" do - parent_task = create_task_class(name: "ParentTask") do - before_execution :setup_data - - def work - (context.callbacks ||= []) << :work - end - - private - - def setup_data - (context.callbacks ||= []) << :before_execution - end + describe "callback interactions" do + it "runs before_validation before input resolution so it can populate context" do + task = create_task_class(name: "PrefillTask") do + before_validation { context.name = "Alice" } + required :name + define_method(:work) { context.greeting = "Hi, #{name}" } end - child_task = create_task_class(base: parent_task, name: "ChildTask") do - deregister :callback, :before_execution, :setup_data - end + expect(task.execute.context[:greeting]).to eq("Hi, Alice") + end - result = child_task.execute + it "fires on_failed even when failure came from a raised exception" do + task = create_erroring_task do + on_failed { (context.log ||= []) << :handled_failure } + end - expect(result).to be_successful - expect(result).to have_matching_context(callbacks: [:work]) + expect(task.execute.context[:log]).to eq(%i[handled_failure]) end end end diff --git a/spec/integration/tasks/chain_spec.rb b/spec/integration/tasks/chain_spec.rb new file mode 100644 index 000000000..ec7de2b9a --- /dev/null +++ b/spec/integration/tasks/chain_spec.rb @@ -0,0 +1,110 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe "Task chain tracking", type: :feature do + after { CMDx::Chain.clear } + + describe "a root execution with no subtasks" do + subject(:result) { create_successful_task.execute } + + it "collects only itself and exposes a UUIDv7 chain_id" do + expect(result.chain.size).to eq(1) + expect(result.chain.first).to be(result) + expect(result.chain_index).to eq(0) + expect(result.chain.id).to match(/\A\h{8}-\h{4}-7\h{3}-\h{4}-\h{12}\z/) + end + end + + describe "a nested execution" do + subject(:result) { create_nested_task(strategy: strategy, status: :success).execute } + + shared_examples "collects the full stack" do + it "records inner/middle/outer in execution order" do + expect(result.chain.size).to eq(3) + + names = result.chain.map { |r| r.task.name } + expect(names.first).to start_with("OuterTask") + expect(names.last).to start_with("MiddleTask") + end + + it "indexes each result by its chain position" do + indexes = result.chain.map(&:chain_index) + expect(indexes).to eq([0, 1, 2]) + end + + it "shares a single chain_id across every result" do + ids = result.chain.map { |r| r.chain.id }.uniq + expect(ids).to eq([result.chain.id]) + end + + it "returns all results successful" do + expect(result.chain).to all(have_attributes(status: CMDx::Signal::SUCCESS)) + end + end + + context "with swallow strategy" do + let(:strategy) { :swallow } + + it_behaves_like "collects the full stack" + end + + context "with throw strategy" do + let(:strategy) { :throw } + + it_behaves_like "collects the full stack" + end + end + + describe "propagating a failed subtask" do + context "when the outer swallows the failure" do + subject(:result) { create_nested_task(strategy: :swallow, status: :failure).execute } + + it "keeps the failed inner in the chain but the outer reports success" do + expect(result).to have_attributes(status: CMDx::Signal::SUCCESS) + inner = result.chain.find { |r| r.task.name.start_with?("InnerTask") } + expect(inner).to have_attributes(status: CMDx::Signal::FAILED) + end + end + + context "when the outer throws the failure" do + subject(:result) { create_nested_task(strategy: :throw, status: :failure).execute } + + it "propagates failure up and still records each layer" do + expect(result.status).to eq(CMDx::Signal::FAILED) + expect(result.chain).to all(have_attributes(status: CMDx::Signal::FAILED)) + end + end + end + + describe "fiber storage lifecycle" do + it "clears the chain after a root execution completes" do + create_nested_task(strategy: :swallow, status: :success).execute + + expect(CMDx::Chain.current).to be_nil + end + + it "joins a pre-existing chain instead of replacing it" do + CMDx::Chain.current = CMDx::Chain.new + + first = create_successful_task.execute + second = create_successful_task.execute + + expect(first.chain.id).to eq(second.chain.id) + expect(first.chain).to be(second.chain) + expect(second.chain_index).to eq(1) + expect(CMDx::Chain.current).not_to be_nil + end + end + + describe "isolation between root executions" do + it "does not leak chain state across runs" do + first = create_nested_task(strategy: :swallow, status: :success).execute + second = create_successful_task.execute + + expect(first.chain.id).not_to eq(second.chain.id) + expect(first.chain.size).to eq(3) + expect(second.chain.size).to eq(1) + end + end +end diff --git a/spec/integration/tasks/deprecation_spec.rb b/spec/integration/tasks/deprecation_spec.rb new file mode 100644 index 000000000..4e3533a7b --- /dev/null +++ b/spec/integration/tasks/deprecation_spec.rb @@ -0,0 +1,196 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe "Task deprecation", type: :feature do + def deprecated_task(value, **options, &) + create_task_class(name: "DeprecatedTask") do + deprecation value, **options, & + define_method(:work) { (context.executed ||= []) << :ran } + end + end + + describe "no deprecation declared" do + it "is not deprecated" do + expect(create_successful_task.execute).to have_attributes(deprecated?: false) + end + end + + describe "built-in modes" do + it ":log still executes and marks as deprecated" do + result = deprecated_task(:log).execute + + expect(result).to have_attributes( + status: CMDx::Signal::SUCCESS, + deprecated?: true, + context: have_attributes(executed: %i[ran]) + ) + end + + it ":warn calls Kernel.warn and still executes" do + expect(Kernel).to receive(:warn).with(/DEPRECATED/) + + expect(deprecated_task(:warn).execute).to have_attributes( + status: CMDx::Signal::SUCCESS, + deprecated?: true + ) + end + + it ":error raises before work runs, under execute" do + task = deprecated_task(:error) + + expect { task.execute }.to raise_error(CMDx::DeprecationError, /usage prohibited/) + end + + it ":error raises under execute! as well" do + expect { deprecated_task(:error).execute! }.to raise_error(CMDx::DeprecationError) + end + end + + describe "callable forms" do + it "runs a block in the task's instance context" do + task = create_task_class(name: "BlockDep") do + deprecation { context.block_ran = true } + define_method(:work) { (context.executed ||= []) << :ran } + end + + expect(task.execute.context).to have_attributes(block_ran: true, executed: %i[ran]) + end + + it "invokes a Proc with the task instance" do + seen = nil + callable = ->(t) { seen = t.class.name } + + deprecated_task(callable).execute + + expect(seen).to match(/DeprecatedTask/) + end + + it "resolves a Symbol via an instance method" do + task = create_task_class(name: "SymbolDep") do + deprecation :resolve_mode + define_method(:work) { nil } + define_method(:resolve_mode) { :handled } + end + + expect(task.execute).to have_attributes(deprecated?: true) + end + + it "raises ArgumentError for an unsupported value" do + task = deprecated_task(Object.new) + + expect { task.execute }.to raise_error(ArgumentError, /Symbol, Proc, or respond to #call/) + end + end + + describe "conditional gating" do + it "if: truthy activates" do + task = create_task_class(name: "IfTruthy") do + deprecation :log, if: :legacy? + define_method(:work) { nil } + define_method(:legacy?) { true } + end + + expect(task.execute).to have_attributes(deprecated?: true) + end + + it "if: falsy skips" do + task = create_task_class(name: "IfFalsy") do + deprecation :log, if: :legacy? + define_method(:work) { nil } + define_method(:legacy?) { false } + end + + expect(task.execute).to have_attributes(deprecated?: false) + end + + it "unless: falsy activates" do + task = create_task_class(name: "UnlessFalsy") do + deprecation :log, unless: :modern? + define_method(:work) { nil } + define_method(:modern?) { false } + end + + expect(task.execute).to have_attributes(deprecated?: true) + end + + it "unless: truthy skips" do + task = create_task_class(name: "UnlessTruthy") do + deprecation :log, unless: :modern? + define_method(:work) { nil } + define_method(:modern?) { true } + end + + expect(task.execute).to have_attributes(deprecated?: false) + end + + it "Proc conditions evaluate in the task context" do + task = create_task_class(name: "ProcCond") do + deprecation :log, if: proc { respond_to?(:work) } + define_method(:work) { nil } + end + + expect(task.execute).to have_attributes(deprecated?: true) + end + end + + describe "inheritance" do + let(:parent) do + create_task_class(name: "ParentDep") do + deprecation :log + define_method(:work) { nil } + end + end + + it "child inherits parent deprecation" do + child = create_task_class(base: parent, name: "ChildDep") { define_method(:work) { nil } } + + expect(child.deprecation).to be(parent.deprecation) + expect(child.execute).to have_attributes(deprecated?: true) + end + + it "child can override with its own deprecation" do + child = create_task_class(base: parent, name: "OverrideDep") do + deprecation :warn + define_method(:work) { nil } + end + + expect(Kernel).to receive(:warn).with(/DEPRECATED/) + + child.execute + end + end + + describe "telemetry" do + it "emits :task_deprecated before :error halts execution" do + events = [] + task = create_task_class(name: "DeprecatedErrorTelemetry") do + deprecation :error + telemetry.subscribe(:task_deprecated) { |e| events << e } + define_method(:work) { nil } + end + + expect { task.execute }.to raise_error(CMDx::DeprecationError) + expect(events.size).to eq(1) + end + + it "does not emit when conditions gate the deprecation" do + events = [] + task = create_task_class(name: "DepGated") do + deprecation :log, if: -> { false } + telemetry.subscribe(:task_deprecated) { |e| events << e } + define_method(:work) { nil } + end + + task.execute + + expect(events).to be_empty + end + end + + describe "result integration" do + it "surfaces deprecated: true in result#to_h" do + expect(deprecated_task(:log).execute.to_h).to include(deprecated: true) + end + end +end diff --git a/spec/integration/tasks/errors_spec.rb b/spec/integration/tasks/errors_spec.rb new file mode 100644 index 000000000..b91facba0 --- /dev/null +++ b/spec/integration/tasks/errors_spec.rb @@ -0,0 +1,168 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe "Task error collection", type: :feature do + describe "adding errors inside #work" do + context "with no errors added" do + subject(:result) { create_successful_task.execute } + + it "returns success with an empty errors object" do + expect(result).to have_attributes(status: CMDx::Signal::SUCCESS) + expect(result.errors).to have_attributes(empty?: true, size: 0, count: 0) + expect(result.errors.to_h).to eq({}) + expect(result.errors.to_s).to eq("") + end + end + + context "with a single error" do + subject(:result) do + create_task_class(name: "SingleError") do + define_method(:work) { errors.add(:name, "is required") } + end.execute + end + + it "fails and surfaces the message in reason" do + expect(result).to have_attributes( + status: CMDx::Signal::FAILED, + reason: "name is required", + cause: nil + ) + expect(result.errors.to_h).to eq(name: ["is required"]) + expect(result.errors.full_messages).to eq(name: ["name is required"]) + end + end + + context "with multiple errors across attributes" do + subject(:result) do + create_task_class(name: "MultiError") do + define_method(:work) do + errors.add(:name, "is required") + errors.add(:email, "is invalid") + errors.add(:email, "is required") + end + end.execute + end + + it "groups messages by attribute and computes size vs count distinctly" do + expect(result.errors.size).to eq(2) + expect(result.errors.count).to eq(3) + expect(result.errors.keys).to contain_exactly(:name, :email) + expect(result.errors.to_h).to eq( + name: ["is required"], + email: ["is invalid", "is required"] + ) + end + + it "joins full messages for #to_s" do + expect(result.errors.to_s) + .to eq("name is required. email is invalid. email is required") + end + end + + context "with duplicate messages on the same attribute" do + subject(:result) do + create_task_class(name: "DupError") do + define_method(:work) do + errors.add(:name, "is required") + errors.add(:name, "is required") + end + end.execute + end + + it "deduplicates via Set" do + expect(result.errors.to_h).to eq(name: ["is required"]) + end + end + + context "with errors plus an explicit fail!" do + subject(:result) do + create_task_class(name: "ErrorsAndFail") do + define_method(:work) do + errors.add(:base, "something broke") + fail!("explicit failure") + end + end.execute + end + + it "prefers the explicit fail! reason while retaining the errors object" do + expect(result).to have_attributes(reason: "explicit failure") + expect(result.errors.to_h).to eq(base: ["something broke"]) + end + end + end + + describe "Errors query API" do + subject(:errors) do + create_task_class(name: "QueryErrors") do + define_method(:work) do + errors.add(:name, "is required") + errors.add(:email, "is invalid") + end + end.execute.errors + end + + it "answers key? for known and unknown keys" do + expect(errors.key?(:name)).to be(true) + expect(errors.key?(:other)).to be(false) + end + + it "answers added? for known and unknown messages" do + expect(errors.added?(:name, "is required")).to be(true) + expect(errors.added?(:name, "different")).to be(false) + expect(errors.added?(:other, "anything")).to be(false) + end + + it "returns an empty frozen set for unknown keys" do + expect(errors[:unknown]).to be_empty + end + end + + describe "#to_hash format switching" do + subject(:errors) do + create_task_class(name: "ToHashErrors") do + define_method(:work) do + errors.add(:name, "is required") + errors.add(:email, "is invalid") + end + end.execute.errors + end + + it "returns short messages by default" do + expect(errors.to_hash).to eq( + name: ["is required"], + email: ["is invalid"] + ) + end + + it "returns fully-qualified messages when passed true" do + expect(errors.to_hash(true)).to eq( + name: ["name is required"], + email: ["email is invalid"] + ) + end + end + + describe "post-execution state" do + it "freezes the errors object" do + result = create_task_class(name: "FrozenErrors") do + define_method(:work) { errors.add(:name, "is required") } + end.execute + + expect(result.errors).to be_frozen + end + end + + describe "blocking execute!" do + it "raises Fault with the collected error message" do + task = create_task_class(name: "BangErrors") do + define_method(:work) do + errors.add(:name, "is required") + errors.add(:email, "is invalid") + end + end + + expect { task.execute! }.to raise_error(CMDx::Fault, "name is required. email is invalid") + end + end +end diff --git a/spec/integration/tasks/execution_spec.rb b/spec/integration/tasks/execution_spec.rb index ed459ca28..e1341e24d 100644 --- a/spec/integration/tasks/execution_spec.rb +++ b/spec/integration/tasks/execution_spec.rb @@ -3,813 +3,280 @@ require "spec_helper" RSpec.describe "Task execution", type: :feature do - context "when non-bang" do - subject(:result) { task.execute } - - context "when simple task" do - context "when successful" do - let(:task) { create_successful_task } - - it "returns success" do - expect(result).to be_successful - expect(result).to have_matching_context(executed: %i[success]) - end - end - - context "when skipping" do - let(:task) { create_skipping_task } - - it "returns skipped" do - expect(result).to have_skipped - expect(result).to have_empty_context - end - end - - context "when failing" do - let(:task) { create_failing_task } - - it "returns failure" do - expect(result).to have_failed - expect(result).to have_empty_context - end + describe "non-blocking execute" do + context "when successful" do + subject(:result) { create_successful_task.execute } + + it "returns a complete/success result" do + expect(result).to have_attributes( + state: CMDx::Signal::COMPLETE, + status: CMDx::Signal::SUCCESS, + reason: nil, + metadata: {}, + cause: nil + ) + expect(result.context).to have_attributes(executed: %i[success]) end - context "when erroring" do - let(:task) { create_erroring_task } - - it "returns failure" do - expect(result).to have_failed( - reason: "[CMDx::TestError] borked error", - cause: be_a(CMDx::TestError) - ) - expect(result).to have_empty_context - end + it "populates metadata attributes" do + expect(result.id).to match(/\A\h{8}-\h{4}-7\h{3}-\h{4}-\h{12}\z/) + expect(result.chain.id).to match(/\A\h{8}-\h{4}-7\h{3}-\h{4}-\h{12}\z/) + expect(result.chain_index).to eq(0) + expect(result.duration).to be_a(Float).and be >= 0 + expect(result.retries).to eq(0) + expect(result).to have_attributes(strict?: false, retried?: false, deprecated?: false, rolled_back?: false) end end - context "with nested tasks" do - context "when swallowing" do - context "when successful" do - let(:task) { create_nested_task } + context "when skipping" do + subject(:result) { create_skipping_task(reason: "not needed", code: 42).execute } - it "returns success" do - expect(result).to be_successful - expect(result).to have_matching_context(executed: %i[inner middle outer]) - end - end - - context "when skipping" do - let(:task) { create_nested_task(status: :skipped) } - - it "returns success" do - expect(result).to be_successful - expect(result).to have_matching_context(executed: %i[middle outer]) - end - end - - context "when failing" do - let(:task) { create_nested_task(status: :failure) } - - it "returns failure" do - expect(result).to be_successful - expect(result).to have_matching_context(executed: %i[middle outer]) - end - end - - context "when erroring" do - let(:task) { create_nested_task(status: :error) } - - it "returns success" do - expect(result).to be_successful - expect(result).to have_matching_context(executed: %i[middle outer]) - end - end + it "returns an interrupted/skipped result with reason and metadata" do + expect(result).to have_attributes( + state: CMDx::Signal::INTERRUPTED, + status: CMDx::Signal::SKIPPED, + reason: "not needed", + metadata: { code: 42 }, + cause: nil + ) + expect(result.context).to be_empty end + end - context "when throwing" do - context "when successful" do - let(:task) { create_nested_task(strategy: :throw) } - - it "returns success" do - expect(result).to be_successful - expect(result).to have_matching_context(executed: %i[inner middle outer]) - end - end - - context "when skipping" do - let(:task) { create_nested_task(strategy: :throw, status: :skipped) } - - it "returns skipped" do - expect(result).to have_skipped - expect(result).to have_empty_context - end - end - - context "when failing" do - let(:task) { create_nested_task(strategy: :throw, status: :failure) } - - it "returns failure" do - expect(result).to have_failed( - outcome: CMDx::Result::INTERRUPTED, - threw_failure: hash_including( - index: 1, - class: start_with("MiddleTask") - ), - caused_failure: hash_including( - index: 2, - class: start_with("InnerTask") - ) - ) - expect(result).to have_empty_context - end - end + context "when failing" do + subject(:result) { create_failing_task(reason: "broken", code: "E1").execute } - context "when erroring" do - let(:task) { create_nested_task(strategy: :throw, status: :error) } - - it "returns failure" do - expect(result).to have_failed( - outcome: CMDx::Result::INTERRUPTED, - reason: "[CMDx::TestError] borked error", - threw_failure: hash_including( - index: 1, - class: start_with("MiddleTask") - ), - caused_failure: hash_including( - index: 2, - class: start_with("InnerTask") - ) - ) - expect(result).to have_empty_context - end - end + it "returns an interrupted/failed result with reason and metadata" do + expect(result).to have_attributes( + state: CMDx::Signal::INTERRUPTED, + status: CMDx::Signal::FAILED, + reason: "broken", + metadata: { code: "E1" }, + cause: nil + ) + expect(result.context).to be_empty end + end - context "when raising" do - context "when successful" do - let(:task) { create_nested_task(strategy: :raise) } - - it "returns success" do - expect(result).to be_successful - expect(result).to have_matching_context(executed: %i[inner middle outer]) - end - end - - context "when skipping" do - let(:task) { create_nested_task(strategy: :raise, status: :skipped) } - - it "returns skipped" do - expect(result).to be_successful - expect(result).to have_matching_context(executed: %i[middle outer]) - end - end - - context "when failing" do - let(:task) { create_nested_task(strategy: :raise, status: :failure) } - - it "returns failure" do - expect(result).to have_failed( - outcome: CMDx::Result::INTERRUPTED, - threw_failure: hash_including( - index: 1, - class: start_with("MiddleTask") - ), - caused_failure: hash_including( - index: 2, - class: start_with("InnerTask") - ) - ) - expect(result).to have_empty_context - end - end + context "when raising an uncaught exception" do + subject(:result) { create_erroring_task.execute } - context "when erroring" do - let(:task) { create_nested_task(strategy: :raise, status: :error) } - - it "returns failure" do - expect(result).to have_failed( - outcome: CMDx::Result::INTERRUPTED, - reason: "[CMDx::TestError] borked error", - cause: be_a(CMDx::TestError), - threw_failure: hash_including( - index: 1, - class: start_with("MiddleTask") - ), - caused_failure: hash_including( - index: 2, - class: start_with("InnerTask") - ) - ) - expect(result).to have_empty_context - end - end + it "captures the error as a failed result" do + expect(result).to have_attributes( + state: CMDx::Signal::INTERRUPTED, + status: CMDx::Signal::FAILED, + reason: "[CMDx::TestError] borked error", + cause: be_a(CMDx::TestError) + ) end + end - context "when throw to raise" do - context "when successful" do - let(:task) { create_nested_task(strategy: :throw_raise) } - - it "returns success" do - expect(result).to be_successful - expect(result).to have_matching_context(executed: %i[inner middle outer]) - end - end - - context "when skipping" do - let(:task) { create_nested_task(strategy: :throw_raise, status: :skipped) } - - it "returns skipped" do - expect(result).to be_successful - expect(result).to have_matching_context(executed: %i[middle outer]) - end - end - - context "when failing" do - let(:task) { create_nested_task(strategy: :throw_raise, status: :failure) } - - it "returns failure" do - expect(result).to have_failed( - outcome: CMDx::Result::INTERRUPTED, - threw_failure: hash_including( - index: 1, - class: start_with("MiddleTask") - ), - caused_failure: hash_including( - index: 2, - class: start_with("InnerTask") - ) - ) - expect(result).to have_empty_context - end - end - - context "when erroring" do - let(:task) { create_nested_task(strategy: :throw_raise, status: :error) } - - it "returns failure" do - expect(result).to have_failed( - outcome: CMDx::Result::INTERRUPTED, - reason: "[CMDx::TestError] borked error", - cause: be_a(CMDx::FailFault), - threw_failure: hash_including( - index: 1, - class: start_with("MiddleTask") - ), - caused_failure: hash_including( - index: 2, - class: start_with("InnerTask") - ) - ) - expect(result).to have_empty_context + context "when calling success! explicitly" do + subject(:result) do + create_task_class(name: "EarlyExitTask") do + define_method(:work) do + success!("done", code: :ok) + context.unreachable = true end - end + end.execute end - context "when raise to throw" do - context "when successful" do - let(:task) { create_nested_task(strategy: :raise_throw) } - - it "returns success" do - expect(result).to be_successful - expect(result).to have_matching_context(executed: %i[inner middle outer]) - end - end - - context "when skipping" do - let(:task) { create_nested_task(strategy: :raise_throw, status: :skipped) } - - it "returns skipped" do - expect(result).to be_successful - expect(result).to have_matching_context(executed: %i[outer]) - end - end - - context "when failing" do - let(:task) { create_nested_task(strategy: :raise_throw, status: :failure) } - - it "returns failure" do - expect(result).to have_failed( - outcome: CMDx::Result::INTERRUPTED, - threw_failure: hash_including( - index: 1, - class: start_with("MiddleTask") - ), - caused_failure: hash_including( - index: 2, - class: start_with("InnerTask") - ) - ) - expect(result).to have_empty_context - end - end - - context "when erroring" do - let(:task) { create_nested_task(strategy: :raise_throw, status: :error) } - - it "returns failure" do - expect(result).to have_failed( - outcome: CMDx::Result::INTERRUPTED, - reason: "[CMDx::TestError] borked error", - cause: be_a(CMDx::FailFault), - threw_failure: hash_including( - index: 1, - class: start_with("MiddleTask") - ), - caused_failure: hash_including( - index: 2, - class: start_with("InnerTask") - ) - ) - expect(result).to have_empty_context - end - end + it "short-circuits execution with success reason/metadata" do + expect(result).to have_attributes( + status: CMDx::Signal::SUCCESS, + reason: "done", + metadata: { code: :ok } + ) + expect(result.context[:unreachable]).to be_nil end end - end - - context "when bang" do - subject(:result) { task.execute! } - - context "when simple task" do - context "when successful" do - let(:task) { create_successful_task } - - it "returns success" do - expect(result).to be_successful - expect(result).to have_matching_context(executed: %i[success]) - end - end - - context "when skipping" do - let(:task) { create_skipping_task } - it "returns skipped" do - expect(result).to have_skipped - expect(result).to have_empty_context + context "when given a block" do + it "yields the result and returns the block's value" do + yielded = nil + value = create_successful_task.execute do |r| + yielded = r + :from_block end - end - - context "when failing" do - let(:task) { create_failing_task } - - it "raise a CMDx::FailFault" do - expect { result }.to raise_error(CMDx::FailFault, "Unspecified") - end - end - - context "when erroring" do - let(:task) { create_erroring_task } - it "raise a CMDx::TestError" do - expect { result }.to raise_error(CMDx::TestError, "borked error") - end + expect(yielded).to be_a(CMDx::Result) + expect(value).to eq(:from_block) end end - context "with nested tasks" do - context "when swallowing" do - context "when successful" do - let(:task) { create_nested_task } - - it "returns success" do - expect(result).to be_successful - expect(result).to have_matching_context(executed: %i[inner middle outer]) - end - end - - context "when skipping" do - let(:task) { create_nested_task(status: :skipped) } - - it "returns success" do - expect(result).to be_successful - expect(result).to have_matching_context(executed: %i[middle outer]) - end - end - - context "when failing" do - let(:task) { create_nested_task(status: :failure) } - - it "returns failure" do - expect(result).to be_successful - expect(result).to have_matching_context(executed: %i[middle outer]) - end - end - - context "when erroring" do - let(:task) { create_nested_task(status: :error) } - - it "returns success" do - expect(result).to be_successful - expect(result).to have_matching_context(executed: %i[middle outer]) - end - end - end - - context "when throwing" do - context "when successful" do - let(:task) { create_nested_task(strategy: :throw) } - - it "returns success" do - expect(result).to be_successful - expect(result).to have_matching_context(executed: %i[inner middle outer]) - end - end - - context "when skipping" do - let(:task) { create_nested_task(strategy: :throw, status: :skipped) } - - it "returns skipped" do - expect(result).to have_skipped - expect(result).to have_empty_context - end - end - - context "when failing" do - let(:task) { create_nested_task(strategy: :throw, status: :failure) } - - it "raise a CMDx::FailFault" do - expect { result }.to raise_error(CMDx::FailFault, "Unspecified") - end - end - - context "when erroring" do - let(:task) { create_nested_task(strategy: :throw, status: :error) } - - it "raise a CMDx::FailFault" do - expect { result }.to raise_error(CMDx::FailFault, "[CMDx::TestError] borked error") - end + context "when passed an initial context" do + subject(:task) do + create_task_class(name: "ContextTask") do + define_method(:work) { context.total = context[:a] + context[:b] } end end - context "when raising" do - context "when successful" do - let(:task) { create_nested_task(strategy: :raise) } - - it "returns success" do - expect(result).to be_successful - expect(result).to have_matching_context(executed: %i[inner middle outer]) - end - end - - context "when skipping" do - let(:task) { create_nested_task(strategy: :raise, status: :skipped) } - - it "returns skipped" do - expect(result).to be_successful - expect(result).to have_matching_context(executed: %i[middle outer]) - end - end - - context "when failing" do - let(:task) { create_nested_task(strategy: :raise, status: :failure) } - - it "raise a CMDx::FailFault" do - expect { result }.to raise_error(CMDx::FailFault, "Unspecified") - end - end - - context "when erroring" do - let(:task) { create_nested_task(strategy: :raise, status: :error) } - - it "raise a CMDx::TestError" do - expect { result }.to raise_error(CMDx::TestError, "borked error") - end - end + it "accepts a hash" do + expect(task.execute(a: 2, b: 3).context[:total]).to eq(5) end - context "when throw to raise" do - context "when successful" do - let(:task) { create_nested_task(strategy: :throw_raise) } - - it "returns success" do - expect(result).to be_successful - expect(result).to have_matching_context(executed: %i[inner middle outer]) - end - end - - context "when skipping" do - let(:task) { create_nested_task(strategy: :throw_raise, status: :skipped) } - - it "returns skipped" do - expect(result).to be_successful - expect(result).to have_matching_context(executed: %i[middle outer]) - end - end - - context "when failing" do - let(:task) { create_nested_task(strategy: :throw_raise, status: :failure) } - - it "raise a CMDx::FailFault" do - expect { result }.to raise_error(CMDx::FailFault, "Unspecified") - end - end - - context "when erroring" do - let(:task) { create_nested_task(strategy: :throw_raise, status: :error) } - - it "raise a CMDx::FailFault" do - expect { result }.to raise_error(CMDx::FailFault, "[CMDx::TestError] borked error") - end - end + it "accepts a pre-built Context" do + ctx = CMDx::Context.new(a: 4, b: 6) + expect(task.execute(ctx).context[:total]).to eq(10) end + end - context "when raise to throw" do - context "when successful" do - let(:task) { create_nested_task(strategy: :raise_throw) } - - it "returns success" do - expect(result).to be_successful - expect(result).to have_matching_context(executed: %i[inner middle outer]) - end - end - - context "when skipping" do - let(:task) { create_nested_task(strategy: :raise_throw, status: :skipped) } - - it "returns skipped" do - expect(result).to be_successful - expect(result).to have_matching_context(executed: %i[outer]) - end - end - - context "when failing" do - let(:task) { create_nested_task(strategy: :raise_throw, status: :failure) } - - it "raise a CMDx::FailFault" do - expect { result }.to raise_error(CMDx::FailFault, "Unspecified") - end - end - - context "when erroring" do - let(:task) { create_nested_task(strategy: :raise_throw, status: :error) } + context "with aliases" do + let(:task) { create_successful_task } - it "raise a CMDx::FailFault" do - expect { result }.to raise_error(CMDx::FailFault, "[CMDx::TestError] borked error") - end - end + it "aliases call to execute" do + expect(task.call).to be_a(CMDx::Result) end end end - context "when inheriting" do - context "when assuming the work method" do - it "captures the execution order" do - parent_task = create_task_class(name: "ParentTask") do - def work = (context.executed ||= []) << :parent - end - child_task = create_task_class(base: parent_task, name: "ChildTask") - - result = child_task.execute + describe "blocking execute!" do + context "when successful" do + subject(:result) { create_successful_task.execute! } - expect(result).to be_successful - expect(result).to have_matching_context(executed: %i[parent]) + it "returns the result and marks it strict" do + expect(result).to have_attributes(status: CMDx::Signal::SUCCESS, strict?: true) end end - context "when overriding the work method" do - it "captures the execution order" do - parent_task = create_task_class(name: "ParentTask") do - def work = (context.executed ||= []) << :parent - end - child_task = create_task_class(base: parent_task, name: "ChildTask") do - def work = (context.executed ||= []) << :child - end + context "when skipping" do + subject(:result) { create_skipping_task.execute! } - result = child_task.execute - - expect(result).to be_successful - expect(result).to have_matching_context(executed: %i[child]) + it "returns the result without raising" do + expect(result).to have_attributes(status: CMDx::Signal::SKIPPED, strict?: true) end end - context "when super-ing the work method" do - it "captures the execution order" do - parent_task = create_task_class(name: "ParentTask") do - def work = (context.executed ||= []) << :parent - end - child_task = create_task_class(base: parent_task, name: "ChildTask") do - def work - super - (context.executed ||= []) << :child - end - end + context "when failing via fail!" do + let(:task) { create_failing_task(reason: "boom", code: 99) } - result = child_task.execute + it "raises a Fault with the reason and original metadata" do + expect { task.execute! }.to raise_error(CMDx::Fault, "boom") + end - expect(result).to be_successful - expect(result).to have_matching_context(executed: %i[parent child]) + it "raises with the default reason when none was provided" do + expect { create_failing_task.execute! }.to raise_error(CMDx::Fault, "Unspecified") end end - end - - context "when using block" do - context "when class method" do - let(:task) { create_successful_task } - it "yields the result" do - task.execute do |result| - expect(result).to be_successful - expect(result).to have_matching_context(executed: %i[success]) - end + context "when raising an uncaught exception" do + it "re-raises the original error" do + expect { create_erroring_task(reason: "kaboom").execute! } + .to raise_error(CMDx::TestError, "kaboom") end end - context "when instance method" do - let(:task) { create_successful_task.new } + context "when given a block" do + it "yields the result before returning" do + captured = nil + create_successful_task.execute! { |r| captured = r.status } + expect(captured).to eq(CMDx::Signal::SUCCESS) + end + end - it "yields the result" do - task.execute do |result| - expect(result).to be_successful - expect(result).to have_matching_context(executed: %i[success]) - end + context "with aliases" do + it "aliases call! to execute!" do + expect { create_failing_task.call! }.to raise_error(CMDx::Fault) end end end - describe "durability" do - context "with any exception" do - it "retries the task n times after first issue without rerunning the middlewares" do - counter = instance_double("counter", incr: nil) - - task = create_task_class do - settings retries: 2 - register :middleware, CMDx::Middlewares::Correlate, id: proc { - counter.incr - "abc-123" - } + describe "Result#on" do + it "yields the result for matching events and chains" do + result = create_successful_task.execute + triggered = [] - def work - context.retries ||= 0 - context.retries += 1 - raise CMDx::TestError, "borked error" unless self.class.settings.retries < context.retries + returned = result + .on(:success) { |r| triggered << [:success, r.status] } + .on(:complete) { triggered << :complete } + .on(:ok) { triggered << :ok } + .on(:failed) { triggered << :never } - (context.executed ||= []) << :success - end - end - - expect(counter).to receive(:incr).once - - result = task.execute - - expect(result).to be_successful - expect(result).to have_matching_context(retries: 3, executed: %i[success]) - expect(result.retries).to eq(2) - end + expect(returned).to be(result) + expect(triggered).to eq([[:success, "success"], :complete, :ok]) end - context "with a specific exception" do - it "skips retries if the exception is not in the retry_on setting" do - counter = instance_double("counter", incr: nil) + it "accepts multiple events in a single call" do + result = create_failing_task.execute + fired = false - task = create_task_class do - settings retries: 2, retry_on: RuntimeError - register :middleware, CMDx::Middlewares::Correlate, id: proc { - counter.incr - "abc-123" - } + result.on(:success, :failed) { fired = true } - def work - context.retries ||= 0 - context.retries += 1 - raise CMDx::TestError, "borked error" unless self.class.settings.retries < context.retries - - (context.executed ||= []) << :success - end - end - - expect(counter).to receive(:incr).once + expect(fired).to be(true) + end - result = task.execute + it "requires a block" do + result = create_successful_task.execute + expect { result.on(:success) }.to raise_error(ArgumentError, /block required/) + end - expect(result).to have_failed( - reason: "[CMDx::TestError] borked error", - cause: be_a(CMDx::TestError) - ) - expect(result).to have_matching_context(retries: 1) - expect(result).to have_matching_metadata(source: :exception) - end + it "rejects unknown events" do + result = create_successful_task.execute + expect { result.on(:bogus) { nil } }.to raise_error(ArgumentError, /unknown event/) end end - describe "rollback" do - context "when rollback is configured" do - it "calls rollback on failure" do - task = create_task_class do - settings rollback_on: :failed + describe "nested task execution" do + context "with swallow strategy" do + it "captures the inner failure without halting the outer" do + result = create_nested_task(strategy: :swallow, status: :failure).execute - def work - fail!("something went wrong") - end - - def rollback - (context.rolled_back ||= []) << :yes - end - end - - result = task.execute - - expect(result).to have_failed(reason: "something went wrong") - expect(result).to be_rolled_back - expect(result.context.rolled_back).to eq([:yes]) + expect(result).to have_attributes(status: CMDx::Signal::SUCCESS) + expect(result.context[:executed]).to eq(%i[middle outer]) end + end - it "calls rollback on skip if configured" do - task = create_task_class do - settings rollback_on: %i[failed skipped] - - def work - skip!("skipping") - end - - def rollback - (context.rolled_back ||= []) << :yes - end - end - - result = task.execute + context "with throw strategy" do + it "propagates the inner failure up the stack" do + result = create_nested_task(strategy: :throw, status: :failure, reason: "inner-broke").execute - expect(result).to have_skipped(reason: "skipping") - expect(result).to be_rolled_back - expect(result.context.rolled_back).to eq([:yes]) + expect(result).to have_attributes(status: CMDx::Signal::FAILED, reason: "inner-broke") + expect(result.context[:executed]).to be_nil end end - context "when rollback is explicitly disabled" do - it "does not call rollback" do - task = create_task_class do - settings rollback_on: [] - - def work - fail!("something went wrong") - end - - def rollback - (context.rolled_back ||= []) << :yes - end - end - - result = task.execute + context "with raise strategy" do + it "re-raises the inner exception out to the outer" do + result = create_nested_task(strategy: :raise, status: :error, reason: "explode").execute - expect(result).to have_failed(reason: "something went wrong") - expect(result).not_to be_rolled_back - expect(result.context.rolled_back).to be_nil + expect(result).to have_attributes( + status: CMDx::Signal::FAILED, + reason: "[CMDx::TestError] explode", + cause: be_a(CMDx::TestError) + ) end end + end - context "when rollback is configured but status does not match" do - it "does not call rollback" do - task = create_task_class do - settings rollback_on: %i[failed] - - def work - skip!("skipping") - end - - def rollback - (context.rolled_back ||= []) << :yes - end - end - - result = task.execute + describe "result pattern matching" do + it "deconstructs into [type, task, state, status, reason, metadata, cause, origin]" do + task = create_failing_task(reason: "nope", kind: :minor) + result = task.execute - expect(result).to have_skipped(reason: "skipping") - expect(result).not_to be_rolled_back - expect(result.context.rolled_back).to be_nil - end + expect(result.deconstruct).to eq( + ["Task", task, "interrupted", "failed", "nope", { kind: :minor }, nil, nil] + ) end end - describe "dry run" do - context "when enabled" do - it "allows conditional logic execution" do - task = create_task_class do - def work - context.result = dry_run? ? "mocked" : "real" - end - end + describe "post-execution freezing" do + it "freezes the context, errors, and task" do + result = create_successful_task.execute - result = task.execute(dry_run: true) - - expect(result).to be_successful - expect(result).to be_dry_run - expect(result.context.result).to eq("mocked") - end + expect(result.context).to be_frozen + expect(result.errors).to be_frozen end + end - context "when disabled" do - it "executes real logic" do - task = create_task_class do - def work - context.result = dry_run? ? "mocked" : "real" - end - end + describe "missing #work implementation" do + let(:bare) { create_task_class(name: "Bare") } - result = task.execute + it "re-raises ImplementationError from execute" do + expect { bare.execute }.to raise_error(CMDx::ImplementationError, /undefined method.*#work/) + end - expect(result).to be_successful - expect(result).not_to be_dry_run - expect(result.context.result).to eq("real") - end + it "re-raises ImplementationError from execute!" do + expect { bare.execute! }.to raise_error(CMDx::ImplementationError, /undefined method.*#work/) end end end diff --git a/spec/integration/tasks/handlers_spec.rb b/spec/integration/tasks/handlers_spec.rb deleted file mode 100644 index b561b0c8a..000000000 --- a/spec/integration/tasks/handlers_spec.rb +++ /dev/null @@ -1,332 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe "Task handlers", type: :feature do - context "when using status-based handlers" do - it "executes on_success handler" do - task = create_successful_task - - result = task.execute - handled = [] - - result.on(:success) { handled << :on_success } - - expect(handled).to eq([:on_success]) - end - - it "executes on_failed handler" do - task = create_failing_task(reason: "Test failure") - - result = task.execute - handled = [] - - result.on(:failed) { handled << :on_failed } - - expect(handled).to eq([:on_failed]) - end - - it "executes on_skipped handler" do - task = create_skipping_task(reason: "Test skip") - - result = task.execute - handled = [] - - result.on(:skipped) { handled << :on_skipped } - - expect(handled).to eq([:on_skipped]) - end - - it "only executes the matching handler" do - success_task = create_successful_task - failed_task = create_failing_task - - success_result = success_task.execute - failed_result = failed_task.execute - - success_handled = [] - failed_handled = [] - - success_result - .on(:success) { success_handled << :success } - .on(:failed) { success_handled << :failed } - .on(:skipped) { success_handled << :skipped } - - failed_result - .on(:success) { failed_handled << :success } - .on(:failed) { failed_handled << :failed } - .on(:skipped) { failed_handled << :skipped } - - expect(success_handled).to eq([:success]) - expect(failed_handled).to eq([:failed]) - end - end - - context "when using state-based handlers" do - it "executes on_complete handler" do - task = create_successful_task - - result = task.execute - handled = [] - - result.on(:complete) { handled << :on_complete } - - expect(handled).to eq([:on_complete]) - end - - it "executes on_interrupted handler" do - task = create_failing_task - - result = task.execute - handled = [] - - result.on(:interrupted) { handled << :on_interrupted } - - expect(handled).to eq([:on_interrupted]) - end - - it "only executes the matching state handler" do - complete_task = create_successful_task - interrupted_task = create_failing_task - - complete_result = complete_task.execute - interrupted_result = interrupted_task.execute - - complete_handled = [] - interrupted_handled = [] - - complete_result - .on(:complete) { complete_handled << :complete } - .on(:interrupted) { complete_handled << :interrupted } - - interrupted_result - .on(:complete) { interrupted_handled << :complete } - .on(:interrupted) { interrupted_handled << :interrupted } - - expect(complete_handled).to eq([:complete]) - expect(interrupted_handled).to eq([:interrupted]) - end - end - - context "when using outcome-based handlers" do - it "executes on_good for success" do - task = create_successful_task - - result = task.execute - handled = [] - - result.on(:good) { handled << :on_good } - - expect(handled).to eq([:on_good]) - end - - it "executes on_good for skipped" do - task = create_skipping_task - - result = task.execute - handled = [] - - result.on(:good) { handled << :on_good } - - expect(handled).to eq([:on_good]) - end - - it "does not execute on_good for failed" do - task = create_failing_task - - result = task.execute - handled = [] - - result.on(:good) { handled << :on_good } - - expect(handled).to be_empty - end - - it "executes on_bad for skipped" do - task = create_skipping_task - - result = task.execute - handled = [] - - result.on(:bad) { handled << :on_bad } - - expect(handled).to eq([:on_bad]) - end - - it "executes on_bad for failed" do - task = create_failing_task - - result = task.execute - handled = [] - - result.on(:bad) { handled << :on_bad } - - expect(handled).to eq([:on_bad]) - end - - it "does not execute on_bad for success" do - task = create_successful_task - - result = task.execute - handled = [] - - result.on(:bad) { handled << :on_bad } - - expect(handled).to be_empty - end - end - - context "when chaining handlers" do - it "allows method chaining" do - task = create_successful_task - - result = task.execute - handled = [] - - result - .on(:success) { handled << :success } - .on(:complete) { handled << :complete } - .on(:good) { handled << :good } - - expect(handled).to eq(%i[success complete good]) - end - - it "chains handlers regardless of outcome" do - task = create_failing_task - - result = task.execute - handled = [] - - result - .on(:success) { handled << :success } - .on(:failed) { handled << :failed } - .on(:complete) { handled << :complete } - .on(:interrupted) { handled << :interrupted } - .on(:good) { handled << :good } - .on(:bad) { handled << :bad } - - expect(handled).to eq(%i[failed interrupted bad]) - end - end - - context "when accessing result data in handlers" do - it "provides access to result in handler" do - task = create_task_class do - def work - context.value = 42 - end - end - - result = task.execute - captured_value = nil - - result.on(:success) { |r| captured_value = r.context.value } - - expect(captured_value).to eq(42) - end - - it "provides access to metadata in handler" do - task = create_task_class do - def work - fail!("Test failure", error_code: "TEST.FAILED", retry_count: 3) - end - end - - result = task.execute - captured_metadata = nil - - result.on(:failed) { |r| captured_metadata = r.metadata } - - expect(captured_metadata).to include(error_code: "TEST.FAILED", retry_count: 3) - end - - it "provides access to task in handler" do - task = create_task_class(name: "TestTask") do - def work = nil - end - - result = task.execute - captured_task_class = nil - - result.on(:success) { |r| captured_task_class = r.task.class.name } - - expect(captured_task_class).to match(/TestTask/) - end - end - - context "when using handlers for control flow" do - it "uses handlers for conditional logic" do - success_task = create_successful_task - failed_task = create_failing_task - - success_outcome = nil - failed_outcome = nil - - success_task - .execute - .on(:success) { success_outcome = "success_path" } - .on(:failed) { success_outcome = "failure_path" } - - failed_task - .execute - .on(:success) { failed_outcome = "success_path" } - .on(:failed) { failed_outcome = "failure_path" } - - expect(success_outcome).to eq("success_path") - expect(failed_outcome).to eq("failure_path") - end - - it "uses handlers for side effects" do - task = create_task_class do - def work - context.value = 100 - end - end - - notifications = [] - - task - .execute - .on(:success) { |r| notifications << "Processed: #{r.context.value}" } - .on(:complete) { notifications << "Completed" } - - expect(notifications).to eq(["Processed: 100", "Completed"]) - end - end - - context "when combining with block execution" do - it "allows using both block and handlers" do - task = create_successful_task - - block_executed = false - handler_executed = false - captured_result = nil - - task.execute do |r| - block_executed = true - captured_result = r - end - - captured_result.on(:success) { handler_executed = true } - - expect(block_executed).to be(true) - expect(handler_executed).to be(true) - end - end - - context "when using handlers for cleanup" do - it "executes cleanup on any outcome" do - success_task = create_successful_task - failed_task = create_failing_task - skipped_task = create_skipping_task - - cleanup_count = 0 - - success_task.execute.on(:good) { cleanup_count += 1 } - failed_task.execute.on(:bad) { cleanup_count += 1 } - skipped_task.execute.on(:bad) { cleanup_count += 1 } - - expect(cleanup_count).to eq(3) - end - end -end diff --git a/spec/integration/tasks/inputs_spec.rb b/spec/integration/tasks/inputs_spec.rb new file mode 100644 index 000000000..8e2726bf5 --- /dev/null +++ b/spec/integration/tasks/inputs_spec.rb @@ -0,0 +1,503 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe "Task input resolution", type: :feature do + describe "presence rules" do + let(:task) do + create_task_class(name: "PresenceTask") do + required :email + optional :name + + define_method(:work) { nil } + end + end + + it "succeeds when required keys are present (optional missing is fine)" do + expect(task.execute(email: "a@b.c")).to have_attributes(status: CMDx::Signal::SUCCESS) + end + + it "fails when a required key is absent" do + result = task.execute(name: "Alice") + + expect(result).to have_attributes(status: CMDx::Signal::FAILED) + expect(result.errors.to_h).to eq(email: ["is required"]) + end + + it "collects errors for every missing required key" do + result = create_task_class(name: "MultiRequired") do + required :email, :name + define_method(:work) { nil } + end.execute + + expect(result.errors.to_h.keys).to contain_exactly(:email, :name) + end + + it "passes the required check for an explicit nil value" do + expect(task.execute(email: nil).errors.to_h).not_to have_key(:email) + end + + it "raises a Fault under execute!" do + expect { task.execute! }.to raise_error(CMDx::Fault, /email is required/) + end + end + + describe "conditional required" do + let(:task) do + create_task_class(name: "ConditionalRequired") do + required :publisher, if: :magazine? + required :title + + define_method(:work) { nil } + define_method(:magazine?) { context[:type] == "magazine" } + end + end + + it "requires the key when condition is true" do + result = task.execute(title: "Issue 1", type: "magazine") + + expect(result.errors.to_h).to include(publisher: ["is required"]) + end + + it "treats the key as optional when condition is false" do + expect(task.execute(title: "Blog", type: "blog")) + .to have_attributes(status: CMDx::Signal::SUCCESS) + end + end + + describe "coercion" do + it "coerces to the declared type and exposes the coerced value" do + task = create_task_class(name: "CoerceTask") do + input :count, coerce: :integer + input :active, coerce: :boolean + define_method(:work) do + context.resolved = [count, active] + end + end + + result = task.execute(count: "42", active: "yes") + + expect(result.context[:resolved]).to eq([42, true]) + end + + it "fails when coercion cannot succeed" do + task = create_task_class(name: "CoerceFail") do + input :count, coerce: :integer + define_method(:work) { nil } + end + + result = task.execute(count: "abc") + + expect(result.errors.to_h[:count].first).to include("integer") + end + + it "falls back through a multi-type list" do + task = create_task_class(name: "MultiCoerce") do + input :value, coerce: %i[integer float] + define_method(:work) { context.resolved = value } + end + + expect(task.execute(value: "3.14").context[:resolved]).to eq(3.14) + end + + it "threads per-type options to the coercer" do + task = create_task_class(name: "OptionedCoerce") do + input :recorded_at, coerce: { date: { strptime: "%m-%d-%Y" } } + define_method(:work) { context.resolved = recorded_at } + end + + expect(task.execute(recorded_at: "01-23-2024").context[:resolved]) + .to eq(Date.new(2024, 1, 23)) + end + + describe "inline :coerce callables" do + it "invokes a Symbol handler with the value on the task" do + task = create_task_class(name: "InlineSymbolCoerce") do + input :value, coerce: :double_it + define_method(:work) { context.resolved = value } + define_method(:double_it) { |v| v.to_i * 2 } + end + + expect(task.execute(value: "21").context[:resolved]).to eq(42) + end + + it "invokes a Proc handler via instance_exec, exposing self as the task" do + task = create_task_class(name: "InlineProcCoerce") do + input :value, coerce: ->(v) { v.to_f * tax_rate } + define_method(:work) { context.resolved = value } + define_method(:tax_rate) { 1.5 } + end + + expect(task.execute(value: "10").context[:resolved]).to eq(15.0) + end + + it "invokes a class callable with (value, task)" do + coercer = Class.new do + def self.call(value, task) + multiplier = task.context.multiplier || 1 + value.to_i * multiplier + end + end + + task = create_task_class(name: "InlineClassCoerce") do + input :value, coerce: coercer + define_method(:work) { context.resolved = value } + end + + expect(task.execute(value: "5", multiplier: 3).context[:resolved]).to eq(15) + end + + it "falls through to an inline callable when the prior built-in coercion fails" do + task = create_task_class(name: "InlineFallbackCoerce") do + input :value, coerce: [:integer, ->(v) { "fallback:#{v}" }] + define_method(:work) { context.resolved = value } + end + + expect(task.execute(value: "abc").context[:resolved]).to eq("fallback:abc") + end + end + + it "supports custom registered coercions with options" do + task = create_task_class(name: "CustomCoerce") do + register :coercion, :temperature, proc { |v, unit: :celsius, **| + value = v.to_f + unit == :fahrenheit ? (value * 9.0 / 5.0) + 32.0 : value + } + input :temp, coerce: { temperature: { unit: :fahrenheit } } + define_method(:work) { context.resolved = temp } + end + + expect(task.execute(temp: 100).context[:resolved]).to eq(212.0) + end + end + + describe "defaults" do + it "uses a static default when the key is absent" do + task = create_task_class(name: "Default") do + input :level, default: "basic" + define_method(:work) { context.resolved = level } + end + + expect(task.execute.context[:resolved]).to eq("basic") + end + + it "evaluates a callable default lazily" do + task = create_task_class(name: "CallableDefault") do + input :stamp, default: proc { Time.now.to_i } + define_method(:work) { context.resolved = stamp } + end + + expect(task.execute.context[:resolved]).to be_a(Integer) + end + + it "prefers the provided value over the default" do + task = create_task_class(name: "PreferProvided") do + input :level, default: "basic" + define_method(:work) { context.resolved = level } + end + + expect(task.execute(level: "advanced").context[:resolved]).to eq("advanced") + end + + it "enforces the required check before applying the default" do + task = create_task_class(name: "RequiredDefault") do + required :token, default: "fallback" + define_method(:work) { context.resolved = token } + end + + expect(task.execute.errors.to_h).to include(token: ["is required"]) + expect(task.execute(token: nil).context[:resolved]).to eq("fallback") + end + end + + describe "transform" do + it "applies a Symbol transform" do + task = create_task_class(name: "SymbolTransform") do + input :email, transform: :downcase + define_method(:work) { context.resolved = email } + end + + expect(task.execute(email: "USER@TEST.COM").context[:resolved]).to eq("user@test.com") + end + + it "applies a Proc transform after coercion, in the task instance" do + task = create_task_class(name: "ProcTransform") do + input :amount, coerce: :float, transform: ->(v) { v * tax_rate } + define_method(:work) { context.resolved = amount } + define_method(:tax_rate) { 1.08 } + end + + expect(task.execute(amount: "100").context[:resolved]).to be_within(0.01).of(108.0) + end + end + + describe "validations" do + it "runs built-in validators" do + task = create_task_class(name: "Validated") do + input :title, presence: true, length: { min: 3, max: 50 } + define_method(:work) { nil } + end + + expect(task.execute(title: "AB").errors.to_h[:title]).not_to be_empty + expect(task.execute(title: "ok title")).to have_attributes(status: CMDx::Signal::SUCCESS) + end + + it "validates after coerce and transform" do + task = create_task_class(name: "FullPipeline") do + input :frequency, + coerce: :string, + transform: :downcase, + inclusion: { in: %w[hourly daily weekly monthly] } + define_method(:work) { context.resolved = frequency } + end + + expect(task.execute(frequency: "DAILY").context[:resolved]).to eq("daily") + expect(task.execute(frequency: "BIWEEKLY").errors.to_h).to include(:frequency) + end + + describe "inline :validate callables" do + it "invokes a Symbol handler with the value on the task" do + task = create_task_class(name: "InlineSymbolValidate") do + input :slug, validate: :reject_reserved + define_method(:work) { context.resolved = slug } + define_method(:reject_reserved) do |v| + CMDx::Validators::Failure.new("is reserved") if v == "admin" + end + end + + expect(task.execute(slug: "alice")).to have_attributes(status: CMDx::Signal::SUCCESS) + expect(task.execute(slug: "admin").errors.to_h[:slug]).to include("is reserved") + end + + it "invokes a Proc handler via instance_exec, exposing self as the task" do + task = create_task_class(name: "InlineProcValidate") do + input :slug, validate: lambda { |v| + CMDx::Validators::Failure.new("bad") unless v.size >= min_size + } + define_method(:work) { context.resolved = slug } + define_method(:min_size) { 4 } + end + + expect(task.execute(slug: "okok")).to have_attributes(status: CMDx::Signal::SUCCESS) + expect(task.execute(slug: "no").errors.to_h[:slug]).to include("bad") + end + + it "invokes a class callable with (value, task)" do + validator = Class.new do + def self.call(value, task) + return unless task.context.reserved.include?(value) + + CMDx::Validators::Failure.new("is reserved") + end + end + + task = create_task_class(name: "InlineClassValidate") do + input :handle, validate: validator + define_method(:work) { context.resolved = handle } + end + + expect(task.execute(handle: "alice", reserved: %w[root admin])) + .to have_attributes(status: CMDx::Signal::SUCCESS) + expect(task.execute(handle: "root", reserved: %w[root admin]).errors.to_h[:handle]) + .to include("is reserved") + end + + it "runs every handler in an array and accumulates failures" do + task = create_task_class(name: "InlineChainValidate") do + input :slug, validate: [ + ->(v) { CMDx::Validators::Failure.new("must be lowercase") if v != v.downcase }, + ->(v) { CMDx::Validators::Failure.new("too short") if v.size < 3 } + ] + define_method(:work) { nil } + end + + result = task.execute(slug: "AB") + expect(result.errors.to_h[:slug]).to include("must be lowercase", "too short") + end + end + + it "supports custom registered validators returning Failure" do + task = create_task_class(name: "CustomValidator") do + register :validator, :api_key, proc { |v, **| + CMDx::Validators::Failure.new("invalid API key format") unless v.to_s.match?(/\A[a-z0-9]{32}\z/) + } + input :access_key, api_key: true + define_method(:work) { nil } + end + + expect(task.execute(access_key: "a" * 32)).to have_attributes(status: CMDx::Signal::SUCCESS) + expect(task.execute(access_key: "short").errors.to_h[:access_key]) + .to include("invalid API key format") + end + end + + describe "naming" do + it "renames readers via prefix, suffix, and as:" do + task = create_task_class(name: "Naming") do + input :format, prefix: "report_" + input :version, suffix: "_tag" + input :scheduled_at, as: :due_date + define_method(:work) do + context.resolved = [report_format, version_tag, due_date] + end + end + + result = task.execute(format: "pdf", version: "v1.2.3", scheduled_at: "2024-12-15") + + expect(result.context[:resolved]).to eq(["pdf", "v1.2.3", "2024-12-15"]) + end + end + + describe "nested inputs" do + let(:task) do + create_task_class(name: "Nested") do + required :network do + required :host, :port + optional :protocol + end + + define_method(:work) do + context.resolved = [host, port, protocol] + end + end + end + + it "resolves parent and children together" do + result = task.execute(network: { host: "api.example.com", port: 443, protocol: "https" }) + + expect(result.context[:resolved]).to eq(["api.example.com", 443, "https"]) + end + + it "fails when the parent is missing" do + expect(task.execute.errors.to_h).to include(:network) + end + + it "fails when a required child is missing" do + result = task.execute(network: { host: "api.example.com" }) + + expect(result.errors.to_h).to include(:port) + end + + it "preserves falsy child values when the parent is present" do + result = task.execute(network: { host: "h", port: 1, protocol: nil }) + + expect(result.context[:resolved]).to eq(["h", 1, nil]) + end + end + + describe "inheritance" do + let(:parent) do + create_task_class(name: "ParentInputs") do + required :tenant_id + define_method(:work) { nil } + end + end + + it "inherits parent inputs alongside its own" do + child = create_task_class(base: parent, name: "ChildInputs") do + required :user_id + define_method(:work) { nil } + end + + expect(child.execute(tenant_id: "t", user_id: "u")) + .to have_attributes(status: CMDx::Signal::SUCCESS) + expect(child.execute(user_id: "u").errors.to_h).to include(:tenant_id) + end + + it "deregisters inherited inputs via deregister" do + child = create_task_class(base: parent, name: "ChildRemove") do + deregister :input, :tenant_id + define_method(:work) { nil } + end + + expect(child.execute).to have_attributes(status: CMDx::Signal::SUCCESS) + expect(child.inputs.registry).not_to have_key(:tenant_id) + end + end + + describe "source option" do + it "resolves from a Symbol method" do + task = create_task_class(name: "SourceMethod") do + input :host, source: :server_config + define_method(:work) { context.resolved = host } + define_method(:server_config) { { host: "db.example.com" } } + end + + expect(task.execute.context[:resolved]).to eq("db.example.com") + end + + it "resolves from a Proc" do + task = create_task_class(name: "SourceProc") do + input :stamp, source: proc { Time.now.to_i } + define_method(:work) { context.resolved = stamp } + end + + expect(task.execute.context[:resolved]).to be_a(Integer) + end + + it "resolves from a Struct as the source value" do + config = Struct.new(:host, :port, keyword_init: true) + task = create_task_class(name: "SourceStruct") do + required :host, source: :cfg + required :port, source: :cfg + define_method(:work) { context.resolved = [host, port] } + define_method(:cfg) { config.new(host: "h", port: 5432) } + end + + expect(task.execute.context[:resolved]).to eq(["h", 5432]) + end + + it "falls back to string keys inside the source" do + task = create_task_class(name: "StringKeys") do + input :enabled, source: :params + define_method(:work) { context.resolved = enabled } + define_method(:params) { { "enabled" => false } } + end + + expect(task.execute.context[:resolved]).to be(false) + end + + it "fails with a helpful message for an unsupported source type" do + task = create_task_class(name: "BadSource") do + input :name, source: 42 + define_method(:work) { nil } + end + + expect(task.execute(name: "Alice").reason) + .to include("must be a Symbol, Proc, or respond to #call") + expect { task.execute!(name: "Alice") }.to raise_error(ArgumentError) + end + end + + describe "falsy values" do + let(:task) do + create_task_class(name: "Falsy") do + required :flag + input :count, coerce: :integer + define_method(:work) { context.resolved = [flag, count] } + end + end + + it "preserves false as a valid value" do + expect(task.execute(flag: false, count: 0).context[:resolved]).to eq([false, 0]) + end + + it "passes the required check for an explicit nil" do + expect(task.execute(flag: nil).errors.to_h).not_to have_key(:flag) + end + end + + describe "inputs_schema" do + it "serializes the declared inputs" do + task = create_task_class(name: "Schema") do + required :email, coerce: :string, format: /\A.+@.+\z/ + optional :role, default: "member", inclusion: { in: %w[member admin] } + end + + schema = task.inputs_schema + + expect(schema[:email]).to include(required: true, options: hash_including(coerce: :string)) + expect(schema[:role]).to include(required: false, options: hash_including(default: "member")) + end + end +end diff --git a/spec/integration/tasks/middlewares_spec.rb b/spec/integration/tasks/middlewares_spec.rb index b9e79f696..7d66e5ea9 100644 --- a/spec/integration/tasks/middlewares_spec.rb +++ b/spec/integration/tasks/middlewares_spec.rb @@ -3,54 +3,180 @@ require "spec_helper" RSpec.describe "Task middlewares", type: :feature do - context "when using correlate middleware" do - it "assigns a correlation ID to the result metadata" do + describe "execution wrapping" do + it "wraps #work with before and after hooks" do + log = [] task = create_successful_task do - register :middleware, CMDx::Middlewares::Correlate, id: proc { "abc-123" } + register :middleware, proc { |_task, &nxt| + log << :before + nxt.call + log << :after + } end result = task.execute - expect(result.metadata[:correlation_id]).to eq("abc-123") + expect(result).to have_attributes(status: CMDx::Signal::SUCCESS) + expect(log).to eq(%i[before after]) end - it "resuses the correlation ID from the outer task" do - inner_task = create_successful_task(name: "InnerTask") do - register :middleware, CMDx::Middlewares::Correlate, id: proc { "abc-456" } + it "nests multiple middlewares in registration order" do + log = [] + task = create_successful_task do + register :middleware, proc { |_task, &nxt| + log << :outer_in + nxt.call + log << :outer_out + } + register :middleware, proc { |_task, &nxt| + log << :inner_in + nxt.call + log << :inner_out + } end - outer_task = create_task_class(name: "OuterTask") do - register :middleware, CMDx::Middlewares::Correlate, id: proc { "abc-123" } + + task.execute + + expect(log).to eq(%i[outer_in inner_in inner_out outer_out]) + end + + it "still runs callbacks and #work inside the middleware chain" do + log = [] + task = create_successful_task do + register :middleware, proc { |_task, &nxt| + log << :mw_in + nxt.call + log << :mw_out + } + before_execution { log << :before_execution } + on_success { log << :on_success } end - outer_task.define_method(:work) { context.inner_result = inner_task.execute(context) } - outer_result = outer_task.execute - inner_result = outer_result.context.inner_result + task.execute - expect(inner_result.metadata[:correlation_id]).to eq("abc-123") - expect(outer_result.metadata[:correlation_id]).to eq("abc-123") + expect(log).to eq(%i[mw_in before_execution on_success mw_out]) end end - context "when using runtime middleware" do - it "assigns the runtime to the result metadata" do + describe "registration forms" do + it "accepts a block" do + log = [] task = create_successful_task do - register :middleware, CMDx::Middlewares::Runtime + register(:middleware) do |_task, &nxt| + log << :block_in + nxt.call + log << :block_out + end end - result = task.execute + task.execute + + expect(log).to eq(%i[block_in block_out]) + end + + it "accepts a callable object with #call" do + mw_class = Class.new do + def call(task) + task.context.touched = true + yield + end + end + + task = create_successful_task do + register :middleware, mw_class.new + end + + expect(task.execute.context[:touched]).to be(true) + end + + it "rejects non-callable middlewares" do + expect do + create_task_class(name: "BadMiddleware") { register :middleware, "nope" } + end.to raise_error(ArgumentError, /middleware must respond to #call/) + end + + it "rejects both a callable and a block" do + expect do + create_task_class(name: "BothMiddleware") do + register(:middleware, proc {}) { nil } + end + end.to raise_error(ArgumentError, /either a callable or a block, not both/) + end + end + + describe "misbehavior" do + it "raises MiddlewareError when the middleware never yields" do + task = create_successful_task do + register :middleware, proc { |_task| } + end + + expect { task.execute }.to raise_error(CMDx::MiddlewareError, /did not yield the next_link/) + end + end + + describe "inheritance" do + it "inherits parent middlewares and preserves nesting order" do + log = [] + parent = create_successful_task(name: "Parent") do + register :middleware, proc { |_task, &nxt| + log << :parent_in + nxt.call + log << :parent_out + } + end + + child = create_successful_task(base: parent, name: "Child") do + register :middleware, proc { |_task, &nxt| + log << :child_in + nxt.call + log << :child_out + } + end + + child.execute + + expect(log).to eq(%i[parent_in child_in child_out parent_out]) + end + end + + describe "positional insertion" do + it "inserts a middleware at the given index" do + log = [] + make = lambda do |label| + proc do |_task, &nxt| + log << :"#{label}_in" + nxt.call + log << :"#{label}_out" + end + end - expect(result.metadata[:runtime]).to be_a(Integer) + task = create_successful_task do + register :middleware, make.call(:outer) + register :middleware, make.call(:inner) + register :middleware, make.call(:middle), at: 1 + end + + task.execute + + expect(log).to eq(%i[outer_in middle_in inner_in inner_out middle_out outer_out]) end end - context "when using timeout middleware" do - it "raises a failure fault" do - task = create_task_class do - register :middleware, CMDx::Middlewares::Timeout, seconds: 0.001 + describe "deregister" do + it "removes a middleware by reference" do + log = [] + mw = proc do |_task, &nxt| + log << :removed_mw + nxt.call end - task.define_method(:work) { sleep(0.002) } + task = create_successful_task do + register :middleware, mw + deregister :middleware, mw + end + + task.execute - expect { task.execute }.to raise_error(CMDx::FailFault, "[CMDx::TimeoutError] execution exceeded 0.001 seconds") + expect(log).to be_empty end end end diff --git a/spec/integration/tasks/output_spec.rb b/spec/integration/tasks/output_spec.rb new file mode 100644 index 000000000..e6c552aab --- /dev/null +++ b/spec/integration/tasks/output_spec.rb @@ -0,0 +1,219 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe "Task output verification", type: :feature do + describe "required outputs" do + context "when all declared keys are set" do + subject(:result) do + create_task_class(name: "AllOutputsSet") do + output :user, :token, required: true + define_method(:work) do + context.user = "alice" + context.token = "abc123" + end + end.execute + end + + it "succeeds with no errors" do + expect(result).to have_attributes(status: CMDx::Signal::SUCCESS) + expect(result.errors).to be_empty + end + end + + context "when one required key is missing" do + subject(:result) do + create_task_class(name: "OneMissing") do + output :user, :token, required: true + define_method(:work) { context.user = "alice" } + end.execute + end + + it "fails with an error only for the missing key" do + expect(result.errors.to_h).to eq(token: ["must be set in the context"]) + end + end + + context "when multiple required keys are missing" do + subject(:result) do + create_task_class(name: "MultiMissing") do + output :user, :token, :session, required: true + define_method(:work) { context.user = "alice" } + end.execute + end + + it "collects errors for every missing key" do + expect(result.errors.to_h).to eq( + token: ["must be set in the context"], + session: ["must be set in the context"] + ) + end + end + + context "when a required key is set to nil" do + subject(:result) do + create_task_class(name: "NilValue") do + output :user, required: true + define_method(:work) { context.user = nil } + end.execute + end + + it "succeeds because the key exists" do + expect(result).to have_attributes(status: CMDx::Signal::SUCCESS) + end + end + end + + describe "non-required outputs" do + it "succeeds even when the key is missing" do + task = create_task_class(name: "OptionalOutputs") do + output :user, :token + define_method(:work) { context.user = "alice" } + end + + expect(task.execute).to have_attributes(status: CMDx::Signal::SUCCESS) + end + end + + describe "skipped/failed outcomes" do + it "does not verify outputs when the task skips" do + task = create_task_class(name: "SkipOutputs") do + output :user, required: true + define_method(:work) { skip!("not needed") } + end + + result = task.execute + + expect(result).to have_attributes(status: CMDx::Signal::SKIPPED) + expect(result.errors).to be_empty + end + + it "does not verify outputs when the task fails" do + task = create_task_class(name: "FailOutputs") do + output :user, required: true + define_method(:work) { fail!("broken") } + end + + result = task.execute + + expect(result).to have_attributes(status: CMDx::Signal::FAILED, reason: "broken") + expect(result.errors).to be_empty + end + end + + describe "inheritance" do + let(:parent) do + create_task_class(name: "ParentOutputs") do + output :user, required: true + end + end + + it "inherits parent outputs alongside its own" do + child = create_task_class(base: parent, name: "ChildOutputs") do + output :token, required: true + define_method(:work) do + context.user = "alice" + context.token = "abc" + end + end + + expect(child.outputs.registry.keys).to contain_exactly(:user, :token) + expect(child.execute).to have_attributes(status: CMDx::Signal::SUCCESS) + end + + it "fails when a parent-declared output is missing in the child" do + child = create_task_class(base: parent, name: "MissingParent") do + output :token, required: true + define_method(:work) { context.token = "abc" } + end + + expect(child.execute.errors.to_h).to eq(user: ["must be set in the context"]) + end + + it "removes inherited outputs via deregister" do + child = create_task_class(base: parent, name: "NoParent") do + deregister :output, :user + define_method(:work) { nil } + end + + expect(child.execute).to have_attributes(status: CMDx::Signal::SUCCESS) + expect(child.outputs.registry).not_to have_key(:user) + end + end + + describe "defaults" do + it "fills in a required output via default when work doesn't set it" do + task = create_task_class(name: "DefaultVersion") do + output :version, required: true, default: "v2" + define_method(:work) { nil } + end + + result = task.execute + + expect(result).to have_attributes(status: CMDx::Signal::SUCCESS) + expect(result.context.version).to eq("v2") + end + + it "lets work override the default when it sets a value" do + task = create_task_class(name: "DefaultOverridden") do + output :version, default: "v2" + define_method(:work) { context.version = "v3" } + end + + expect(task.execute.context.version).to eq("v3") + end + + it "applies the default when work explicitly writes nil" do + task = create_task_class(name: "DefaultNilWrite") do + output :version, default: "v2" + define_method(:work) { context.version = nil } + end + + expect(task.execute.context.version).to eq("v2") + end + + it "flows defaults through coercion" do + task = create_task_class(name: "DefaultCoerced") do + output :retention_days, default: "7", coerce: :integer + define_method(:work) { nil } + end + + expect(task.execute.context.retention_days).to eq(7) + end + end + + describe "transform" do + it "normalizes values the task wrote" do + task = create_task_class(name: "TransformEmail") do + output :email, coerce: :string, transform: :downcase + define_method(:work) { context.email = "Alice@Example.COM" } + end + + expect(task.execute.context.email).to eq("alice@example.com") + end + + it "runs after coerce and before validation" do + task = create_task_class(name: "TransformClamp") do + output :days, coerce: :integer, transform: proc { |v| v.clamp(1, 5) }, + numeric: { min: 1, max: 5 } + define_method(:work) { context.days = "42" } + end + + result = task.execute + + expect(result).to have_attributes(status: CMDx::Signal::SUCCESS) + expect(result.context.days).to eq(5) + end + end + + describe "blocking execute!" do + it "raises a Fault for a missing required output" do + task = create_task_class(name: "BangOutput") do + output :user, required: true + define_method(:work) { nil } + end + + expect { task.execute! }.to raise_error(CMDx::Fault, "user must be set in the context") + end + end +end diff --git a/spec/integration/tasks/retry_spec.rb b/spec/integration/tasks/retry_spec.rb new file mode 100644 index 000000000..cb9f93e04 --- /dev/null +++ b/spec/integration/tasks/retry_spec.rb @@ -0,0 +1,140 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe "Task retry", type: :feature do + describe "without retry_on" do + it "returns success with retries: 0" do + result = create_successful_task.execute + + expect(result).to have_attributes(status: CMDx::Signal::SUCCESS, retries: 0, retried?: false) + end + end + + describe "transient errors within the limit" do + it "retries until success and reports the retry count" do + task = create_flaky_task(failures: 2) do + retry_on CMDx::TestError, limit: 3, delay: 0 + end + + result = task.execute + + expect(result).to have_attributes( + status: CMDx::Signal::SUCCESS, + retries: 2, + retried?: true + ) + expect(result.context).to have_attributes(executed: %i[success]) + end + end + + describe "exhausting retries" do + let(:task) do + create_flaky_task(failures: 5) do + retry_on CMDx::TestError, limit: 2, delay: 0 + end + end + + it "captures the error as a failed result under execute" do + result = task.execute + + expect(result).to have_attributes( + status: CMDx::Signal::FAILED, + reason: start_with("[CMDx::TestError]"), + cause: be_a(CMDx::TestError) + ) + end + + it "re-raises the original error under execute!" do + expect { task.execute! }.to raise_error(CMDx::TestError, /flaky attempt/) + end + end + + describe "selective matching" do + it "does not retry for unmatched exceptions" do + task = create_erroring_task do + retry_on ArgumentError, limit: 3, delay: 0 + end + + expect(task.execute).to have_attributes(status: CMDx::Signal::FAILED, retries: 0) + end + + it "supports multiple exception classes" do + counter = { n: 0 } + task = create_task_class(name: "MixedError") do + retry_on CMDx::TestError, ArgumentError, limit: 3, delay: 0 + define_method(:work) do + counter[:n] += 1 + raise(counter[:n].odd? ? CMDx::TestError : ArgumentError, "try #{counter[:n]}") + end + end + + result = task.execute + + expect(result).to have_attributes(status: CMDx::Signal::FAILED, retries: 3) + expect(counter[:n]).to eq(4) + end + + it "does not retry signal-based failures (fail!)" do + task = create_failing_task(reason: "stop") do + retry_on CMDx::TestError, limit: 3, delay: 0 + end + + expect(task.execute).to have_attributes( + status: CMDx::Signal::FAILED, + reason: "stop", + retries: 0 + ) + end + end + + describe "jitter" do + it "accepts exponential jitter without affecting success semantics" do + task = create_flaky_task(failures: 2) do + retry_on CMDx::TestError, limit: 3, delay: 0, jitter: :exponential + end + + expect(task.execute).to have_attributes(status: CMDx::Signal::SUCCESS, retries: 2) + end + + it "accepts a Proc jitter that receives attempt and delay" do + seen = [] + jitter = lambda do |attempt, delay| + seen << [attempt, delay] + 0 + end + task = create_flaky_task(failures: 2) do + retry_on CMDx::TestError, limit: 3, delay: 0.0001, jitter: jitter + end + + task.execute + + expect(seen.map(&:first)).to eq([0, 1]) + expect(seen.map(&:last)).to all(eq(0.0001)) + end + end + + describe "inheritance" do + it "inherits the parent retry policy" do + parent = create_successful_task(name: "ParentRetry") do + retry_on CMDx::TestError, limit: 5, delay: 0 + end + + child = create_flaky_task(base: parent, name: "ChildRetry", failures: 2) + + expect(child.execute).to have_attributes(status: CMDx::Signal::SUCCESS, retries: 2) + end + + it "merges child retry options (exceptions + limit) onto the parent's" do + parent = create_successful_task(name: "ParentRetry2") do + retry_on CMDx::TestError, limit: 1, delay: 0 + end + + child = create_flaky_task(base: parent, name: "ChildRetry2", failures: 2) do + retry_on CMDx::TestError, limit: 5 + end + + expect(child.execute).to have_attributes(status: CMDx::Signal::SUCCESS, retries: 2) + end + end +end diff --git a/spec/integration/tasks/returns_spec.rb b/spec/integration/tasks/returns_spec.rb deleted file mode 100644 index ec0ec9256..000000000 --- a/spec/integration/tasks/returns_spec.rb +++ /dev/null @@ -1,346 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe "Task returns", type: :feature do - context "when declaring" do - context "with no returns" do - it "does not validate returns" do - task = create_task_class do - def work - (context.executed ||= []) << :success - end - end - - result = task.execute - - expect(result).to be_successful - expect(result).to have_matching_context(executed: %i[success]) - end - end - - context "with single return" do - context "when return is present" do - it "returns success" do - task = create_task_class do - returns :user - - def work - context.user = "John" - end - end - - result = task.execute - - expect(result).to be_successful - expect(result).to have_matching_context(user: "John") - end - end - - context "when return is missing" do - it "returns failure" do - task = create_task_class do - returns :user - - def work - (context.executed ||= []) << :success - end - end - - result = task.execute - - expect(result).to have_failed( - reason: "Invalid", - cause: be_a(CMDx::FailFault) - ) - expect(result).to have_matching_metadata( - errors: { - full_message: "user must be set in the context", - messages: { user: ["must be set in the context"] } - } - ) - end - end - end - - context "with multiple returns" do - context "when all returns are present" do - it "returns success" do - task = create_task_class do - returns :user, :token - - def work - context.user = "John" - context.token = "abc123" - end - end - - result = task.execute - - expect(result).to be_successful - expect(result).to have_matching_context(user: "John", token: "abc123") - end - end - - context "when some returns are missing" do - it "returns failure with all missing returns" do - task = create_task_class do - returns :user, :token - - def work - context.user = "John" - end - end - - result = task.execute - - expect(result).to have_failed( - reason: "Invalid", - cause: be_a(CMDx::FailFault) - ) - expect(result).to have_matching_metadata( - errors: { - full_message: "token must be set in the context", - messages: { token: ["must be set in the context"] } - } - ) - end - end - - context "when all returns are missing" do - it "returns failure with all missing returns" do - task = create_task_class do - returns :user, :token - - def work = nil - end - - result = task.execute - - expect(result).to have_failed( - reason: "Invalid", - cause: be_a(CMDx::FailFault) - ) - expect(result).to have_matching_metadata( - errors: { - full_message: "user must be set in the context. token must be set in the context", - messages: { - user: ["must be set in the context"], - token: ["must be set in the context"] - } - } - ) - end - end - end - end - - context "when task skips" do - it "does not validate returns" do - task = create_task_class do - returns :user - - def work - skip!("not needed") - end - end - - result = task.execute - - expect(result).to have_skipped(reason: "not needed") - end - end - - context "when task fails" do - it "does not validate returns" do - task = create_task_class do - returns :user - - def work - fail!("something went wrong") - end - end - - result = task.execute - - expect(result).to have_failed(reason: "something went wrong") - end - end - - context "when using bang execution" do - context "when return is missing" do - it "raises a CMDx::FailFault" do - task = create_task_class do - returns :user - - def work = nil - end - - expect { task.execute! }.to raise_error(CMDx::FailFault, "Invalid") - end - end - - context "when return is present" do - it "returns success" do - task = create_task_class do - returns :user - - def work - context.user = "John" - end - end - - result = task.execute! - - expect(result).to be_successful - expect(result).to have_matching_context(user: "John") - end - end - end - - context "when inheriting" do - it "inherits parent returns" do - parent_task = create_task_class(name: "ParentTask") do - returns :user - - def work - context.user = "John" - end - end - child_task = create_task_class(base: parent_task, name: "ChildTask") do - returns :token - - def work - super - context.token = "abc123" - end - end - - result = child_task.execute - - expect(result).to be_successful - expect(result).to have_matching_context(user: "John", token: "abc123") - end - - it "fails when inherited return is missing" do - parent_task = create_task_class(name: "ParentTask") do - returns :user - - def work - context.user = "John" - end - end - child_task = create_task_class(base: parent_task, name: "ChildTask") do - returns :token - end - - result = child_task.execute - - expect(result).to have_failed( - reason: "Invalid", - cause: be_a(CMDx::FailFault) - ) - expect(result).to have_matching_metadata( - errors: { - full_message: "token must be set in the context", - messages: { token: ["must be set in the context"] } - } - ) - end - end - - context "when removing returns" do - it "removes inherited returns" do - parent_task = create_task_class(name: "ParentTask") do - returns :user, :token - - def work - context.user = "John" - context.token = "abc123" - end - end - child_task = create_task_class(base: parent_task, name: "ChildTask") do - remove_return :token - - def work - context.user = "John" - end - end - - result = child_task.execute - - expect(result).to be_successful - expect(result).to have_matching_context(user: "John") - end - end - - context "with attributes and returns" do - context "when attribute validation fails" do - it "does not validate returns" do - task = create_task_class do - required :name - returns :user - - def work - context.user = name - end - end - - result = task.execute - - expect(result).to have_failed( - reason: "Invalid", - cause: be_a(CMDx::FailFault) - ) - expect(result).to have_matching_metadata( - errors: { - full_message: "name must be accessible via the context source method", - messages: { name: ["must be accessible via the context source method"] } - } - ) - end - end - - context "when attribute validation passes but return is missing" do - it "fails due to missing return" do - task = create_task_class do - required :name - returns :user - - def work = nil - end - - result = task.execute(name: "John") - - expect(result).to have_failed( - reason: "Invalid", - cause: be_a(CMDx::FailFault) - ) - expect(result).to have_matching_metadata( - errors: { - full_message: "user must be set in the context", - messages: { user: ["must be set in the context"] } - } - ) - end - end - - context "when both attribute and return are valid" do - it "returns success" do - task = create_task_class do - required :name - returns :user - - def work - context.user = { name: name } - end - end - - result = task.execute(name: "John") - - expect(result).to be_successful - expect(result).to have_matching_context(user: { name: "John" }) - end - end - end -end diff --git a/spec/integration/tasks/rollback_spec.rb b/spec/integration/tasks/rollback_spec.rb new file mode 100644 index 000000000..22a84497b --- /dev/null +++ b/spec/integration/tasks/rollback_spec.rb @@ -0,0 +1,101 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe "Task rollback", type: :feature do + describe "#rollback runs on failure" do + context "when fail! was called" do + it "invokes rollback and marks the result as rolled back" do + task = create_failing_task(reason: "boom") do + define_method(:rollback) { (context.log ||= []) << :rolled } + end + + result = task.execute + + expect(result).to have_attributes( + status: CMDx::Signal::FAILED, + rolled_back?: true + ) + expect(result.context[:log]).to eq(%i[rolled]) + end + end + + context "when an exception was raised" do + it "invokes rollback and marks the result as rolled back" do + task = create_erroring_task do + define_method(:rollback) { (context.log ||= []) << :rolled } + end + + result = task.execute + + expect(result).to have_attributes( + status: CMDx::Signal::FAILED, + rolled_back?: true, + cause: be_a(CMDx::TestError) + ) + expect(result.context[:log]).to eq(%i[rolled]) + end + end + end + + describe "#rollback is skipped on success or skip" do + it "does not run for a successful task" do + task = create_successful_task do + define_method(:rollback) { context.rolled = true } + end + + expect(task.execute).to have_attributes(rolled_back?: false) + end + + it "does not run for a skipped task" do + task = create_skipping_task do + define_method(:rollback) { context.rolled = true } + end + + result = task.execute + + expect(result).to have_attributes(status: CMDx::Signal::SKIPPED, rolled_back?: false) + expect(result.context).not_to respond_to(:rolled) + end + end + + describe "rollback ordering" do + it "runs before on_failed and on_ko callbacks" do + task = create_failing_task(reason: "boom") do + on_failed { (context.log ||= []) << :on_failed } + on_ko { (context.log ||= []) << :on_ko } + define_method(:rollback) { (context.log ||= []) << :rollback } + end + + expect(task.execute.context[:log]).to eq(%i[rollback on_failed on_ko]) + end + end + + describe "without a #rollback method" do + it "does not mark the result as rolled back" do + expect(create_failing_task.execute).to have_attributes(rolled_back?: false) + end + end + + describe "blocking execute!" do + it "runs rollback before raising the Fault for fail!" do + log = [] + task = create_failing_task(reason: "boom") do + define_method(:rollback) { log << :rolled } + end + + expect { task.execute! }.to raise_error(CMDx::Fault, "boom") + expect(log).to eq(%i[rolled]) + end + + it "runs rollback before raising the original exception" do + log = [] + task = create_erroring_task(reason: "kaboom") do + define_method(:rollback) { log << :rolled } + end + + expect { task.execute! }.to raise_error(CMDx::TestError, "kaboom") + expect(log).to eq(%i[rolled]) + end + end +end diff --git a/spec/integration/tasks/settings_spec.rb b/spec/integration/tasks/settings_spec.rb new file mode 100644 index 000000000..efd112617 --- /dev/null +++ b/spec/integration/tasks/settings_spec.rb @@ -0,0 +1,134 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe "Task settings", type: :feature do + describe "defaults" do + let(:task) { create_successful_task } + + it "falls back to the global configuration" do + config = CMDx.configuration + expect(task.settings).to have_attributes( + logger: config.logger, + log_level: config.log_level, + log_formatter: config.log_formatter, + backtrace_cleaner: config.backtrace_cleaner, + tags: [] + ) + end + + it "builds a per-instance logger via LoggerProxy" do + instance = task.new + expect(instance.logger).to be_a(Logger) + end + end + + describe "task-level overrides" do + it "overrides the logger" do + custom = Logger.new(nil) + task = create_successful_task { settings(logger: custom) } + + expect(task.settings.logger).to be(custom) + expect(task.execute).to have_attributes(status: CMDx::Signal::SUCCESS) + end + + it "overrides tags and surfaces them on the result" do + task = create_successful_task { settings(tags: %w[billing critical]) } + + expect(task.execute.tags).to eq(%w[billing critical]) + end + + it "applies log_level by dup'ing the base logger" do + base_logger = Logger.new(nil) + base_logger.level = Logger::WARN + + task = create_successful_task do + settings(logger: base_logger, log_level: Logger::DEBUG) + end + + instance = task.new + expect(instance.logger).not_to be(base_logger) + expect(instance.logger.level).to eq(Logger::DEBUG) + expect(base_logger.level).to eq(Logger::WARN) + end + + it "applies log_formatter by dup'ing the base logger" do + formatter = CMDx::LogFormatters::JSON.new + task = create_successful_task { settings(log_formatter: formatter) } + + expect(task.new.logger.formatter).to be(formatter) + end + + it "reuses the base logger when neither level nor formatter changes" do + base_logger = Logger.new(nil) + base_logger.level = Logger::DEBUG + base_logger.formatter = proc { |*| "x" } + + task = create_successful_task do + settings( + logger: base_logger, + log_level: Logger::DEBUG, + log_formatter: base_logger.formatter + ) + end + + expect(task.new.logger).to be(base_logger) + end + + it "applies backtrace_cleaner to Fault backtraces" do + cleaner = ->(bt) { bt.first(2) } + task = create_failing_task(reason: "broke") { settings(backtrace_cleaner: cleaner) } + + expect(task.settings.backtrace_cleaner).to be(cleaner) + expect { task.execute! }.to raise_error(CMDx::Fault) do |error| + expect(error.backtrace.size).to eq(2) + end + end + end + + describe "inheritance" do + let(:custom_logger) { Logger.new(nil) } + let(:parent) do + logger = custom_logger + create_task_class(name: "Parent") do + settings(logger: logger, tags: %w[base]) + define_method(:work) { nil } + end + end + + it "inherits parent settings when the child defines none" do + child = create_task_class(base: parent, name: "PlainChild") { define_method(:work) { nil } } + + expect(child.settings).to have_attributes(logger: custom_logger, tags: %w[base]) + end + + it "merges child overrides on top of inherited settings" do + child_logger = Logger.new(nil) + cl = child_logger + child = create_task_class(base: parent, name: "OverridingChild") do + settings(logger: cl, tags: %w[override]) + define_method(:work) { nil } + end + + expect(child.settings).to have_attributes(logger: child_logger, tags: %w[override]) + end + + it "does not leak child settings back to the parent" do + _child = create_task_class(base: parent, name: "LeakChild") do + settings(tags: %w[leaked]) + define_method(:work) { nil } + end + + expect(parent.settings.tags).to eq(%w[base]) + end + end + + describe "idempotency" do + it "returns the same Settings instance across reads" do + task = create_successful_task { settings(tags: %w[a]) } + first = task.settings + + expect(task.settings).to be(first) + end + end +end diff --git a/spec/integration/tasks/telemetry_spec.rb b/spec/integration/tasks/telemetry_spec.rb new file mode 100644 index 000000000..0c402f23f --- /dev/null +++ b/spec/integration/tasks/telemetry_spec.rb @@ -0,0 +1,168 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe "Task telemetry", type: :feature do + let(:events) { [] } + let(:sub) { ->(event) { events << event } } + + def with_telemetry(task, *names) + names.each { |n| task.telemetry.subscribe(n, sub) } + task + end + + describe "lifecycle events" do + it "emits task_started and task_executed in order for a success" do + task = with_telemetry(create_successful_task, :task_started, :task_executed) + + result = task.execute + + expect(events.map(&:name)).to eq(%i[task_started task_executed]) + expect(events.last.payload[:result]).to be(result) + expect(events.last.task_class).to be(task) + end + + it "emits task_rolled_back between failure and task_executed" do + task = with_telemetry( + create_failing_task(reason: "boom") { define_method(:rollback) { nil } }, + :task_started, :task_rolled_back, :task_executed + ) + + task.execute + + expect(events.map(&:name)).to eq(%i[task_started task_rolled_back task_executed]) + end + + it "emits task_retried with the attempt number" do + task = with_telemetry( + create_flaky_task(failures: 2) { retry_on CMDx::TestError, limit: 3, delay: 0 }, + :task_retried + ) + + task.execute + + expect(events.map(&:name)).to eq(%i[task_retried task_retried]) + expect(events.map { |e| e.payload[:attempt] }).to eq([1, 2]) + end + + it "emits task_deprecated for deprecated tasks" do + task = with_telemetry( + create_successful_task { deprecation :log }, + :task_deprecated + ) + + task.execute + + expect(events.map(&:name)).to eq(%i[task_deprecated]) + end + end + + describe "event payload" do + it "carries chain_id, task class, task id, payload and timestamp" do + task = with_telemetry(create_successful_task, :task_executed) + + before = Time.now.utc + result = task.execute + + event = events.first + expect(event).to have_attributes( + name: :task_executed, + chain_id: result.chain.id, + task_type: task.type, + task_class: task, + task_id: result.id + ) + expect(event.payload[:result]).to be(result) + expect(event.timestamp).to be_a(Time).and(be >= before) + end + end + + describe "subscriber registration" do + it "supports both callables and blocks" do + task = create_successful_task + task.telemetry.subscribe(:task_executed) { |e| events << e } + + task.execute + expect(events.size).to eq(1) + end + + it "rejects non-callables" do + expect { create_successful_task.telemetry.subscribe(:task_executed, :nope) } + .to raise_error(ArgumentError, /must respond to #call/) + end + + it "rejects callable and block together" do + expect do + create_successful_task.telemetry.subscribe(:task_executed, sub) { |_| nil } + end.to raise_error(ArgumentError, /either a callable or a block/) + end + + it "rejects unknown events" do + expect { create_successful_task.telemetry.subscribe(:nope, sub) } + .to raise_error(ArgumentError, /unknown event/) + end + + it "unsubscribe removes a specific subscriber" do + task = with_telemetry(create_successful_task, :task_executed) + task.telemetry.unsubscribe(:task_executed, sub) + + task.execute + + expect(events).to be_empty + end + end + + describe "zero-cost when no subscribers" do + it "produces no events" do + create_successful_task.execute + expect(events).to be_empty + end + end + + describe "inheritance" do + it "child inherits parent subscribers and adds its own" do + parent_events = [] + parent = create_successful_task(name: "Parent") do + telemetry.subscribe(:task_executed, ->(e) { parent_events << e }) + end + + child_events = [] + child = create_successful_task(base: parent, name: "Child") do + telemetry.subscribe(:task_executed, ->(e) { child_events << e }) + end + + child.execute + + expect(parent_events.size).to eq(1) + expect(child_events.size).to eq(1) + end + + it "does not leak child subscribers back to the parent" do + parent_events = [] + parent = create_successful_task(name: "Parent2") do + telemetry.subscribe(:task_executed, ->(e) { parent_events << e }) + end + + child_events = [] + _child = create_successful_task(base: parent, name: "Child2") do + telemetry.subscribe(:task_executed, ->(e) { child_events << e }) + end + + parent.execute + + expect(parent_events.size).to eq(1) + expect(child_events).to be_empty + end + end + + describe "global configuration" do + it "global subscribers are seeded into new tasks" do + global = [] + CMDx.configuration.telemetry.subscribe(:task_executed, ->(e) { global << e }) + + create_successful_task.execute + + expect(global.size).to eq(1) + end + end +end diff --git a/spec/integration/workflows/breakpoints_spec.rb b/spec/integration/workflows/breakpoints_spec.rb deleted file mode 100644 index 53c009e43..000000000 --- a/spec/integration/workflows/breakpoints_spec.rb +++ /dev/null @@ -1,190 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe "Workflow breakpoints", type: :feature do - context "with default breakpoints" do - it "continues on skipped tasks" do - task1 = create_successful_task(name: "Task1") - task2 = create_skipping_task(name: "Task2", reason: "Skipped") - task3 = create_successful_task(name: "Task3") - - workflow = create_workflow_class do - task task1 - task task2 - task task3 - end - - result = workflow.new.execute - - expect(result).to be_successful - expect(result.chain.results.size).to eq(4) - expect(result.chain.results[2].skipped?).to be(true) - end - - it "halts on failed tasks" do - task1 = create_successful_task(name: "Task1") - task2 = create_failing_task(name: "Task2", reason: "Failed") - task3 = create_successful_task(name: "Task3") - - workflow = create_workflow_class do - task task1 - task task2 - task task3 - end - - result = workflow.new.execute - - expect(result).to have_failed(reason: "Failed", outcome: "interrupted") - expect(result.chain.results.size).to eq(3) - expect(result.chain.results[2].failed?).to be(true) - end - end - - context "with custom workflow breakpoints" do - it "halts on both skipped and failed" do - task1 = create_successful_task(name: "Task1") - task2 = create_skipping_task(name: "Task2", reason: "Skipped") - task3 = create_successful_task(name: "Task3") - - workflow = create_workflow_class do - settings(workflow_breakpoints: %w[skipped failed]) - - task task1 - task task2 - task task3 - end - - result = workflow.new.execute - - expect(result).to have_skipped(reason: "Skipped") - expect(result.chain.results.size).to eq(3) - end - - it "never halts with empty breakpoints" do - task1 = create_successful_task(name: "Task1") - task2 = create_failing_task(name: "Task2", reason: "Failed") - task3 = create_skipping_task(name: "Task3", reason: "Skipped") - task4 = create_successful_task(name: "Task4") - - workflow = create_workflow_class do - settings(workflow_breakpoints: []) - - task task1 - task task2 - task task3 - task task4 - end - - result = workflow.new.execute - - expect(result).to be_successful - expect(result.chain.results.size).to eq(5) - end - end - - context "with group-level breakpoints" do - it "applies different breakpoints to different groups" do - critical_task1 = create_successful_task(name: "CriticalTask1") - critical_task2 = create_skipping_task(name: "CriticalTask2", reason: "Skipped") - optional_task1 = create_successful_task(name: "OptionalTask1") - optional_task2 = create_failing_task(name: "OptionalTask2", reason: "Failed") - - workflow = create_workflow_class do - tasks critical_task1, - critical_task2, - workflow_breakpoints: %w[skipped failed] - - tasks optional_task1, - optional_task2, - breakpoints: [] - end - - result = workflow.new.execute - - expect(result).to be_successful - expect(result.chain.results.size).to eq(5) - end - - it "respects group breakpoints independently" do - group1_task1 = create_successful_task(name: "Group1Task1") - group1_task2 = create_skipping_task(name: "Group1Task2", reason: "Skipped") - group2_task1 = create_successful_task(name: "Group2Task1") - group2_task2 = create_skipping_task(name: "Group2Task2", reason: "Skipped") - - workflow = create_workflow_class do - tasks group1_task1, - group1_task2, - breakpoints: %w[skipped] - - tasks group2_task1, - group2_task2, - breakpoints: [] - end - - result = workflow.new.execute - - expect(result).to have_skipped(reason: "Skipped") - expect(result.chain.results.size).to eq(3) - end - end - - context "when propagating failures" do - it "throws failures from nested tasks" do - inner_task = create_failing_task(name: "InnerTask", reason: "Inner failure") - - outer_task = create_task_class(name: "OuterTask") do - define_method(:work) do - result = inner_task.execute - throw!(result) if result.failed? - end - end - - workflow = create_workflow_class do - task outer_task - end - - result = workflow.new.execute - - expect(result).to have_failed(reason: "Inner failure", outcome: "interrupted") - expect(result.threw_failure?).to be(false) - end - end - - context "with bang execution" do - it "raises on workflow failure" do - task1 = create_successful_task(name: "Task1") - task2 = create_failing_task(name: "Task2", reason: "Failed") - - workflow = create_workflow_class do - task task1 - task task2 - end - - expect { workflow.new.execute(raise: true) }.to raise_error(CMDx::FailFault) - end - end - - context "when continuing after failures" do - it "executes all tasks when breakpoints are empty" do - task1 = create_successful_task(name: "Task1") - task2 = create_failing_task(name: "Task2", reason: "Failed") - task3 = create_successful_task(name: "Task3") - - workflow = create_workflow_class do - settings(workflow_breakpoints: []) - - task task1 - task task2 - task task3 - end - - result = workflow.new.execute - - expect(result).to be_successful - expect(result.chain.results.size).to eq(4) - expect(result.chain.results[2].failed?).to be(true) - expect(result.chain.results[3].success?).to be(true) - end - end -end diff --git a/spec/integration/workflows/conditionals_spec.rb b/spec/integration/workflows/conditionals_spec.rb index fa7c571e2..13bb8d5d8 100644 --- a/spec/integration/workflows/conditionals_spec.rb +++ b/spec/integration/workflows/conditionals_spec.rb @@ -3,182 +3,155 @@ require "spec_helper" RSpec.describe "Workflow conditionals", type: :feature do - context "when using if conditionals" do - it "executes task when condition is true" do - task1 = create_successful_task(name: "Task1") + after { CMDx::Chain.clear } - workflow = create_workflow_class do - task task1, if: :should_execute? + let(:guarded_task) do + create_task_class(name: "Guarded") { define_method(:work) { context.ran = true } } + end - def should_execute? - context.execute_task == true - end + describe "if: conditionals" do + it "runs when a Symbol resolves truthy" do + t = guarded_task + workflow = create_workflow_class do + task t, if: :enabled? + define_method(:enabled?) { context[:enabled] } end - enabled_result = workflow.execute(execute_task: true) - disabled_result = workflow.execute(execute_task: false) - - expect(enabled_result).to be_successful - expect(enabled_result.chain.results.size).to eq(2) - - expect(disabled_result).to be_successful - expect(disabled_result.chain.results.size).to eq(1) + expect(workflow.execute(enabled: true).context[:ran]).to be(true) end - it "uses proc for if condition" do - task1 = create_successful_task(name: "Task1") - + it "skips when a Symbol resolves falsy" do + t = guarded_task workflow = create_workflow_class do - task task1, if: -> { context.enabled == true } + task t, if: :enabled? + define_method(:enabled?) { context[:enabled] } end - enabled_result = workflow.execute(enabled: true) - disabled_result = workflow.execute(enabled: false) + expect(workflow.execute(enabled: false).context[:ran]).to be_nil + end + + it "runs when a Proc resolves truthy (evaluated in the workflow instance)" do + t = guarded_task + workflow = create_workflow_class { task t, if: proc { context[:flag] } } - expect(enabled_result.chain.results.size).to eq(2) - expect(disabled_result.chain.results.size).to eq(1) + expect(workflow.execute(flag: true).context[:ran]).to be(true) end - it "uses lambda for if condition" do - task1 = create_successful_task(name: "Task1") + it "supports a lambda" do + t = guarded_task + workflow = create_workflow_class { task t, if: -> { context.key?(:trigger) } } - workflow = create_workflow_class do - task task1, if: proc { context.enabled == true } - end + expect(workflow.execute.context[:ran]).to be_nil + expect(workflow.execute(trigger: :go).context[:ran]).to be(true) + end - enabled_result = workflow.execute(enabled: true) - disabled_result = workflow.execute(enabled: false) + it "supports a callable object that receives the workflow" do + checker = Class.new do + def call(task) = task.context[:allowed] + end.new + t = guarded_task + workflow = create_workflow_class { task t, if: checker } - expect(enabled_result.chain.results.size).to eq(2) - expect(disabled_result.chain.results.size).to eq(1) + expect(workflow.execute(allowed: true).context[:ran]).to be(true) + expect(workflow.execute(allowed: false).context[:ran]).to be_nil end end - context "when using unless conditionals" do - it "executes task when condition is false" do - task1 = create_successful_task(name: "Task1") - + describe "unless: conditionals" do + it "runs when the guard is falsy" do + t = guarded_task workflow = create_workflow_class do - task task1, unless: :should_skip? - - def should_skip? - context.skip_task == true - end + task t, unless: :disabled? + define_method(:disabled?) { context[:disabled] } end - enabled_result = workflow.execute(skip_task: false) - disabled_result = workflow.execute(skip_task: true) - - expect(enabled_result.chain.results.size).to eq(2) - expect(disabled_result.chain.results.size).to eq(1) + expect(workflow.execute(disabled: false).context[:ran]).to be(true) end - it "uses proc for unless condition" do - task1 = create_successful_task(name: "Task1") - + it "skips when the guard is truthy" do + t = guarded_task workflow = create_workflow_class do - task task1, unless: -> { context.disabled == true } + task t, unless: :disabled? + define_method(:disabled?) { context[:disabled] } end - enabled_result = workflow.execute(disabled: false) - disabled_result = workflow.execute(disabled: true) - - expect(enabled_result.chain.results.size).to eq(2) - expect(disabled_result.chain.results.size).to eq(1) + expect(workflow.execute(disabled: true).context[:ran]).to be_nil end end - context "when combining if and unless" do - it "requires both conditions to be satisfied" do - task1 = create_successful_task(name: "Task1") + describe "combining if: and unless:" do + it "skips when both are specified and unless is truthy" do + t = guarded_task + workflow = create_workflow_class { task t, if: proc { true }, unless: proc { true } } - workflow = create_workflow_class do - task task1, - if: :should_execute?, - unless: :should_skip? + expect(workflow.execute.context[:ran]).to be_nil + end + + it "runs only when if: is truthy and unless: is falsy" do + t = guarded_task + workflow = create_workflow_class { task t, if: proc { true }, unless: proc { false } } + + expect(workflow.execute.context[:ran]).to be(true) + end + end - def should_execute? - context.enabled == true - end + describe "group scoping" do + it "applies conditionals to every task in a sequential group" do + a = create_task_class(name: "A") { define_method(:work) { context.a = true } } + b = create_task_class(name: "B") { define_method(:work) { context.b = true } } - def should_skip? - context.override == true - end + workflow = create_workflow_class do + tasks a, b, if: proc { !context[:skip_group] } end - both_satisfied = workflow.execute(enabled: true, override: false) - if_false = workflow.execute(enabled: false, override: false) - unless_false = workflow.execute(enabled: true, override: true) + result = workflow.execute(skip_group: true) - expect(both_satisfied.chain.results.size).to eq(2) - expect(if_false.chain.results.size).to eq(1) - expect(unless_false.chain.results.size).to eq(1) + expect(result.context[:a]).to be_nil + expect(result.context[:b]).to be_nil end - end - context "when using conditionals with groups" do - it "applies condition to all tasks in group" do - task1 = create_successful_task(name: "Task1") - task2 = create_successful_task(name: "Task2") - task3 = create_successful_task(name: "Task3") + it "applies conditionals to every task in a parallel group" do + a = create_task_class(name: "A") { define_method(:work) { context.a = true } } + b = create_task_class(name: "B") { define_method(:work) { context.b = true } } workflow = create_workflow_class do - tasks task1, task2, task3, if: :group_enabled? - - def group_enabled? - context.enable_group == true - end + tasks a, b, strategy: :parallel, if: proc { context[:run] } end - enabled_result = workflow.execute(enable_group: true) - disabled_result = workflow.execute(enable_group: false) + result = workflow.execute(run: false) - expect(enabled_result.chain.results.size).to eq(4) - expect(disabled_result.chain.results.size).to eq(1) + expect(result.context[:a]).to be_nil + expect(result.context[:b]).to be_nil end end - context "when conditionals affect execution flow" do - it "skips tasks based on runtime conditions" do - setup_task = create_task_class(name: "SetupTask") do - def work - context.setup_complete = true - end - end - conditional_task = create_successful_task(name: "ConditionalTask") - final_task = create_successful_task(name: "FinalTask") + describe "mixing groups with different conditions" do + it "runs groups whose conditions match and skips the rest" do + a = create_task_class(name: "A") { define_method(:work) { (context.log ||= []) << :a } } + b = create_task_class(name: "B") { define_method(:work) { (context.log ||= []) << :b } } + c = create_task_class(name: "C") { define_method(:work) { (context.log ||= []) << :c } } workflow = create_workflow_class do - task setup_task - task conditional_task, if: proc { context.setup_complete == true } - task final_task + task a, if: proc { true } + task b, unless: proc { true } + task c end - result = workflow.execute - - expect(result).to be_successful - expect(result.chain.results.size).to eq(4) - expect(result).to have_matching_context(setup_complete: true) + expect(workflow.execute.context[:log]).to eq(%i[a c]) end end - context "when using complex conditional logic" do - it "evaluates complex conditions" do - task1 = create_successful_task(name: "Task1") + describe "condition visibility into the workflow" do + it "Proc conditions can reach task-level context that earlier tasks populated" do + first = create_task_class(name: "First") { define_method(:work) { context.gate = true } } + second = create_task_class(name: "Second") { define_method(:work) { context.second_ran = true } } workflow = create_workflow_class do - task task1, if: proc { - context.env == "production" && context.feature_enabled == true - } + task first + task second, if: proc { context[:gate] } end - prod_enabled = workflow.execute(env: "production", feature_enabled: true) - prod_disabled = workflow.execute(env: "production", feature_enabled: false) - dev_enabled = workflow.execute(env: "development", feature_enabled: true) - - expect(prod_enabled.chain.results.size).to eq(2) - expect(prod_disabled.chain.results.size).to eq(1) - expect(dev_enabled.chain.results.size).to eq(1) + expect(workflow.execute.context[:second_ran]).to be(true) end end end diff --git a/spec/integration/workflows/execution_spec.rb b/spec/integration/workflows/execution_spec.rb index dbe4485af..b2c6b1614 100644 --- a/spec/integration/workflows/execution_spec.rb +++ b/spec/integration/workflows/execution_spec.rb @@ -3,103 +3,181 @@ require "spec_helper" RSpec.describe "Workflow execution", type: :feature do - context "when non-blocking" do - subject(:result) { workflow.execute } + after { CMDx::Chain.clear } + + describe "happy path" do + it "runs every declared task in order and shares a chain" do + t1 = create_successful_task(name: "T1") + t2 = create_successful_task(name: "T2") + t3 = create_successful_task(name: "T3") + workflow = create_workflow_class { tasks t1, t2, t3 } + + result = workflow.execute + + expect(result).to have_attributes( + status: CMDx::Signal::SUCCESS, + state: CMDx::Signal::COMPLETE + ) + expect(result.chain.size).to eq(4) + expect(result.chain.map { |r| r.task.name }).to all(be_a(String)) + expect(result.chain.first.type).to eq("Workflow") + expect(result.chain.last.type).to eq("Task") + expect(result.chain.first(3).map(&:type)).to eq(%w[Workflow Task Task]) + end + end - context "when successful" do - let(:workflow) { create_successful_workflow } + describe "failure semantics" do + it "halts on failure and skips subsequent tasks" do + ok = create_successful_task(name: "Ok") + fl = create_failing_task(name: "Fail", reason: "boom") + af = create_successful_task(name: "After") - it "returns success" do - expect(result).to be_successful - expect(result).to have_matching_context(executed: %i[success inner middle outer success]) + workflow = create_workflow_class do + task ok + task fl + task af end + + result = workflow.execute + + expect(result).to have_attributes(status: CMDx::Signal::FAILED, reason: "boom") + expect(result.context[:executed]).to eq(%i[success]) end - context "when skipping" do - let(:workflow) { create_skipping_workflow } + it "skipped tasks do not halt the pipeline" do + t1 = create_successful_task(name: "Pre") + sk = create_skipping_task(name: "Skip") + t2 = create_successful_task(name: "Post") - it "returns success" do - expect(result).to be_successful - expect(result).to have_matching_context(executed: %i[success success]) + workflow = create_workflow_class do + task t1 + task sk + task t2 end + + expect(workflow.execute).to have_attributes(status: CMDx::Signal::SUCCESS) end - context "when failing" do - let(:workflow) { create_failing_workflow } - - it "returns failure" do - expect(result).to have_failed( - outcome: CMDx::Result::INTERRUPTED, - threw_failure: hash_including( - index: 2, - class: start_with("OuterTask") - ), - caused_failure: hash_including( - index: 4, - class: start_with("InnerTask") - ) - ) - expect(result).to have_matching_context(executed: %i[success]) - end + it "propagates the failure through threw_failure/caused_failure" do + fl = create_failing_task(name: "Fail", reason: "boom") + workflow = create_workflow_class { task fl } + + result = workflow.execute + + expect(result.caused_failure).not_to be_nil + expect(result.threw_failure).not_to be_nil + end + end + + describe "strict mode" do + it "raises Fault when a task fails under execute!" do + fl = create_failing_task(name: "StrictFail", reason: "strict boom") + workflow = create_workflow_class { task fl } + + expect { workflow.execute! }.to raise_error(CMDx::Fault, "strict boom") end + end - context "when erroring" do - let(:workflow) { create_erroring_workflow } - - it "returns failure" do - expect(result).to have_failed( - outcome: CMDx::Result::INTERRUPTED, - reason: "[CMDx::TestError] borked error", - cause: be_a(CMDx::FailFault), - threw_failure: hash_including( - index: 2, - class: start_with("OuterTask") - ), - caused_failure: hash_including( - index: 4, - class: start_with("InnerTask") - ) - ) - expect(result).to have_matching_context(executed: %i[success]) + describe "context flow" do + it "threads the same context through every task" do + adder = create_task_class(name: "Adder") do + define_method(:work) { context.total = (context[:total] || context[:seed]) + 5 } + end + doubler = create_task_class(name: "Doubler") do + define_method(:work) { context.total = context[:total] * 2 } end + + workflow = create_workflow_class do + task adder + task doubler + end + + expect(workflow.execute(seed: 10).context[:total]).to eq(30) end end - context "when blocking" do - subject(:result) { workflow.execute! } + describe "empty and invalid pipelines" do + it "succeeds when no tasks are declared" do + expect(create_workflow_class.execute).to have_attributes(status: CMDx::Signal::SUCCESS) + end - context "when successful" do - let(:workflow) { create_successful_workflow } + it "raises when a group contains no tasks" do + expect { create_workflow_class { tasks } }.not_to raise_error + end - it "returns success" do - expect(result).to be_successful - expect(result).to have_matching_context(executed: %i[success inner middle outer success]) - end + it "rejects non-task arguments" do + expect { create_workflow_class { task Object } } + .to raise_error(TypeError, /is not a Task/) end - context "when skipping" do - let(:workflow) { create_skipping_workflow } + it "rejects defining #work on a workflow" do + expect do + create_workflow_class { define_method(:work) { nil } } + end.to raise_error(CMDx::ImplementationError, /cannot define .+#work in a workflow/) + end - it "returns success" do - expect(result).to be_successful - expect(result).to have_matching_context(executed: %i[success success]) - end + it "fails with an invalid strategy (wrapped by runtime)" do + t = create_successful_task(name: "T") + workflow = create_workflow_class { tasks t, strategy: :nope } + + expect(workflow.execute).to have_attributes( + status: CMDx::Signal::FAILED, + reason: /invalid strategy/ + ) end + end + + describe "workflow-level integrations" do + it "runs workflow before_execution callbacks before delegating to the pipeline" do + t = create_successful_task(name: "T") + workflow = create_workflow_class do + before_execution :setup + task t + private + define_method(:setup) { context.setup = true } + end - context "when failing" do - let(:workflow) { create_failing_workflow } + expect(workflow.execute.context[:setup]).to be(true) + end - it "returns failure" do - expect { result }.to raise_error(CMDx::FailFault, "Unspecified") + it "validates workflow-level inputs" do + t = create_successful_task(name: "T") + workflow = create_workflow_class do + required :name, coerce: :string + task t end + + expect(workflow.execute(name: "ok")).to have_attributes(status: CMDx::Signal::SUCCESS) + expect(workflow.execute).to have_attributes(status: CMDx::Signal::FAILED) end + end - context "when erroring" do - let(:workflow) { create_erroring_workflow } + describe "group ordering" do + it "runs multiple groups in declaration order" do + a = create_task_class(name: "A") { define_method(:work) { (context.order ||= []) << :a } } + b = create_task_class(name: "B") { define_method(:work) { (context.order ||= []) << :b } } + c = create_task_class(name: "C") { define_method(:work) { (context.order ||= []) << :c } } - it "returns failure" do - expect { result }.to raise_error(CMDx::FailFault, "[CMDx::TestError] borked error") + workflow = create_workflow_class do + task a + tasks b, c end + + expect(workflow.execute.context[:order]).to eq(%i[a b c]) + end + end + + describe "inheritance" do + it "child workflow inherits parent pipeline and can extend it" do + a = create_task_class(name: "A") { define_method(:work) { (context.log ||= []) << :a } } + b = create_task_class(name: "B") { define_method(:work) { (context.log ||= []) << :b } } + + parent = create_workflow_class(name: "Parent") { task a } + child = create_workflow_class(base: parent, name: "Child") { task b } + + expect(parent.pipeline.size).to eq(1) + expect(child.pipeline.size).to eq(2) + expect(child.execute.context[:log]).to eq(%i[a b]) end end end diff --git a/spec/integration/workflows/nested_spec.rb b/spec/integration/workflows/nested_spec.rb new file mode 100644 index 000000000..213d4cc6d --- /dev/null +++ b/spec/integration/workflows/nested_spec.rb @@ -0,0 +1,95 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe "Nested workflows", type: :feature do + after { CMDx::Chain.clear } + + describe "nesting a workflow inside another workflow" do + let(:inner) do + step = create_task_class(name: "InnerStep") { define_method(:work) { context.inner_done = true } } + create_workflow_class(name: "InnerWorkflow") { task step } + end + + let(:outer) do + inner_wf = inner + finalize = create_task_class(name: "Finalize") do + define_method(:work) { context.finalized = context[:inner_done] } + end + create_workflow_class(name: "OuterWorkflow") do + task inner_wf + task finalize + end + end + + it "shares context across the nested workflow" do + result = outer.execute + + expect(result).to have_attributes(status: CMDx::Signal::SUCCESS) + expect(result.context).to have_attributes(inner_done: true, finalized: true) + end + + it "records everything under the same chain_id" do + result = outer.execute + + expect(result.chain.map { |r| r.chain.id }.uniq.size).to eq(1) + expect(result.chain.size).to eq(4) # inner_step, inner_wf, finalize, outer_wf + end + end + + describe "inner failure propagation" do + it "halts the outer workflow and skips following tasks" do + fl = create_failing_task(name: "InnerFail", reason: "inner boom") + inner = create_workflow_class(name: "InnerFailWf") { task fl } + after = create_task_class(name: "After") { define_method(:work) { context.ran_after = true } } + outer = create_workflow_class(name: "OuterFailWf") do + task inner + task after + end + + result = outer.execute + + expect(result).to have_attributes(status: CMDx::Signal::FAILED, reason: "inner boom") + expect(result.context[:ran_after]).to be_nil + end + + it "captures thrown/caused failure references across boundaries" do + fl = create_failing_task(name: "InnerFail2", reason: "boom") + inner = create_workflow_class(name: "InnerFailWf2") { task fl } + outer = create_workflow_class(name: "OuterFailWf2") { task inner } + + result = outer.execute + + expect(result.caused_failure).not_to be_nil + expect(result.threw_failure).not_to be_nil + end + end + + describe "deep nesting" do + it "threads context through multiple levels" do + step = create_task_class(name: "DeepStep") { define_method(:work) { context.deep = true } } + l3 = create_workflow_class(name: "Level3") { task step } + l2 = create_workflow_class(name: "Level2") { task l3 } + l1 = create_workflow_class(name: "Level1") { task l2 } + + expect(l1.execute.context[:deep]).to be(true) + end + end + + describe "mixing tasks and nested workflows" do + it "runs tasks before and after a nested workflow in declaration order" do + pre = create_task_class(name: "Pre") { define_method(:work) { (context.log ||= []) << :pre } } + mid = create_task_class(name: "Mid") { define_method(:work) { (context.log ||= []) << :mid } } + post = create_task_class(name: "Post") { define_method(:work) { (context.log ||= []) << :post } } + inner = create_workflow_class(name: "Mid") { task mid } + + outer = create_workflow_class(name: "Mixed") do + task pre + task inner + task post + end + + expect(outer.execute.context[:log]).to eq(%i[pre mid post]) + end + end +end diff --git a/spec/integration/workflows/parallel_spec.rb b/spec/integration/workflows/parallel_spec.rb new file mode 100644 index 000000000..45c7c67e1 --- /dev/null +++ b/spec/integration/workflows/parallel_spec.rb @@ -0,0 +1,126 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe "Workflow parallel execution", type: :feature do + after { CMDx::Chain.clear } + + describe "successful parallel execution" do + it "merges context contributions from every task" do + a = create_task_class(name: "A") { define_method(:work) { context.a = "from_a" } } + b = create_task_class(name: "B") { define_method(:work) { context.b = "from_b" } } + + workflow = create_workflow_class { tasks a, b, strategy: :parallel } + + result = workflow.execute + + expect(result).to have_attributes(status: CMDx::Signal::SUCCESS) + expect(result.context).to have_attributes(a: "from_a", b: "from_b") + end + + it "isolates writes between parallel tasks (each runs on a deep-dup'd context)" do + a = create_task_class(name: "A") do + define_method(:work) do + context.shared = "modified_by_a" + context.a = true + end + end + b = create_task_class(name: "B") do + define_method(:work) do + context.b_saw = context[:shared] + context.b = true + end + end + + workflow = create_workflow_class { tasks a, b, strategy: :parallel } + + result = workflow.execute(shared: "original") + + expect(result.context).to have_attributes(a: true, b: true, b_saw: "original") + end + + it "shares a single chain_id across all parallel results" do + a = create_successful_task(name: "A") + b = create_successful_task(name: "B") + + workflow = create_workflow_class { tasks a, b, strategy: :parallel } + + result = workflow.execute + + expect(result.chain.map { |r| r.chain.id }.uniq.size).to eq(1) + expect(result.chain.size).to eq(3) + end + end + + describe "failure handling" do + it "reports failure when any parallel task fails and halts subsequent groups" do + ok = create_task_class(name: "Ok") { define_method(:work) { context.ok = true } } + fl = create_failing_task(name: "Fail", reason: "parallel boom") + after = create_task_class(name: "After") { define_method(:work) { context.ran_after = true } } + + workflow = create_workflow_class do + tasks ok, fl, strategy: :parallel + task after + end + + result = workflow.execute + + expect(result).to have_attributes(status: CMDx::Signal::FAILED, reason: "parallel boom") + expect(result.context[:ok]).to be(true) + expect(result.context[:ran_after]).to be_nil + end + + it "raises Fault under execute!" do + fl = create_failing_task(name: "Fail", reason: "strict") + workflow = create_workflow_class { tasks fl, strategy: :parallel } + + expect { workflow.execute! }.to raise_error(CMDx::Fault, "strict") + end + end + + describe "pool sizing" do + it "finishes every task even when pool_size is smaller than the task count" do + a = create_task_class(name: "A") { define_method(:work) { context.a = true } } + b = create_task_class(name: "B") { define_method(:work) { context.b = true } } + c = create_task_class(name: "C") { define_method(:work) { context.c = true } } + + workflow = create_workflow_class { tasks a, b, c, strategy: :parallel, pool_size: 2 } + + expect(workflow.execute.context).to have_attributes(a: true, b: true, c: true) + end + end + + describe "mixing sequential and parallel groups" do + it "threads context through sequential -> parallel -> sequential" do + setup = create_task_class(name: "Setup") { define_method(:work) { context.setup = true } } + a = create_task_class(name: "ParA") { define_method(:work) { context.par_a = context[:setup] } } + b = create_task_class(name: "ParB") { define_method(:work) { context.par_b = context[:setup] } } + done = create_task_class(name: "Done") do + define_method(:work) { context.finalized = context[:par_a] && context[:par_b] } + end + + workflow = create_workflow_class do + task setup + tasks a, b, strategy: :parallel + task done + end + + result = workflow.execute + + expect(result.context).to have_attributes(setup: true, par_a: true, par_b: true, finalized: true) + end + end + + describe "concurrency" do + it "actually runs tasks on separate threads" do + a = create_task_class(name: "A") { define_method(:work) { context.a_thread = Thread.current.object_id } } + b = create_task_class(name: "B") { define_method(:work) { context.b_thread = Thread.current.object_id } } + + workflow = create_workflow_class { tasks a, b, strategy: :parallel } + result = workflow.execute + + expect(result.context[:a_thread]).not_to eq(Thread.current.object_id) + expect(result.context[:b_thread]).not_to eq(Thread.current.object_id) + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index ad31e20b0..1458fbd73 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -6,7 +6,6 @@ require "rspec" require "cmdx" -require "cmdx/rspec" spec_path = Pathname.new(File.expand_path("../spec", File.dirname(__FILE__))) @@ -33,21 +32,11 @@ c.syntax = :expect end - config.include CMDx::RSpec::Helpers config.include CMDx::Testing::TaskBuilders config.include CMDx::Testing::WorkflowBuilders config.before do CMDx.reset_configuration! CMDx.configuration.logger = Logger.new(nil) - CMDx.configuration.freeze_results = false - CMDx::Chain.clear - CMDx::Middlewares::Correlate.clear - end - - config.after do - CMDx.reset_configuration! - CMDx::Chain.clear - CMDx::Middlewares::Correlate.clear end end diff --git a/spec/support/config/i18n.rb b/spec/support/config/i18n.rb index 5f364cb8c..824bd6561 100644 --- a/spec/support/config/i18n.rb +++ b/spec/support/config/i18n.rb @@ -2,7 +2,7 @@ require "i18n" -locales = Dir[CMDx.gem_path.join("lib/locales/*.yml")] +locales = Dir[File.expand_path("../../../lib/locales/*.yml", __dir__)] I18n.load_path += locales I18n.available_locales = locales.map { |path| File.basename(path, ".yml").to_sym } diff --git a/spec/support/helpers/ruby_version.rb b/spec/support/helpers/ruby_version.rb deleted file mode 100644 index 71c1a27dd..000000000 --- a/spec/support/helpers/ruby_version.rb +++ /dev/null @@ -1,19 +0,0 @@ -# frozen_string_literal: true - -module RubyVersion - - extend self - - def version - @version ||= Gem::Version.new(RUBY_VERSION) - end - - def min?(min) - version >= Gem::Version.new(min.to_s) - end - - def max?(max) - version <= Gem::Version.new(max.to_s) - end - -end diff --git a/spec/support/helpers/task_builders.rb b/spec/support/helpers/task_builders.rb index d566c8e48..009bc3386 100644 --- a/spec/support/helpers/task_builders.rb +++ b/spec/support/helpers/task_builders.rb @@ -7,17 +7,12 @@ module CMDx module Testing module TaskBuilders - def mock_settings(**attrs) - settings = CMDx::Settings.allocate - attrs.each { |k, v| settings.public_send(:"#{k}=", v) } - settings - end - # Base def create_task_class(base: CMDx::Task, name: "AnonymousTask", &) task_class = Class.new(base) task_class.define_singleton_method(:name) { @name ||= name.to_s + rand(9999).to_s.rjust(4, "0") } + task_class.define_singleton_method(:type) { @type ||= "Task" } task_class.class_eval(&) if block_given? task_class end @@ -61,6 +56,22 @@ def create_erroring_task(base: CMDx::Task, name: "ErroringTask", reason: nil, ** task_class end + # Flaky (for retry testing) + + def create_flaky_task(base: CMDx::Task, name: "FlakyTask", failures: 2, error_class: CMDx::TestError, &) + counter = { attempts: 0 } + task_class = create_task_class(base:, name:) + task_class.class_eval(&) if block_given? + task_class.define_method(:work) do + counter[:attempts] += 1 + raise error_class, "flaky attempt #{counter[:attempts]}" if counter[:attempts] <= failures + + (context.executed ||= []) << :success + end + task_class.define_singleton_method(:attempts_counter) { counter } + task_class + end + # Nested def create_nested_task(base: CMDx::Task, strategy: :swallow, status: :success, reason: nil, **metadata, &) diff --git a/spec/support/helpers/workflow_builders.rb b/spec/support/helpers/workflow_builders.rb index 4ca5d36c8..aabe64cb2 100644 --- a/spec/support/helpers/workflow_builders.rb +++ b/spec/support/helpers/workflow_builders.rb @@ -10,6 +10,7 @@ def create_workflow_class(base: CMDx::Task, name: "AnonymousWorkflow", &) workflow_class = Class.new(base) workflow_class.include(CMDx::Workflow) workflow_class.define_singleton_method(:name) { @name ||= name.to_s + rand(9999).to_s.rjust(4, "0") } + workflow_class.define_singleton_method(:type) { @type ||= "Workflow" } workflow_class.class_eval(&) if block_given? workflow_class end From 0697ad4c540fac8195d51d1364a0f1bb1b687a78 Mon Sep 17 00:00:00 2001 From: Juan Gomez <drexed@users.noreply.github.com> Date: Mon, 20 Apr 2026 20:53:58 -0400 Subject: [PATCH 03/54] V2 --- .cursor/skills/issue-debugging/SKILL.md | 2 +- .../technical-docs/assets/yard-templates.md | 14 + .cursor/skills/test-patterns/SKILL.md | 1 + CHANGELOG.md | 3 +- docs/basics/execution.md | 8 + docs/configuration.md | 10 +- docs/middlewares.md | 28 +- docs/overrides/home.html | 78 +++--- docs/stylesheets/extra.css | 258 ++++++++++++++++-- docs/v2-migration.md | 15 +- lib/cmdx/runtime.rb | 8 +- lib/cmdx/signal.rb | 10 - lib/cmdx/task.rb | 51 ++-- lib/generators/cmdx/templates/install.rb | 7 +- mkdocs.yml | 4 +- skills/SKILL.md | 1 + skills/references/configuration.md | 1 + spec/cmdx/chain_spec.rb | 6 +- spec/cmdx/result_spec.rb | 28 +- spec/cmdx/signal_spec.rb | 54 ++-- spec/cmdx/task_spec.rb | 46 +++- spec/integration/tasks/middlewares_spec.rb | 150 ++++++++++ 22 files changed, 605 insertions(+), 178 deletions(-) diff --git a/.cursor/skills/issue-debugging/SKILL.md b/.cursor/skills/issue-debugging/SKILL.md index 2c17d2d28..0475b0431 100644 --- a/.cursor/skills/issue-debugging/SKILL.md +++ b/.cursor/skills/issue-debugging/SKILL.md @@ -152,7 +152,7 @@ When the bug doesn't fit neatly into the classification table, follow this tree: - Yes → Read the backtrace bottom-up. Find the first CMDx frame. That's your entry point. - No → Go to 2. 2. **Is the result in an unexpected state/status?** - - Yes → Trace signal construction. Check which of `success!`/`skip!`/`fail!`/`throw!` was called, or if `catch` fell through to `Signal::Success`. + - Yes → Trace signal construction. Check which of `success!`/`skip!`/`fail!`/`throw!` was called, or if `catch` fell through to `Signal.success`. - No → Go to 3. 3. **Is context data wrong or missing?** - Yes → Trace context mutations. Check `Context.build` passthrough, `method_missing` typos, freeze timing. diff --git a/.cursor/skills/technical-docs/assets/yard-templates.md b/.cursor/skills/technical-docs/assets/yard-templates.md index 9ada2a0cb..4ae393275 100644 --- a/.cursor/skills/technical-docs/assets/yard-templates.md +++ b/.cursor/skills/technical-docs/assets/yard-templates.md @@ -72,6 +72,20 @@ def initialize(context = EMPTY_HASH) def self.execute!(context = {}, &) ``` +## Instance Method with `strict:` Kwarg (raise toggled by caller) + +```ruby +# Executes this task instance through {Runtime}. +# +# @param strict [Boolean] when +true+, re-raises {Fault}/exceptions on failure; +# when +false+, swallows them and returns the {Result} +# @yieldparam result [Result] +# @return [Result, Object] the yielded block's value when a block is given, +# otherwise the {Result} +# @raise [Fault, StandardError] only when +strict: true+ and the task fails +def execute(strict: false) +``` + ## Signal Method (never returns) ```ruby diff --git a/.cursor/skills/test-patterns/SKILL.md b/.cursor/skills/test-patterns/SKILL.md index d4d00999d..2a8cca66f 100644 --- a/.cursor/skills/test-patterns/SKILL.md +++ b/.cursor/skills/test-patterns/SKILL.md @@ -150,6 +150,7 @@ Cross-reference the completed spec against `references/checklist.md`. | Workflow breakpoints | Workflow breakpoints spec | `settings(workflow_breakpoints: %w[skipped failed])` | | Group-level breakpoints | Workflow breakpoints spec | `tasks t1, t2, breakpoints: []` | | Bang vs non-bang execution | Workflow/task execution specs | `execute` returns result, `execute!` raises `CMDx::FailFault` | +| Instance-level execution | Task/workflow specs that pre-build the instance | `task = klass.new(ctx); task.execute` (or `task.execute(strict: true)` to raise) | | Configuration reset | `cmdx_spec.rb` | `after { described_class.reset_configuration! }` | | Runtime context mutation | Conditionals spec | Setup task writes to context, later task reads via `if: proc` | diff --git a/CHANGELOG.md b/CHANGELOG.md index 1330b1745..3b32d9924 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), Full runtime rewrite: the v1 state-machine plus Zeitwerk architecture is replaced by an explicit signal-based runtime, immutable results, fiber-local chains, and a slimmer registry surface. See [docs/v2-migration.md](docs/v2-migration.md) for the full upgrade guide. ### Added -- Add `CMDx::Signal` halt token thrown via `catch(Signal::TAG)` (`:cmdx_signal`), with `Signal::Success` / `Signal::Skipped` / `Signal::Failed` frozen singleton constants, `Signal.success` / `.skipped` / `.failed` class methods that return them when called with no args, and `Signal.echoed(other)` for propagating nested fault outcomes (auto-sets `:origin` to the upstream `Result`) +- Add `CMDx::Signal` halt token thrown via `catch(Signal::TAG)` (`:cmdx_signal`) - Add `Signal#ok?` / `Signal#ko?` predicates - Add `CMDx::Runtime` orchestrating the full task lifecycle and building the final `Result` - Add `CMDx::Telemetry` pub/sub for `:task_started`, `:task_deprecated`, `:task_retried`, `:task_rolled_back`, `:task_executed`; emits `Telemetry::Event` data objects with `chain_id`, `chain_root`, `task_type`, `task_class`, `task_id`, `name`, `payload`, `timestamp` @@ -24,6 +24,7 @@ Full runtime rewrite: the v1 state-machine plus Zeitwerk architecture is replace - 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 - Add `Task#success!` for signaling a successful halt, joining `skip!` / `fail!` / `throw!` - Add `Task.execute` / `Task.execute!` as the execution entry points (aliased as `call` / `call!` for backward compatibility) +- Add `Task#execute(strict:)` instance method (aliased as `#call`) - Add `Result#on(:success, :failed, ...)` chainable predicate-dispatch helper - Add `Result#deconstruct` / `Result#deconstruct_keys` for pattern matching; `deconstruct` returns `[type, task, state, status, reason, metadata, cause, origin]` - Add `Result#strict?`, `Result#deprecated?`, `Result#duration`, `Result#chain_index`, `Result#chain_root?`, `Result#backtrace`, `Result#errors`, `Result#tags`, `Result#origin`, and `Result#ctx` alias diff --git a/docs/basics/execution.md b/docs/basics/execution.md index 84d66726d..945fb6bc3 100644 --- a/docs/basics/execution.md +++ b/docs/basics/execution.md @@ -13,6 +13,14 @@ Both methods return results, but handle failures differently: `call` / `call!` are aliases. `execute` / `execute!` also accept a block — when given, the block receives the `Result` and its return value is returned instead of the result. +Both class-level entry points forward to `Task#execute(strict:)`, which is also public — useful when you already have a task instance: + +```ruby +task = CreateAccount.new(email: "user@example.com") +result = task.execute # strict: false → returns Result +task.execute(strict: true) # strict: true → raises Fault on failure +``` + ```mermaid flowchart LR subgraph Methods diff --git a/docs/configuration.md b/docs/configuration.md index 564ed150a..d8042487a 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -87,11 +87,11 @@ CMDx.configure do |config| # Proc / Lambda — must declare &next_link to pass the block config.middlewares.register(proc do |task, &next_link| - started = Process.clock_gettime(Process::CLOCK_MONOTONIC) - next_link.call - ensure - duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started - Rails.logger.debug { "#{task.class} ran in #{(duration * 1000).round(2)}ms" } + locale = Current.user.locale || I18n.default_locale + I18n.with_locale(locale) do + task.metadata[:locale] = locale + next_link.call + end end) # Insert at a specific position diff --git a/docs/middlewares.md b/docs/middlewares.md index 93f2174a9..077a0a924 100644 --- a/docs/middlewares.md +++ b/docs/middlewares.md @@ -194,18 +194,26 @@ ensure } ``` -### Recording every result +### Enriching result metadata -`Result` data isn't visible from middleware — subscribe to `:task_executed` telemetry instead: +Mutate `task.metadata` to attach request-scoped data (e.g. a Rails `request_id`) without polluting `context`. The hash is merged into every `Signal` the task throws, so it surfaces on `result.metadata` and the default JSON log line — regardless of whether the task succeeds, skips, or fails: ```ruby -CMDx.configuration.telemetry.subscribe(:task_executed) do |event| - result = event.payload[:result] - AuditLog.create!( - task_class: event.task_class.name, - status: result.status, - duration: result.duration, - chain_id: event.chain_id - ) +class RequestIdMiddleware + def call(task) + task.metadata[:request_id] = Current.request_id + yield + end +end + +class ApplicationTask < CMDx::Task + register :middleware, RequestIdMiddleware end ``` + +```ruby +result = ProcessOrder.execute(order_id: 42) +result.metadata[:request_id] #=> "req-abc123" +``` + +Explicit `success!/skip!/fail!/throw!(metadata: {...})` keys are merged on top, so user code can always override middleware-supplied values. diff --git a/docs/overrides/home.html b/docs/overrides/home.html index cde5043fb..7059c91f1 100644 --- a/docs/overrides/home.html +++ b/docs/overrides/home.html @@ -418,27 +418,27 @@ } [data-md-color-scheme="default"] .problem-code { - background: #ffffff; + background: var(--md-code-bg-color--lighter); color: #1a1a1a; } -[data-md-color-scheme="default"] .problem-code .k { color: #cf222e; } -[data-md-color-scheme="default"] .problem-code .nc { color: #953800; } -[data-md-color-scheme="default"] .problem-code .n { color: #1a1a1a; } -[data-md-color-scheme="default"] .problem-code .s { color: #0a3069; } -[data-md-color-scheme="default"] .problem-code .c { color: #6e7781; } -[data-md-color-scheme="default"] .problem-code .ss { color: #0550ae; } -[data-md-color-scheme="default"] .problem-code .mi { color: #0550ae; } -[data-md-color-scheme="default"] .problem-code .o { color: #cf222e; } +[data-md-color-scheme="default"] .problem-code .k { color: #a40e0e; } +[data-md-color-scheme="default"] .problem-code .nc { color: #7a3700; } +[data-md-color-scheme="default"] .problem-code .n { color: #0e1116; } +[data-md-color-scheme="default"] .problem-code .s { color: #02491b; } +[data-md-color-scheme="default"] .problem-code .c { color: #57606a; } +[data-md-color-scheme="default"] .problem-code .ss { color: #022b6b; } +[data-md-color-scheme="default"] .problem-code .mi { color: #022b6b; } +[data-md-color-scheme="default"] .problem-code .o { color: #a40e0e; } .problem-code code { font-family: inherit; color: inherit; } -.problem-code .k { color: #c586c0; } -.problem-code .nc { color: #4ec9b0; } -.problem-code .n { color: #9cdcfe; } -.problem-code .s { color: #ce9178; } -.problem-code .c { color: #6a9955; } -.problem-code .ss { color: #4fc1ff; } -.problem-code .mi { color: #b5cea8; } -.problem-code .o { color: #d4d4d4; } +.problem-code .k { color: #ff7b72; } +.problem-code .nc { color: #ffbd2e; } +.problem-code .n { color: #f0f3f6; } +.problem-code .s { color: #85e89d; } +.problem-code .c { color: #9ea7b3; } +.problem-code .ss { color: #79c0ff; } +.problem-code .mi { color: #79c0ff; } +.problem-code .o { color: #ff7b72; } .problem-annos { list-style: none; @@ -728,29 +728,29 @@ color: #d4d4d4 !important; } -.showcase-code .k { color: #c586c0; } -.showcase-code .nc { color: #4ec9b0; } -.showcase-code .nb { color: #4fc1ff; } -.showcase-code .n { color: #9cdcfe; } -.showcase-code .s { color: #ce9178; } -.showcase-code .c { color: #6a9955; } -.showcase-code .ss { color: #4fc1ff; } -.showcase-code .mi { color: #b5cea8; } -.showcase-code .o { color: #d4d4d4; } -.showcase-code .p { color: #d4d4d4; } +.showcase-code .k { color: #ff7b72; } +.showcase-code .nc { color: #ffbd2e; } +.showcase-code .nb { color: #79c0ff; } +.showcase-code .n { color: #f0f3f6; } +.showcase-code .s { color: #85e89d; } +.showcase-code .c { color: #9ea7b3; } +.showcase-code .ss { color: #79c0ff; } +.showcase-code .mi { color: #79c0ff; } +.showcase-code .o { color: #ff7b72; } +.showcase-code .p { color: #f0f3f6; } [data-md-color-scheme="default"] .showcase-code pre { background: #ffffff !important; } -[data-md-color-scheme="default"] .showcase-code code { color: #1a1a1a !important; } -[data-md-color-scheme="default"] .showcase-code .k { color: #cf222e; } -[data-md-color-scheme="default"] .showcase-code .nc { color: #953800; } -[data-md-color-scheme="default"] .showcase-code .nb { color: #0550ae; } -[data-md-color-scheme="default"] .showcase-code .n { color: #1a1a1a; } -[data-md-color-scheme="default"] .showcase-code .s { color: #0a3069; } -[data-md-color-scheme="default"] .showcase-code .c { color: #6e7781; } -[data-md-color-scheme="default"] .showcase-code .ss { color: #0550ae; } -[data-md-color-scheme="default"] .showcase-code .mi { color: #0550ae; } -[data-md-color-scheme="default"] .showcase-code .o { color: #1a1a1a; } -[data-md-color-scheme="default"] .showcase-code .p { color: #1a1a1a; } +[data-md-color-scheme="default"] .showcase-code code { color: #0e1116 !important; } +[data-md-color-scheme="default"] .showcase-code .k { color: #a40e0e; } +[data-md-color-scheme="default"] .showcase-code .nc { color: #7a3700; } +[data-md-color-scheme="default"] .showcase-code .nb { color: #022b6b; } +[data-md-color-scheme="default"] .showcase-code .n { color: #0e1116; } +[data-md-color-scheme="default"] .showcase-code .s { color: #02491b; } +[data-md-color-scheme="default"] .showcase-code .c { color: #57606a; } +[data-md-color-scheme="default"] .showcase-code .ss { color: #022b6b; } +[data-md-color-scheme="default"] .showcase-code .mi { color: #022b6b; } +[data-md-color-scheme="default"] .showcase-code .o { color: #a40e0e; } +[data-md-color-scheme="default"] .showcase-code .p { color: #0e1116; } .showcase-output { background: #0a0a0a; @@ -899,7 +899,7 @@ } [data-md-color-scheme="default"] .telemetry { - background: #fafafa; + background: #ffffff; color: #1a1a1a; } [data-md-color-scheme="default"] .telemetry-head { color: #57606a; } diff --git a/docs/stylesheets/extra.css b/docs/stylesheets/extra.css index daca76e69..8103cbbc2 100644 --- a/docs/stylesheets/extra.css +++ b/docs/stylesheets/extra.css @@ -5,40 +5,41 @@ --md-primary-fg-color--dark: #fe1817; /* Accent color shades */ - --md-accent-fg-color: hsla(#{hex2hsl(#fe1817)}, 1); - --md-accent-fg-color--transparent: hsla(#{hex2hsl(#fe1817)}, 0.1); + --md-accent-fg-color: #fe1817; + --md-accent-fg-color--transparent: rgba(254, 24, 23, 0.1); } -/* GitHub High Contrast Light syntax highlighting */ +/* High-contrast syntax highlighting aligned with cmdx theme */ [data-md-color-scheme="default"] { - --md-code-hl-color: #0e1116; - --md-code-hl-keyword-color: #a0095d; - --md-code-hl-string-color: #024c1a; - --md-code-hl-name-color: #622cbc; - --md-code-hl-function-color: #622cbc; - --md-code-hl-number-color: #0349b4; - --md-code-hl-constant-color: #702c00; - --md-code-hl-comment-color: #66707b; - --md-code-hl-operator-color: #a0095d; - --md-code-hl-punctuation-color:#0e1116; - --md-code-hl-variable-color: #702c00; - --md-code-hl-generic-color: #622cbc; -} - -/* GitHub High Contrast Dark syntax highlighting */ + --md-code-hl-color: rgba(254, 24, 23, 0.18); + --md-code-hl-keyword-color: #a40e0e; + --md-code-hl-string-color: #02491b; + --md-code-hl-name-color: #0e1116; + --md-code-hl-function-color: #4f0d8a; + --md-code-hl-number-color: #022b6b; + --md-code-hl-constant-color: #022b6b; + --md-code-hl-comment-color: #57606a; + --md-code-hl-operator-color: #a40e0e; + --md-code-hl-punctuation-color: #0e1116; + --md-code-hl-variable-color: #7a3700; + --md-code-hl-generic-color: #0e1116; + --md-code-hl-special-color: #4f0d8a; +} + [data-md-color-scheme="slate"] { - --md-code-hl-color: #f0f3f6; - --md-code-hl-keyword-color: #ff9492; - --md-code-hl-string-color: #addcff; - --md-code-hl-name-color: #dbb7ff; - --md-code-hl-function-color: #dbb7ff; - --md-code-hl-number-color: #91cbff; - --md-code-hl-constant-color: #ffb757; - --md-code-hl-comment-color: #9ea7b3; - --md-code-hl-operator-color: #ff9492; - --md-code-hl-punctuation-color:#f0f3f6; - --md-code-hl-variable-color: #ffb757; - --md-code-hl-generic-color: #dbb7ff; + --md-code-hl-color: rgba(254, 24, 23, 0.30); + --md-code-hl-keyword-color: #ff7b72; + --md-code-hl-string-color: #85e89d; + --md-code-hl-name-color: #f0f3f6; + --md-code-hl-function-color: #d2a8ff; + --md-code-hl-number-color: #79c0ff; + --md-code-hl-constant-color: #79c0ff; + --md-code-hl-comment-color: #9ea7b3; + --md-code-hl-operator-color: #ff7b72; + --md-code-hl-punctuation-color: #f0f3f6; + --md-code-hl-variable-color: #ffbd2e; + --md-code-hl-generic-color: #f0f3f6; + --md-code-hl-special-color: #d2a8ff; } /* Light scheme palette aligned with docs/overrides/home.html */ @@ -90,6 +91,31 @@ --md-mermaid-sequence-note-border-color: #d4a72c; --md-mermaid-sequence-number-bg-color: #1a1a1a; --md-mermaid-sequence-number-fg-color: #ffffff; + + --md-typeset-mark-color: rgba(254, 24, 23, 0.18); + + --md-typeset-del-color: rgba(254, 24, 23, 0.15); + --md-typeset-ins-color: rgba(40, 200, 64, 0.18); + + --md-typeset-kbd-color: #ffffff; + --md-typeset-kbd-accent-color: #ececec; + --md-typeset-kbd-border-color: rgba(0, 0, 0, 0.12); + + --md-typeset-table-color: rgba(0, 0, 0, 0.12); + --md-typeset-table-color--light: rgba(0, 0, 0, 0.04); + + --md-admonition-bg-color: #ffffff; + --md-admonition-fg-color: #1a1a1a; + + --md-warning-bg-color: #fff8c5; + --md-warning-fg-color: #1a1a1a; + + --md-shadow-z1: 0 1px 2px rgba(0, 0, 0, 0.06), 0 1px 1px rgba(0, 0, 0, 0.04); + --md-shadow-z2: 0 2px 6px rgba(0, 0, 0, 0.08), 0 1px 2px rgba(0, 0, 0, 0.06); + --md-shadow-z3: 0 6px 18px rgba(0, 0, 0, 0.12), 0 2px 6px rgba(0, 0, 0, 0.08); + + --md-tooltip-bg-color: #1a1a1a; + --md-tooltip-fg-color: #ffffff; } [data-md-color-scheme="default"] .mermaid, @@ -124,8 +150,180 @@ --md-footer-fg-color: #d4d4d4; --md-footer-fg-color--light: #7a8290; --md-footer-fg-color--lighter: rgba(212, 212, 212, 0.45); + + --md-typeset-mark-color: rgba(254, 24, 23, 0.30); + + --md-typeset-del-color: rgba(254, 24, 23, 0.22); + --md-typeset-ins-color: rgba(40, 200, 64, 0.22); + + --md-typeset-kbd-color: #1c1c1c; + --md-typeset-kbd-accent-color: #2a2a2a; + --md-typeset-kbd-border-color: rgba(255, 255, 255, 0.12); + + --md-typeset-table-color: rgba(255, 255, 255, 0.12); + --md-typeset-table-color--light: rgba(255, 255, 255, 0.04); + + --md-admonition-bg-color: #161616; + --md-admonition-fg-color: #d4d4d4; + + --md-warning-bg-color: #3b2d04; + --md-warning-fg-color: #fff8c5; + + --md-shadow-z1: 0 1px 2px rgba(0, 0, 0, 0.5), 0 1px 1px rgba(0, 0, 0, 0.4); + --md-shadow-z2: 0 2px 6px rgba(0, 0, 0, 0.55), 0 1px 2px rgba(0, 0, 0, 0.45); + --md-shadow-z3: 0 6px 18px rgba(0, 0, 0, 0.6), 0 2px 6px rgba(0, 0, 0, 0.5); + + --md-tooltip-bg-color: #1c1c1c; + --md-tooltip-fg-color: #d4d4d4; + + --md-mermaid-edge-color: #7a8290; + --md-mermaid-node-bg-color: #161616; + --md-mermaid-node-fg-color: #d4d4d4; + --md-mermaid-label-bg-color: #161616; + --md-mermaid-label-fg-color: #d4d4d4; + --md-mermaid-sequence-actor-bg-color: #161616; + --md-mermaid-sequence-actor-border-color:#d4d4d4; + --md-mermaid-sequence-actor-fg-color: #d4d4d4; + --md-mermaid-sequence-actor-line-color: #7a8290; + --md-mermaid-sequence-actorman-bg-color: #161616; + --md-mermaid-sequence-actorman-line-color:#7a8290; + --md-mermaid-sequence-box-bg-color: #0d0d0d; + --md-mermaid-sequence-box-fg-color: #d4d4d4; + --md-mermaid-sequence-label-bg-color: #d4d4d4; + --md-mermaid-sequence-label-fg-color: #0d0d0d; + --md-mermaid-sequence-loop-bg-color: #161616; + --md-mermaid-sequence-loop-fg-color: #d4d4d4; + --md-mermaid-sequence-loop-border-color: #7a8290; + --md-mermaid-sequence-message-fg-color: #d4d4d4; + --md-mermaid-sequence-message-line-color:#7a8290; + --md-mermaid-sequence-note-bg-color: #3b2d04; + --md-mermaid-sequence-note-fg-color: #fff8c5; + --md-mermaid-sequence-note-border-color: #d4a72c; + --md-mermaid-sequence-number-bg-color: #fe1817; + --md-mermaid-sequence-number-fg-color: #ffffff; +} + +/* Inline <code> matches <pre> background */ +.md-typeset code, +.md-typeset .highlight, +.md-typeset .highlight > pre { + background-color: var(--md-code-bg-color); +} + +/* Rounded corners + subtle border for code surfaces */ +.md-typeset code { + border: 1px solid var(--md-default-fg-color--lightest); + border-radius: 6px; } +.md-typeset pre, +.md-typeset .highlight, +.md-typeset .highlight > pre, +.md-typeset .highlighttable, +.md-typeset .highlighttable .linenodiv, +.md-typeset .highlighttable .linenos, +.md-typeset .highlighttable td.code { + border-radius: 8px; +} + +.md-typeset .highlight, +.md-typeset .highlighttable { + border: 1px solid var(--md-default-fg-color--lightest); +} + +.md-typeset .highlighttable .highlight, +.md-typeset .highlighttable pre { + border: none; +} + +.md-typeset pre > code { + background-color: transparent; + border-radius: 0; + border: none; + margin: -10px 0; +} + +.tabbed-block { + margin-top: 20px; +} + +.tabbed-block pre { + padding: 10px 0; +} + +.tabbed-block pre > code { + margin: 0; +} + +.md-typeset .highlighttable .linenodiv, +.md-typeset .highlighttable .linenos { + border-top-right-radius: 0; + border-bottom-right-radius: 0; +} + +.md-typeset .highlighttable td.code { + border-top-left-radius: 0; + border-bottom-left-radius: 0; +} + +/* Admonition type colors aligned with cmdx palette + note/quote -> slate, abstract/info -> blue, tip/success -> green, + question -> purple, warning -> amber, failure/danger/bug -> red, example -> violet */ + +:root { + --cmdx-admon-note: 122, 130, 144; + --cmdx-admon-abstract: 68, 147, 248; + --cmdx-admon-info: 68, 147, 248; + --cmdx-admon-tip: 40, 200, 64; + --cmdx-admon-success: 40, 200, 64; + --cmdx-admon-question: 130, 80, 223; + --cmdx-admon-warning: 255, 189, 46; + --cmdx-admon-failure: 254, 24, 23; + --cmdx-admon-danger: 254, 24, 23; + --cmdx-admon-bug: 254, 24, 23; + --cmdx-admon-example: 192, 132, 252; + --cmdx-admon-quote: 122, 130, 144; +} + +.md-typeset .admonition.note, .md-typeset details.note { border-color: rgb(var(--cmdx-admon-note)); } +.md-typeset .admonition.abstract, .md-typeset details.abstract { border-color: rgb(var(--cmdx-admon-abstract)); } +.md-typeset .admonition.info, .md-typeset details.info { border-color: rgb(var(--cmdx-admon-info)); } +.md-typeset .admonition.tip, .md-typeset details.tip { border-color: rgb(var(--cmdx-admon-tip)); } +.md-typeset .admonition.success, .md-typeset details.success { border-color: rgb(var(--cmdx-admon-success)); } +.md-typeset .admonition.question, .md-typeset details.question { border-color: rgb(var(--cmdx-admon-question)); } +.md-typeset .admonition.warning, .md-typeset details.warning { border-color: rgb(var(--cmdx-admon-warning)); } +.md-typeset .admonition.failure, .md-typeset details.failure { border-color: rgb(var(--cmdx-admon-failure)); } +.md-typeset .admonition.danger, .md-typeset details.danger { border-color: rgb(var(--cmdx-admon-danger)); } +.md-typeset .admonition.bug, .md-typeset details.bug { border-color: rgb(var(--cmdx-admon-bug)); } +.md-typeset .admonition.example, .md-typeset details.example { border-color: rgb(var(--cmdx-admon-example)); } +.md-typeset .admonition.quote, .md-typeset details.quote { border-color: rgb(var(--cmdx-admon-quote)); } + +.md-typeset .note > .admonition-title, .md-typeset .note > summary { background-color: rgba(var(--cmdx-admon-note), 0.1); } +.md-typeset .abstract > .admonition-title, .md-typeset .abstract > summary { background-color: rgba(var(--cmdx-admon-abstract), 0.1); } +.md-typeset .info > .admonition-title, .md-typeset .info > summary { background-color: rgba(var(--cmdx-admon-info), 0.1); } +.md-typeset .tip > .admonition-title, .md-typeset .tip > summary { background-color: rgba(var(--cmdx-admon-tip), 0.1); } +.md-typeset .success > .admonition-title, .md-typeset .success > summary { background-color: rgba(var(--cmdx-admon-success), 0.1); } +.md-typeset .question > .admonition-title, .md-typeset .question > summary { background-color: rgba(var(--cmdx-admon-question), 0.1); } +.md-typeset .warning > .admonition-title, .md-typeset .warning > summary { background-color: rgba(var(--cmdx-admon-warning), 0.12); } +.md-typeset .failure > .admonition-title, .md-typeset .failure > summary { background-color: rgba(var(--cmdx-admon-failure), 0.1); } +.md-typeset .danger > .admonition-title, .md-typeset .danger > summary { background-color: rgba(var(--cmdx-admon-danger), 0.1); } +.md-typeset .bug > .admonition-title, .md-typeset .bug > summary { background-color: rgba(var(--cmdx-admon-bug), 0.1); } +.md-typeset .example > .admonition-title, .md-typeset .example > summary { background-color: rgba(var(--cmdx-admon-example), 0.1); } +.md-typeset .quote > .admonition-title, .md-typeset .quote > summary { background-color: rgba(var(--cmdx-admon-quote), 0.1); } + +.md-typeset .note > .admonition-title::before, .md-typeset .note > summary::before { background-color: rgb(var(--cmdx-admon-note)); } +.md-typeset .abstract > .admonition-title::before, .md-typeset .abstract > summary::before { background-color: rgb(var(--cmdx-admon-abstract)); } +.md-typeset .info > .admonition-title::before, .md-typeset .info > summary::before { background-color: rgb(var(--cmdx-admon-info)); } +.md-typeset .tip > .admonition-title::before, .md-typeset .tip > summary::before { background-color: rgb(var(--cmdx-admon-tip)); } +.md-typeset .success > .admonition-title::before, .md-typeset .success > summary::before { background-color: rgb(var(--cmdx-admon-success)); } +.md-typeset .question > .admonition-title::before, .md-typeset .question > summary::before { background-color: rgb(var(--cmdx-admon-question)); } +.md-typeset .warning > .admonition-title::before, .md-typeset .warning > summary::before { background-color: rgb(var(--cmdx-admon-warning)); } +.md-typeset .failure > .admonition-title::before, .md-typeset .failure > summary::before { background-color: rgb(var(--cmdx-admon-failure)); } +.md-typeset .danger > .admonition-title::before, .md-typeset .danger > summary::before { background-color: rgb(var(--cmdx-admon-danger)); } +.md-typeset .bug > .admonition-title::before, .md-typeset .bug > summary::before { background-color: rgb(var(--cmdx-admon-bug)); } +.md-typeset .example > .admonition-title::before, .md-typeset .example > summary::before { background-color: rgb(var(--cmdx-admon-example)); } +.md-typeset .quote > .admonition-title::before, .md-typeset .quote > summary::before { background-color: rgb(var(--cmdx-admon-quote)); } + /* Light/Dark mode logo switching */ .only-dark, .only-light { max-width: 240px !important; } diff --git a/docs/v2-migration.md b/docs/v2-migration.md index c9c72896e..87cfbb1a0 100644 --- a/docs/v2-migration.md +++ b/docs/v2-migration.md @@ -106,12 +106,17 @@ See [Configuration](configuration.md) for the full surface. ### Execution Entry Points ```ruby -MyTask.execute(name: "x") # unchanged -MyTask.execute!(name: "x") # unchanged -MyTask.call / .call! # still aliases of execute / execute! +MyTask.execute(name: "x") # unchanged +MyTask.execute!(name: "x") # unchanged +MyTask.call / .call! # still aliases of execute / execute! + +task = MyTask.new(name: "x") +task.execute # unchanged +task.execute(strict: true) # unchanged +task.call / .call(strict: true) # still aliases of execute / execute! ``` -`Task#execute` (the **instance** method) was removed. Use `Runtime.execute(task)` to drive the lifecycle manually. `MyTask.new(...)` still works but is rarely useful outside tests. +`MyTask.new(ctx).execute` runs an already-built task instance through `Runtime`. The class-level `MyTask.execute` / `MyTask.execute!` simply forward to it. `Runtime.execute(task)` is still available for callers that need to drive the lifecycle directly without going through `Task`. ### Removed Instance Accessors @@ -266,7 +271,7 @@ class Timing started = monotonic yield ensure - task.context.timing_ms = elapsed + task.metadata[:ms] = elapsed end end ``` diff --git a/lib/cmdx/runtime.rb b/lib/cmdx/runtime.rb index a66bec043..6a53d3d2a 100644 --- a/lib/cmdx/runtime.rb +++ b/lib/cmdx/runtime.rb @@ -154,13 +154,13 @@ def perform_work resolve_inputs! retry_execution { @task.work } verify_outputs! - Signal.success + Signal.success(nil, metadata: @task.metadata) rescue Fault => e - Signal.echoed(e.result, cause: e) + Signal.echoed(e.result, cause: e, metadata: @task.metadata) rescue Error => e raise(e) rescue StandardError => e - Signal.failed("[#{e.class}] #{e.message}", cause: e) + Signal.failed("[#{e.class}] #{e.message}", cause: e, metadata: @task.metadata) end end @@ -201,7 +201,7 @@ def verify_outputs! def signal_errors! return if @task.errors.empty? - throw(Signal::TAG, Signal.failed(@task.errors.to_s)) + throw(Signal::TAG, Signal.failed(@task.errors.to_s, metadata: @task.metadata)) end def emit_telemetry(name, payload = EMPTY_HASH) diff --git a/lib/cmdx/signal.rb b/lib/cmdx/signal.rb index 7242f9ec3..d86e7d00c 100644 --- a/lib/cmdx/signal.rb +++ b/lib/cmdx/signal.rb @@ -33,8 +33,6 @@ class << self # @param options [Hash{Symbol => Object}] optional `:metadata`, `:cause`, `:backtrace` # @return [Signal] frozen success singleton when no args, otherwise a new instance def success(reason = nil, **options) - return Success if reason.nil? && options.empty? - new(COMPLETE, SUCCESS, **options, reason:) end @@ -42,8 +40,6 @@ def success(reason = nil, **options) # @param options [Hash{Symbol => Object}] optional `:metadata`, `:cause`, `:backtrace` # @return [Signal] frozen skipped singleton when no args, otherwise a new instance def skipped(reason = nil, **options) - return Skipped if reason.nil? && options.empty? - new(INTERRUPTED, SKIPPED, **options, reason:) end @@ -51,8 +47,6 @@ def skipped(reason = nil, **options) # @param options [Hash{Symbol => Object}] optional `:metadata`, `:cause`, `:backtrace` # @return [Signal] frozen failed singleton when no args, otherwise a new instance def failed(reason = nil, **options) - return Failed if reason.nil? && options.empty? - new(INTERRUPTED, FAILED, **options, reason:) end @@ -89,10 +83,6 @@ def initialize(state, status, **options) @options = options.freeze end - Success = new(COMPLETE, SUCCESS) - Skipped = new(INTERRUPTED, SKIPPED) - Failed = new(INTERRUPTED, FAILED) - # @return [Boolean] true when the task ran to completion without interruption def complete? state == COMPLETE diff --git a/lib/cmdx/task.rb b/lib/cmdx/task.rb index 4726b187e..40ac6a80f 100644 --- a/lib/cmdx/task.rb +++ b/lib/cmdx/task.rb @@ -245,9 +245,8 @@ def type # @param context [Hash, Context, #context, #to_h] # @yieldparam result [Result] # @return [Result, Object] the yielded block's value when a block is given - def execute(context = EMPTY_HASH) - result = Runtime.execute(new(context), strict: false) - block_given? ? yield(result) : result + def execute(context = EMPTY_HASH, &) + new(context).execute(strict: false, &) end alias call execute @@ -258,9 +257,8 @@ def execute(context = EMPTY_HASH) # @yieldparam result [Result] # @return [Result, Object] # @raise [Fault, StandardError] on task failure - def execute!(context = EMPTY_HASH) - result = Runtime.execute(new(context), strict: true) - block_given? ? yield(result) : result + def execute!(context = EMPTY_HASH, &) + new(context).execute(strict: true, &) end alias call! execute! @@ -286,15 +284,30 @@ def undefine_input_reader(input) end - attr_reader :context, :errors + attr_reader :context, :errors, :metadata alias ctx context # @param context [Hash, Context, #context, #to_h] def initialize(context = EMPTY_HASH) - @context = Context.build(context) - @errors = Errors.new + @context = Context.build(context) + @errors = Errors.new + @metadata = {} end + # Executes this task instance through {Runtime}. + # + # @param strict [Boolean] when `true`, re-raises {Fault}/exceptions on failure; + # when `false`, swallows them and returns the {Result} + # @yieldparam result [Result] + # @return [Result, Object] the yielded block's value when a block is given, + # otherwise the {Result} + # @raise [Fault, StandardError] only when `strict: true` and the task fails + def execute(strict: false) + result = Runtime.execute(self, strict:) + block_given? ? yield(result) : result + end + alias call execute + # @return [Logger] a logger tailored to this task's settings def logger @logger ||= LoggerProxy.logger(self) @@ -314,25 +327,27 @@ def work # Signals a successful halt. # # @param reason [String, nil] - # @param metadata [Hash{Symbol => Object}] + # @param sigdata [Hash{Symbol => Object}] # @return [void] throws `Signal::TAG`; never returns # @raise [FrozenError] when the task has already been frozen (post-execution) # @note Must be called from inside `work` (inside Runtime's `catch(:cmdx_signal)`). - def success!(reason = nil, **metadata) + def success!(reason = nil, **sigdata) raise FrozenError, "cannot throw signals" if frozen? + metadata.merge!(sigdata) unless sigdata.empty? throw(Signal::TAG, Signal.success(reason, metadata:)) end # Signals a skip (interrupted + skipped). # # @param reason [String, nil] - # @param metadata [Hash{Symbol => Object}] + # @param sigdata [Hash{Symbol => Object}] # @return [void] throws `Signal::TAG`; never returns # @raise [FrozenError] - def skip!(reason = nil, **metadata) + def skip!(reason = nil, **sigdata) raise FrozenError, "cannot throw signals" if frozen? + metadata.merge!(sigdata) unless sigdata.empty? throw(Signal::TAG, Signal.skipped(reason, metadata:)) end @@ -340,12 +355,13 @@ def skip!(reason = nil, **metadata) # backtrace for Fault propagation. # # @param reason [String, nil] - # @param metadata [Hash{Symbol => Object}] + # @param sigdata [Hash{Symbol => Object}] # @return [void] throws `Signal::TAG`; never returns # @raise [FrozenError] - def fail!(reason = nil, **metadata) + def fail!(reason = nil, **sigdata) raise FrozenError, "cannot throw signals" if frozen? + metadata.merge!(sigdata) unless sigdata.empty? throw(Signal::TAG, Signal.failed(reason, metadata:, backtrace: caller_locations(1))) end @@ -353,14 +369,15 @@ def fail!(reason = nil, **metadata) # `other` didn't fail. # # @param other [Result] - # @param metadata [Hash{Symbol => Object}] + # @param sigdata [Hash{Symbol => Object}] # @return [void] # @raise [FrozenError] - def throw!(other, **metadata) + def throw!(other, **sigdata) raise FrozenError, "cannot throw signals" if frozen? return unless other.failed? + metadata.merge!(sigdata) unless sigdata.empty? throw(Signal::TAG, Signal.echoed(other, metadata:, backtrace: caller_locations(1))) end diff --git a/lib/generators/cmdx/templates/install.rb b/lib/generators/cmdx/templates/install.rb index 8477d9225..91218663b 100644 --- a/lib/generators/cmdx/templates/install.rb +++ b/lib/generators/cmdx/templates/install.rb @@ -30,8 +30,11 @@ # Example — run each task under the current user's locale: # # config.middlewares.register(proc do |task, &next_link| - # locale = task.context.current_user&.locale || I18n.default_locale - # I18n.with_locale(locale) { next_link.call } + # locale = Current.user.locale || I18n.default_locale + # I18n.with_locale(locale) do + # task.metadata[:locale] = locale + # next_link.call + # end # end) # =========================================================================== diff --git a/mkdocs.yml b/mkdocs.yml index 0614afa8f..cad3a2f40 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -17,8 +17,8 @@ theme: icon: repo: fontawesome/brands/github font: - text: IBM Plex Sans - code: IBM Plex Mono + text: DM Sans + code: DM Mono palette: # Palette toggle for automatic mode - media: "(prefers-color-scheme)" diff --git a/skills/SKILL.md b/skills/SKILL.md index 8e2b65f3f..fbb6d7b41 100644 --- a/skills/SKILL.md +++ b/skills/SKILL.md @@ -323,6 +323,7 @@ end class MyTask < CMDx::Task register :middleware, AuditMiddleware.new register :middleware, ->(task, &next_link) { + task.metadata[:tracked] = true Timer.track(task.class) { next_link.call } } register :middleware, OuterMiddleware, at: 0 # insert at index diff --git a/skills/references/configuration.md b/skills/references/configuration.md index cdbb8ebee..ce50bc730 100644 --- a/skills/references/configuration.md +++ b/skills/references/configuration.md @@ -156,6 +156,7 @@ end ```ruby register :middleware, ->(task, &next_link) { + task.metadata[:tracked] = true Timer.track(task.class) { next_link.call } } ``` diff --git a/spec/cmdx/chain_spec.rb b/spec/cmdx/chain_spec.rb index ceac2b88e..514277858 100644 --- a/spec/cmdx/chain_spec.rb +++ b/spec/cmdx/chain_spec.rb @@ -8,7 +8,7 @@ let(:task_class) { create_task_class(name: "ChainSampleTask") } let(:task) { task_class.new } - def build_result(signal = CMDx::Signal::Success, **opts) + def build_result(signal = CMDx::Signal.success, **opts) CMDx::Result.new(chain, task, signal, **opts) end @@ -156,7 +156,7 @@ def build_result(signal = CMDx::Signal::Success, **opts) describe "#state" do it "returns the state of the root result" do - chain.push(build_result(CMDx::Signal::Success, root: true)) + chain.push(build_result(CMDx::Signal.success, root: true)) expect(chain.state).to eq(CMDx::Signal::COMPLETE) end @@ -174,7 +174,7 @@ def build_result(signal = CMDx::Signal::Success, **opts) describe "#status" do it "returns the status of the root result" do - chain.push(build_result(CMDx::Signal::Success, root: true)) + chain.push(build_result(CMDx::Signal.success, root: true)) expect(chain.status).to eq(CMDx::Signal::SUCCESS) end diff --git a/spec/cmdx/result_spec.rb b/spec/cmdx/result_spec.rb index 4130f60d7..d4ffd7fc0 100644 --- a/spec/cmdx/result_spec.rb +++ b/spec/cmdx/result_spec.rb @@ -13,13 +13,13 @@ def build(signal, **opts) describe "#initialize" do it "freezes the options hash" do - result = build(CMDx::Signal::Success, id: "abc") + result = build(CMDx::Signal.success, id: "abc") expect(result.instance_variable_get(:@options)).to be_frozen end end describe "simple delegators" do - let(:result) { build(CMDx::Signal::Success, id: "rid-1", duration: 0.01) } + let(:result) { build(CMDx::Signal.success, id: "rid-1", duration: 0.01) } it "returns id, duration, task class, type" do expect(result).to have_attributes( @@ -41,7 +41,7 @@ def build(signal, **opts) end it "reports the full chain results array" do - other = build(CMDx::Signal::Success) + other = build(CMDx::Signal.success) chain << result chain << other @@ -57,7 +57,7 @@ def build(signal, **opts) describe "signal delegators" do it "exposes state/status predicates" do - result = build(CMDx::Signal::Success) + result = build(CMDx::Signal.success) expect(result).to have_attributes( state: "complete", status: "success", complete?: true, interrupted?: false, @@ -77,8 +77,8 @@ def build(signal, **opts) end describe "#on" do - let(:success) { build(CMDx::Signal::Success) } - let(:failed) { build(CMDx::Signal::Failed) } + let(:success) { build(CMDx::Signal.success) } + let(:failed) { build(CMDx::Signal.failed) } it "yields when any of the listed events match" do yielded = [] @@ -107,7 +107,7 @@ def build(signal, **opts) describe "failure helpers" do context "when the result is not failed" do - let(:result) { build(CMDx::Signal::Success) } + let(:result) { build(CMDx::Signal.success) } it "caused_failure/threw_failure are nil and predicates are false" do expect(result.caused_failure).to be_nil @@ -160,9 +160,9 @@ def build(signal, **opts) describe "option-backed predicates" do it "retries/retried?/strict?/deprecated?/rolled_back?" do - r1 = build(CMDx::Signal::Success, retries: 2) - r2 = build(CMDx::Signal::Success, strict: true, deprecated: true, rolled_back: true) - r3 = build(CMDx::Signal::Success) + r1 = build(CMDx::Signal.success, retries: 2) + r2 = build(CMDx::Signal.success, strict: true, deprecated: true, rolled_back: true) + r3 = build(CMDx::Signal.success) expect(r1.retries).to eq(2) expect(r1.retried?).to be(true) @@ -175,13 +175,13 @@ def build(signal, **opts) describe "#tags" do it "returns the task's settings tags" do task_class.settings(tags: %w[billing]) - expect(build(CMDx::Signal::Success).tags).to eq(%w[billing]) + expect(build(CMDx::Signal.success).tags).to eq(%w[billing]) end end describe "#to_h" do it "includes core fields for a success result" do - chain << (result = build(CMDx::Signal::Success, id: "rid", duration: 0.1)) + chain << (result = build(CMDx::Signal.success, id: "rid", duration: 0.1)) hash = result.to_h expect(hash).to include( @@ -212,7 +212,7 @@ def build(signal, **opts) describe "#to_s" do it "renders a space-separated key=value summary" do - chain << (result = build(CMDx::Signal::Success, id: "rid")) + chain << (result = build(CMDx::Signal.success, id: "rid")) expect(result.to_s).to include("id=\"rid\"", "state=\"complete\"", "status=\"success\"") end end @@ -274,7 +274,7 @@ def build(signal, **opts) end it "reflects default option-backed values" do - plain = build(CMDx::Signal::Success) + plain = build(CMDx::Signal.success) expect(plain.deconstruct_keys(nil)).to include( strict: false, deprecated: false, diff --git a/spec/cmdx/signal_spec.rb b/spec/cmdx/signal_spec.rb index ac7964b37..9d637311a 100644 --- a/spec/cmdx/signal_spec.rb +++ b/spec/cmdx/signal_spec.rb @@ -4,12 +4,6 @@ RSpec.describe CMDx::Signal do describe ".success" do - context "without arguments" do - it "returns the shared Success singleton" do - expect(described_class.success).to be(described_class::Success) - end - end - context "with a reason" do it "builds a new complete/success signal with the reason" do signal = described_class.success("ok now") @@ -19,7 +13,7 @@ status: described_class::SUCCESS, reason: "ok now" ) - expect(signal).not_to be(described_class::Success) + expect(signal).not_to be(described_class.success) end end @@ -38,10 +32,6 @@ end describe ".skipped" do - it "returns the Skipped singleton when no args" do - expect(described_class.skipped).to be(described_class::Skipped) - end - it "builds an interrupted/skipped signal when given a reason" do signal = described_class.skipped("nope") @@ -54,10 +44,6 @@ end describe ".failed" do - it "returns the Failed singleton when no args" do - expect(described_class.failed).to be(described_class::Failed) - end - it "builds an interrupted/failed signal when given a reason" do signal = described_class.failed("broken") @@ -94,13 +80,13 @@ describe "singletons" do it "expose the canonical state/status pairs" do - expect(described_class::Success).to have_attributes( + expect(described_class.success).to have_attributes( state: described_class::COMPLETE, status: described_class::SUCCESS ) - expect(described_class::Skipped).to have_attributes( + expect(described_class.skipped).to have_attributes( state: described_class::INTERRUPTED, status: described_class::SKIPPED ) - expect(described_class::Failed).to have_attributes( + expect(described_class.failed).to have_attributes( state: described_class::INTERRUPTED, status: described_class::FAILED ) end @@ -108,37 +94,37 @@ describe "state predicates" do it "complete? is true only for COMPLETE state" do - expect(described_class::Success.complete?).to be(true) - expect(described_class::Failed.complete?).to be(false) + expect(described_class.success.complete?).to be(true) + expect(described_class.failed.complete?).to be(false) end it "interrupted? is true only for INTERRUPTED state" do - expect(described_class::Success.interrupted?).to be(false) - expect(described_class::Failed.interrupted?).to be(true) - expect(described_class::Skipped.interrupted?).to be(true) + expect(described_class.success.interrupted?).to be(false) + expect(described_class.failed.interrupted?).to be(true) + expect(described_class.skipped.interrupted?).to be(true) end end describe "status predicates" do it "success? / skipped? / failed? match status" do - expect(described_class::Success.success?).to be(true) - expect(described_class::Skipped.skipped?).to be(true) - expect(described_class::Failed.failed?).to be(true) + expect(described_class.success.success?).to be(true) + expect(described_class.skipped.skipped?).to be(true) + expect(described_class.failed.failed?).to be(true) - expect(described_class::Success.failed?).to be(false) - expect(described_class::Failed.success?).to be(false) + expect(described_class.success.failed?).to be(false) + expect(described_class.failed.success?).to be(false) end it "ok? is true when not failed" do - expect(described_class::Success.ok?).to be(true) - expect(described_class::Skipped.ok?).to be(true) - expect(described_class::Failed.ok?).to be(false) + expect(described_class.success.ok?).to be(true) + expect(described_class.skipped.ok?).to be(true) + expect(described_class.failed.ok?).to be(false) end it "ko? is true when not success" do - expect(described_class::Success.ko?).to be(false) - expect(described_class::Skipped.ko?).to be(true) - expect(described_class::Failed.ko?).to be(true) + expect(described_class.success.ko?).to be(false) + expect(described_class.skipped.ko?).to be(true) + expect(described_class.failed.ko?).to be(true) end end diff --git a/spec/cmdx/task_spec.rb b/spec/cmdx/task_spec.rb index 768fb23b7..2df46a01e 100644 --- a/spec/cmdx/task_spec.rb +++ b/spec/cmdx/task_spec.rb @@ -144,6 +144,50 @@ end end + describe "#execute" do + it "returns a result for a successful task" do + task = create_successful_task.new + + expect(task.execute).to be_success + end + + it "returns a failed result with strict: false (default)" do + task = create_failing_task(reason: "x").new + + expect(task.execute).to be_failed + end + + it "raises a Fault with strict: true on failure" do + task = create_failing_task(reason: "x").new + + expect { task.execute(strict: true) }.to raise_error(CMDx::Fault, "x") + end + + it "does not raise with strict: true on success" do + task = create_successful_task.new + + expect(task.execute(strict: true)).to be_success + end + + it "yields the result to the block and returns the block's value" do + task = create_successful_task.new + received = nil + returned = task.execute do |r| + received = r + :from_block + end + + expect(received).to be_success + expect(returned).to eq(:from_block) + end + + it "is aliased as #call" do + task = create_successful_task.new + + expect(task.call).to be_success + end + end + describe "#initialize" do it "builds a Context and an empty Errors" do task = create_task_class.new(a: 1) @@ -203,7 +247,7 @@ end it "throw! is a no-op for non-failed signals" do - source = CMDx::Signal::Success + source = CMDx::Signal.success task = klass.new result = catch(CMDx::Signal::TAG) do task.send(:throw!, source) diff --git a/spec/integration/tasks/middlewares_spec.rb b/spec/integration/tasks/middlewares_spec.rb index 7d66e5ea9..b919e9db6 100644 --- a/spec/integration/tasks/middlewares_spec.rb +++ b/spec/integration/tasks/middlewares_spec.rb @@ -179,4 +179,154 @@ def call(task) expect(log).to be_empty end end + + describe "metadata enrichment" do + let(:request_id_middleware) do + Class.new do + def call(task) + task.metadata[:request_id] = "req-abc123" + yield + end + end.new + end + + it "propagates middleware-injected metadata on implicit success" do + task = create_successful_task do + register :middleware, proc { |t, &nxt| + t.metadata[:request_id] = "req-abc123" + nxt.call + } + end + + result = task.execute + + expect(result).to have_attributes( + status: CMDx::Signal::SUCCESS, + metadata: { request_id: "req-abc123" } + ) + end + + it "propagates middleware-injected metadata on explicit success!" do + task = create_task_class do + register :middleware, proc { |t, &nxt| + t.metadata[:request_id] = "req-abc123" + nxt.call + } + define_method(:work) { success!("ok", code: 200) } + end + + expect(task.execute.metadata).to eq(request_id: "req-abc123", code: 200) + end + + it "propagates middleware-injected metadata on skip!" do + task = create_skipping_task(reason: "later") do + register :middleware, proc { |t, &nxt| + t.metadata[:request_id] = "req-abc123" + nxt.call + } + end + + expect(task.execute).to have_attributes( + status: CMDx::Signal::SKIPPED, + metadata: { request_id: "req-abc123" } + ) + end + + it "propagates middleware-injected metadata on fail!" do + task = create_failing_task(reason: "boom", code: "E001") do + register :middleware, proc { |t, &nxt| + t.metadata[:request_id] = "req-abc123" + nxt.call + } + end + + expect(task.execute.metadata).to eq(request_id: "req-abc123", code: "E001") + end + + it "propagates middleware-injected metadata when an unrescued exception fails the task" do + task = create_erroring_task(reason: "boom") do + register :middleware, proc { |t, &nxt| + t.metadata[:request_id] = "req-abc123" + nxt.call + } + end + + expect(task.execute).to have_attributes( + status: CMDx::Signal::FAILED, + metadata: { request_id: "req-abc123" } + ) + end + + it "propagates middleware-injected metadata when input validation fails" do + task = create_task_class do + register :middleware, proc { |t, &nxt| + t.metadata[:request_id] = "req-abc123" + nxt.call + } + input :name, type: :string, required: true + define_method(:work) { :unreachable } + end + + expect(task.execute).to have_attributes( + status: CMDx::Signal::FAILED, + metadata: { request_id: "req-abc123" } + ) + end + + it "lets explicit signal kwargs override middleware-set keys" do + task = create_task_class do + register :middleware, proc { |t, &nxt| + t.metadata[:request_id] = "from-mw" + nxt.call + } + define_method(:work) { fail!("boom", request_id: "from-work") } + end + + expect(task.execute.metadata[:request_id]).to eq("from-work") + end + + it "supports a class-based middleware mutating task.metadata" do + mw = request_id_middleware + task = create_successful_task { register :middleware, mw } + + expect(task.execute.metadata).to eq(request_id: "req-abc123") + end + + it "preserves the Signal.success singleton when no middleware mutates metadata" do + task = create_successful_task + + expect(task.execute.metadata).to be_empty + end + + it "stacks contributions from multiple middlewares" do + task = create_successful_task do + register :middleware, proc { |t, &nxt| + t.metadata[:request_id] = "req-abc123" + nxt.call + } + register :middleware, proc { |t, &nxt| + t.metadata[:tenant_id] = 42 + nxt.call + } + end + + expect(task.execute.metadata).to eq(request_id: "req-abc123", tenant_id: 42) + end + + it "carries metadata into echoed signals from re-thrown peer faults" do + inner = create_failing_task(reason: "inner boom", inner_key: :v) + task = create_task_class do + register :middleware, proc { |t, &nxt| + t.metadata[:request_id] = "req-abc123" + nxt.call + } + define_method(:work) { throw!(inner.execute(context)) } + end + + result = task.execute + + expect(result).to have_attributes(status: CMDx::Signal::FAILED, reason: "inner boom") + expect(result.metadata[:request_id]).to eq("req-abc123") + end + end end From 71f8e3f4247ab55aad389e916da1bb9712a8fc16 Mon Sep 17 00:00:00 2001 From: Juan Gomez <drexed@users.noreply.github.com> Date: Mon, 20 Apr 2026 23:59:33 -0400 Subject: [PATCH 04/54] V2 --- lib/generators/cmdx/install_generator.rb | 10 ++++++++++ lib/generators/cmdx/task_generator.rb | 16 ++++++++++++++++ lib/generators/cmdx/workflow_generator.rb | 16 ++++++++++++++++ 3 files changed, 42 insertions(+) diff --git a/lib/generators/cmdx/install_generator.rb b/lib/generators/cmdx/install_generator.rb index f1855c002..dbc570bf4 100644 --- a/lib/generators/cmdx/install_generator.rb +++ b/lib/generators/cmdx/install_generator.rb @@ -1,12 +1,22 @@ # frozen_string_literal: true module Cmdx + # Rails generator that scaffolds the CMDx initializer at + # `config/initializers/cmdx.rb`. The initializer template seeds global + # {CMDx.configuration} defaults (middlewares, callbacks, coercions, + # validators, telemetry) that all tasks inherit from. + # + # Invoked via `rails generate cmdx:install`. class InstallGenerator < Rails::Generators::Base source_root File.expand_path("templates", __dir__) desc "Creates CMDx initializer with global configuration settings" + # Copies the initializer template into the host application's + # `config/initializers` directory. + # + # @return [void] def copy_initializer_file copy_file("install.rb", "config/initializers/cmdx.rb") end diff --git a/lib/generators/cmdx/task_generator.rb b/lib/generators/cmdx/task_generator.rb index c6fae255a..1ad781ea1 100644 --- a/lib/generators/cmdx/task_generator.rb +++ b/lib/generators/cmdx/task_generator.rb @@ -1,12 +1,23 @@ # frozen_string_literal: true module Cmdx + # Rails generator that scaffolds a new {CMDx::Task} subclass under + # `app/tasks`, honoring nested module paths supplied through the NAME + # argument (e.g. `Billing::ChargeCard` writes to + # `app/tasks/billing/charge_card.rb`). + # + # Invoked via `rails generate cmdx:task NAME`. + # + # @see CMDx::Task class TaskGenerator < Rails::Generators::NamedBase source_root File.expand_path("templates", __dir__) desc "Creates a task with the given NAME" + # Renders `task.rb.tt` into `app/tasks/<class_path>/<file_name>.rb`. + # + # @return [void] def copy_files path = File.join("app/tasks", class_path, "#{file_name}.rb") template("task.rb.tt", path) @@ -14,6 +25,11 @@ def copy_files private + # Selects the parent class for the generated task: prefers the host + # application's `ApplicationTask` when defined, falling back to + # {CMDx::Task} otherwise. Consumed by the ERB template via `<%= %>`. + # + # @return [Class] either `ApplicationTask` or {CMDx::Task} def parent_class_name ApplicationTask rescue NameError diff --git a/lib/generators/cmdx/workflow_generator.rb b/lib/generators/cmdx/workflow_generator.rb index 6f821fae9..7d64dd4ea 100644 --- a/lib/generators/cmdx/workflow_generator.rb +++ b/lib/generators/cmdx/workflow_generator.rb @@ -1,12 +1,23 @@ # frozen_string_literal: true module Cmdx + # Rails generator that scaffolds a new {CMDx::Workflow} subclass under + # `app/tasks`, honoring nested module paths supplied through the NAME + # argument (e.g. `Billing::Checkout` writes to + # `app/tasks/billing/checkout.rb`). + # + # Invoked via `rails generate cmdx:workflow NAME`. + # + # @see CMDx::Workflow class WorkflowGenerator < Rails::Generators::NamedBase source_root File.expand_path("templates", __dir__) desc "Creates a workflow with the given NAME" + # Renders `workflow.rb.tt` into `app/tasks/<class_path>/<file_name>.rb`. + # + # @return [void] def copy_files path = File.join("app/tasks", class_path, "#{file_name}.rb") template("workflow.rb.tt", path) @@ -14,6 +25,11 @@ def copy_files private + # Selects the parent class for the generated workflow: prefers the host + # application's `ApplicationTask` when defined, falling back to + # {CMDx::Task} otherwise. Consumed by the ERB template via `<%= %>`. + # + # @return [Class] either `ApplicationTask` or {CMDx::Task} def parent_class_name ApplicationTask rescue NameError From 2322b21c6e2ff1aca24f8b89cc0267626136777b Mon Sep 17 00:00:00 2001 From: Juan Gomez <drexed@users.noreply.github.com> Date: Tue, 21 Apr 2026 00:02:14 -0400 Subject: [PATCH 05/54] V2 --- .ruby-version | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.ruby-version b/.ruby-version index fcdb2e109..4d54daddb 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -4.0.0 +4.0.2 From 49088b158a62b2de394cbce7bf76c01bcb3ac686 Mon Sep 17 00:00:00 2001 From: Juan Gomez <drexed@users.noreply.github.com> Date: Tue, 21 Apr 2026 00:30:35 -0400 Subject: [PATCH 06/54] V2 --- lib/cmdx/version.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/cmdx/version.rb b/lib/cmdx/version.rb index 437b03963..e7b8300a6 100644 --- a/lib/cmdx/version.rb +++ b/lib/cmdx/version.rb @@ -2,7 +2,7 @@ module CMDx - # Semantic version string for the CMDx library. + # Gem version. Bumped on release; mirrored in the gemspec. VERSION = "2.0.0" end From 75f4c01621b4bf8932ecf3a0ea447323fcd2ab5a Mon Sep 17 00:00:00 2001 From: Juan Gomez <drexed@users.noreply.github.com> Date: Tue, 21 Apr 2026 09:08:32 -0400 Subject: [PATCH 07/54] V2 --- lib/cmdx/railtie.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/cmdx/railtie.rb b/lib/cmdx/railtie.rb index e37e53587..9a05229df 100644 --- a/lib/cmdx/railtie.rb +++ b/lib/cmdx/railtie.rb @@ -11,7 +11,7 @@ class Railtie < Rails::Railtie initializer("cmdx.configure_rails") do |app| available_locales = app.config.i18n.available_locales.join(",") locale_path = File.expand_path("../locales/{#{available_locales}}.yml", __dir__) - I18n.load_path += Dir[locale_path] + ::I18n.load_path += Dir[locale_path] CMDx.configure do |config| config.logger = Rails.logger From 8d5c56654efd1b0334d9b7921e3df8ba7048b966 Mon Sep 17 00:00:00 2001 From: Juan Gomez <drexed@users.noreply.github.com> Date: Tue, 21 Apr 2026 10:37:44 -0400 Subject: [PATCH 08/54] V2 --- CHANGELOG.md | 12 +++--- Gemfile.lock | 2 +- README.md | 4 +- docs/basics/chain.md | 22 +++++------ .../blog/posts/cmdx-v2-the-runtime-rewrite.md | 10 ++--- .../posts/real-world-cmdx-external-apis.md | 4 +- .../real-world-cmdx-multi-tenant-saas.md | 4 +- .../posts/real-world-cmdx-user-onboarding.md | 16 ++++---- docs/configuration.md | 6 +-- docs/deprecation.md | 2 +- docs/getting_started.md | 4 +- docs/logging.md | 32 ++++++++-------- docs/outcomes/result.md | 30 +++++++-------- docs/overrides/home.html | 6 +-- docs/v2-migration.md | 38 +++++++++---------- examples/active_record_query_tagging.md | 18 ++++----- lib/cmdx/chain.rb | 4 +- lib/cmdx/result.rb | 29 ++++++++------ lib/cmdx/runtime.rb | 13 +++---- lib/cmdx/task.rb | 3 +- lib/cmdx/telemetry.rb | 2 +- skills/references/configuration.md | 4 +- skills/references/result.md | 18 ++++----- skills/references/testing.md | 4 +- spec/cmdx/chain_spec.rb | 2 +- spec/cmdx/result_spec.rb | 30 +++++++-------- spec/cmdx/runtime_spec.rb | 2 +- spec/integration/tasks/chain_spec.rb | 20 +++++----- spec/integration/tasks/execution_spec.rb | 6 +-- spec/integration/tasks/telemetry_spec.rb | 12 +++--- spec/integration/workflows/nested_spec.rb | 4 +- spec/integration/workflows/parallel_spec.rb | 4 +- 32 files changed, 184 insertions(+), 183 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b32d9924..873ec13e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,7 @@ Full runtime rewrite: the v1 state-machine plus Zeitwerk architecture is replace - Add `CMDx::Signal` halt token thrown via `catch(Signal::TAG)` (`:cmdx_signal`) - Add `Signal#ok?` / `Signal#ko?` predicates - Add `CMDx::Runtime` orchestrating the full task lifecycle and building the final `Result` -- Add `CMDx::Telemetry` pub/sub for `:task_started`, `:task_deprecated`, `:task_retried`, `:task_rolled_back`, `:task_executed`; emits `Telemetry::Event` data objects with `chain_id`, `chain_root`, `task_type`, `task_class`, `task_id`, `name`, `payload`, `timestamp` +- 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` - Add `CMDx::Deprecation` for declarative class-level deprecation (`:log`, `:warn`, `:error`, Symbol, Proc, callable) with `:if` / `:unless` gating - Add `CMDx::Input` / `CMDx::Inputs` (replaces `Attribute` / `AttributeRegistry` / `AttributeValue`) supporting `:source`, `:default`, `:transform`, `:as`, `:prefix` / `:suffix`, and nested children via DSL block - Add `CMDx::Output` / `CMDx::Outputs` for first-class declared outputs verified against `task.context` after `work` (required-presence, default application, coercion, transformation, validation, write-back of final value); `:default` and `:transform` mirror input semantics — defaults fire for nil/absent values and can satisfy `:required`, transforms run between coerce and validate @@ -27,7 +27,7 @@ Full runtime rewrite: the v1 state-machine plus Zeitwerk architecture is replace - Add `Task#execute(strict:)` instance method (aliased as `#call`) - Add `Result#on(:success, :failed, ...)` chainable predicate-dispatch helper - Add `Result#deconstruct` / `Result#deconstruct_keys` for pattern matching; `deconstruct` returns `[type, task, state, status, reason, metadata, cause, origin]` -- Add `Result#strict?`, `Result#deprecated?`, `Result#duration`, `Result#chain_index`, `Result#chain_root?`, `Result#backtrace`, `Result#errors`, `Result#tags`, `Result#origin`, and `Result#ctx` alias +- Add `Result#strict?`, `Result#deprecated?`, `Result#duration`, `Result#index`, `Result#root?`, `Result#backtrace`, `Result#errors`, `Result#tags`, `Result#origin`, and `Result#ctx` alias - Add `Signal#origin` / `Result#origin` — upstream `Result` a signal/result was echoed from (`nil` for locally originated failures); set by `Task#throw!`, `Pipeline` when propagating workflow failures, and `Runtime` when rescuing a `Fault` inside `work` - Add `Chain#unshift`, `Chain#root`, `Chain#state`, `Chain#status`, `Chain#last`, `Chain#freeze`; Runtime `unshift`s the root result (so `chain.root` and `chain[0]` point to the outermost task) and freezes the chain on root teardown - Add `Fault.for?(*tasks)` and `Fault.matches?(&block)` anonymous matcher subclasses suitable for `rescue` @@ -60,7 +60,7 @@ Full runtime rewrite: the v1 state-machine plus Zeitwerk architecture is replace - 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` - `Fault#initialize` takes a single `Result`; `task`, `context`, and `chain` delegate to it; `Runtime` raises `Fault.new(@result.caused_failure)` so `fault.task` always points at the originating leaf (including in workflows and nested `execute!` chains) - `Runtime` finalizes the `Result` before `raise_signal!` so the `Fault` it raises always carries a fully-built `Result` -- `Result#to_h` / `to_s` / `deconstruct_keys` now include `:origin` (compact `{ task:, id: }` hash, or `nil` for locally originated failures) +- `Result#to_h` / `to_s` / `deconstruct_keys` now include `:origin` (compact `{ task:, tid: }` hash, or `nil` for locally originated failures) - 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 - Generators emit the new `def work` template; the install template documents the new middleware / callback / telemetry / coercion / validator registration shapes - Slim `Configuration` to: `middlewares`, `callbacks`, `coercions`, `validators`, `telemetry`, `default_locale`, `backtrace_cleaner`, `logger`, `log_level`, `log_formatter` @@ -69,8 +69,8 @@ Full runtime rewrite: the v1 state-machine plus Zeitwerk architecture is replace - **BREAKING**: Remove `Result::STATES = [INITIALIZED, EXECUTING, COMPLETE, INTERRUPTED]`, the `executed!` / `executing!` transitions, and the `executed?` / `initialized?` / `executing?` predicates - **BREAKING**: Remove `Task#id`, `Task#result`, `Task#chain` direct accessors — read these off the `Result` returned by `execute` - **BREAKING**: Remove `Result#threw_failure?` predicate (`result.thrown_failure?` remains, with semantics flipped — true when the result re-threw an upstream failure) -- **BREAKING**: Remove `Result#chain_id` — read it off the chain: `result.chain.id` -- **BREAKING**: `Result#to_h` no longer produces nested `caused_failure` / `threw_failure` hashes; failure references render as `{ task:, id: }` and `to_s` formats them as `<TaskClass uuid>` +- **BREAKING**: Remove `Result#chain_id` — read it off the chain: `result.cid` +- **BREAKING**: `Result#to_h` no longer produces nested `caused_failure` / `threw_failure` hashes; failure references render as `{ task:, tid: }` and `to_s` formats them as `<TaskClass uuid>` - Remove `CMDx::Executor` (replaced by `CMDx::Runtime`) - Remove `CMDx::Attribute`, `CMDx::AttributeRegistry`, `CMDx::AttributeValue` (replaced by `Input` / `Inputs` and `Output` / `Outputs`) - Remove `CMDx::Resolver` (value resolution is owned by `Input#resolve`) @@ -93,7 +93,7 @@ See [docs/v2-migration.md](docs/v2-migration.md) for the full upgrade guide. At - Rename `def call` → `def work`; `MyTask.call(ctx)` still works (aliased) but prefer `MyTask.execute(ctx)` - Replace `register :attribute, ...` with `required :name, ...` / `optional :name, ...` / `output :name, ...` -- Replace `result.chain_id` with `result.chain.id` +- Replace `result.chain_id` with `result.cid` - Replace `task.id` / `task.result` / `task.chain` with reads off the `Result` returned by `execute` - Subscribe to lifecycle observability via `config.telemetry.subscribe(:task_executed) { |event| ... }` instead of the removed `Runtime` / `Correlate` middlewares diff --git a/Gemfile.lock b/Gemfile.lock index 449ca4993..dca73c5e9 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -13,7 +13,7 @@ GEM concurrent-ruby (1.3.6) date (3.5.1) diff-lcs (1.6.2) - erb (6.0.3) + erb (6.0.4) i18n (1.14.8) concurrent-ruby (~> 1.0) json (2.19.4) diff --git a/README.md b/README.md index 6ca942789..de437d0f8 100644 --- a/README.md +++ b/README.md @@ -132,9 +132,9 @@ end Every execution emits a structured log line with the chain id, task identity, state, status, reason, metadata, and duration — enough to correlate nested tasks and reconstruct what happened. ```log -I, [2026-04-19T18:42:37.000000Z #3784] INFO -- cmdx: chain_id="018c2b95-b764-7fff-a1d2-..." chain_index=1 chain_root=false type="Task" task=SendAnalyzedEmail id="018c2b95-c091-..." state="complete" status="success" reason=nil metadata={} duration=34.7 tags=[] +I, [2026-04-19T18:42:37.000000Z #3784] INFO -- cmdx: cid="018c2b95-b764-7fff-a1d2-..." index=1 root=false type="Task" task=SendAnalyzedEmail id="018c2b95-c091-..." state="complete" status="success" reason=nil metadata={} duration=34.7 tags=[] -I, [2026-04-19T18:43:15.000000Z #3784] INFO -- cmdx: chain_id="018c2b95-b764-7fff-a1d2-..." chain_index=0 chain_root=true type="Task" task=AnalyzeMetrics id="018c2b95-b764-..." state="complete" status="success" reason=nil metadata={} duration=187.4 tags=[] +I, [2026-04-19T18:43:15.000000Z #3784] INFO -- cmdx: cid="018c2b95-b764-7fff-a1d2-..." index=0 root=true type="Task" task=AnalyzeMetrics id="018c2b95-b764-..." state="complete" status="success" reason=nil metadata={} duration=187.4 tags=[] ``` Ready to dive in? Check out the [Getting Started](https://drexed.github.io/cmdx/getting_started/) guide. diff --git a/docs/basics/chain.md b/docs/basics/chain.md index 0257841bb..cc94be77e 100644 --- a/docs/basics/chain.md +++ b/docs/basics/chain.md @@ -11,21 +11,17 @@ From a result, reach the chain via: | Method | Returns | |--------|---------| | `result.chain` | The owning `CMDx::Chain` (Enumerable; `id`, `size`, `first`, `last`, etc.) | -| `result.chain.id` | The chain's UUID v7 (`String`) | -| `result.chain_index` | This result's zero-based position in the chain (`Integer`, `nil` if absent) | -| `result.chain_root?` | `true` when this result is the chain's root | +| `result.cid` | The chain's UUID v7 (`String`) | +| `result.index` | This result's zero-based position in the chain (`Integer`, `nil` if absent) | +| `result.root?` | `true` when this result is the chain's root | | `CMDx::Chain.current` | The live `Chain` object (only inside execution) | -!!! note - - `result.chain_id` is **not** a method on `Result`—it only appears as a key in `result.to_h`. Use `result.chain.id` to access the UUID. - ```ruby result = ImportDataset.execute(dataset_id: 456) -result.chain.id #=> "018c2b95-b764-7615-a924-cc5b910ed1e5" -result.chain_index #=> 0 -result.chain_root? #=> true +result.cid #=> "018c2b95-b764-7615-a924-cc5b910ed1e5" +result.index #=> 0 +result.root? #=> true result.chain.size #=> 4 result.chain.first #=> root result (ImportDataset) result.chain.last #=> last subtask result @@ -56,7 +52,7 @@ class ImportDataset < CMDx::Task result1.chain.size #=> 1 (the parent hasn't finalized yet) result2 = TransformData.execute!(context) - result2.chain.id == result1.chain.id #=> true + result2.cid == result1.cid #=> true result2.chain.size #=> 2 SaveToDatabase.execute(dataset_id: context.dataset_id) @@ -84,13 +80,13 @@ The active chain lives on `Fiber[]` (fiber-local), so each `Thread`'s root fiber # Thread A — its root fiber gets a fresh chain Thread.new do result = ImportDataset.execute(file_path: "/data/batch1.csv") - result.chain.id #=> "018c2b95-b764-7615-a924-cc5b910ed1e5" + result.cid #=> "018c2b95-b764-7615-a924-cc5b910ed1e5" end # Thread B — completely separate chain Thread.new do result = ImportDataset.execute(file_path: "/data/batch2.csv") - result.chain.id #=> "018c2c11-c821-7892-b156-dd7c921fe2a3" + result.cid #=> "018c2c11-c821-7892-b156-dd7c921fe2a3" end # Inspect or clear the current fiber's chain (rarely needed) diff --git a/docs/blog/posts/cmdx-v2-the-runtime-rewrite.md b/docs/blog/posts/cmdx-v2-the-runtime-rewrite.md index ac2995790..91b358b45 100644 --- a/docs/blog/posts/cmdx-v2-the-runtime-rewrite.md +++ b/docs/blog/posts/cmdx-v2-the-runtime-rewrite.md @@ -78,7 +78,7 @@ CMDx::Chain.current # accessor CMDx::Chain.clear # cleared automatically on root teardown ``` -`Chain` is now `Enumerable`, has a `Mutex` guarding `push` / `unshift`, and gets cleared when the outermost task finishes. Parallel workflow groups share the parent fiber's chain so every child result still correlates to the same `chain_id`. +`Chain` is now `Enumerable`, has a `Mutex` guarding `push` / `unshift`, and gets cleared when the outermost task finishes. Parallel workflow groups share the parent fiber's chain so every child result still correlates to the same `cid`. ## What You Write Differently @@ -92,7 +92,7 @@ A condensed cheat sheet. The [full migration guide](https://drexed.github.io/cmd | Middleware | `def call(task, options, &block)` | `def call(task); yield; end` (one arg, must `yield`) | | Built-in middlewares | `Correlate`, `Runtime`, `Timeout` auto-registered | removed — subscribe to Telemetry or register your own | | Callbacks | `on_good`, `on_bad`, `on_executed` | `on_ok`, `on_ko` (`on_executed` removed) | -| Chain ID | `result.chain_id` | `result.chain.id` | +| Chain ID | `result.chain_id` | `result.cid` | | Halt reach | code after `fail!` could still run | code after `fail!` is unreachable | | Result mutability | mutable (`result.metadata[:x] = ...`) | frozen | | Breakpoints | `task_breakpoints`, `workflow_breakpoints` | removed — `execute!` is strict mode | @@ -117,7 +117,7 @@ CMDx.configure do |config| end ``` -Events: `:task_started`, `:task_deprecated`, `:task_retried`, `:task_rolled_back`, `:task_executed`. Each event carries `chain_id`, `chain_root`, `task_type`, `task_class`, `task_id`, `name`, `payload`, and `timestamp`. +Events: `:task_started`, `:task_deprecated`, `:task_retried`, `:task_rolled_back`, `:task_executed`. Each event carries `cid`, `root`, `task_type`, `task_class`, `task_id`, `name`, `payload`, and `timestamp`. ### Parallel workflow groups @@ -133,7 +133,7 @@ class FanOutWorkflow < CMDx::Task end ``` -Workers `deep_dup` the workflow context, run in parallel, and merge successful child contexts back into the parent in declaration order. The first failed child halts the pipeline via `throw!`. Shared fiber-local chain — every worker shows up in `result.chain` under the same `chain_id`. +Workers `deep_dup` the workflow context, run in parallel, and merge successful child contexts back into the parent in declaration order. The first failed child halts the pipeline via `throw!`. Shared fiber-local chain — every worker shows up in `result.chain` under the same `cid`. ### `Task#rollback` @@ -198,7 +198,7 @@ Then, across your task classes: - Rename `attribute :x, type: :string` → `input :x, coerce: :string` - Rename `returns :user` → `output :user, required: true` - Update middlewares from `call(task, options, &block)` to `call(task) { yield }`, and register instances (`register :middleware, Foo.new`) instead of classes -- Replace `result.chain_id` with `result.chain.id`, `result.good?` with `result.ok?`, `result.bad?` with `result.ko?` +- Replace `result.chain_id` with `result.cid`, `result.good?` with `result.ok?`, `result.bad?` with `result.ko?` - Drop `task_breakpoints` / `workflow_breakpoints` settings — use `execute!` where you want strict mode - Re-register `Correlate` / `Runtime` / `Timeout` equivalents as Telemetry subscribers or custom middlewares (or delete them — `result.duration` is built in) diff --git a/docs/blog/posts/real-world-cmdx-external-apis.md b/docs/blog/posts/real-world-cmdx-external-apis.md index e22e2aba5..2c21280f4 100644 --- a/docs/blog/posts/real-world-cmdx-external-apis.md +++ b/docs/blog/posts/real-world-cmdx-external-apis.md @@ -122,7 +122,7 @@ class ErrorTracking Sentry.with_scope do |scope| scope.set_tags( task_class: task.class.name, - chain_id: CMDx::Chain.current&.id + cid: CMDx::Chain.current&.id ) yield @@ -339,7 +339,7 @@ Payments::Record → success Payments::SendReceipt → success ``` -Three retries with exponential backoff (1s, 2s, 4s). The user never knows. Logs show the retry warnings with `chain_id` correlation. +Three retries with exponential backoff (1s, 2s, 4s). The user never knows. Logs show the retry warnings with `cid` correlation. ### Scenario 3: Stripe Is Down (Circuit Open) diff --git a/docs/blog/posts/real-world-cmdx-multi-tenant-saas.md b/docs/blog/posts/real-world-cmdx-multi-tenant-saas.md index 7ecf737dc..5f1fcaaff 100644 --- a/docs/blog/posts/real-world-cmdx-multi-tenant-saas.md +++ b/docs/blog/posts/real-world-cmdx-multi-tenant-saas.md @@ -106,7 +106,7 @@ CMDx.configure do |config| next unless tenant Rails.logger.info( - chain_id: event.chain_id, + cid: event.cid, task: event.task_class.name, tenant_slug: tenant.slug, status: event.payload[:result].status, @@ -116,7 +116,7 @@ CMDx.configure do |config| end ``` -Filter your log aggregator by `tenant_slug:"acme"` and see every task execution for that tenant. Cross-reference with `chain_id` to trace a single request — the per-fiber chain ([`lib/cmdx/chain.rb`](https://github.com/drexed/cmdx/blob/main/lib/cmdx/chain.rb)) keeps concurrent tenant requests isolated automatically. +Filter your log aggregator by `tenant_slug:"acme"` and see every task execution for that tenant. Cross-reference with `cid` to trace a single request — the per-fiber chain ([`lib/cmdx/chain.rb`](https://github.com/drexed/cmdx/blob/main/lib/cmdx/chain.rb)) keeps concurrent tenant requests isolated automatically. ## Per-Tenant Configuration diff --git a/docs/blog/posts/real-world-cmdx-user-onboarding.md b/docs/blog/posts/real-world-cmdx-user-onboarding.md index 86e647e8b..246fec25f 100644 --- a/docs/blog/posts/real-world-cmdx-user-onboarding.md +++ b/docs/blog/posts/real-world-cmdx-user-onboarding.md @@ -306,16 +306,16 @@ end Run the workflow and the message field of each log line is the serialized `result.to_h`: ```json -{"chain_id":"abc123","chain_index":1,"chain_root":false,"type":"Task","task":"Users::Register","status":"success","duration":45.2, ...} -{"chain_id":"abc123","chain_index":2,"chain_root":false,"type":"Task","task":"Users::SendVerification","status":"success","duration":12.1, ...} -{"chain_id":"abc123","chain_index":3,"chain_root":false,"type":"Task","task":"Users::ActivateTrial","status":"success","duration":8.0, ...} -{"chain_id":"abc123","chain_index":4,"chain_root":false,"type":"Task","task":"Users::ApplyReferralBonus","status":"skipped","reason":"Invalid referral code — skipping bonus","duration":3.4, ...} -{"chain_id":"abc123","chain_index":5,"chain_root":false,"type":"Task","task":"Users::SendWelcome","status":"success","duration":6.7, ...} -{"chain_id":"abc123","chain_index":6,"chain_root":false,"type":"Task","task":"Users::TrackRegistration","tags":["analytics","onboarding"],"status":"success","duration":2.1, ...} -{"chain_id":"abc123","chain_index":0,"chain_root":true,"type":"Workflow","task":"Users::Onboard","tags":["onboarding"],"status":"success","duration":76.5, ...} +{"cid":"abc123","index":1,"root":false,"type":"Task","task":"Users::Register","status":"success","duration":45.2, ...} +{"cid":"abc123","index":2,"root":false,"type":"Task","task":"Users::SendVerification","status":"success","duration":12.1, ...} +{"cid":"abc123","index":3,"root":false,"type":"Task","task":"Users::ActivateTrial","status":"success","duration":8.0, ...} +{"cid":"abc123","index":4,"root":false,"type":"Task","task":"Users::ApplyReferralBonus","status":"skipped","reason":"Invalid referral code — skipping bonus","duration":3.4, ...} +{"cid":"abc123","index":5,"root":false,"type":"Task","task":"Users::SendWelcome","status":"success","duration":6.7, ...} +{"cid":"abc123","index":6,"root":false,"type":"Task","task":"Users::TrackRegistration","tags":["analytics","onboarding"],"status":"success","duration":2.1, ...} +{"cid":"abc123","index":0,"root":true,"type":"Workflow","task":"Users::Onboard","tags":["onboarding"],"status":"success","duration":76.5, ...} ``` -One `chain_id` links every step. The skipped referral bonus is visible without digging through exception trackers. The root workflow result is at `chain_index: 0` (Runtime `unshift`s the root onto the chain). The workflow still reports `success` because skips are considered good outcomes (`result.ok?`). +One `cid` links every step. The skipped referral bonus is visible without digging through exception trackers. The root workflow result is at `index: 0` (Runtime `unshift`s the root onto the chain). The workflow still reports `success` because skips are considered good outcomes (`result.ok?`). ## Testing the Pipeline diff --git a/docs/configuration.md b/docs/configuration.md index d8042487a..9f1917c90 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -155,7 +155,7 @@ See [Callbacks](callbacks.md) for class-level usage. ### Telemetry -Pub/sub for runtime lifecycle events. Subscribers receive a `Telemetry::Event` data object with `chain_id`, `chain_root`, `task_type`, `task_class`, `task_id`, `name`, `payload`, and `timestamp`. +Pub/sub for runtime lifecycle events. Subscribers receive a `Telemetry::Event` data object with `cid`, `root`, `type`, `task`, `tid`, `name`, `payload`, and `timestamp`. | Event | Payload | |-------|---------| @@ -169,13 +169,13 @@ Pub/sub for runtime lifecycle events. Subscribers receive a `Telemetry::Event` d CMDx.configure do |config| config.telemetry.subscribe(:task_executed, ->(event) { StatsD.timing("cmdx.task", event.payload[:result].duration, tags: [ - "class:#{event.task_class}", + "class:#{event.task}", "status:#{event.payload[:result].status}" ]) }) config.telemetry.subscribe(:task_retried, ->(event) { - Rails.logger.warn("[cmdx] retry ##{event.payload[:attempt]} for #{event.task_class}") + Rails.logger.warn("[cmdx] retry ##{event.payload[:attempt]} for #{event.task}") }) config.telemetry.unsubscribe(:task_executed, my_subscriber) diff --git a/docs/deprecation.md b/docs/deprecation.md index cfdbf10f2..530c02941 100644 --- a/docs/deprecation.md +++ b/docs/deprecation.md @@ -183,7 +183,7 @@ When deprecation fires (and conditions pass), Runtime emits the `:task_deprecate ```ruby CMDx.configure do |config| config.telemetry.subscribe(:task_deprecated, ->(event) { - StatsD.increment("cmdx.deprecated", tags: ["task:#{event.task_class}"]) + StatsD.increment("cmdx.deprecated", tags: ["task:#{event.task}"]) }) end diff --git a/docs/getting_started.md b/docs/getting_started.md index 8a3f6e110..e7e41ec48 100644 --- a/docs/getting_started.md +++ b/docs/getting_started.md @@ -194,9 +194,9 @@ end Every execution emits a structured log line with the chain id, task identity, state, status, reason, metadata, duration, and tags — enough to correlate nested tasks and reconstruct what happened. See [Logging](logging.md) for the full field reference. ```log -I, [2026-04-19T18:42:37.000000Z #3784] INFO -- cmdx: chain_id="018c2b95-b764-7fff-a1d2-..." chain_index=1 chain_root=false type="Task" task=SendAnalyzedEmail id="018c2b95-c091-..." state="complete" status="success" reason=nil metadata={} duration=347.21 ... +I, [2026-04-19T18:42:37.000000Z #3784] INFO -- cmdx: cid="018c2b95-b764-7fff-a1d2-..." index=1 root=false type="Task" task=SendAnalyzedEmail tid="018c2b95-c091-..." state="complete" status="success" reason=nil metadata={} duration=347.21 ... -I, [2026-04-19T18:42:37.535000Z #3784] INFO -- cmdx: chain_id="018c2b95-b764-7fff-a1d2-..." chain_index=0 chain_root=true type="Task" task=AnalyzeMetrics id="018c2b95-b764-..." state="complete" status="success" reason=nil metadata={} duration=1872.04 ... +I, [2026-04-19T18:42:37.535000Z #3784] INFO -- cmdx: cid="018c2b95-b764-7fff-a1d2-..." index=0 root=true type="Task" task=AnalyzeMetrics tid="018c2b95-b764-..." state="complete" status="success" reason=nil metadata={} duration=1872.04 ... ``` !!! note diff --git a/docs/logging.md b/docs/logging.md index 0b8807834..967bb47ad 100644 --- a/docs/logging.md +++ b/docs/logging.md @@ -19,19 +19,19 @@ CMDx logs every task execution at `INFO` with the full `Result#to_h` payload, gi === "Line (default)" ```log - I, [2026-04-19T10:30:45.123456Z #12345] INFO -- cmdx: chain_id="..." chain_index=0 ... state="complete" status="success" ... + I, [2026-04-19T10:30:45.123456Z #12345] INFO -- cmdx: cid="..." index=0 ... state="complete" status="success" ... ``` === "JSON" ```json - {"severity":"INFO","timestamp":"2026-04-19T10:30:45.123456Z","progname":"cmdx","pid":12345,"message":{"chain_id":"...","chain_index":0,"chain_root":true,"type":"Task","task":"MyTask","id":"...","state":"complete","status":"success","reason":null,"metadata":{},"strict":false,"deprecated":false,"retried":false,"retries":0,"duration":12.34,"tags":[]}} + {"severity":"INFO","timestamp":"2026-04-19T10:30:45.123456Z","progname":"cmdx","pid":12345,"message":{"cid":"...","index":0,"root":true,"type":"Task","task":"MyTask","tid":"...","state":"complete","status":"success","reason":null,"metadata":{},"strict":false,"deprecated":false,"retried":false,"retries":0,"duration":12.34,"tags":[]}} ``` === "KeyValue" ```log - severity="INFO" timestamp="2026-04-19T10:30:45.123456Z" progname="cmdx" pid=12345 message={chain_id: "...", chain_index: 0, ...} + severity="INFO" timestamp="2026-04-19T10:30:45.123456Z" progname="cmdx" pid=12345 message={cid: "...", index: 0, ...} ``` === "Logstash" @@ -43,7 +43,7 @@ CMDx logs every task execution at `INFO` with the full `Result#to_h` payload, gi === "Raw" ```log - chain_id="..." chain_index=0 chain_root=true type="Task" task=MyTask id="..." state="complete" status="success" ... + cid="..." index=0 root=true type="Task" task=MyTask tid="..." state="complete" status="success" ... ``` ## Sample Lifecycle @@ -52,21 +52,21 @@ A single chain emitting a successful task, a skipped task, a failed leaf, and th ```log # Success -I, [2026-04-19T17:04:07.292614Z #20108] INFO -- cmdx: chain_id="019b4c2b-087b-79be-8ef2-96c11b659df5" chain_index=0 chain_root=true type="Task" task=GenerateInvoice id="019b4c2b-0878-704d-ba0b-daa5410123ec" context=#<CMDx::Context ...> state="complete" status="success" reason=nil metadata={} strict=false deprecated=false retried=false retries=0 duration=12.34 tags=[] +I, [2026-04-19T17:04:07.292614Z #20108] INFO -- cmdx: cid="019b4c2b-087b-79be-8ef2-96c11b659df5" index=0 root=true type="Task" task=GenerateInvoice tid="019b4c2b-0878-704d-ba0b-daa5410123ec" context=#<CMDx::Context ...> state="complete" status="success" reason=nil metadata={} strict=false deprecated=false retried=false retries=0 duration=12.34 tags=[] # Skipped -I, [2026-04-19T17:04:11.496881Z #20139] INFO -- cmdx: chain_id="019b4c2b-18e8-7af6-a38b-63b042c4fbed" chain_index=0 chain_root=true type="Task" task=ValidateCustomer id="019b4c2b-18e5-7230-af7e-5b4a4bd7cda2" context=#<CMDx::Context ...> state="interrupted" status="skipped" reason="Customer already validated" metadata={} strict=false deprecated=false retried=false retries=0 duration=2.18 tags=[] +I, [2026-04-19T17:04:11.496881Z #20139] INFO -- cmdx: cid="019b4c2b-18e8-7af6-a38b-63b042c4fbed" index=0 root=true type="Task" task=ValidateCustomer tid="019b4c2b-18e5-7230-af7e-5b4a4bd7cda2" context=#<CMDx::Context ...> state="interrupted" status="skipped" reason="Customer already validated" metadata={} strict=false deprecated=false retried=false retries=0 duration=2.18 tags=[] # Failed (root cause via fail!) -I, [2026-04-19T17:04:15.875306Z #20173] INFO -- cmdx: chain_id="019b4c2b-2a02-7dbc-b713-b20a7379704f" chain_index=1 chain_root=false type="Task" task=CalculateTax id="019b4c2b-2a00-70b7-9fab-2f14db9139ef" context=#<CMDx::Context ...> state="interrupted" status="failed" reason="tax service unavailable" metadata={error_code: "TAX_SERVICE_UNAVAILABLE"} strict=false deprecated=false retried=false retries=0 duration=8.92 tags=[] cause=nil origin=nil threw_failure=<CalculateTax 019b4c2b-2a00-...> caused_failure=<CalculateTax 019b4c2b-2a00-...> rolled_back=false +I, [2026-04-19T17:04:15.875306Z #20173] INFO -- cmdx: cid="019b4c2b-2a02-7dbc-b713-b20a7379704f" index=1 root=false type="Task" task=CalculateTax tid="019b4c2b-2a00-70b7-9fab-2f14db9139ef" context=#<CMDx::Context ...> state="interrupted" status="failed" reason="tax service unavailable" metadata={error_code: "TAX_SERVICE_UNAVAILABLE"} strict=false deprecated=false retried=false retries=0 duration=8.92 tags=[] cause=nil origin=nil threw_failure=<CalculateTax 019b4c2b-2a00-...> caused_failure=<CalculateTax 019b4c2b-2a00-...> rolled_back=false # Failed workflow that propagated the above failure -I, [2026-04-19T17:04:15.876012Z #20173] INFO -- cmdx: chain_id="019b4c2b-2a02-7dbc-b713-b20a7379704f" chain_index=0 chain_root=true type="Workflow" task=BillingWorkflow id="019b4c2b-3de6-70b9-9c16-5be13b1a463c" ... state="interrupted" status="failed" reason="tax service unavailable" cause=nil origin=<CalculateTax 019b4c2b-2a00-...> threw_failure=<CalculateTax 019b4c2b-2a00-...> caused_failure=<CalculateTax 019b4c2b-2a00-...> rolled_back=false +I, [2026-04-19T17:04:15.876012Z #20173] INFO -- cmdx: cid="019b4c2b-2a02-7dbc-b713-b20a7379704f" index=0 root=true type="Workflow" task=BillingWorkflow tid="019b4c2b-3de6-70b9-9c16-5be13b1a463c" ... state="interrupted" status="failed" reason="tax service unavailable" cause=nil origin=<CalculateTax 019b4c2b-2a00-...> threw_failure=<CalculateTax 019b4c2b-2a00-...> caused_failure=<CalculateTax 019b4c2b-2a00-...> rolled_back=false ``` !!! tip - Use logging as a low-level event stream to track every task in a request. Pair `chain_id` with your APM's correlation field for distributed tracing. + Use logging as a low-level event stream to track every task in a request. Pair `cid` with your APM's correlation field for distributed tracing. !!! note @@ -91,12 +91,12 @@ Every log entry is built from `Result#to_h`. Available fields: | Field | Description | Example | |-------|-------------|---------| -| `chain_id` | Chain UUID (uuid_v7) | `"018c2b95-b764-7615-..."` | -| `chain_index` | Position in chain (root is 0) | `0`, `1`, `2` | -| `chain_root` | `true` for the root task's result | `true`, `false` | +| `cid` | Chain UUID (uuid_v7) | `"018c2b95-b764-7615-..."` | +| `index` | Position in chain (root is 0) | `0`, `1`, `2` | +| `root` | `true` for the root task's result | `true`, `false` | | `type` | `"Task"` or `"Workflow"` | `"Task"` | | `task` | Task class | `GenerateInvoice` | -| `id` | Result UUID (uuid_v7) | `"018c2b95-..."` | +| `tid` | Task UUID (uuid_v7) | `"018c2b95-..."` | | `context` | Frozen `CMDx::Context` (root teardown) | `#<CMDx::Context ...>` | | `tags` | Tags from `settings(tags: [...])` | `["billing"]` | @@ -126,9 +126,9 @@ These are present **only** when `status == "failed"`: | Field | Description | |-------|-------------| | `cause` | Underlying exception (or `nil` for `fail!`) | -| `origin` | `{ task:, id: }` of the upstream `Result` this failure was echoed from, or `nil` for a locally originated failure | -| `threw_failure` | `{ task:, id: }` of the nearest upstream failed result, or this result | -| `caused_failure` | `{ task:, id: }` of the originating failed result, or this result | +| `origin` | `{ task:, tid: }` of the upstream `Result` this failure was echoed from, or `nil` for a locally originated failure | +| `threw_failure` | `{ task:, tid: }` of the nearest upstream failed result, or this result | +| `caused_failure` | `{ task:, tid: }` of the originating failed result, or this result | | `rolled_back` | `true` when the task's `#rollback` ran | ## Configuration diff --git a/docs/outcomes/result.md b/docs/outcomes/result.md index ce2150ecd..a03655a57 100644 --- a/docs/outcomes/result.md +++ b/docs/outcomes/result.md @@ -12,7 +12,7 @@ A `Result` is the read-only outcome of a task execution. It exposes the signal ( result = BuildApplication.execute(version: "1.2.3") # Identity -result.id #=> "0190..." (uuid_v7 for this execution) +result.tid #=> "0190..." (uuid_v7 for this execution) result.task #=> BuildApplication (the task class) result.type #=> "Task" (or "Workflow") result.context #=> #<CMDx::Context ...> (frozen on root teardown) @@ -21,16 +21,16 @@ result.errors #=> #<CMDx::Errors ...> (frozen on teardown) # Chain placement result.chain #=> #<CMDx::Chain ...> -result.chain.id #=> "0190..." -result.chain_index #=> 0 (root is always 0; children are 1+ in completion order) -result.chain_root? #=> true when this result is the root of its chain +result.cid #=> "0190..." +result.index #=> 0 (root is always 0; children are 1+ in completion order) +result.root? #=> true when this result is the root of its chain # Signal data -result.state #=> "interrupted" -result.status #=> "failed" -result.reason #=> "Build tool not found" -result.metadata #=> { error_code: "BUILD_TOOL.NOT_FOUND" } -result.cause #=> nil, the rescued StandardError, or the propagated Fault +result.state #=> "interrupted" +result.status #=> "failed" +result.reason #=> "Build tool not found" +result.metadata #=> { error_code: "BUILD_TOOL.NOT_FOUND" } +result.cause #=> nil, the rescued StandardError, or the propagated Fault # Lifecycle metadata result.duration #=> 12.34 (milliseconds, monotonic) @@ -175,7 +175,7 @@ end ### Hash Pattern `deconstruct_keys` exposes: -`:chain_root, :type, :task, :state, :status, :reason, :metadata, :cause, :origin, :strict, :deprecated, :retries, :rolled_back, :duration`. +`:root, :type, :task, :state, :status, :reason, :metadata, :cause, :origin, :strict, :deprecated, :retries, :rolled_back, :duration`. ```ruby result = BuildApplication.execute(version: "1.2.3") @@ -187,7 +187,7 @@ in { status: "failed", metadata: { retryable: true } } schedule_build_retry(result) in { status: "failed", reason: String => reason } escalate_build_error("Build failed: #{reason}") -in { chain_root: true, rolled_back: true } +in { root: true, rolled_back: true } alert_root_rollback(result) end ``` @@ -212,8 +212,8 @@ end ```ruby result.to_h #=> { -# chain_id: "0190...", chain_index: 0, chain_root: true, -# type: "Task", task: BuildApplication, id: "0190...", +# cid: "0190...", index: 0, root: true, +# type: "Task", task: BuildApplication, tid: "0190...", # context: #<CMDx::Context ...>, # state: "complete", status: "success", # reason: nil, metadata: {}, @@ -223,7 +223,7 @@ result.to_h # } result.to_s -#=> "chain_id=\"0190...\" chain_index=0 ... state=\"complete\" status=\"success\" ..." +#=> "cid=\"0190...\" index=0 ... state=\"complete\" status=\"success\" ..." ``` -On `failed?` results, `to_h` additionally includes `:cause`, `:origin`, `:threw_failure`, `:caused_failure`, and `:rolled_back`. The `_failure` and `:origin` entries are compact `{ task:, id: }` hashes (and render as `<TaskClass uuid>` in `to_s`) to avoid serializing entire upstream results. `:origin` is `nil` when the failure is locally originated. +On `failed?` results, `to_h` additionally includes `:cause`, `:origin`, `:threw_failure`, `:caused_failure`, and `:rolled_back`. The `_failure` and `:origin` entries are compact `{ task:, tid: }` hashes (and render as `<TaskClass uuid>` in `to_s`) to avoid serializing entire upstream results. `:origin` is `nil` when the failure is locally originated. diff --git a/docs/overrides/home.html b/docs/overrides/home.html index 7059c91f1..c00dc5ff1 100644 --- a/docs/overrides/home.html +++ b/docs/overrides/home.html @@ -1601,7 +1601,7 @@ <h2 class="section-title">From one task to full workflows.</h2> <span class="line"><span class="dim">#</span> result.success? <span class="ok">=> true</span></span> <span class="line"><span class="dim">#</span> result.state => <span class="val">"complete"</span></span> <span class="line"><span class="dim">#</span> result.duration => 12</span> - <span class="line"><span class="dim">#</span> result.chain.id => <span class="val">"018c..e5"</span></span> + <span class="line"><span class="dim">#</span> result.cid => <span class="val">"018c..e5"</span></span> </aside> </div> @@ -1618,7 +1618,7 @@ <h2 class="section-title">From one task to full workflows.</h2> <span class="k">end</span> <span class="k">end</span> -<span class="c"># Every event ships with a chain_id, task_id,</span> +<span class="c"># Every event ships with a cid, task_id,</span> <span class="c"># runtime, origin, and correlation metadata.</span> <span class="c"># Structured logs are emitted automatically.</span> @@ -1918,7 +1918,7 @@ <h2>Start building.</h2> return '<span class="line">' + '<span class="' + lvlCls + '">' + lvl + ',</span> ' + '<span class="ts">[' + l[1] + ']</span> ' + - '<span class="kv">chain_id</span>=<span class="val">"' + chainId + '"</span> ' + + '<span class="kv">cid</span>=<span class="val">"' + chainId + '"</span> ' + '<span class="kv">task</span>=<span class="val">' + l[2] + '</span> ' + '<span class="kv">status</span>=<span class="' + statusClass[l[5]] + '">' + l[3] + '</span> ' + '<span class="kv">runtime</span>=' + l[4] + diff --git a/docs/v2-migration.md b/docs/v2-migration.md index 87cfbb1a0..875afc77b 100644 --- a/docs/v2-migration.md +++ b/docs/v2-migration.md @@ -50,7 +50,7 @@ CMDx 2.0 is a full runtime rewrite. The public DSL — `required`, `optional`, c 1. **Bump the gem.** `bundle update cmdx` and run the suite to surface breakage. 2. **Fix configuration.** Drop removed keys (see [Configuration](#configuration)). `rails generate cmdx:install` regenerates the v2 initializer as a reference. 3. **Fix tasks category-by-category.** Inputs → Outputs → Callbacks → Middlewares → Result consumers. The [Automated Migration Prompt](#automated-migration-prompt) mechanizes most of this. -4. **Audit result-handling code** for state-machine assumptions (`result.executing?`, `result.metadata[:x] = ...`, `result.chain_id`, `result.good?` / `bad?`) and any breakpoint / strict-mode configuration. +4. **Audit result-handling code** for state-machine assumptions (`result.executing?`, `result.metadata[:x] = ...`, `result.cid`, `result.good?` / `bad?`) and any breakpoint / strict-mode configuration. 5. **Move observability** (correlation IDs, runtime metrics, timeouts) to [Telemetry](#telemetry) subscribers or hand-rolled middlewares. 6. **Re-run the suite.** When green, delete dead helpers that papered over v1's rough edges (manual rollbacks, `dry_run:` flags, `SKIP_CMDX_FREEZING` toggles). 7. **Validate.** Run the grep list in [Validating the Migration](#validating-the-migration) to catch stragglers. @@ -125,7 +125,7 @@ Read task-level data off the returned `Result` instead. | v1 | v2 | |---|---| | `MyTask.execute(...).task` → instance | `result.task` → **class** (see [Result Consumers](#result-consumers)) | -| `task.id` | `result.id` | +| `task.id` | `result.tid` | | `task.result` | `execute` returns the `Result` directly | | `task.chain` | `result.chain` (a `Chain`, not an Array) | | `task.dry_run?` | removed — `dry_run` is gone | @@ -368,7 +368,7 @@ CMDx::Chain.current #=> nil (cleared on root teardown) | `result.complete?`, `result.interrupted?`, `result.success?`, `result.skipped?`, `result.failed?` | unchanged | | `result.good?` | `result.ok?` | | `result.bad?` | `result.ko?` | -| `result.chain_id` | `result.chain.id` | +| `result.chain_id` | `result.cid` | | `result.task` (instance) | `result.task` (**class**) | | `result.chain` (Array) | `result.chain` (`Chain`, Enumerable) | | `result.threw_failure?` | `result.thrown_failure?` (semantics flipped: true only when this result re-threw an upstream failure) | @@ -386,11 +386,11 @@ in [_, _, _, "failed", reason, *] then alert(reason) in { task:, status: "failed", cause: } then ... end -result.id # uuid_v7 +result.tid # uuid_v7 result.chain # Chain (Enumerable) -result.chain.id # chain's uuid_v7 -result.chain_index # position in chain -result.chain_root? # true when this result is the chain's root +result.cid # chain's uuid_v7 +result.index # position in chain +result.root? # true when this result is the chain's root result.duration # milliseconds (Float) result.retries # integer result.retried? # bool @@ -409,7 +409,7 @@ result.caused_failure # walks `origin` to the root-cause leaf result.caused_failure? # true when this result originated the failure ``` -`Result#to_h` no longer recursively serializes failure chains. `origin`, `threw_failure`, and `caused_failure` render as `{ task: Class, id: uuid }`, and `to_s` formats them as `<TaskClass uuid>`. +`Result#to_h` no longer recursively serializes failure chains. `origin`, `threw_failure`, and `caused_failure` render as `{ task: Class, tid: uuid }`, and `to_s` formats them as `<TaskClass uuid>`. See [Outcomes - Result](outcomes/result.md) for the full surface. @@ -614,11 +614,11 @@ v1's pattern for observing the runtime was to write a middleware. v2 ships a ded ```ruby CMDx.configure do |config| config.telemetry.subscribe(:task_executed) do |event| - StatsD.timing("cmdx.#{event.task_class}", event.payload[:result].duration) + StatsD.timing("cmdx.#{event.task}", event.payload[:result].duration) end config.telemetry.subscribe(:task_retried) do |event| - Rails.logger.warn("retry #{event.payload[:attempt]} for #{event.task_class}") + Rails.logger.warn("retry #{event.payload[:attempt]} for #{event.task}") end end ``` @@ -631,7 +631,7 @@ end | `:task_rolled_back` | empty | | `:task_executed` | `{ result: Result }` | -Every event carries a `Telemetry::Event` with `chain_id`, `chain_root`, `task_type`, `task_class`, `task_id`, `name`, `payload`, `timestamp`. Subscribe per-task via `MyTask.telemetry.subscribe(...)`. See [Configuration - Telemetry](configuration.md#telemetry). +Every event carries a `Telemetry::Event` with `cid`, `root`, `type`, `task`, `tid`, `name`, `payload`, `timestamp`. Subscribe per-task via `MyTask.telemetry.subscribe(...)`. See [Configuration - Telemetry](configuration.md#telemetry). --- @@ -747,13 +747,13 @@ end ```bash rg --hidden \ - 'task_breakpoints|workflow_breakpoints|rollback_on|dump_context|freeze_results|SKIP_CMDX_FREEZING|\.good\?|\.bad\?|chain_id[^=]|threw_failure\?|dry_run|attributes_schema|remove_attribute|remove_return|on_executed|on_good|on_bad|cmdx\.returns\.missing|cmdx\.faults\.(invalid|unspecified)|CMDx::Executor|CMDx::Middlewares::(Correlate|Runtime|Timeout)|CMDx::(SkipFault|FailFault|UndefinedMethodError)|register\s+:attribute|attribute\s+:' + 'task_breakpoints|workflow_breakpoints|rollback_on|dump_context|freeze_results|SKIP_CMDX_FREEZING|\.good\?|\.bad\?|cid[^=]|threw_failure\?|dry_run|attributes_schema|remove_attribute|remove_return|on_executed|on_good|on_bad|cmdx\.returns\.missing|cmdx\.faults\.(invalid|unspecified)|CMDx::Executor|CMDx::Middlewares::(Correlate|Runtime|Timeout)|CMDx::(SkipFault|FailFault|UndefinedMethodError)|register\s+:attribute|attribute\s+:' ``` -**3. Check one log line.** A successful task logs a v2-shaped record with `chain_id`, `chain_index`, `chain_root`, `type`, `task`, `id`, `state`, `status`, `duration`: +**3. Check one log line.** A successful task logs a v2-shaped record with `cid`, `index`, `root`, `type`, `task`, `id`, `state`, `status`, `duration`: ```text -cmdx: chain_id="0190..." chain_index=0 chain_root=true type="Task" task=MyTask id="0190..." state="complete" status="success" reason=nil metadata={} duration=12.34 ... +cmdx: cid="0190..." index=0 root=true type="Task" task=MyTask tid="0190..." state="complete" status="success" reason=nil metadata={} duration=12.34 ... ``` If you see `initialized` or `executing` in the output, something is serializing a v1 result. @@ -765,7 +765,7 @@ If you see `initialized` or `executing` in the output, something is serializing | Symptom | Fix | |---|---| | `NoMethodError: undefined method 'good?' for Result` | `result.good?` → `result.ok?`, `result.bad?` → `result.ko?` | -| `NoMethodError: undefined method 'chain_id'` | `result.chain_id` → `result.chain.id` | +| `NoMethodError: undefined method 'chain_id'` | `result.chain_id` → `result.cid` | | `NoMethodError: undefined method 'executed?' / 'executing?' / 'initialized?'` | Predicates removed; use `result.complete? \|\| result.interrupted?` | | `CMDx::MiddlewareError: middleware did not yield the next_link` | A middleware's `rescue` / `ensure` / early-return path skipped `yield`. Yield on every code path. | | `CMDx::ImplementationError: cannot define Workflow#work` | A workflow subclass defined `#work`. Delete it and move the body into `task` / `tasks` declarations. | @@ -861,7 +861,7 @@ by the rules here, stop and surface the file:line so a human can resolve it. ## Pass 5 — Result consumers - `result.good?` → `result.ok?`; `result.bad?` → `result.ko?`. -- `result.chain_id` → `result.chain.id`. +- `result.chain_id` → `result.cid`. - `result.threw_failure?` → `result.thrown_failure?` (semantics flipped — v2 is true ONLY when this result re-threw an upstream failure). - Delete any `result.initialized?`, `result.executing?`, or @@ -871,12 +871,12 @@ by the rules here, stop and surface the file:line so a human can resolve it. Move the data onto `task.context` BEFORE the halt. - `result.task` now returns the task CLASS (v1 returned the instance). Code that called `result.task.id`, `result.task.context`, etc. needs - `result.id`, `result.context`, and so on. + `result.tid`, `result.context`, and so on. - `result.chain` now returns the `Chain` object (Enumerable), not an Array. `result.chain.each`, `result.chain.map`, `result.chain.to_a`, and `result.chain[0]` all work. `result.chain.first` / `result.chain.last` too. - Replace `task.id` / `task.result` / `task.chain` (v1 Task instance - accessors) with `result.id` / `result` / `result.chain` off the returned + accessors) with `result.tid` / `result` / `result.chain` off the returned Result. - `rescue CMDx::SkipFault` / `rescue CMDx::FailFault` → `rescue CMDx::Fault`, then branch on `e.result.skipped?` / `e.result.failed?`. @@ -934,7 +934,7 @@ Run this grep from the project root: ```bash rg --hidden \ - 'task_breakpoints|workflow_breakpoints|rollback_on|dump_context|freeze_results|SKIP_CMDX_FREEZING|\.good\?|\.bad\?|chain_id[^=]|threw_failure\?|dry_run|attributes_schema|remove_attribute|remove_return|on_executed|on_good|on_bad|cmdx\.returns\.missing|cmdx\.faults\.(invalid|unspecified)|CMDx::Executor|CMDx::Middlewares::(Correlate|Runtime|Timeout)|CMDx::(SkipFault|FailFault|UndefinedMethodError)|register\s+:attribute|attribute\s+:' + 'task_breakpoints|workflow_breakpoints|rollback_on|dump_context|freeze_results|SKIP_CMDX_FREEZING|\.good\?|\.bad\?|cid[^=]|threw_failure\?|dry_run|attributes_schema|remove_attribute|remove_return|on_executed|on_good|on_bad|cmdx\.returns\.missing|cmdx\.faults\.(invalid|unspecified)|CMDx::Executor|CMDx::Middlewares::(Correlate|Runtime|Timeout)|CMDx::(SkipFault|FailFault|UndefinedMethodError)|register\s+:attribute|attribute\s+:' ``` Every hit is either (a) a string/comment that should be updated, or diff --git a/examples/active_record_query_tagging.md b/examples/active_record_query_tagging.md index 78609d796..db1b95040 100644 --- a/examples/active_record_query_tagging.md +++ b/examples/active_record_query_tagging.md @@ -3,7 +3,7 @@ Add a comment to every query indicating some context to help you track down where that query came from, eg: ```sh -/*cmdx_task_class:ExportReportTask,cmdx_chain_id:018c2b95-b764-7615*/ SELECT * FROM reports WHERE id = 1 +/*cmdx_task_class:ExportReportTask,cmdx_cid:018c2b95-b764-7615*/ SELECT * FROM reports WHERE id = 1 ``` ### Setup @@ -12,20 +12,20 @@ Add a comment to every query indicating some context to help you track down wher # config/application.rb config.active_record.query_log_tags_enabled = true config.active_record.query_log_tags += [ - :cmdx_correlation_id, - :cmdx_chain_id, - :cmdx_task_class, - :cmdx_task_id + :cmdx_task, + :cmdx_tid, + :cmdx_cid, + :cmdx_xid ] # lib/cmdx_query_tagging_middleware.rb class CmdxQueryTaggingMiddleware def self.call(task, **options, &) ActiveSupport::ExecutionContext.set( - cmdx_correlation_id: task.result.metadata[:correlation_id], - cmdx_chain_id: task.chain.id, - cmdx_task_class: task.class.name, - cmdx_task_id: task.id, + cmdx_task: task.class.name, + cmdx_tid: task.id, + cmdx_cid: task.cid, + cmdx_xid: task.result.metadata[:correlation_id], & ) end diff --git a/lib/cmdx/chain.rb b/lib/cmdx/chain.rb index 533fe0eca..9b972866e 100644 --- a/lib/cmdx/chain.rb +++ b/lib/cmdx/chain.rb @@ -37,8 +37,8 @@ def clear attr_reader :id, :results def initialize - @mutex = Mutex.new @id = SecureRandom.uuid_v7 + @mutex = Mutex.new @results = [] end @@ -74,7 +74,7 @@ def last # @return [Result, nil] the root result, or nil when absent def root - @results.find(&:chain_root?) + @results.find(&:root?) end # @return [String, nil] the state of the root result, or nil when absent diff --git a/lib/cmdx/result.rb b/lib/cmdx/result.rb index 61699c537..76475a8be 100644 --- a/lib/cmdx/result.rb +++ b/lib/cmdx/result.rb @@ -24,7 +24,7 @@ class Result # @param task [Task] the executed task instance # @param signal [Signal] the final signal from the task's lifecycle # @param options [Hash{Symbol => Object}] frozen execution metadata - # @option options [String] :id + # @option options [String] :tid # @option options [Boolean] :strict # @option options [Boolean] :deprecated # @option options [Boolean] :rolled_back @@ -38,8 +38,8 @@ def initialize(chain, task, signal, **options) end # @return [String] uuid_v7 identifier for this execution - def id - @options[:id] + def tid + @options[:tid] end # @return [Class<Task>] the task class that ran @@ -52,13 +52,18 @@ def type task.type end + # @return [String] uuid_v7 identifier for the chain this result belongs to + def cid + chain.id + end + # @return [Integer, nil] this result's position in the chain - def chain_index + def index @chain.index(self) end # @return [Boolean] true when this result is the root of the chain - def chain_root? + def root? !!@options[:root] end @@ -250,12 +255,12 @@ def tags # on failure. def to_h @to_h ||= { - chain_id: chain.id, - chain_index:, - chain_root: chain_root?, + cid:, + index:, + root: root?, type:, task:, - id:, + tid:, context:, state:, status:, @@ -292,7 +297,7 @@ def to_s if v.nil? buf << ks << "=nil" elsif ks == "origin" || ks.end_with?("_failure") - buf << ks << "=<" << v[:task].to_s << " " << v[:id] << ">" + buf << ks << "=<" << v[:task].to_s << " " << v[:tid] << ">" else buf << ks << "=" << v.inspect end @@ -329,7 +334,7 @@ def deconstruct # @return [Hash{Symbol => Object}] def deconstruct_keys(*) { - chain_root: chain_root?, + root: root?, type:, task:, state:, @@ -352,7 +357,7 @@ def hash_for_failure(key) r = public_send(key) return if r.nil? - { task: r.task, id: r.id } + { task: r.task, tid: r.tid } end end diff --git a/lib/cmdx/runtime.rb b/lib/cmdx/runtime.rb index 6a53d3d2a..635257f17 100644 --- a/lib/cmdx/runtime.rb +++ b/lib/cmdx/runtime.rb @@ -36,7 +36,6 @@ def execute(task, strict: false) def initialize(task, strict: false) @task = task @strict = strict - @id = SecureRandom.uuid_v7 end # Runs the full lifecycle. Teardown runs in `ensure`, guaranteeing the @@ -112,7 +111,7 @@ def finalize_result @task, @signal, root: @root, - id: @id, + tid: @task.tid, strict: @strict, deprecated: @deprecated, rolled_back: @rolled_back, @@ -209,11 +208,11 @@ def emit_telemetry(name, payload = EMPTY_HASH) return unless telemetry.subscribed?(name) event = Telemetry::Event.new( - chain_id: Chain.current.id, - chain_root: @root, - task_type: @task.class.type, - task_class: @task.class, - task_id: @id, + cid: Chain.current.id, + root: @root, + type: @task.class.type, + task: @task.class, + tid: @task.tid, name:, payload:, timestamp: Time.now.utc diff --git a/lib/cmdx/task.rb b/lib/cmdx/task.rb index 40ac6a80f..9312d7d7b 100644 --- a/lib/cmdx/task.rb +++ b/lib/cmdx/task.rb @@ -284,11 +284,12 @@ def undefine_input_reader(input) end - attr_reader :context, :errors, :metadata + attr_reader :tid, :context, :errors, :metadata alias ctx context # @param context [Hash, Context, #context, #to_h] def initialize(context = EMPTY_HASH) + @tid = SecureRandom.uuid_v7 @context = Context.build(context) @errors = Errors.new @metadata = {} diff --git a/lib/cmdx/telemetry.rb b/lib/cmdx/telemetry.rb index 4de481828..6fe0b4c1d 100644 --- a/lib/cmdx/telemetry.rb +++ b/lib/cmdx/telemetry.rb @@ -7,7 +7,7 @@ module CMDx class Telemetry # Immutable event payload passed to subscribers. - Event = Data.define(:chain_id, :chain_root, :task_type, :task_class, :task_id, :name, :payload, :timestamp) + Event = Data.define(:cid, :root, :type, :task, :tid, :name, :payload, :timestamp) # Lifecycle event names Runtime emits. EVENTS = %i[ diff --git a/skills/references/configuration.md b/skills/references/configuration.md index ce50bc730..1e08a883d 100644 --- a/skills/references/configuration.md +++ b/skills/references/configuration.md @@ -191,12 +191,12 @@ Events (`CMDx::Telemetry::EVENTS`): | `:task_rolled_back` | — | | `:task_executed` | `result:` (finalized `Result`) | -Shared `Event` fields: `chain_id`, `chain_root`, `task_type`, `task_class`, `task_id`, `name`, `payload`, `timestamp`. +Shared `Event` fields: `cid`, `root`, `type`, `task`, `tid`, `name`, `payload`, `timestamp`. ```ruby CMDx.configure do |c| c.telemetry.subscribe(:task_executed) do |event| - StatsD.timing("cmdx.#{event.task_class}", event.payload[:result].duration) + StatsD.timing("cmdx.#{event.task}", event.payload[:result].duration) end end ``` diff --git a/skills/references/result.md b/skills/references/result.md index e511cd78d..d35319373 100644 --- a/skills/references/result.md +++ b/skills/references/result.md @@ -43,7 +43,7 @@ Convenience predicates: |----------|-------------| | `task` | Task **class** that ran. | | `type` | `"Task"` or `"Workflow"`. | -| `id` | UUID v7 for this execution. | +| `tid` | UUID v7 for this execution. | | `context` | Shared `Context` (frozen on root). | | `errors` | `Errors` container (frozen). | | `state`, `status` | Strings. | @@ -59,8 +59,8 @@ Convenience predicates: | `rolled_back?` | `true` when `#rollback` ran. | | `tags` | `task.settings.tags`. | | `chain` | `CMDx::Chain`. | -| `chain_index` | Position in chain (Integer or `nil`). | -| `chain_root?` | `true` for the outermost result in the chain. | +| `index` | Position in chain (Integer or `nil`). | +| `root?` | `true` for the outermost result in the chain. | ## Chain analysis @@ -111,7 +111,7 @@ end Available keys: ``` -:chain_root, :type, :task, :state, :status, :reason, :metadata, +:root, :type, :task, :state, :status, :reason, :metadata, :cause, :origin, :strict, :deprecated, :retries, :rolled_back, :duration ``` @@ -127,9 +127,9 @@ end ### `to_h` -Memoized. Always includes: `:chain_id`, `:chain_index`, `:chain_root`, `:type`, `:task`, `:id`, `:context`, `:state`, `:status`, `:reason`, `:metadata`, `:strict`, `:deprecated`, `:retried`, `:retries`, `:duration`, `:tags`. +Memoized. Always includes: `:cid`, `:index`, `:root`, `:type`, `:task`, `:tid`, `:context`, `:state`, `:status`, `:reason`, `:metadata`, `:strict`, `:deprecated`, `:retried`, `:retries`, `:duration`, `:tags`. -When `failed?`, additionally includes: `:cause`, `:origin`, `:threw_failure`, `:caused_failure`, `:rolled_back`. Failure references render as `{ task: TaskClass, id: "uuid" }` — the live `Result` objects aren't walked to avoid cycles. +When `failed?`, additionally includes: `:cause`, `:origin`, `:threw_failure`, `:caused_failure`, `:rolled_back`. Failure references render as `{ task: TaskClass, tid: "uuid" }` — the live `Result` objects aren't walked to avoid cycles. ### `to_s` @@ -138,9 +138,9 @@ Space-separated `key=value.inspect`. Failure references render as `<TaskClass uu ## Chain ```ruby -result.chain # CMDx::Chain -result.chain.id # UUID v7 -result.chain.size # number of Results in this propagation +result.chain # CMDx::Chain +result.cid # UUID v7 +result.chain.size # number of Results in this propagation result.chain.map(&:task) # Classes, in insertion order ``` diff --git a/skills/references/testing.md b/skills/references/testing.md index 719de4264..844d9406d 100644 --- a/skills/references/testing.md +++ b/skills/references/testing.md @@ -25,7 +25,7 @@ RSpec.describe CreateUser do end ``` -Auto-generated predicate matchers: `be_complete`, `be_interrupted`, `be_success`, `be_skipped`, `be_failed`, `be_ok`, `be_ko`, `be_retried`, `be_rolled_back`, `be_strict`, `be_deprecated`, `be_chain_root`. +Auto-generated predicate matchers: `be_complete`, `be_interrupted`, `be_success`, `be_skipped`, `be_failed`, `be_ok`, `be_ko`, `be_retried`, `be_rolled_back`, `be_strict`, `be_deprecated`, `be_root`. ## Branching with `on` @@ -209,7 +209,7 @@ end `deconstruct`: `[type, task, state, status, reason, metadata, cause, origin]`. -`deconstruct_keys` exposes: `:chain_root`, `:type`, `:task`, `:state`, `:status`, `:reason`, `:metadata`, `:cause`, `:origin`, `:strict`, `:deprecated`, `:retries`, `:rolled_back`, `:duration`. +`deconstruct_keys` exposes: `:root`, `:type`, `:task`, `:state`, `:status`, `:reason`, `:metadata`, `:cause`, `:origin`, `:strict`, `:deprecated`, `:retries`, `:rolled_back`, `:duration`. ```ruby it "deconstructs to array" do diff --git a/spec/cmdx/chain_spec.rb b/spec/cmdx/chain_spec.rb index 514277858..311cfe72d 100644 --- a/spec/cmdx/chain_spec.rb +++ b/spec/cmdx/chain_spec.rb @@ -127,7 +127,7 @@ def build_result(signal = CMDx::Signal.success, **opts) end describe "#root" do - it "returns the result flagged as chain_root" do + it "returns the result flagged as root" do non_root = build_result root = build_result(root: true) chain.push(non_root).push(root) diff --git a/spec/cmdx/result_spec.rb b/spec/cmdx/result_spec.rb index d4ffd7fc0..9819557d9 100644 --- a/spec/cmdx/result_spec.rb +++ b/spec/cmdx/result_spec.rb @@ -13,17 +13,17 @@ def build(signal, **opts) describe "#initialize" do it "freezes the options hash" do - result = build(CMDx::Signal.success, id: "abc") + result = build(CMDx::Signal.success, tid: "abc") expect(result.instance_variable_get(:@options)).to be_frozen end end describe "simple delegators" do - let(:result) { build(CMDx::Signal.success, id: "rid-1", duration: 0.01) } + let(:result) { build(CMDx::Signal.success, tid: "rid-1", duration: 0.01) } - it "returns id, duration, task class, type" do + it "returns tid, duration, task, type" do expect(result).to have_attributes( - id: "rid-1", + tid: "rid-1", duration: 0.01, task: task_class, type: "Task" @@ -37,7 +37,7 @@ def build(signal, **opts) end it "exposes the chain id" do - expect(result.chain.id).to eq(chain.id) + expect(result.cid).to eq(chain.id) end it "reports the full chain results array" do @@ -49,9 +49,9 @@ def build(signal, **opts) expect(other.chain.to_a).to eq([result, other]) end - it "chain_index reports the index of self" do + it "index reports the index of self" do chain << result - expect(result.chain_index).to eq(0) + expect(result.index).to eq(0) end end @@ -181,16 +181,16 @@ def build(signal, **opts) describe "#to_h" do it "includes core fields for a success result" do - chain << (result = build(CMDx::Signal.success, id: "rid", duration: 0.1)) + chain << (result = build(CMDx::Signal.success, tid: "rid", duration: 0.1)) hash = result.to_h expect(hash).to include( - chain_id: chain.id, - chain_index: 0, - chain_root: false, + cid: chain.id, + index: 0, + root: false, type: "Task", task: task_class, - id: "rid", + tid: "rid", state: "complete", status: "success", duration: 0.1 @@ -212,8 +212,8 @@ def build(signal, **opts) describe "#to_s" do it "renders a space-separated key=value summary" do - chain << (result = build(CMDx::Signal.success, id: "rid")) - expect(result.to_s).to include("id=\"rid\"", "state=\"complete\"", "status=\"success\"") + chain << (result = build(CMDx::Signal.success, tid: "rid")) + expect(result.to_s).to include("tid=\"rid\"", "state=\"complete\"", "status=\"success\"") end end @@ -242,7 +242,7 @@ def build(signal, **opts) it "returns the full hash regardless of the keys argument" do expected = { - chain_root: false, + root: false, type: "Task", task: task_class, state: "interrupted", diff --git a/spec/cmdx/runtime_spec.rb b/spec/cmdx/runtime_spec.rb index c616db376..40d0d2b1e 100644 --- a/spec/cmdx/runtime_spec.rb +++ b/spec/cmdx/runtime_spec.rb @@ -82,7 +82,7 @@ end result = described_class.execute(outer.new) - expect(result.chain.id).to eq(result.context.inner_id) + expect(result.cid).to eq(result.context.inner_id) end end diff --git a/spec/integration/tasks/chain_spec.rb b/spec/integration/tasks/chain_spec.rb index ec7de2b9a..b3438c8e3 100644 --- a/spec/integration/tasks/chain_spec.rb +++ b/spec/integration/tasks/chain_spec.rb @@ -8,11 +8,11 @@ describe "a root execution with no subtasks" do subject(:result) { create_successful_task.execute } - it "collects only itself and exposes a UUIDv7 chain_id" do + it "collects only itself and exposes a UUIDv7 cid" do expect(result.chain.size).to eq(1) expect(result.chain.first).to be(result) - expect(result.chain_index).to eq(0) - expect(result.chain.id).to match(/\A\h{8}-\h{4}-7\h{3}-\h{4}-\h{12}\z/) + expect(result.index).to eq(0) + expect(result.cid).to match(/\A\h{8}-\h{4}-7\h{3}-\h{4}-\h{12}\z/) end end @@ -29,13 +29,13 @@ end it "indexes each result by its chain position" do - indexes = result.chain.map(&:chain_index) + indexes = result.chain.map(&:index) expect(indexes).to eq([0, 1, 2]) end - it "shares a single chain_id across every result" do - ids = result.chain.map { |r| r.chain.id }.uniq - expect(ids).to eq([result.chain.id]) + it "shares a single cid across every result" do + ids = result.chain.map(&:cid).uniq + expect(ids).to eq([result.cid]) end it "returns all results successful" do @@ -90,9 +90,9 @@ first = create_successful_task.execute second = create_successful_task.execute - expect(first.chain.id).to eq(second.chain.id) + expect(first.cid).to eq(second.cid) expect(first.chain).to be(second.chain) - expect(second.chain_index).to eq(1) + expect(second.index).to eq(1) expect(CMDx::Chain.current).not_to be_nil end end @@ -102,7 +102,7 @@ first = create_nested_task(strategy: :swallow, status: :success).execute second = create_successful_task.execute - expect(first.chain.id).not_to eq(second.chain.id) + expect(first.cid).not_to eq(second.cid) expect(first.chain.size).to eq(3) expect(second.chain.size).to eq(1) end diff --git a/spec/integration/tasks/execution_spec.rb b/spec/integration/tasks/execution_spec.rb index e1341e24d..1c18a27ad 100644 --- a/spec/integration/tasks/execution_spec.rb +++ b/spec/integration/tasks/execution_spec.rb @@ -19,9 +19,9 @@ end it "populates metadata attributes" do - expect(result.id).to match(/\A\h{8}-\h{4}-7\h{3}-\h{4}-\h{12}\z/) - expect(result.chain.id).to match(/\A\h{8}-\h{4}-7\h{3}-\h{4}-\h{12}\z/) - expect(result.chain_index).to eq(0) + expect(result.tid).to match(/\A\h{8}-\h{4}-7\h{3}-\h{4}-\h{12}\z/) + expect(result.cid).to match(/\A\h{8}-\h{4}-7\h{3}-\h{4}-\h{12}\z/) + expect(result.index).to eq(0) expect(result.duration).to be_a(Float).and be >= 0 expect(result.retries).to eq(0) expect(result).to have_attributes(strict?: false, retried?: false, deprecated?: false, rolled_back?: false) diff --git a/spec/integration/tasks/telemetry_spec.rb b/spec/integration/tasks/telemetry_spec.rb index 0c402f23f..f5bf97321 100644 --- a/spec/integration/tasks/telemetry_spec.rb +++ b/spec/integration/tasks/telemetry_spec.rb @@ -19,7 +19,7 @@ def with_telemetry(task, *names) expect(events.map(&:name)).to eq(%i[task_started task_executed]) expect(events.last.payload[:result]).to be(result) - expect(events.last.task_class).to be(task) + expect(events.last.task).to be(task) end it "emits task_rolled_back between failure and task_executed" do @@ -58,7 +58,7 @@ def with_telemetry(task, *names) end describe "event payload" do - it "carries chain_id, task class, task id, payload and timestamp" do + it "carries cid, tid, task class, payload and timestamp" do task = with_telemetry(create_successful_task, :task_executed) before = Time.now.utc @@ -67,10 +67,10 @@ def with_telemetry(task, *names) event = events.first expect(event).to have_attributes( name: :task_executed, - chain_id: result.chain.id, - task_type: task.type, - task_class: task, - task_id: result.id + cid: result.cid, + type: task.type, + task: task, + tid: result.tid ) expect(event.payload[:result]).to be(result) expect(event.timestamp).to be_a(Time).and(be >= before) diff --git a/spec/integration/workflows/nested_spec.rb b/spec/integration/workflows/nested_spec.rb index 213d4cc6d..dd6548524 100644 --- a/spec/integration/workflows/nested_spec.rb +++ b/spec/integration/workflows/nested_spec.rb @@ -29,10 +29,10 @@ expect(result.context).to have_attributes(inner_done: true, finalized: true) end - it "records everything under the same chain_id" do + it "records everything under the same cid" do result = outer.execute - expect(result.chain.map { |r| r.chain.id }.uniq.size).to eq(1) + expect(result.chain.map(&:cid).uniq.size).to eq(1) expect(result.chain.size).to eq(4) # inner_step, inner_wf, finalize, outer_wf end end diff --git a/spec/integration/workflows/parallel_spec.rb b/spec/integration/workflows/parallel_spec.rb index 45c7c67e1..b15a07540 100644 --- a/spec/integration/workflows/parallel_spec.rb +++ b/spec/integration/workflows/parallel_spec.rb @@ -39,7 +39,7 @@ expect(result.context).to have_attributes(a: true, b: true, b_saw: "original") end - it "shares a single chain_id across all parallel results" do + it "shares a single cid across all parallel results" do a = create_successful_task(name: "A") b = create_successful_task(name: "B") @@ -47,7 +47,7 @@ result = workflow.execute - expect(result.chain.map { |r| r.chain.id }.uniq.size).to eq(1) + expect(result.chain.map(&:cid).uniq.size).to eq(1) expect(result.chain.size).to eq(3) end end From b61d5e7eb342fecccb70ae9676ea8ab690c9b703 Mon Sep 17 00:00:00 2001 From: Juan Gomez <drexed@users.noreply.github.com> Date: Tue, 21 Apr 2026 11:22:06 -0400 Subject: [PATCH 09/54] V2 --- examples/active_job_durability.md | 43 ++-- .../active_record_database_transaction.md | 27 ++- examples/active_record_query_tagging.md | 45 ++-- examples/active_support_instrumentation.md | 50 ++--- examples/flipper_feature_flags.md | 65 +++--- examples/openapi_schema_generation.md | 195 ++++++------------ examples/paper_trail_whatdunnit.md | 43 ++-- examples/pub_sub_task_chaining.md | 45 ++-- examples/redis_idempotency.md | 84 ++++---- examples/sentry_error_tracking.md | 62 +++--- examples/sidekiq_async_execution.md | 30 ++- examples/stoplight_circuit_breaker.md | 44 ++-- 12 files changed, 351 insertions(+), 382 deletions(-) diff --git a/examples/active_job_durability.md b/examples/active_job_durability.md index 885266770..c8fbf7f2a 100644 --- a/examples/active_job_durability.md +++ b/examples/active_job_durability.md @@ -1,51 +1,48 @@ # Active Job Durability -Execute tasks reliably in the background using Active Job to ensure durability and handle retries. +Execute CMDx tasks reliably in the background by routing them through Active Job, so your queue backend (Sidekiq, Solid Queue, etc.) handles durability and retries. -### Adapter Pattern +## Setup -Create a generic job to wrap task execution: +A generic adapter job wraps any task name + context pair: ```ruby +# app/jobs/task_job.rb class TaskJob < ApplicationJob queue_as :default - # Configure retry policy for durability - retry_on StandardError, wait: :exponentially_longer, attempts: 5 + retry_on StandardError, wait: :polynomially_longer, attempts: 5 def perform(task_name, context = {}) - # 1. Resolve the task class - task = task_name.constantize - - # 2. Execute the task - result = task.execute(context) - - # 3. Handle failures - # Raise an exception to trigger Active Job's retry mechanism - # if the task failed unexpectedly. - raise result.cause if result.failure? + result = task_name.constantize.execute(context) + raise result.cause if result.failed? && result.cause end end ``` -### Integration - -Extend your base task to support background execution: +Extend your base task to expose an enqueue helper: ```ruby +# app/tasks/application_task.rb class ApplicationTask < CMDx::Task - # Enqueue the task to be performed later def self.perform_later(**attributes) TaskJob.perform_later(name, attributes) end end ``` -### Usage - -Execute any task asynchronously with full durability: +## Usage ```ruby -# The task will be serialized, persisted to Redis/DB, and retried on failure GenerateInvoice.perform_later(user_id: user.id, date: Date.today) ``` + +## Notes + +!!! note + + `wait: :polynomially_longer` is the Rails 7.1+ default; earlier releases used `:exponentially_longer`. + +!!! tip + + Re-raising `result.cause` lets Active Job's `retry_on` see the original exception. A task that finishes as `failed` via `fail!` (no `cause`) will not retry — inspect `result.reason` / `result.metadata` from an `on_failed` callback or a `:task_executed` telemetry subscriber instead. diff --git a/examples/active_record_database_transaction.md b/examples/active_record_database_transaction.md index 9b8448029..3aecd4925 100644 --- a/examples/active_record_database_transaction.md +++ b/examples/active_record_database_transaction.md @@ -1,27 +1,32 @@ -# Active Record Query Tagging +# Active Record Database Transaction -Wrap task or workflow execution in a database transaction. This is essential for data integrity when multiple steps modify the database. +Wrap a task's entire lifecycle in a database transaction so multi-step writes roll back together when something raises. -### Setup +## Setup ```ruby -# lib/cmdx_database_transaction_middleware.rb +# app/middlewares/cmdx_database_transaction_middleware.rb class CmdxDatabaseTransactionMiddleware - def self.call(task, **options, &) - ActiveRecord::Base.transaction(requires_new: true, &) + def call(_task) + ActiveRecord::Base.transaction(requires_new: true) { yield } end end ``` -### Usage +## Usage ```ruby -class MyTask < CMDx::Task - register :middleware, CmdxDatabaseTransactionMiddleware +class TransferFunds < CMDx::Task + register :middleware, CmdxDatabaseTransactionMiddleware.new def work - # Do work... + # ... end - end ``` + +## Notes + +!!! warning "Important" + + A task that halts with `fail!` returns a `Result` — it does **not** raise. The transaction only rolls back when an exception escapes the inner block. To force a rollback on logical failure, raise inside `rollback` (see [Rollback](../docs/v2-migration.md#rollback)) or call `execute!`, which re-raises as `CMDx::Fault`. diff --git a/examples/active_record_query_tagging.md b/examples/active_record_query_tagging.md index db1b95040..6996b3c42 100644 --- a/examples/active_record_query_tagging.md +++ b/examples/active_record_query_tagging.md @@ -1,46 +1,45 @@ # Active Record Query Tagging -Add a comment to every query indicating some context to help you track down where that query came from, eg: +Annotate every SQL query emitted during a task's execution so the task class, task id, chain id, and correlation id show up in your database logs: -```sh -/*cmdx_task_class:ExportReportTask,cmdx_cid:018c2b95-b764-7615*/ SELECT * FROM reports WHERE id = 1 +```sql +/*cmdx_task:ExportReport,cmdx_tid:018c2b95-b764-...,cmdx_cid:018c2b95-0878-...*/ SELECT * FROM reports WHERE id = 1 ``` -### Setup +## Setup ```ruby # config/application.rb config.active_record.query_log_tags_enabled = true -config.active_record.query_log_tags += [ - :cmdx_task, - :cmdx_tid, - :cmdx_cid, - :cmdx_xid -] - -# lib/cmdx_query_tagging_middleware.rb +config.active_record.query_log_tags += %i[cmdx_task cmdx_tid cmdx_cid cmdx_xid] + +# app/middlewares/cmdx_query_tagging_middleware.rb class CmdxQueryTaggingMiddleware - def self.call(task, **options, &) + def call(task) ActiveSupport::ExecutionContext.set( cmdx_task: task.class.name, - cmdx_tid: task.id, - cmdx_cid: task.cid, - cmdx_xid: task.result.metadata[:correlation_id], - & - ) + cmdx_tid: task.tid, + cmdx_cid: CMDx::Chain.current.id, + cmdx_xid: task.metadata[:correlation_id], + ) { yield } end end ``` -### Usage +## Usage ```ruby -class MyTask < CMDx::Task - register :middleware, CmdxQueryTaggingMiddleware +class ExportReport < CMDx::Task + register :middleware, CmdxQueryTaggingMiddleware.new def work - # Do work... + # ... end - end ``` + +## Notes + +!!! tip + + Pair `cmdx_cid` with your APM's correlation field. CMDx's default log line already emits the same `cid`, so a single id stitches the SQL log, the lifecycle log, and the APM trace together. diff --git a/examples/active_support_instrumentation.md b/examples/active_support_instrumentation.md index 32c13ce0c..f51ee6e27 100644 --- a/examples/active_support_instrumentation.md +++ b/examples/active_support_instrumentation.md @@ -1,59 +1,47 @@ -# ActiveSupport::Notifications Instrumentation +# ActiveSupport Instrumentation -This example demonstrates how to wrap CMDx tasks with `ActiveSupport::Notifications` to instrument execution time and other metrics. +Emit an `ActiveSupport::Notifications` event for every task execution so subscribers (log sinks, APM, StatsD shippers) can time and tag them. -## Middleware - -Create a middleware that wraps the task execution in an instrumentation block. +## Setup ```ruby -# app/middlewares/active_support_instrumentation.rb -class CmdxActiveSupportInstrumentation - def call(task, _options, &) +# app/middlewares/cmdx_instrumentation_middleware.rb +class CmdxInstrumentationMiddleware + def call(task) ActiveSupport::Notifications.instrument( "execute.cmdx", task: task.class.name, - task_id: task.id, - context: task.context.to_h, - & - ) + tid: task.tid, + cid: CMDx::Chain.current.id + ) { yield } end end ``` ## Usage -Register the middleware in your tasks or base task class. - ```ruby -# app/tasks/users/create.rb class Users::Create < CMDx::Task - register :middleware, CmdxActiveSupportInstrumentation + register :middleware, CmdxInstrumentationMiddleware.new required :email def work - # ... logic ... + # ... end end -``` - -## Subscriber -Subscribe to the event to log or process the metrics. - -```ruby # config/initializers/cmdx_instrumentation.rb -ActiveSupport::Notifications.subscribe("execute.cmdx") do |name, start, finish, id, payload| - duration = (finish - start) * 1000 - Rails.logger.info "Task #{payload[:task]} (ID: #{payload[:task_id]}) took #{duration.round(2)}ms" +ActiveSupport::Notifications.subscribe("execute.cmdx") do |*args| + event = ActiveSupport::Notifications::Event.new(*args) + Rails.logger.info( + "#{event.payload[:task]} (#{event.payload[:tid]}) took #{event.duration.round(2)}ms" + ) end ``` -## Result +## Notes -When `Users::Create.execute` is called, it will trigger the notification: +!!! tip -``` -Task Users::Create (ID: 018c2b95-...) took 45.21ms -``` + CMDx ships its own pub/sub with `result.duration`, `result.status`, and `result.retries` baked in — subscribe to `:task_executed` in [Telemetry](../docs/configuration.md#telemetry) when you don't need `ActiveSupport::Notifications`' wider ecosystem. diff --git a/examples/flipper_feature_flags.md b/examples/flipper_feature_flags.md index e05e3465f..603d0d973 100644 --- a/examples/flipper_feature_flags.md +++ b/examples/flipper_feature_flags.md @@ -1,50 +1,69 @@ # Flipper Feature Flags -Control task execution based on Flipper feature flags. +Gate task execution on a [Flipper](https://github.com/flippercloud/flipper) feature. Pick the pattern that matches the outcome you want when the flag is off: a **failed** result, or a **skipped** result. -<https://github.com/flippercloud/flipper> +## Failing When the Flag Is Off -### Setup +A middleware can't throw `skip!` / `fail!` (those must originate inside `work`), but it *can* record an error and yield — the pending error halts the lifecycle with a failed result. ```ruby -# lib/cmdx_flipper_middleware.rb +# app/middlewares/cmdx_flipper_middleware.rb class CmdxFlipperMiddleware - def self.call(task, **options, &) - feature_name = options.fetch(:feature) - actor = options.fetch(:actor, -> { task.context[:user] }) + def initialize(feature:, actor: nil) + @feature = feature + @actor = actor + end - # Resolve actor if it's a proc - actor = actor.call if actor.respond_to?(:call) + def call(task) + actor = resolve_actor(task) - if Flipper.enabled?(feature_name, actor) + if Flipper.enabled?(@feature, actor) yield else - # Option 1: Skip the task - task.skip!("Feature #{feature_name} is disabled") + task.errors.add(:base, "feature #{@feature} is disabled") + yield + end + end + + private - # Option 2: Fail the task - # task.fail!("Feature #{feature_name} is disabled") + def resolve_actor(task) + case @actor + when nil then task.context[:user] + when Symbol then task.send(@actor) + when Proc then task.instance_exec(&@actor) + else @actor.respond_to?(:call) ? @actor.call(task) : @actor end end end ``` -### Usage - ```ruby class NewFeatureTask < CMDx::Task - # Execute only if :new_feature is enabled for the user in context - register :middleware, CmdxFlipperMiddleware, - feature: :new_feature + register :middleware, CmdxFlipperMiddleware.new(feature: :new_feature) + register :middleware, CmdxFlipperMiddleware.new(feature: :beta_access, actor: -> { context[:company] }) - # Customize the actor resolution - register :middleware, CmdxFlipperMiddleware, - feature: :beta_access, - actor: -> { task.context[:company] } + def work + # ... + end +end +``` + +## Skipping When the Flag Is Off + +For a true `skipped` outcome, check inside `work` and halt with `skip!`: +```ruby +class NewFeatureTask < CMDx::Task def work + skip!("feature new_feature is disabled") unless Flipper.enabled?(:new_feature, context[:user]) # ... end end ``` +## Notes + +!!! note + + Middlewares wrap the entire lifecycle but sit **outside** `catch(Signal::TAG)` — calling `skip!` / `fail!` from a middleware escapes as `UncaughtThrowError`. Only `work` can emit those signals. See [Middlewares — Common Patterns](../docs/middlewares.md#common-patterns). diff --git a/examples/openapi_schema_generation.md b/examples/openapi_schema_generation.md index eb4fde3ba..5c7beb38a 100644 --- a/examples/openapi_schema_generation.md +++ b/examples/openapi_schema_generation.md @@ -1,174 +1,111 @@ # OpenAPI Schema Generation -This example demonstrates how to use the `CMDx::Attribute#to_h` method to introspect your command's attributes and generate an OpenAPI (Swagger) compatible schema definition. This is useful for automatically documenting your API endpoints based on your command objects. +Project a task's `inputs_schema` into an OpenAPI property definition so your API docs stay in lockstep with the task contract. -## Example Command - -First, let's define a command with various attribute types, including nested attributes and validations. +## Example Task ```ruby -require 'cmdx' -require 'json' - -class CreateUser < CMDx::Command - required :email, types: String, description: "The user's email address" - required :age, types: Integer, description: "The user's age in years" - optional :newsletter, types: [TrueClass, FalseClass], description: "Subscribe to newsletter", default: false - - required :address, types: Hash, description: "User's physical address" do - required :street, types: String, description: "Street name and number" - required :city, types: String, description: "City name" - optional :zip_code, types: [String, Integer], description: "Postal code" +class CreateUser < CMDx::Task + required :email, coerce: :string, description: "The user's email address" + required :age, coerce: :integer, description: "The user's age in years" + optional :newsletter, coerce: :boolean, default: false, description: "Subscribe to newsletter" + + required :address, coerce: :hash, description: "User's physical address" do + required :street, coerce: :string, description: "Street name and number" + required :city, coerce: :string, description: "City name" + optional :zip_code, coerce: [:string, :integer], description: "Postal code" end - def call - # implementation details... + def work + # ... end end ``` -## Schema Generator +## Generator -Now, let's create a simple generator that converts `CMDx` attributes into OpenAPI schema property definitions. +One recursive method walks `inputs_schema` and emits an OpenAPI `object` schema. Each input's `:options` hash carries the `:coerce` declaration — map its first symbol to an OpenAPI primitive. ```ruby -class OpenApiGenerator - TYPE_MAPPING = { - String => 'string', - Integer => 'integer', - Float => 'number', - TrueClass => 'boolean', - FalseClass => 'boolean', - Array => 'array', - Hash => 'object' - } - - def self.generate_properties(command_class) - properties = {} - required_fields = [] - - # Iterate over all attributes defined in the command - # attributes are stored in the settings[:attributes] registry - command_class.settings.attributes.registry.each do |attribute| - # Use to_h to get the raw attribute data - data = attribute.to_h - - name = data[:name] - prop_def = { - description: data[:description] - } - - # Handle Types - types = data[:types] - if types.any? - # Simple type mapping for the first type found, or 'string' fallback - # In a real generator, you might handle multiple types (oneOf) - mapped_type = TYPE_MAPPING[types.first] || 'string' - prop_def[:type] = mapped_type - end - - # Handle Nested Attributes (if type is object/Hash) - if data[:children].any? - prop_def[:type] = 'object' - nested_props, nested_required = generate_nested_properties(data[:children]) - prop_def[:properties] = nested_props - prop_def[:required] = nested_required unless nested_required.empty? - end - - properties[name] = prop_def - required_fields << name if data[:required] - end - - { - type: 'object', - properties: properties, - required: required_fields - } +# lib/openapi_schema_generator.rb +class OpenApiSchemaGenerator + COERCE_TO_TYPE = { + string: "string", + symbol: "string", + date: "string", + date_time: "string", + time: "string", + integer: "integer", + float: "number", + big_decimal: "number", + boolean: "boolean", + array: "array", + hash: "object" + }.freeze + + def self.generate(task_class) + build_object(task_class.inputs_schema.values) end - def self.generate_nested_properties(children_data) + def self.build_object(inputs) properties = {} - required_fields = [] + required = [] - children_data.each do |child_data| - name = child_data[:name] - prop_def = { - description: child_data[:description] - } + inputs.each do |input| + properties[input[:name]] = build_property(input) + required << input[:name] if input[:required] + end - types = child_data[:types] - if types.any? - mapped_type = TYPE_MAPPING[types.first] || 'string' - prop_def[:type] = mapped_type - end + { type: "object", properties:, required: } + end - # Recursion for deeper nesting would go here if needed + def self.build_property(input) + prop = { description: input[:description] }.compact + coerce = Array(input.dig(:options, :coerce)).first + prop[:type] = COERCE_TO_TYPE.fetch(coerce, "string") if coerce - properties[name] = prop_def - required_fields << name if child_data[:required] + if input[:children].any? + prop[:type] = "object" + nested = build_object(input[:children]) + prop[:properties] = nested[:properties] + prop[:required] = nested[:required] unless nested[:required].empty? end - [properties, required_fields] + prop end end ``` ## Usage -Generate the schema and output it as JSON. - ```ruby -schema = OpenApiGenerator.generate_properties(CreateUser) -puts JSON.pretty_generate(schema) +puts JSON.pretty_generate(OpenApiSchemaGenerator.generate(CreateUser)) ``` -## Output - -The resulting JSON schema structure: - ```json { "type": "object", "properties": { - "email": { - "description": "The user's email address", - "type": "string" - }, - "age": { - "description": "The user's age in years", - "type": "integer" - }, - "newsletter": { - "description": "Subscribe to newsletter", - "type": "boolean" - }, + "email": { "description": "The user's email address", "type": "string" }, + "age": { "description": "The user's age in years", "type": "integer" }, + "newsletter": { "description": "Subscribe to newsletter", "type": "boolean" }, "address": { "description": "User's physical address", "type": "object", "properties": { - "street": { - "description": "Street name and number", - "type": "string" - }, - "city": { - "description": "City name", - "type": "string" - }, - "zip_code": { - "description": "Postal code", - "type": "string" - } + "street": { "description": "Street name and number", "type": "string" }, + "city": { "description": "City name", "type": "string" }, + "zip_code": { "description": "Postal code", "type": "string" } }, - "required": [ - "street", - "city" - ] + "required": ["street", "city"] } }, - "required": [ - "email", - "age", - "address" - ] + "required": ["email", "age", "address"] } ``` + +## Notes + +!!! tip + + `inputs_schema` returns `{ name, description, required, options, children }` per input — the full declaration options sit under `:options`, so you can extend the generator to emit `format`, `enum`, `default`, etc. straight from validator/default keys (see [Inputs — Definitions](../docs/inputs/definitions.md#introspection)). diff --git a/examples/paper_trail_whatdunnit.md b/examples/paper_trail_whatdunnit.md index 9eb143e1c..a2119fe29 100644 --- a/examples/paper_trail_whatdunnit.md +++ b/examples/paper_trail_whatdunnit.md @@ -1,39 +1,38 @@ # Paper Trail Whatdunnit -Tag paper trail version records with which service made a change with a custom `whatdunnit` attribute. +Tag every [PaperTrail](https://github.com/paper-trail-gem/paper_trail) version record with the task class that caused the change. See [Storing Metadata](https://github.com/paper-trail-gem/paper_trail?tab=readme-ov-file#4c-storing-metadata). -<https://github.com/paper-trail-gem/paper_trail?tab=readme-ov-file#4c-storing-metadata> - -### Setup +## Setup ```ruby -# lib/cmdx_paper_trail_middleware.rb +# app/middlewares/cmdx_paper_trail_middleware.rb class CmdxPaperTrailMiddleware - def self.call(task, **options, &) - # This makes sure to reset the whatdunnit value to the previous - # value for nested task calls - - begin - PaperTrail.request.controller_info ||= {} - old_whatdunnit = PaperTrail.request.controller_info[:whatdunnit] - PaperTrail.request.controller_info[:whatdunnit] = task.class.name - yield - ensure - PaperTrail.request.controller_info[:whatdunnit] = old_whatdunnit - end + def call(task) + PaperTrail.request.controller_info ||= {} + previous = PaperTrail.request.controller_info[:whatdunnit] + PaperTrail.request.controller_info[:whatdunnit] = task.class.name + + yield + ensure + PaperTrail.request.controller_info[:whatdunnit] = previous end end ``` -### Usage +## Usage ```ruby -class MyTask < CMDx::Task - register :middleware, CmdxPaperTrailMiddleware +class UpdateSubscription < CMDx::Task + register :middleware, CmdxPaperTrailMiddleware.new def work - # Do work... + # ... end - end ``` + +## Notes + +!!! note + + Restoring the previous value in `ensure` keeps nested tasks from leaking their `whatdunnit` back to the caller when the chain unwinds. diff --git a/examples/pub_sub_task_chaining.md b/examples/pub_sub_task_chaining.md index c0adb228c..cf3c9ec21 100644 --- a/examples/pub_sub_task_chaining.md +++ b/examples/pub_sub_task_chaining.md @@ -1,10 +1,8 @@ # Pub/Sub Task Chaining -Decouple task execution using a Pub/Sub mechanism (like `ActiveSupport::Notifications`) where one task's success triggers another. +Decouple task sequencing by publishing a notification when one task succeeds and having a subscriber kick off the next. -### Setup - -Define the consumer task and the subscriber that listens for the event. +## Setup ```ruby # app/tasks/send_welcome_email.rb @@ -12,49 +10,46 @@ class SendWelcomeEmail < CMDx::Task required :user_id def work - # In a real app, this might be an API call to an email provider - puts "📧 Sending welcome email to User ##{user_id}..." + WelcomeMailer.with(user_id:).deliver_later end end # config/initializers/cmdx_subscriptions.rb ActiveSupport::Notifications.subscribe("user.registered") do |*args| event = ActiveSupport::Notifications::Event.new(*args) - - # Kick off the second task - SendWelcomeEmail.call(user_id: event.payload[:user_id]) + SendWelcomeEmail.execute(user_id: event.payload[:user_id]) end ``` -### Usage - -Define the publisher task that emits the event upon success. +## Usage ```ruby class RegisterUser < CMDx::Task - required :email - required :name + required :email, :name - # Publish the event only if the task succeeds on_success :publish_registration_event def work - # Simulate user creation logic - context.user_id = rand(1000..9999) - puts "✅ User '#{name}' registered with ID #{context.user_id}" + user = User.create!(email:, name:) + context.user_id = user.id end private def publish_registration_event - ActiveSupport::Notifications.instrument( - "user.registered", - user_id: context.user_id, - email: email - ) + ActiveSupport::Notifications.instrument("user.registered", user_id: context.user_id, email:) end end -# Execute the first task -RegisterUser.call(email: "jane@example.com", name: "Jane Doe") +RegisterUser.execute(email: "jane@example.com", name: "Jane Doe") ``` + +## Notes + +!!! warning "Important" + + `on_success` fires **after** `work`, so any payload the subscriber needs (`context.user_id` above) must be written to the context inside `work` first. + +!!! tip + + For framework-wide observability (not task-to-task chaining), subscribe to CMDx's own `:task_executed` event in [Telemetry](../docs/configuration.md#telemetry) — the subscriber receives the finalized `Result`, no custom instrumentation needed. diff --git a/examples/redis_idempotency.md b/examples/redis_idempotency.md index 0f11f37fa..3796a0b69 100644 --- a/examples/redis_idempotency.md +++ b/examples/redis_idempotency.md @@ -1,71 +1,69 @@ # Redis Idempotency -Ensure tasks are executed exactly once using Redis to store execution state. This is critical for non-idempotent operations like charging a credit card or sending an email. +Guard non-idempotent operations (charging a card, sending an email) by recording a Redis key around the task and refusing duplicates. -### Setup +## Setup ```ruby -# lib/cmdx_redis_idempotency_middleware.rb +# app/middlewares/cmdx_redis_idempotency_middleware.rb class CmdxRedisIdempotencyMiddleware - def self.call(task, **options, &block) - key = generate_key(task, options[:key]) - ttl = options[:ttl] || 5.minutes.to_i + def initialize(key:, ttl: 300) + @key = key + @ttl = ttl + end + + def call(task) + redis_key = "cmdx:idempotency:#{task.class.name}:#{resolve_id(task)}" - # Attempt to lock the key - if Redis.current.set(key, "processing", nx: true, ex: ttl) + if Redis.current.set(redis_key, "processing", nx: true, ex: @ttl) begin - block.call.tap |result| - Redis.current.set(key, result.status, xx: true, ex: ttl) - end - rescue => e - Redis.current.del(key) - raise(e) + yield + Redis.current.set(redis_key, "done", xx: true, ex: @ttl) + rescue StandardError + Redis.current.del(redis_key) + raise end else - # Key exists, handle duplicate - status = Redis.current.get(key) - - if status == "processing" - task.result.tap { |r| r.skip!("Duplicate request: currently processing", halt: true) } - else - task.result.tap { |r| r.skip!("Duplicate request: already processed (#{status})", halt: true) } - end + task.errors.add(:base, "duplicate request (state=#{Redis.current.get(redis_key)})") + yield end end - def self.generate_key(task, key_gen) - id = if key_gen.respond_to?(:call) - key_gen.call(task) - elsif key_gen.is_a?(Symbol) - task.send(key_gen) - else - task.context[:idempotency_key] - end + private - "cmdx:idempotency:#{task.class.name}:#{id}" + def resolve_id(task) + case @key + when Symbol then task.send(@key) + when Proc then @key.call(task) + else task.context[@key] + end end end ``` -### Usage +## Usage ```ruby class ChargeCustomer < CMDx::Task - # Use context[:payment_id] as the unique key - register :middleware, CmdxIdempotencyMiddleware, - key: ->(t) { t.context[:payment_id] } + register :middleware, CmdxRedisIdempotencyMiddleware.new(key: ->(t) { t.context[:payment_id] }) + + required :payment_id def work - # Charge logic... + # ... end end -# First run: Executes -ChargeCustomer.call(payment_id: "123") -# => Success - -# Second run: Skips -ChargeCustomer.call(payment_id: "123") -# => Skipped (reason: "Duplicate request: already processed (success)") +ChargeCustomer.execute(payment_id: "pay_123") # first call -> success +ChargeCustomer.execute(payment_id: "pay_123") # second call -> failed ("duplicate request ...") ``` +## Notes + +!!! warning "Important" + + A middleware cannot emit a `skipped` signal — that must originate inside `work`. This middleware surfaces duplicates as **failed** results by appending to `task.errors` before `yield`, so `signal_errors!` halts during input resolution with a clear reason. + +!!! tip + + To treat duplicates as *skipped* instead of *failed*, move the Redis check into `work` and call `skip!("duplicate")`. See [Outcomes — Annotating a Successful Result](../docs/outcomes/result.md#annotating-a-successful-result) for the `skip!` / `success!` / `fail!` mechanics. diff --git a/examples/sentry_error_tracking.md b/examples/sentry_error_tracking.md index 70b27cd68..8e9ed8368 100644 --- a/examples/sentry_error_tracking.md +++ b/examples/sentry_error_tracking.md @@ -1,42 +1,29 @@ # Sentry Error Tracking -Report unhandled exceptions and unexpected task failures to Sentry with detailed context. +Ship unhandled exceptions and logical failures to [Sentry](https://github.com/getsentry/sentry-ruby) with task-scoped context. -<https://github.com/getsentry/sentry-ruby> +## Reporting Exceptions -### Setup +A middleware wraps `yield` in a Sentry scope and captures anything that escapes. ```ruby -# lib/cmdx_sentry_middleware.rb +# app/middlewares/cmdx_sentry_middleware.rb class CmdxSentryMiddleware - def self.call(task, **options, &) + def call(task) Sentry.with_scope do |scope| - scope.set_tags(task: task.class.name) - scope.set_context(:user, Current.user.sentry_attributes) - - yield.tap do |result| - # Optional: Report logical failures if needed - if Array(options[:report_on]).include?(result.status) - Sentry.capture_message("Task #{result.status}: #{result.reason}", level: :warning) - end - end + scope.set_tags(task: task.class.name, tid: task.tid) + yield end - rescue => e + rescue StandardError => e Sentry.capture_exception(e) - raise(e) # Re-raise to let the task handle the error or bubble up + raise end end ``` -### Usage - ```ruby class ProcessPayment < CMDx::Task - # Report exceptions only - register :middleware, CmdxSentryMiddleware - - # Report exceptions AND logical failures (result.failure?) - register :middleware, CmdxSentryMiddleware, report_on: %w[failed skipped] + register :middleware, CmdxSentryMiddleware.new def work # ... @@ -44,3 +31,32 @@ class ProcessPayment < CMDx::Task end ``` +## Reporting Logical Failures + +A middleware can't see the finalized `Result`; subscribe to CMDx's `:task_executed` event instead. + +```ruby +# config/initializers/cmdx_sentry.rb +CMDx.configure do |config| + config.telemetry.subscribe(:task_executed) do |event| + result = event.payload[:result] + next if result.success? + + Sentry.capture_message( + "Task #{result.status}: #{result.reason}", + level: result.failed? ? :error : :warning, + tags: { task: event.task.name, tid: event.tid, cid: event.cid } + ) + end +end +``` + +## Notes + +!!! note + + The middleware re-raises after reporting so CMDx's own `perform_work` rescue can turn the exception into a `failed` result. Swallowing it would leave the task in limbo. + +!!! tip + + Only rescued exceptions populate `result.cause`. Failures that originate from `fail!` have `cause: nil`, so inspect `result.reason` / `result.metadata` in the telemetry subscriber. diff --git a/examples/sidekiq_async_execution.md b/examples/sidekiq_async_execution.md index f66ab1fee..e6a95b9f2 100644 --- a/examples/sidekiq_async_execution.md +++ b/examples/sidekiq_async_execution.md @@ -1,29 +1,37 @@ -# Sidekiq Async Execute +# Sidekiq Async Execution -Execute tasks asynchronously using Sidekiq without creating separate job classes. +Run tasks asynchronously via [Sidekiq](https://github.com/sidekiq/sidekiq) without defining a separate job class. -<https://github.com/sidekiq/sidekiq> - -### Setup +## Setup ```ruby -class MyTask < CMDx::Task +class ProcessExport < CMDx::Task include Sidekiq::Job + required :user_id + def work - # Do work... + # ... end - # Use execute! to trigger Sidekiq's retry logic on failures/exceptions. def perform(context = {}) self.class.execute!(context) end - end ``` -### Usage +## Usage ```ruby -MyTask.perform_async(work: "fast") +ProcessExport.perform_async(user_id: 42) ``` + +## Notes + +!!! note + + Sidekiq instantiates the class to call `#perform`, which then forwards to `self.class.execute!(context)` — that's a fresh task instance running the full CMDx lifecycle. `execute!` re-raises on failure, which triggers Sidekiq's `sidekiq_retry` machinery. + +!!! warning "Important" + + Sidekiq serializes arguments as JSON, so every value in `context` must round-trip through `JSON.dump` — pass `user_id: 42`, not `user: User.find(42)`. diff --git a/examples/stoplight_circuit_breaker.md b/examples/stoplight_circuit_breaker.md index 9bfc8d466..39db91874 100644 --- a/examples/stoplight_circuit_breaker.md +++ b/examples/stoplight_circuit_breaker.md @@ -1,36 +1,44 @@ # Stoplight Circuit Breaker -Integrate circuit breakers to protect external service calls and prevent cascading failures when dependencies are unavailable. +Wrap a task in a [Stoplight](https://github.com/bolshakov/stoplight) circuit breaker to shed load when a downstream dependency is misbehaving. -<https://github.com/bolshakov/stoplight> - -### Setup +## Setup ```ruby -# lib/cmdx_stoplight_middleware.rb +# app/middlewares/cmdx_stoplight_middleware.rb class CmdxStoplightMiddleware - def self.call(task, **options, &) - light = Stoplight(options[:name] || task.class.name, **options) - light.run(&) + def initialize(**options) + @options = options + end + + def call(task) + name = @options[:name] || task.class.name + Stoplight(name, **@options).run { yield } rescue Stoplight::Error::RedLight => e - task.result.tap { |r| r.fail!("[#{e.class}] #{e.message}", cause: e) } + task.errors.add(:base, "[#{e.class}] #{e.message}") + yield end end ``` -### Usage +## Usage ```ruby -class MyTask < CMDx::Task - # With default options - register :middleware, CmdxStoplightMiddleware - - # With stoplight options - register :middleware, CmdxStoplightMiddleware, cool_off_time: 10 +class FetchInventory < CMDx::Task + register :middleware, CmdxStoplightMiddleware.new(cool_off_time: 10) def work - # Do work... + # ... end - end ``` + +## Notes + +!!! note + + When the light is red, the middleware records the breaker error on `task.errors` and `yield`s. `signal_errors!` picks the error up during input resolution and halts with a **failed** result; `execute!` surfaces it as `CMDx::Fault`. + +!!! tip + + Stoplight itself needs a data store for production use (`Stoplight::DataStore::Redis`, etc.). Configure it once at boot — the middleware only wires the breaker around each execution. From bffb3ab5924d2a3eae79eafc625c503c0405b87c Mon Sep 17 00:00:00 2001 From: Juan Gomez <drexed@users.noreply.github.com> Date: Tue, 21 Apr 2026 11:56:49 -0400 Subject: [PATCH 10/54] V2 --- lib/cmdx/input.rb | 4 ++-- spec/cmdx/chain_spec.rb | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/cmdx/input.rb b/lib/cmdx/input.rb index b8fa0c39e..7d74cbf44 100644 --- a/lib/cmdx/input.rb +++ b/lib/cmdx/input.rb @@ -216,7 +216,7 @@ def fetch_by_name(obj) # so an explicit nil is treated as not provided (triggers default/required). value = obj[name] [value, !value.nil?] - elsif obj.respond_to?(name) + elsif obj.respond_to?(name, true) [obj.send(name), true] else [nil, false] @@ -241,7 +241,7 @@ def apply_default(task) def apply_transform(value, task) case transform when Symbol - if value.respond_to?(transform) + if value.respond_to?(transform, true) value.send(transform) else task.send(transform, value) diff --git a/spec/cmdx/chain_spec.rb b/spec/cmdx/chain_spec.rb index 311cfe72d..24a521925 100644 --- a/spec/cmdx/chain_spec.rb +++ b/spec/cmdx/chain_spec.rb @@ -270,3 +270,4 @@ def build_result(signal = CMDx::Signal.success, **opts) end end end + From edf2d2a4d9fb622d70eef6d623ac581bd9e60d10 Mon Sep 17 00:00:00 2001 From: Juan Gomez <drexed@users.noreply.github.com> Date: Tue, 21 Apr 2026 12:19:13 -0400 Subject: [PATCH 11/54] V2 --- lib/cmdx/input.rb | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/cmdx/input.rb b/lib/cmdx/input.rb index 7d74cbf44..6f9ee5512 100644 --- a/lib/cmdx/input.rb +++ b/lib/cmdx/input.rb @@ -203,7 +203,9 @@ def resolve_from_parent_with_key(parent_value) end def fetch_by_name(obj) - if obj.respond_to?(:key?) + if obj.respond_to?(name, true) + [obj.send(name), true] + elsif obj.respond_to?(:key?) if obj.key?(name) [obj[name], true] elsif obj.key?(name_str = name.to_s) @@ -214,10 +216,8 @@ def fetch_by_name(obj) elsif obj.respond_to?(:[]) # Without #key? we cannot distinguish "key absent" from "value is nil", # so an explicit nil is treated as not provided (triggers default/required). - value = obj[name] + value = obj[name] || obj[name.to_s] [value, !value.nil?] - elsif obj.respond_to?(name, true) - [obj.send(name), true] else [nil, false] end From db92236948108d3b473a922c75937cf1f1b143b2 Mon Sep 17 00:00:00 2001 From: Juan Gomez <drexed@users.noreply.github.com> Date: Tue, 21 Apr 2026 14:28:21 -0400 Subject: [PATCH 12/54] V2 --- docs/outcomes/errors.md | 135 +++++++++++++++++++++++++++++++++++++++ lib/cmdx/errors.rb | 16 +++++ mkdocs.yml | 2 + spec/cmdx/errors_spec.rb | 44 +++++++++++++ 4 files changed, 197 insertions(+) create mode 100644 docs/outcomes/errors.md diff --git a/docs/outcomes/errors.md b/docs/outcomes/errors.md new file mode 100644 index 000000000..54778b4b7 --- /dev/null +++ b/docs/outcomes/errors.md @@ -0,0 +1,135 @@ +# Outcomes - Errors + +`CMDx::Errors` is the per-task container for keyed, deduplicating failure messages — typically one key per attribute name. Validators, coercions, output verification, and hand-rolled `errors.add(...)` calls inside `work` all write here; a non-empty container at any lifecycle checkpoint causes `Runtime` to throw a failed signal. + +!!! note + + `task.errors` and `result.errors` are the same object. Runtime teardown freezes `Errors` alongside `Task` and `Context`, so post-execution the container, its hash, and each underlying message `Set` are frozen. + +## Access + +Inside `work`, errors are reachable via the `errors` reader (or `task.errors` from outside). After execution, the same container is exposed on the frozen result: + +```ruby +class CreateUser < CMDx::Task + required :email, :password + + def work + errors.add(:email, "already taken") if User.exists?(email: email) + errors.add(:email, "must be verified") unless email_verified?(email) + end +end + +result = CreateUser.execute(email: "taken@example.com", password: "secret") + +result.failed? #=> true +result.errors.to_h #=> { email: ["already taken", "must be verified"] } +result.errors.frozen? #=> true +``` + +## API + +| Method | Purpose | +| ------ | ------- | +| `add(key, message)` | Append a message under a key; duplicates for the same key are silently dropped (backed by a `Set`). | +| `errors[key] = message` | Alias for `add`. | +| `merge!(other)` | Union every `(key, message)` pair from another `Errors` (or any object responding to `#to_hash`) into self. | +| `delete(key)` | Remove the key entirely; returns the removed `Set` or `nil`. | +| `clear` | Empty the container. Raises `FrozenError` post-teardown. | + +| Method | Returns | +| ------ | ------- | +| `errors[key]` | `Array<String>` of messages under `key`, or a frozen empty array when absent. | +| `errors.added?(key, message)` | `true` when the exact message was recorded under `key`. | +| `errors.key?(key)` / `for?(key)` | `true` when `key` has at least one message. | +| `errors.keys` | Keys that have at least one message, in insertion order. | +| `errors.empty?` | `true` when no messages have been added. | +| `errors.size` | Number of distinct keys. | +| `errors.count` | Total messages across all keys. | +| `errors.each` | Yields `[Symbol, Set<String>]` pairs. `each_key` and `each_value` are also available. | + +```ruby +def work + errors.add(:amount, "must be positive") if amount.negative? + errors[:amount] = "cannot exceed daily limit" if amount > 10_000 + + # Fold in errors from a child task's result without overwriting local ones + sub = ValidateAddress.execute(address: context.address) + errors.merge!(sub.errors) if sub.failed? +end +``` + +Because `Errors` includes `Enumerable`, every standard enumerable method works (`any?`, `select`, `find`, `group_by`, `partition`, ...): + +```ruby +result.errors.any? { |_key, set| set.size > 1 } # keys with multiple messages +result.errors.select { |key, _set| key.to_s.start_with?("address_") } +``` + +## Rendering + +```ruby +class ConfigureServer < CMDx::Task + required :hostname, :port, coerce: :integer +end + +result = ConfigureServer.execute(port: "abc") + +result.errors.to_h +#=> { hostname: ["is required"], port: ["could not coerce into an integer"] } + +result.errors.full_messages +#=> { hostname: ["hostname is required"], +# port: ["port could not coerce into an integer"] } + +result.errors.to_s +#=> "hostname is required. port could not coerce into an integer" + +result.reason == result.errors.to_s #=> true +``` + +`to_hash` mirrors `to_h` by default and `full_messages` when called with `true`. + +## Failure Propagation + +Runtime checks `task.errors.empty?` at three lifecycle checkpoints: after input resolution, after `work` returns, and after output verification. A non-empty container at any checkpoint short-circuits the rest of the lifecycle by throwing a failed signal whose `reason` is `errors.to_s` and whose `metadata` is `task.metadata`. + +```mermaid +flowchart LR + Resolve[Resolve inputs] --> C1{errors.empty?} + C1 -->|no| Fail["throw Signal.failed<br/>reason = errors.to_s<br/>metadata = task.metadata"] + C1 -->|yes| Work[work] + Work --> C2{errors.empty?} + C2 -->|no| Fail + C2 -->|yes| Verify[Verify outputs] + Verify --> C3{errors.empty?} + C3 -->|no| Fail + C3 -->|yes| Ok[Signal.success] +``` + +This is `Runtime#signal_errors!`, called at each stage. + +!!! warning "Important" + + Adding errors inside `work` does **not** halt execution immediately — the throw happens after `work` returns (and again after output verification). To halt mid-`work`, use `fail!(...)` instead. + +## Freeze Semantics + +```ruby +result = CreateUser.execute(email: "") + +result.errors.frozen? #=> true +result.errors.messages.frozen? #=> true +result.errors.messages[:email].frozen? #=> true (the underlying Set) +result.errors[:email].frozen? #=> false (#[] returns a fresh Array via Set#to_a) +result.errors.add(:x, "y") #=> raises FrozenError +``` + +`Errors#freeze` deep-freezes every message `Set` before freezing the container itself. + +## See Also + +- [Inputs - Validations](../inputs/validations.md) — validators that populate `errors` automatically. +- [Inputs - Coercions](../inputs/coercions.md) — coercion failures land here. +- [Outputs](../outputs.md) — output verification errors fold into the same container. +- [v1 → v2 Migration](../v2-migration.md#errors) — what changed about `Errors` in 2.0. diff --git a/lib/cmdx/errors.rb b/lib/cmdx/errors.rb index a4335a2f0..d56b3c147 100644 --- a/lib/cmdx/errors.rb +++ b/lib/cmdx/errors.rb @@ -24,6 +24,22 @@ def add(key, message) end alias []= add + # Copies every message from `other` into self. Existing messages are + # preserved and duplicates (same key + message) are silently dropped by + # the underlying Set. Accepts any object that responds to `#to_hash` + # returning `Hash{Symbol => Enumerable<String>}` — typically another + # {Errors} instance. + # + # @param other [Errors, #to_hash] + # @return [void] + # @example Combine validation errors from a nested task + # parent.errors.merge!(child.result.errors) + def merge!(other) + other.to_hash.each do |key, messages| + messages.each { |message| add(key, message) } + end + end + # @param key [Symbol] # @return [Array<String>] messages for `key`, or a frozen empty array def [](key) diff --git a/mkdocs.yml b/mkdocs.yml index cad3a2f40..93c3907ba 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -127,6 +127,7 @@ plugins: - outcomes/result.md: Result objects exposing execution state, status, context, and metadata - outcomes/states.md: Execution lifecycle states (initialized, executing, complete, interrupted) - outcomes/statuses.md: Business outcome statuses (success, skipped, failed) and transitions + - outcomes/errors.md: Structured per-attribute error container accumulated during input/output verification and exposed on result.errors Attributes: - inputs/definitions.md: Attribute declarations (required/optional), validation, and type coercion - inputs/naming.md: Customizing accessor method names with prefixes and suffixes @@ -166,6 +167,7 @@ nav: - Result: outcomes/result.md - States: outcomes/states.md - Statuses: outcomes/statuses.md + - Errors: outcomes/errors.md - Inputs: - Definitions: inputs/definitions.md - Naming: inputs/naming.md diff --git a/spec/cmdx/errors_spec.rb b/spec/cmdx/errors_spec.rb index 362bde43e..ebec942e5 100644 --- a/spec/cmdx/errors_spec.rb +++ b/spec/cmdx/errors_spec.rb @@ -36,6 +36,50 @@ end end + describe "#merge!" do + let(:other) { described_class.new } + + it "copies messages from another Errors instance" do + other.add(:name, "is required") + other.add(:age, "too young") + errors.merge!(other) + + expect(errors.to_h).to eq(name: ["is required"], age: ["too young"]) + end + + it "preserves existing messages and unions with incoming ones" do + errors.add(:name, "is required") + other.add(:name, "is too short") + other.add(:age, "too young") + errors.merge!(other) + + expect(errors[:name]).to contain_exactly("is required", "is too short") + expect(errors[:age]).to contain_exactly("too young") + end + + it "deduplicates overlapping messages under the same key" do + errors.add(:name, "is required") + other.add(:name, "is required") + errors.merge!(other) + + expect(errors[:name]).to contain_exactly("is required") + end + + it "is a no-op when the other container is empty" do + errors.add(:name, "is required") + errors.merge!(other) + + expect(errors.to_h).to eq(name: ["is required"]) + end + + it "accepts any object responding to #to_hash" do + hash_like = Class.new { def to_hash = { name: ["is required"] } }.new + errors.merge!(hash_like) + + expect(errors[:name]).to contain_exactly("is required") + end + end + describe "#[]" do it "returns the array of messages for a key" do errors.add(:age, "too young") From 5b00420df1d13ca6e7c481f37202d17ecd8e3d73 Mon Sep 17 00:00:00 2001 From: Juan Gomez <drexed@users.noreply.github.com> Date: Tue, 21 Apr 2026 22:49:58 -0400 Subject: [PATCH 13/54] V2 --- lib/cmdx/inputs.rb | 2 +- lib/cmdx/output.rb | 2 +- lib/cmdx/pipeline.rb | 4 +--- lib/cmdx/settings.rb | 7 ++++++- lib/cmdx/signal.rb | 12 +++++++---- lib/cmdx/workflow.rb | 8 +++++--- lib/generators/cmdx/templates/install.rb | 16 ++++++++++----- spec/cmdx/inputs_spec.rb | 14 +++++++++++++ spec/cmdx/output_spec.rb | 20 +++++++++++++++++++ spec/cmdx/pipeline_spec.rb | 4 ++-- spec/cmdx/settings_spec.rb | 21 ++++++++++++++++++++ spec/cmdx/workflow_spec.rb | 18 ++++++++++++----- spec/integration/workflows/execution_spec.rb | 2 +- 13 files changed, 104 insertions(+), 26 deletions(-) diff --git a/lib/cmdx/inputs.rb b/lib/cmdx/inputs.rb index 420269aa8..3e6a0c056 100644 --- a/lib/cmdx/inputs.rb +++ b/lib/cmdx/inputs.rb @@ -76,7 +76,7 @@ def resolve(task) private def resolve_children(input, parent_value, task) - return if input.children.empty? || parent_value.nil? + return if input.children.empty? || parent_value.nil? || parent_value.is_a?(Coercions::Failure) input.children.each do |child| child_value = child.resolve_from_parent(parent_value, task) diff --git a/lib/cmdx/output.rb b/lib/cmdx/output.rb index 22b8a421e..66773837a 100644 --- a/lib/cmdx/output.rb +++ b/lib/cmdx/output.rb @@ -133,7 +133,7 @@ def apply_default(task) def apply_transform(value, task) case transform when Symbol - if value.respond_to?(transform) + if value.respond_to?(transform, true) value.send(transform) else task.send(transform, value) diff --git a/lib/cmdx/pipeline.rb b/lib/cmdx/pipeline.rb index 4c67aecca..39358ab67 100644 --- a/lib/cmdx/pipeline.rb +++ b/lib/cmdx/pipeline.rb @@ -29,11 +29,9 @@ def initialize(workflow) # failed result halts execution by throwing through the workflow. # # @return [void] - # @raise [ArgumentError] for an empty task group or unknown strategy + # @raise [ArgumentError] for an unknown strategy def execute @workflow.class.pipeline.each do |group| - raise ArgumentError, "no tasks in group" if group.tasks.empty? - next unless Util.satisfied?(group.options[:if], group.options[:unless], @workflow) halt = diff --git a/lib/cmdx/settings.rb b/lib/cmdx/settings.rb index 25a76ac08..f43ccb2b3 100644 --- a/lib/cmdx/settings.rb +++ b/lib/cmdx/settings.rb @@ -55,9 +55,14 @@ def backtrace_cleaner end end + # Returns a fresh array each call so callers can mutate the result + # without affecting other tasks (or hitting `FrozenError` on the + # shared sentinel). + # # @return [Array<Symbol, String>] task tags, surfaced on result hashes def tags - @options[:tags] || EMPTY_ARRAY + tags = @options[:tags] + tags ? tags.dup : [] end end diff --git a/lib/cmdx/signal.rb b/lib/cmdx/signal.rb index d86e7d00c..abe456d00 100644 --- a/lib/cmdx/signal.rb +++ b/lib/cmdx/signal.rb @@ -27,25 +27,29 @@ class Signal class << self - # Builds (or returns a memoized singleton of) a successful signal. + # Builds a successful signal (state `complete`, status `success`). # # @param reason [String, nil] optional human-readable reason # @param options [Hash{Symbol => Object}] optional `:metadata`, `:cause`, `:backtrace` - # @return [Signal] frozen success singleton when no args, otherwise a new instance + # @return [Signal] new instance with frozen options def success(reason = nil, **options) new(COMPLETE, SUCCESS, **options, reason:) end + # Builds a skipped signal (state `interrupted`, status `skipped`). + # # @param reason [String, nil] optional human-readable reason # @param options [Hash{Symbol => Object}] optional `:metadata`, `:cause`, `:backtrace` - # @return [Signal] frozen skipped singleton when no args, otherwise a new instance + # @return [Signal] new instance with frozen options def skipped(reason = nil, **options) new(INTERRUPTED, SKIPPED, **options, reason:) end + # Builds a failed signal (state `interrupted`, status `failed`). + # # @param reason [String, nil] optional human-readable reason # @param options [Hash{Symbol => Object}] optional `:metadata`, `:cause`, `:backtrace` - # @return [Signal] frozen failed singleton when no args, otherwise a new instance + # @return [Signal] new instance with frozen options def failed(reason = nil, **options) new(INTERRUPTED, FAILED, **options, reason:) end diff --git a/lib/cmdx/workflow.rb b/lib/cmdx/workflow.rb index 98752112a..6662d8496 100644 --- a/lib/cmdx/workflow.rb +++ b/lib/cmdx/workflow.rb @@ -22,8 +22,8 @@ def pipeline @pipeline ||= [] end - # Declares a task group. With no tasks, returns the pipeline. Tasks - # must be `Task` subclasses. + # Declares a task group. With no arguments, returns the pipeline. + # Tasks must be `Task` subclasses. # # @param tasks [Array<Class<Task>>] # @param options [Hash{Symbol => Object}] @@ -32,9 +32,11 @@ def pipeline # @option options [Symbol, Proc, #call] :if # @option options [Symbol, Proc, #call] :unless # @return [Array<ExecutionGroup>] the full pipeline + # @raise [DefinitionError] when called with options but no tasks # @raise [TypeError] when any element isn't a `Task` subclass def tasks(*tasks, **options) - return pipeline if tasks.empty? + # return pipeline if tasks.empty? && options.empty? + raise DefinitionError, "#{name}: cannot declare an empty task group" if tasks.empty? pipeline << ExecutionGroup.new( tasks: diff --git a/lib/generators/cmdx/templates/install.rb b/lib/generators/cmdx/templates/install.rb index 91218663b..c33f6c48e 100644 --- a/lib/generators/cmdx/templates/install.rb +++ b/lib/generators/cmdx/templates/install.rb @@ -47,18 +47,24 @@ # :on_ok, :on_ko # # config.callbacks.register(:on_failed, proc do |task| - # Rails.logger.error("[cmdx] #{task.class.name} failed: #{task.result.metadata[:reason]}") + # Rails.logger.error("[cmdx] #{task.class.name} failed: #{task.metadata[:reason]}") # end) # =========================================================================== # Telemetry # =========================================================================== - # Events: - # :task_started, :task_deprecated, :task_retried, - # :task_rolled_back, :task_executed + # Events and payloads: + # :task_started payload: {} + # :task_deprecated payload: {} + # :task_retried payload: { attempt: Integer } + # :task_rolled_back payload: {} + # :task_executed payload: { result: CMDx::Result } + # + # Every event also carries: event.cid, event.tid, event.task, event.type, + # event.root, event.timestamp. # # config.telemetry.subscribe(:task_executed, proc do |event| - # StatsD.timing("cmdx.#{event.name}", event.payload[:runtime]) + # StatsD.timing("cmdx.task", event.payload[:result].duration) # end) # =========================================================================== diff --git a/spec/cmdx/inputs_spec.rb b/spec/cmdx/inputs_spec.rb index 3c185bb10..981f5d94a 100644 --- a/spec/cmdx/inputs_spec.rb +++ b/spec/cmdx/inputs_spec.rb @@ -125,6 +125,20 @@ expect(task.instance_variable_defined?(:@_input_email)).to be(false) end + + it "skips nested resolution when the parent failed to coerce" do + inputs.register(task_class, :profile, coerce: :hash) do + required :email + end + + task = task_class.new + task.context.profile = "not-json" + inputs.resolve(task) + + expect(task.errors.keys).to eq([:profile]) + expect(task.errors[:email]).to be_empty + expect(task.instance_variable_defined?(:@_input_email)).to be(false) + end end describe "ChildBuilder DSL" do diff --git a/spec/cmdx/output_spec.rb b/spec/cmdx/output_spec.rb index e8f4f13ed..041145582 100644 --- a/spec/cmdx/output_spec.rb +++ b/spec/cmdx/output_spec.rb @@ -258,6 +258,26 @@ def call(task) expect(task.context[:name]).to eq("42!!!") end + it "resolves a private method on the value" do + klass = Class.new do + def initialize(v) = (@v = v) + + private + + def squish = @v.gsub(/\s+/, " ").strip + end + + task_class = create_task_class(name: "OutTransformPrivate") do + output :line, transform: :squish + end + task = task_class.new + task.context[:line] = klass.new(" hi there ") + + task_class.outputs.registry[:line].verify(task) + + expect(task.context[:line]).to eq("hi there") + end + it "applies a Proc transform via instance_exec" do task_class = create_task_class(name: "OutTransformProc") do output :tags, transform: proc { |v| v.uniq.sort } diff --git a/spec/cmdx/pipeline_spec.rb b/spec/cmdx/pipeline_spec.rb index c2492a90a..b7f620937 100644 --- a/spec/cmdx/pipeline_spec.rb +++ b/spec/cmdx/pipeline_spec.rb @@ -28,11 +28,11 @@ end context "when a group has no tasks" do - it "fails the workflow with the error surfaced via the fault" do + it "is a silent no-op (declaration-time validation prevents this in normal use)" do workflow_class = create_workflow_class workflow_class.pipeline << CMDx::Workflow::ExecutionGroup.new(tasks: [], options: {}) - expect { workflow_class.execute! }.to raise_error(ArgumentError, "no tasks in group") + expect(workflow_class.execute).to be_success end end diff --git a/spec/cmdx/settings_spec.rb b/spec/cmdx/settings_spec.rb index 80d03b60b..300ba4c6c 100644 --- a/spec/cmdx/settings_spec.rb +++ b/spec/cmdx/settings_spec.rb @@ -77,6 +77,27 @@ it "returns the configured tags" do expect(described_class.new(tags: %w[a b]).tags).to eq(%w[a b]) end + + it "returns a fresh array on each call so callers can mutate without leaking" do + settings = described_class.new(tags: %w[a]) + + first = settings.tags + first << "mutated" + + expect(settings.tags).to eq(%w[a]) + expect(first).not_to be_frozen + end + + it "default empty array is mutable and unique per call" do + settings = described_class.new + + a = settings.tags + b = settings.tags + a << :x + + expect(b).to eq([]) + expect(a).not_to be(b) + end end describe "immutability" do diff --git a/spec/cmdx/workflow_spec.rb b/spec/cmdx/workflow_spec.rb index 42700f3a2..3746f16ff 100644 --- a/spec/cmdx/workflow_spec.rb +++ b/spec/cmdx/workflow_spec.rb @@ -11,11 +11,6 @@ end describe ".tasks" do - it "returns the pipeline when called with no tasks" do - workflow = create_workflow_class - expect(workflow.tasks).to be(workflow.pipeline) - end - it "appends an ExecutionGroup with options" do task = create_successful_task workflow = create_workflow_class do @@ -44,6 +39,19 @@ end.to raise_error(TypeError, /is not a Task/) end + it "raises DefinitionError when called with options but no tasks" do + expect do + create_workflow_class { tasks if: proc { true } } + end.to raise_error(CMDx::DefinitionError, /cannot declare an empty task group/) + end + + it "raises DefinitionError when splat resolves to empty with options" do + empty = [] + expect do + create_workflow_class { tasks(*empty, strategy: :parallel) } + end.to raise_error(CMDx::DefinitionError, /cannot declare an empty task group/) + end + it "is aliased as .task" do t = create_successful_task workflow = create_workflow_class { task t } diff --git a/spec/integration/workflows/execution_spec.rb b/spec/integration/workflows/execution_spec.rb index b2c6b1614..d9912d963 100644 --- a/spec/integration/workflows/execution_spec.rb +++ b/spec/integration/workflows/execution_spec.rb @@ -102,7 +102,7 @@ end it "raises when a group contains no tasks" do - expect { create_workflow_class { tasks } }.not_to raise_error + expect { create_workflow_class { tasks } }.to raise_error(CMDx::DefinitionError, /cannot declare an empty task group/) end it "rejects non-task arguments" do From 58924c04fb4ae4a7dece1407e2a1cc7fec73d1db Mon Sep 17 00:00:00 2001 From: Juan Gomez <drexed@users.noreply.github.com> Date: Wed, 22 Apr 2026 09:19:05 -0400 Subject: [PATCH 14/54] V2 --- CHANGELOG.md | 10 ++- docs/basics/context.md | 17 ++++++ docs/middlewares.md | 26 ++++++++ docs/outcomes/errors.md | 17 ++++++ docs/outcomes/result.md | 15 +++-- docs/outputs.md | 52 +++++++++++++++- docs/retries.md | 31 ++++++++++ lib/cmdx/context.rb | 15 +++++ lib/cmdx/errors.rb | 20 ++++++ lib/cmdx/middlewares.rb | 26 +++++--- lib/cmdx/output.rb | 71 ++++++++++++++++++++-- lib/cmdx/outputs.rb | 60 +++++++++++++++++- lib/cmdx/result.rb | 49 +++------------ lib/cmdx/retry.rb | 20 ++++++ lib/cmdx/task.rb | 5 +- spec/cmdx/chain_spec.rb | 1 - spec/cmdx/errors_spec.rb | 37 ++++++++++++ spec/cmdx/middlewares_spec.rb | 63 +++++++++++++++++-- spec/cmdx/output_spec.rb | 54 ++++++++++++++++- spec/cmdx/outputs_spec.rb | 40 ++++++++++++ spec/cmdx/result_spec.rb | 77 +++++++++++++++++++----- spec/cmdx/retry_spec.rb | 54 +++++++++++++++++ spec/cmdx/task_spec.rb | 6 +- spec/integration/tasks/execution_spec.rb | 9 +-- 24 files changed, 680 insertions(+), 95 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 873ec13e7..62a219e07 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,7 +26,7 @@ Full runtime rewrite: the v1 state-machine plus Zeitwerk architecture is replace - Add `Task.execute` / `Task.execute!` as the execution entry points (aliased as `call` / `call!` for backward compatibility) - Add `Task#execute(strict:)` instance method (aliased as `#call`) - Add `Result#on(:success, :failed, ...)` chainable predicate-dispatch helper -- Add `Result#deconstruct` / `Result#deconstruct_keys` for pattern matching; `deconstruct` returns `[type, task, state, status, reason, metadata, cause, origin]` +- Add `Result#deconstruct` / `Result#deconstruct_keys` for pattern matching; `deconstruct` returns `#to_h.to_a` pairs and `deconstruct_keys(keys)` slices `#to_h` (`nil` returns the full hash) - Add `Result#strict?`, `Result#deprecated?`, `Result#duration`, `Result#index`, `Result#root?`, `Result#backtrace`, `Result#errors`, `Result#tags`, `Result#origin`, and `Result#ctx` alias - Add `Signal#origin` / `Result#origin` — upstream `Result` a signal/result was echoed from (`nil` for locally originated failures); set by `Task#throw!`, `Pipeline` when propagating workflow failures, and `Runtime` when rescuing a `Fault` inside `work` - Add `Chain#unshift`, `Chain#root`, `Chain#state`, `Chain#status`, `Chain#last`, `Chain#freeze`; Runtime `unshift`s the root result (so `chain.root` and `chain[0]` point to the outermost task) and freezes the chain on root teardown @@ -38,6 +38,11 @@ Full runtime rewrite: the v1 state-machine plus Zeitwerk architecture is replace - Add `Configuration#backtrace_cleaner` and `Configuration#telemetry` - Add `CMDx.reset_configuration!` which clears global registry ivars on `Task` for clean test setup/teardown; subclasses that already cloned their registries are unaffected - Add `:if` / `:unless` gates to `Callbacks#register` (Symbol, Proc, or any `#call`-able); per-event DSL helpers (`before_execution`, `on_success`, etc.) forward the options through +- Add `:if` / `:unless` gates to `Middlewares#register` (Symbol, Proc, or any `#call`-able); evaluated per task in `Middlewares#process` — skipped middlewares are bypassed and the chain continues +- Add `:if` / `:unless` gates to `Retry` / `Task.retry_on`; gate receives `(task, error, attempt)` and, when falsy, re-raises the exception instead of retrying (no further wait). Adds `Retry#condition_if` / `Retry#condition_unless` readers +- Add `Outputs#register` block DSL (`Outputs::ChildBuilder`) for nested outputs via `required` / `optional` / `output` / `outputs`, arbitrarily deep; `Output#children`, `Output#verify_from_parent`, and `:children` in `Output#to_h` / `Task.outputs_schema`. `Task.outputs` forwards the block +- Add `Context#deconstruct` / `Context#deconstruct_keys` for pattern matching +- Add `Errors#deconstruct` / `Errors#deconstruct_keys` for pattern matching ### Changed - **BREAKING**: Rename `#call` → `#work` on task subclasses; `Task.execute` / `Task.execute!` are the new entry points (`call` / `call!` kept as aliases) @@ -61,6 +66,9 @@ Full runtime rewrite: the v1 state-machine plus Zeitwerk architecture is replace - `Fault#initialize` takes a single `Result`; `task`, `context`, and `chain` delegate to it; `Runtime` raises `Fault.new(@result.caused_failure)` so `fault.task` always points at the originating leaf (including in workflows and nested `execute!` chains) - `Runtime` finalizes the `Result` before `raise_signal!` so the `Fault` it raises always carries a fully-built `Result` - `Result#to_h` / `to_s` / `deconstruct_keys` now include `:origin` (compact `{ task:, tid: }` hash, or `nil` for locally originated failures) +- **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"], *]`) +- `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 +- `Middlewares` registry entries are now `[callable, options.freeze]` tuples — callers that read `Task.middlewares.registry` directly must map `.first` to recover the callable - 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 - Generators emit the new `def work` template; the install template documents the new middleware / callback / telemetry / coercion / validator registration shapes - Slim `Configuration` to: `middlewares`, `callbacks`, `coercions`, `validators`, `telemetry`, `default_locale`, `backtrace_cleaner`, `logger`, `log_level`, `log_formatter` diff --git a/docs/basics/context.md b/docs/basics/context.md index 8c2b30103..dbf0ffa7b 100644 --- a/docs/basics/context.md +++ b/docs/basics/context.md @@ -124,3 +124,20 @@ CreateShippingLabel.execute(result) Passing a live `Context`, `Task`, or `Result` shares the context by reference — writes in the callee are visible to the caller. Use `context.deep_dup` when you need an isolated snapshot. `context.to_h` exposes the backing hash by reference. `Context.build(context.to_h)` rebuilds a fresh top-level table (symbolized keys) but nested mutable values are still shared — use `deep_dup` for full isolation. + +## Pattern Matching + +`Context` supports both array and hash deconstruction (Ruby 3.0+). + +```ruby +result = CalculateShipping.execute(weight: 2.5, destination: "CA", options: { carrier: "ups" }) + +case result.context +in { destination: "CA", weight: Float => kg } if kg > 1.0 + bulk_ship(kg) +in { options: { carrier: String => code } } + track_with(code) +end +``` + +`deconstruct_keys(nil)` returns the full backing table; a key list slices it (unknown keys are omitted). `deconstruct` yields `[[key, value], ...]` pairs for find-pattern matches (`in [*, [:weight, Float], *]`). diff --git a/docs/middlewares.md b/docs/middlewares.md index 077a0a924..a776aaa80 100644 --- a/docs/middlewares.md +++ b/docs/middlewares.md @@ -123,6 +123,32 @@ end `register` requires either a callable or a block (not both). `deregister` requires either a `middleware` argument or `at:` (not both). Both raise `ArgumentError` otherwise. +## Conditional Registration + +`:if` / `:unless` gate a middleware at `#process` time (per task, per execution) without changing the registry. Symbol, Proc, and any `#call`-able resolve against the task — same semantics as callback gates. + +```ruby +class ProcessCampaign < CMDx::Task + register :middleware, AuditMiddleware, if: :audited? + register :middleware, CacheMiddleware, unless: ->(task) { task.context.skip_cache } + register :middleware, TracingMiddleware, if: TracingSampler # #call(task) + + def work + # ... + end + + private + + def audited? = context.tenant_id.present? +end +``` + +When a gate is falsy, the middleware is skipped and the chain walks straight to the next link — inner middlewares still run. Gates do not need to yield; only the middleware itself does. + +!!! note + + The inline "Conditional wrapping" pattern is still useful when you need the middleware to run but want to gate only its side-effects. Use `:if`/`:unless` when you want to skip the middleware entirely; use inline branching when the middleware should wrap but alter behavior. + ## Safety If a middleware forgets to call `yield` (or `next_link.call`), the chain raises `CMDx::MiddlewareError` instead of silently bypassing the task body: diff --git a/docs/outcomes/errors.md b/docs/outcomes/errors.md index 54778b4b7..b1cd7f2ba 100644 --- a/docs/outcomes/errors.md +++ b/docs/outcomes/errors.md @@ -90,6 +90,23 @@ result.reason == result.errors.to_s #=> true `to_hash` mirrors `to_h` by default and `full_messages` when called with `true`. +## Pattern Matching + +`Errors` supports both array and hash deconstruction (Ruby 3.0+). + +```ruby +result = CreateUser.execute(email: "taken@example.com") + +case result.errors +in { email: [String => first, *] } + notify_user(first) +in { base: messages } if messages.any? + render_flash(messages) +end +``` + +`deconstruct_keys(nil)` returns the full `to_h` (`{ key => [messages] }`); a key list slices it — unknown keys are omitted. `deconstruct` yields `[[key, messages], ...]` pairs for find-pattern matches. + ## Failure Propagation Runtime checks `task.errors.empty?` at three lifecycle checkpoints: after input resolution, after `work` returns, and after output verification. A non-empty container at any checkpoint short-circuits the rest of the lifecycle by throwing a failed signal whose `reason` is `errors.to_s` and whose `metadata` is `task.metadata`. diff --git a/docs/outcomes/result.md b/docs/outcomes/result.md index a03655a57..b2a70b76f 100644 --- a/docs/outcomes/result.md +++ b/docs/outcomes/result.md @@ -159,23 +159,22 @@ result ### Array Pattern -`deconstruct` returns `[type, task, state, status, reason, metadata, cause, origin]`: +`deconstruct` returns `to_h.to_a` — an array of `[key, value]` pairs in insertion order. Use find-patterns to match on specific entries regardless of position: ```ruby result = BuildApplication.execute(version: "1.2.3") -case result -in [_, _, "complete", "success", *] then redirect_to(build_success_page) -in [_, _, "interrupted", "failed", reason, *] then retry_build_with_backoff(result, reason) -in [_, _, "interrupted", "skipped", *] then log_skip_and_continue -in ["Workflow", BuildApplication, *] then handle_build_workflow(result) +case result.deconstruct +in [*, [:status, "success"], *] then redirect_to(build_success_page) +in [*, [:status, "failed"], *, [:reason, reason], *] then retry_build_with_backoff(result, reason) +in [*, [:status, "skipped"], *] then log_skip_and_continue +in [*, [:type, "Workflow"], *] then handle_build_workflow(result) end ``` ### Hash Pattern -`deconstruct_keys` exposes: -`:root, :type, :task, :state, :status, :reason, :metadata, :cause, :origin, :strict, :deprecated, :retries, :rolled_back, :duration`. +`deconstruct_keys(keys)` delegates to `#to_h` — `nil` returns the full hash, a key list slices it (unknown keys are omitted). Keys always present: `:cid, :index, :root, :type, :task, :tid, :context, :state, :status, :reason, :metadata, :strict, :deprecated, :retried, :retries, :duration, :tags`. Failure-only keys (`:cause`, `:origin`, `:threw_failure`, `:caused_failure`, `:rolled_back`) appear only on `failed?` results. ```ruby result = BuildApplication.execute(version: "1.2.3") diff --git a/docs/outputs.md b/docs/outputs.md index 02dcfc342..e17d86e48 100644 --- a/docs/outputs.md +++ b/docs/outputs.md @@ -111,6 +111,41 @@ class LightweightTask < ApplicationTask end ``` +## Nesting + +Declare child outputs against a parent value with a block. Children are verified against the resolved parent — any object that responds to the child's name, `#[]`, or `#key?` (Hash, Struct, Data, OpenStruct, Context, ...). All output options (`:required`, `:default`, `:coerce`, `:transform`, validators, `:if`/`:unless`) work per-child and nest arbitrarily deep. + +```ruby +class CreateUser < CMDx::Task + output :user do + required :id, coerce: :integer + optional :email + end + + output :billing do + required :plan + + optional :address do + required :city + optional :postal_code + end + end + + def work + context.user = User.create!(params) + context.billing = { plan: "pro", address: { city: "Lisbon" } } + end +end +``` + +Child verification only runs when the parent value is present and not a coercion failure — missing optional parents skip their children entirely. Required children on a present parent that doesn't expose the child key add `cmdx.outputs.missing` to `task.errors` under the child's name. + +!!! note + + Children are *verified* against the parent value but written back to `task.context[<child>]` only if they were already there; nested outputs do not synthesize top-level context keys. Use inputs' [Nesting](inputs/definitions.md#nesting) when you need parent-backed accessors. + +`deregister :output, :user` still removes the top-level declaration (and its children in one shot). + ## Verification Behavior Verification runs **after** `work` completes successfully. If `work` threw a `skip!`, `fail!`, or `throw!` signal, outputs are not verified. @@ -173,5 +208,20 @@ CreateUser.outputs_schema # => { user: { name: :user, # description: "the persisted user", # required: true, -# options: { required: true, description: "the persisted user" } } } +# options: { required: true, description: "the persisted user" }, +# children: [] } } +``` + +Nested declarations serialize their children recursively: + +```ruby +class CreateUser < CMDx::Task + output :user do + required :id, coerce: :integer + end +end + +CreateUser.outputs_schema[:user][:children] +# => [{ name: :id, description: nil, required: true, +# options: { required: true, coerce: :integer }, children: [] }] ``` diff --git a/docs/retries.md b/docs/retries.md index ba0df0669..0f7ddd2a8 100644 --- a/docs/retries.md +++ b/docs/retries.md @@ -23,6 +23,8 @@ end | `delay:` | `0.5` | Base delay in seconds; `0` disables sleeping between attempts | | `max_delay:` | `nil` | Upper bound clamp applied after jitter is computed | | `jitter:` | `nil` | Strategy for spreading delays — see [Jitter](#jitter) below | +| `if:` | `nil` | Gate evaluated per attempt; when falsy the exception is re-raised instead of retried — see [Conditional Retries](#conditional-retries) | +| `unless:` | `nil` | Inverse of `if:` — when truthy the exception is re-raised | ```ruby class ProcessPayment < CMDx::Task @@ -149,6 +151,35 @@ class FetchAnalytics < CMDx::Task end ``` +## Conditional Retries + +`:if` / `:unless` gate each retry attempt. The gate receives `(task, error, attempt)` — when falsy (`if`) or truthy (`unless`), the rescued exception is re-raised instead of retried, skipping any remaining budget and the `wait` between attempts. + +Symbol, Proc, and any `#call`-able resolve against the task (Procs via `instance_exec`, Symbols via `task.send(sym, error, attempt)`, callables via `gate.call(task, error, attempt)`). + +```ruby +class FetchProfile < CMDx::Task + retry_on ApiError, + limit: 5, + delay: 1.0, + if: ->(_task, error, _attempt) { error.retryable? } + + retry_on Net::ReadTimeout, if: :transient?, limit: 3 + + def work + context.profile = ApiClient.fetch(context.user_id) + end + + private + + def transient?(error, _attempt) = !error.message.include?("permanent") +end +``` + +!!! note + + The gate fires *before* `wait` sleeps. When the gate rejects, no delay elapses — the exception propagates immediately and Runtime converts it to a failed result (or raises under `execute!`). + ## Behavior - **Same task instance** — retries reuse the same task object. `context` and any side effects from previous attempts persist. diff --git a/lib/cmdx/context.rb b/lib/cmdx/context.rb index 8c988eda6..05d07fe92 100644 --- a/lib/cmdx/context.rb +++ b/lib/cmdx/context.rb @@ -187,6 +187,21 @@ def to_s @table.map { |k, v| "#{k}=#{v.inspect}" }.join(" ") end + # Pattern-matching support for `case context in {...}`. + # + # @param keys [Array<Symbol>, nil] restrict the returned hash to these keys + # @return [Hash{Symbol => Object}] + def deconstruct_keys(keys) + keys.nil? ? @table : @table.slice(*keys) + end + + # Pattern-matching support for `case context in [...]`. + # + # @return [Array<Array(Symbol, Object)>] + def deconstruct + @table.to_a + end + # Returns a deep copy. Non-mutable scalars are shared; Hashes/Arrays are # recursively duplicated; other objects fall back to `#dup` (and then # to the original on `StandardError`). diff --git a/lib/cmdx/errors.rb b/lib/cmdx/errors.rb index d56b3c147..b767eaf40 100644 --- a/lib/cmdx/errors.rb +++ b/lib/cmdx/errors.rb @@ -134,6 +134,26 @@ def to_s full_messages.values.flatten.join(". ") end + # Pattern-matching support for `case errors in {...}`. + # + # @param keys [Array<Symbol>, nil] restrict the returned hash to these keys + # @return [Hash{Symbol => Array<String>}] + # + # @example + # case task.errors + # in { name: [_, *] } then handle_name_errors(task) + # end + def deconstruct_keys(keys) + keys.nil? ? to_h : to_h.slice(*keys) + end + + # Pattern-matching support for `case errors in [...]`. + # + # @return [Array<Array(Symbol, Array<String>)>] + def deconstruct + to_h.to_a + end + # Freezes the container and every message set. Called by Runtime teardown. # # @return [Errors] self diff --git a/lib/cmdx/middlewares.rb b/lib/cmdx/middlewares.rb index 0a1c54815..beeb46cea 100644 --- a/lib/cmdx/middlewares.rb +++ b/lib/cmdx/middlewares.rb @@ -17,16 +17,20 @@ def initialize_copy(source) end # Inserts a middleware. With no `:at`, appends. With `:at`, inserts at - # the given (clamped) index — supports negative indexing. + # the given (clamped) index — supports negative indexing. `:if`/`:unless` + # gates evaluated against the task at process time. # # @param callable [#call, nil] provide either this or a block - # @param at [Integer, nil] insertion index + # @param options [Hash{Symbol => Object}] + # @option options [Symbol, Proc, #call] :if gate that must evaluate truthy + # @option options [Symbol, Proc, #call] :unless gate that must evaluate falsy # @yield the middleware body, receiving `(task)` and `next_link` via block # @return [Middlewares] self for chaining # @raise [ArgumentError] when both or neither of `callable`/block are given, # when the callable doesn't respond to `#call`, or when `:at` isn't an Integer - def register(callable = nil, at: nil, &block) + def register(callable = nil, **options, &block) middleware = callable || block + at = options.delete(:at) if callable && block raise ArgumentError, "provide either a callable or a block, not both" @@ -36,11 +40,13 @@ def register(callable = nil, at: nil, &block) raise ArgumentError, "at must be an Integer" end + entry = [middleware, options.freeze] + if at.nil? - registry << middleware + registry << entry else at = [at.clamp(-registry.size - 1, registry.size), registry.size].min - registry.insert(at, middleware) + registry.insert(at, entry) end self @@ -63,7 +69,7 @@ def deregister(middleware = nil, at: nil) end if at.nil? - registry.delete(middleware) + registry.reject! { |mw, _opts| mw == middleware } else registry.delete_at(at) end @@ -98,7 +104,13 @@ def process(task) processed = true yield else - registry[i].call(task) { chain.call(i + 1) } + mw, opts = registry[i] + + if Util.satisfied?(opts[:if], opts[:unless], task) + mw.call(task) { chain.call(i + 1) } + else + chain.call(i + 1) + end end end chain.call(0) diff --git a/lib/cmdx/output.rb b/lib/cmdx/output.rb index 66773837a..941e32af9 100644 --- a/lib/cmdx/output.rb +++ b/lib/cmdx/output.rb @@ -6,9 +6,11 @@ module CMDx # and run validators against the value the task wrote to `task.context[name]`. class Output - attr_reader :name + attr_reader :name, :children # @param name [Symbol, String] output key (symbolized) + # @param children [Array<Output>] nested child outputs verified against this + # output's resolved value # @param options [Hash{Symbol => Object}] declaration options # @option options [String] :description (also accepts `:desc`) # @option options [Boolean] :required @@ -17,9 +19,10 @@ class Output # @option options [Symbol, Array, Hash, Proc] :coerce # @option options [Object, Symbol, Proc, #call] :default # @option options [Symbol, Proc, #call] :transform mutator applied after coercion - def initialize(name, **options) - @name = name.to_sym - @options = options.freeze + def initialize(name, children: EMPTY_ARRAY, **options) + @name = name.to_sym + @children = children.freeze + @options = options.freeze end # @return [String, nil] @@ -71,7 +74,8 @@ def to_h name:, description:, required: required?, - options: @options + options: @options, + children: children.map(&:to_h) } end @@ -111,10 +115,67 @@ def verify(task) task.class.validators.validate(task, name, value, validators) task.context[name] = value + verify_children(value, task) + end + + # Verifies a child output against `parent_value` (read-only; child + # validation/coercion errors are still collected on the task). + # + # @param parent_value [#[], #key?, Object] the parent output's verified value + # @param task [Task] + # @return [void] + def verify_from_parent(parent_value, task) + return if parent_value.nil? || parent_value.is_a?(Coercions::Failure) + + value, key_provided = fetch_by_name(parent_value) + value = apply_default(task) if value.nil? + + if required?(task) && !key_provided && value.nil? + task.errors.add(name, I18nProxy.t("cmdx.outputs.missing")) + return + end + + return if !key_provided && value.nil? + + coercions = task.class.coercions.extract(@options) + value = task.class.coercions.coerce(task, name, value, coercions) + return if value.is_a?(Coercions::Failure) + + value = apply_transform(value, task) if transform + + validators = task.class.validators.extract(@options) + task.class.validators.validate(task, name, value, validators) + + verify_children(value, task) end private + def verify_children(value, task) + return if children.empty? || value.nil? || value.is_a?(Coercions::Failure) + + children.each { |child| child.verify_from_parent(value, task) } + end + + def fetch_by_name(obj) + if obj.respond_to?(name, true) + [obj.send(name), true] + elsif obj.respond_to?(:key?) + if obj.key?(name) + [obj[name], true] + elsif obj.respond_to?(:[]) && obj.key?(name_str = name.to_s) + [obj[name_str], true] + else + [nil, false] + end + elsif obj.respond_to?(:[]) + value = obj[name] || obj[name.to_s] + [value, !value.nil?] + else + [nil, false] + end + end + def apply_default(task) return if default.nil? diff --git a/lib/cmdx/outputs.rb b/lib/cmdx/outputs.rb index b4a8ef543..9c42041f9 100644 --- a/lib/cmdx/outputs.rb +++ b/lib/cmdx/outputs.rb @@ -16,14 +16,18 @@ def initialize_copy(source) @registry = source.registry.dup end - # Declares one or more output keys. All share the same `options`. + # Declares one or more output keys. All share the same `options`. A block + # nests child outputs under each declared key (see {ChildBuilder}). # # @param keys [Array<Symbol>] # @param options [Hash{Symbol => Object}] passed through to {Output#initialize} + # @yield block evaluated in a {ChildBuilder} for nested outputs # @return [Outputs] self for chaining - def register(*keys, **options) + def register(*keys, **options, &block) + children = block ? ChildBuilder.build(&block) : EMPTY_ARRAY + keys.each do |key| - output = Output.new(key, **options) + output = Output.new(key, children:, **options) registry[output.name] = output end @@ -56,5 +60,55 @@ def verify(task) registry.each_value { |output| output.verify(task) } end + # DSL receiver for the block passed to {Outputs#register}. Builds a frozen + # list of child {Output}s. Supports arbitrary nesting: every DSL method + # accepts its own block. + class ChildBuilder + + class << self + + # @yield (see Outputs#register) + # @return [Array<Output>] frozen list of built children + def build(&) + builder = new + builder.instance_eval(&) + builder.children.freeze + end + + end + + attr_reader :children + + def initialize + @children = [] + end + + # @param names [Array<Symbol>] + # @param options [Hash{Symbol => Object}] + # @return [Array<Output>] + def outputs(*names, **options, &) + build(*names, **options, &) + end + alias output outputs + + # Declares optional child outputs (equivalent to `outputs ..., required: false`). + def optional(*names, **options, &) + build(*names, required: false, **options, &) + end + + # Declares required child outputs (equivalent to `outputs ..., required: true`). + def required(*names, **options, &) + build(*names, required: true, **options, &) + end + + private + + def build(*names, **options, &block) + nested = block ? self.class.build(&block) : EMPTY_ARRAY + names.map { |name| children << Output.new(name, children: nested, **options) } + end + + end + end end diff --git a/lib/cmdx/result.rb b/lib/cmdx/result.rb index 76475a8be..50814ce9b 100644 --- a/lib/cmdx/result.rb +++ b/lib/cmdx/result.rb @@ -305,50 +305,19 @@ def to_s end end - # Pattern-matching support for `case result in [...]`. - # - # @return [Array(String, Class<Task>, String, String, String, Hash, Exception)] - # `[type, task, state, status, reason, metadata, cause, origin]` - # - # @example - # case result - # in [_, _, "complete", "success", *] then handle_success(result) - # in [_, _, _, "failed", reason, *] then handle_failure(reason) - # end - def deconstruct - [ - type, - task, - state, - status, - reason, - metadata, - cause, - origin - ] - end - # Pattern-matching support for `case result in {...}`. # # @param keys [Array<Symbol>, nil] restrict the returned hash to these keys # @return [Hash{Symbol => Object}] - def deconstruct_keys(*) - { - root: root?, - type:, - task:, - state:, - status:, - reason:, - metadata:, - cause:, - origin:, - strict: strict?, - deprecated: deprecated?, - retries:, - rolled_back: rolled_back?, - duration: - } + def deconstruct_keys(keys) + keys.nil? ? to_h : to_h.slice(*keys) + end + + # Pattern-matching support for `case result in [...]`. + # + # @return [Array<Array(Symbol, Object)>] + def deconstruct + to_h.to_a end private diff --git a/lib/cmdx/retry.rb b/lib/cmdx/retry.rb index 872c7388c..d7eb0056d 100644 --- a/lib/cmdx/retry.rb +++ b/lib/cmdx/retry.rb @@ -60,6 +60,16 @@ def jitter @options[:jitter] || @block end + # @return [Symbol, Proc, #call, nil] `:if` gate evaluated per attempt; receives `(task, error, attempt)` + def condition_if + @options[:if] + end + + # @return [Symbol, Proc, #call, nil] `:unless` gate evaluated per attempt; receives `(task, error, attempt)` + def condition_unless + @options[:unless] + end + # Sleeps `attempt`'s jittered/bounded delay. No-op when the base delay is zero. # # @param attempt [Integer] zero-based retry attempt number @@ -109,10 +119,20 @@ def process(task = nil, &) return yield(attempt) rescue *exceptions => e raise(e) if attempt >= limit + raise(e) unless retryable?(task, e, attempt) wait(attempt, task) end end + private + + def retryable?(task, error, attempt) + return true if condition_if.nil? && condition_unless.nil? + return false if task.nil? + + Util.satisfied?(condition_if, condition_unless, task, error, attempt) + end + end end diff --git a/lib/cmdx/task.rb b/lib/cmdx/task.rb index 9312d7d7b..52b231f56 100644 --- a/lib/cmdx/task.rb +++ b/lib/cmdx/task.rb @@ -214,8 +214,9 @@ def inputs_schema # # @param keys [Array<Symbol>] # @param options [Hash{Symbol => Object}] see {Output#initialize} + # @yield nested-output DSL block (see {Outputs::ChildBuilder}) # @return [Outputs] - def outputs(*keys, **options) + def outputs(*keys, **options, &) @outputs ||= if superclass.respond_to?(:outputs) superclass.outputs.dup @@ -225,7 +226,7 @@ def outputs(*keys, **options) return @outputs if keys.empty? - @outputs.register(*keys, **options) + @outputs.register(*keys, **options, &) end alias output outputs diff --git a/spec/cmdx/chain_spec.rb b/spec/cmdx/chain_spec.rb index 24a521925..311cfe72d 100644 --- a/spec/cmdx/chain_spec.rb +++ b/spec/cmdx/chain_spec.rb @@ -270,4 +270,3 @@ def build_result(signal = CMDx::Signal.success, **opts) end end end - diff --git a/spec/cmdx/errors_spec.rb b/spec/cmdx/errors_spec.rb index ebec942e5..1a9578fc3 100644 --- a/spec/cmdx/errors_spec.rb +++ b/spec/cmdx/errors_spec.rb @@ -226,6 +226,43 @@ end end + describe "pattern matching support" do + before do + errors.add(:name, "is required") + errors.add(:age, "too young") + end + + describe "#deconstruct_keys" do + it "returns the full to_h when keys is nil" do + expect(errors.deconstruct_keys(nil)).to eq(name: ["is required"], age: ["too young"]) + end + + it "slices to the requested keys" do + expect(errors.deconstruct_keys([:name])).to eq(name: ["is required"]) + expect(errors.deconstruct_keys([:missing])).to eq({}) + end + + it "supports hash patterns in case/in" do + matched = + case errors + in { name: [String => msg, *] } + msg + end + + expect(matched).to eq("is required") + end + end + + describe "#deconstruct" do + it "returns the messages as an array of [key, messages] pairs" do + expect(errors.deconstruct).to contain_exactly( + [:name, ["is required"]], + [:age, ["too young"]] + ) + end + end + end + describe "#freeze" do it "freezes the messages hash and each message set" do errors.add(:name, "is required") diff --git a/spec/cmdx/middlewares_spec.rb b/spec/cmdx/middlewares_spec.rb index e86986cbb..a359c1e96 100644 --- a/spec/cmdx/middlewares_spec.rb +++ b/spec/cmdx/middlewares_spec.rb @@ -32,7 +32,7 @@ it "appends the callable and returns self" do expect(middlewares.register(mw)).to be(middlewares) - expect(middlewares.registry).to eq([mw]) + expect(middlewares.registry).to eq([[mw, {}]]) end it "accepts a block" do @@ -40,6 +40,14 @@ expect(middlewares.size).to eq(1) end + it "stores :if/:unless options on the entry" do + gate = -> { true } + middlewares.register(mw, if: :run?, unless: gate) + _, opts = middlewares.registry.first + expect(opts).to eq(if: :run?, unless: gate) + expect(opts).to be_frozen + end + it "raises when both callable and block are given" do expect { middlewares.register(mw) { |_t, &blk| blk.call } } .to raise_error(ArgumentError, "provide either a callable or a block, not both") @@ -64,7 +72,7 @@ middlewares.register(b) middlewares.register(c, at: 1) - expect(middlewares.registry).to eq([a, c, b]) + expect(middlewares.registry.map(&:first)).to eq([a, c, b]) end it "clamps out-of-bounds indices to the valid range" do @@ -76,8 +84,8 @@ middlewares.register(b, at: 100) middlewares.register(c, at: -100) - expect(middlewares.registry.first).to be(c) - expect(middlewares.registry.last).to be(b) + expect(middlewares.registry.first.first).to be(c) + expect(middlewares.registry.last.first).to be(b) end end @@ -162,6 +170,53 @@ expect { middlewares.process(task) { :inner } } .to raise_error(CMDx::MiddlewareError, "middleware did not yield the next_link") end + + context "with :if/:unless gates" do + let(:task) { Struct.new(:enabled).new(false) } + + it "skips middleware whose :if gate is falsy" do + trace = [] + mw = lambda { |_t, &blk| + trace << :ran + blk.call + } + middlewares.register(mw, if: :enabled) + + middlewares.process(task) { trace << :inner } + + expect(trace).to eq([:inner]) + end + + it "runs middleware whose :unless gate is falsy" do + task.enabled = true + trace = [] + mw = lambda { |_t, &blk| + trace << :ran + blk.call + } + middlewares.register(mw, unless: proc { !enabled }) + + middlewares.process(task) { trace << :inner } + + expect(trace).to eq(%i[ran inner]) + end + + it "still walks subsequent middlewares when an earlier one is gated out" do + trace = [] + middlewares.register(lambda { |_t, &blk| + trace << :a + blk.call + }, if: proc { false }) + middlewares.register(lambda { |_t, &blk| + trace << :b + blk.call + }) + + middlewares.process(task) { trace << :inner } + + expect(trace).to eq(%i[b inner]) + end + end end describe "#size / #empty?" do diff --git a/spec/cmdx/output_spec.rb b/spec/cmdx/output_spec.rb index 041145582..fa6730a4e 100644 --- a/spec/cmdx/output_spec.rb +++ b/spec/cmdx/output_spec.rb @@ -102,9 +102,17 @@ def inactive? = false name: :user, description: "d", required: true, - options: { description: "d", required: true, type: :string } + options: { description: "d", required: true, type: :string }, + children: [] ) end + + it "serializes children recursively" do + child = described_class.new(:id, type: :integer) + output = described_class.new(:user, children: [child], type: :hash) + + expect(output.to_h[:children]).to eq([child.to_h]) + end end describe "#verify" do @@ -321,5 +329,49 @@ def call(value, _task) expect(task.errors).to be_empty end end + + context "with nested children" do + it "verifies required children against the parent value" do + task_class = create_task_class(name: "OutChildRequired") do + output :user do + required :id + optional :email + end + end + task = task_class.new + task.context[:user] = { email: "x@y" } + + task_class.outputs.registry[:user].verify(task) + + expect(task.errors[:id]).to include(CMDx::I18nProxy.t("cmdx.outputs.missing")) + end + + it "skips child verification when parent is nil" do + task_class = create_task_class(name: "OutChildSkip") do + output :user do + required :id + end + end + task = task_class.new + + task_class.outputs.registry[:user].verify(task) + + expect(task.errors).to be_empty + end + + it "validates child values against the parent" do + task_class = create_task_class(name: "OutChildValidate") do + output :user do + required :age, coerce: :integer, numeric: { min: 18 } + end + end + task = task_class.new + task.context[:user] = { age: "12" } + + task_class.outputs.registry[:user].verify(task) + + expect(task.errors.keys).to include(:age) + end + end end end diff --git a/spec/cmdx/outputs_spec.rb b/spec/cmdx/outputs_spec.rb index ccb009e83..3cdaddb0f 100644 --- a/spec/cmdx/outputs_spec.rb +++ b/spec/cmdx/outputs_spec.rb @@ -85,4 +85,44 @@ expect(task.errors.keys).to contain_exactly(:user, :token) end end + + describe "nested outputs" do + it "builds child outputs from a block" do + outputs.register(:user) do + required :id, type: :integer + optional :email + end + + user = outputs.registry[:user] + expect(user.children.map(&:name)).to eq(%i[id email]) + expect(user.children.first.required).to be(true) + expect(user.children.last.required).to be(false) + end + + it "supports arbitrary nesting" do + outputs.register(:user) do + output :address do + required :city + end + end + + address = outputs.registry[:user].children.first + expect(address.name).to eq(:address) + expect(address.children.map(&:name)).to eq([:city]) + end + + it "freezes the children list" do + outputs.register(:user) { required :id } + expect(outputs.registry[:user].children).to be_frozen + end + + it "exposes children through to_h (schema export)" do + outputs.register(:user) do + required :id + end + + schema = outputs.registry[:user].to_h + expect(schema[:children].first).to include(name: :id, required: true) + end + end end diff --git a/spec/cmdx/result_spec.rb b/spec/cmdx/result_spec.rb index 9819557d9..a5971085f 100644 --- a/spec/cmdx/result_spec.rb +++ b/spec/cmdx/result_spec.rb @@ -221,10 +221,34 @@ def build(signal, **opts) let(:result) { build(CMDx::Signal.failed("boom", metadata: { k: 1 })) } describe "#deconstruct" do - it "deconstructs to [type, task, state, status, reason, metadata, cause, origin]" do - expect(result.deconstruct).to eq( - ["Task", task_class, "interrupted", "failed", "boom", { k: 1 }, nil, nil] + it "returns #to_h as an array of [key, value] pairs" do + chain << result + expect(result.deconstruct).to eq(result.to_h.to_a) + end + + it "includes failure-specific pairs when the result failed" do + pairs = result.deconstruct.to_h + expect(pairs).to include( + type: "Task", + task: task_class, + state: "interrupted", + status: "failed", + reason: "boom", + metadata: { k: 1 }, + cause: nil ) + expect(pairs).to have_key(:threw_failure) + expect(pairs).to have_key(:caused_failure) + end + + it "supports find-pattern array matching on the pairs" do + matched = + case result.deconstruct + in [*, [:status, "failed"], *] + :found + end + + expect(matched).to eq(:found) end end @@ -237,11 +261,19 @@ def build(signal, **opts) retries: 3, rolled_back: true, duration: 0.25 - ) + ).tap { |r| chain << r } + end + + it "delegates to #to_h when keys is nil" do + expect(result.deconstruct_keys(nil)).to eq(result.to_h) end - it "returns the full hash regardless of the keys argument" do - expected = { + it "returns the full pattern hash when keys is nil" do + full = result.deconstruct_keys(nil) + + expect(full).to include( + cid: chain.id, + index: 0, root: false, type: "Task", task: task_class, @@ -253,14 +285,27 @@ def build(signal, **opts) origin: nil, strict: true, deprecated: true, + retried: true, retries: 3, rolled_back: true, duration: 0.25 - } + ) + expect(full.keys).to include(:tid, :context, :tags, :threw_failure, :caused_failure) + end - expect(result.deconstruct_keys(nil)).to eq(expected) - expect(result.deconstruct_keys(%i[status reason])).to eq(expected) - expect(result.deconstruct_keys([])).to eq(expected) + it "exposes context as a live reference" do + expect(result.deconstruct_keys(nil)[:context]).to be(task.context) + end + + it "renders threw_failure/caused_failure as {task:, tid:} hashes" do + full = result.deconstruct_keys(nil) + expect(full[:threw_failure]).to eq(task: task_class, tid: nil) + expect(full[:caused_failure]).to eq(task: task_class, tid: nil) + end + + it "slices to the requested keys" do + expect(result.deconstruct_keys(%i[status reason])).to eq(status: "failed", reason: "boom") + expect(result.deconstruct_keys([])).to eq({}) end it "supports hash pattern matching" do @@ -273,16 +318,18 @@ def build(signal, **opts) expect(matched).to eq(["boom", 3]) end - it "reflects default option-backed values" do + it "omits failure-only keys for a non-failed result" do plain = build(CMDx::Signal.success) - expect(plain.deconstruct_keys(nil)).to include( + hash = plain.deconstruct_keys(nil) + + expect(hash).to include( strict: false, deprecated: false, + retried: false, retries: 0, - rolled_back: false, - duration: nil, - cause: nil + duration: nil ) + expect(hash.keys).not_to include(:cause, :origin, :threw_failure, :caused_failure, :rolled_back) end end end diff --git a/spec/cmdx/retry_spec.rb b/spec/cmdx/retry_spec.rb index 8208590fa..f4173c67c 100644 --- a/spec/cmdx/retry_spec.rb +++ b/spec/cmdx/retry_spec.rb @@ -205,5 +205,59 @@ expect(attempts).to eq([0, 1, 2]) end + + context "with :if/:unless gates" do + let(:task) do + Class.new do + attr_accessor :seen_attempts, :seen_errors + + def initialize + @seen_attempts = [] + @seen_errors = [] + end + + def transient?(error, attempt) + @seen_errors << error + @seen_attempts << attempt + error.message != "permanent" + end + end.new + end + + it "stops retrying when :if returns false" do + retry_ = described_class.new([error_class], limit: 5, delay: 0, if: :transient?) + count = 0 + + expect do + retry_.process(task) do |_attempt| + count += 1 + raise error_class, "permanent" + end + end.to raise_error(error_class, "permanent") + + expect(count).to eq(1) + expect(task.seen_attempts).to eq([0]) + end + + it "still retries while :if returns true" do + retry_ = described_class.new([error_class], limit: 3, delay: 0, if: :transient?) + attempts = 0 + + retry_.process(task) do |_attempt| + attempts += 1 + raise(error_class, "transient") if attempts < 3 + end + + expect(attempts).to eq(3) + end + + it "stops retrying when :unless is truthy" do + retry_ = described_class.new([error_class], limit: 3, delay: 0, unless: proc { |e, _a| e.message == "stop" }) + + expect do + retry_.process(Object.new) { raise error_class, "stop" } + end.to raise_error(error_class, "stop") + end + end end end diff --git a/spec/cmdx/task_spec.rb b/spec/cmdx/task_spec.rb index 2df46a01e..34b3acad2 100644 --- a/spec/cmdx/task_spec.rb +++ b/spec/cmdx/task_spec.rb @@ -24,7 +24,7 @@ mw = ->(_t, &blk) { blk.call } base.register(:middleware, mw) - expect(child.middlewares.registry).to include(mw) + expect(child.middlewares.registry.map(&:first)).to include(mw) end it "callbacks/telemetry/coercions/validators are dup-isolated per subclass" do @@ -55,10 +55,10 @@ klass = create_task_class mw = ->(_t, &blk) { blk.call } klass.register(:middleware, mw) - expect(klass.middlewares.registry).to include(mw) + expect(klass.middlewares.registry.map(&:first)).to include(mw) klass.deregister(:middleware, mw) - expect(klass.middlewares.registry).not_to include(mw) + expect(klass.middlewares.registry.map(&:first)).not_to include(mw) end it "raises on an unknown registry type" do diff --git a/spec/integration/tasks/execution_spec.rb b/spec/integration/tasks/execution_spec.rb index 1c18a27ad..f8fbf231e 100644 --- a/spec/integration/tasks/execution_spec.rb +++ b/spec/integration/tasks/execution_spec.rb @@ -249,13 +249,14 @@ end describe "result pattern matching" do - it "deconstructs into [type, task, state, status, reason, metadata, cause, origin]" do + it "deconstructs into [[key, value], ...] pairs mirroring #to_h" do task = create_failing_task(reason: "nope", kind: :minor) result = task.execute - expect(result.deconstruct).to eq( - ["Task", task, "interrupted", "failed", "nope", { kind: :minor }, nil, nil] - ) + expect(result.deconstruct).to eq(result.to_h.to_a) + expect(result.deconstruct.assoc(:status)).to eq([:status, "failed"]) + expect(result.deconstruct.assoc(:reason)).to eq([:reason, "nope"]) + expect(result.deconstruct.assoc(:metadata)).to eq([:metadata, { kind: :minor }]) end end From 5fe34cd50db964c3147c01f5681213ffd5427bc1 Mon Sep 17 00:00:00 2001 From: Juan Gomez <drexed@users.noreply.github.com> Date: Wed, 22 Apr 2026 09:22:04 -0400 Subject: [PATCH 15/54] Update retry.rb --- lib/cmdx/retry.rb | 21 +-------------------- 1 file changed, 1 insertion(+), 20 deletions(-) diff --git a/lib/cmdx/retry.rb b/lib/cmdx/retry.rb index d7eb0056d..f2e34aff0 100644 --- a/lib/cmdx/retry.rb +++ b/lib/cmdx/retry.rb @@ -60,16 +60,6 @@ def jitter @options[:jitter] || @block end - # @return [Symbol, Proc, #call, nil] `:if` gate evaluated per attempt; receives `(task, error, attempt)` - def condition_if - @options[:if] - end - - # @return [Symbol, Proc, #call, nil] `:unless` gate evaluated per attempt; receives `(task, error, attempt)` - def condition_unless - @options[:unless] - end - # Sleeps `attempt`'s jittered/bounded delay. No-op when the base delay is zero. # # @param attempt [Integer] zero-based retry attempt number @@ -119,20 +109,11 @@ def process(task = nil, &) return yield(attempt) rescue *exceptions => e raise(e) if attempt >= limit - raise(e) unless retryable?(task, e, attempt) + raise(e) unless Util.satisfied?(@options[:if], @options[:unless], task, e, attempt) wait(attempt, task) end end - private - - def retryable?(task, error, attempt) - return true if condition_if.nil? && condition_unless.nil? - return false if task.nil? - - Util.satisfied?(condition_if, condition_unless, task, error, attempt) - end - end end From 2075ccff21a3d9ba874bf3bf9b84395517b4bc02 Mon Sep 17 00:00:00 2001 From: Juan Gomez <drexed@users.noreply.github.com> Date: Wed, 22 Apr 2026 09:32:30 -0400 Subject: [PATCH 16/54] Update docs --- docs/getting_started.md | 43 +++++++++++++++++ docs/tips_and_tricks.md | 4 ++ examples/dry_monads_interop.md | 68 +++++++++++++++++++++++++++ examples/graphql_resolvers.md | 76 +++++++++++++++++++++++++++++++ examples/opentelemetry_tracing.md | 69 ++++++++++++++++++++++++++++ examples/pundit_authorization.md | 72 +++++++++++++++++++++++++++++ 6 files changed, 332 insertions(+) create mode 100644 examples/dry_monads_interop.md create mode 100644 examples/graphql_resolvers.md create mode 100644 examples/opentelemetry_tracing.md create mode 100644 examples/pundit_authorization.md diff --git a/docs/getting_started.md b/docs/getting_started.md index e7e41ec48..ab45e0059 100644 --- a/docs/getting_started.md +++ b/docs/getting_started.md @@ -203,6 +203,49 @@ I, [2026-04-19T18:42:37.535000Z #3784] INFO -- cmdx: cid="018c2b95-b764-7fff-a1d With a durable log sink, these lines double as a log-only event sourcing record — a time-ordered history of every task execution, its inputs, and its outcome. +## Task Lifecycle + +Every `Task.execute` runs the same orchestrated lifecycle. The diagram below traces the path from invocation to a frozen `Result`, including how signals (`success!` / `skip!` / `fail!` / `throw!`) and exceptions interleave with middlewares, callbacks, retries, and rollback. + +```mermaid +flowchart TD + Invoke([Task.execute / execute!]) --> Chain[Acquire/reuse Chain] + Chain --> MW[Middlewares.process] + MW --> Dep{Deprecation?} + Dep -->|:log / :warn| TelemDep[Emit :task_deprecated] + Dep -->|:error| Raise([raise DeprecationError]) + Dep -->|none| BE[before_execution] + TelemDep --> BE + BE --> BV[before_validation] + BV --> Resolve[Resolve inputs] + Resolve --> Retry{retry_on} + Retry --> Work[work] + Work -->|success!| OK[Signal.success] + Work -->|skip!| OK + Work -->|fail! / throw!| Fail[Signal.failed] + Work -.->|raises Fault| Echo[Signal.echoed] + Work -.->|raises StandardError| Fail + Retry -.->|retriable error| Retry + OK --> Verify[Verify outputs] + Fail --> Rollback{task.rollback?} + Echo --> Rollback + Rollback -->|yes| DoRollback[Run rollback] + Rollback -->|no| Cb + DoRollback --> Cb["on_state / on_status / on_ok / on_ko"] + Verify --> Cb + Cb --> Finalize[Finalize Result + Chain] + Finalize --> Telem[Emit :task_executed] + Telem --> Teardown[Freeze context & errors, clear chain] + Teardown --> Out([Frozen Result]) +``` + +Key invariants: + +- **Middlewares wrap everything inside `execute`** — telemetry, deprecation, callbacks, work, rollback, and result finalization all happen inside the middleware chain. +- **Retry only wraps `work`** — input resolution and output verification run exactly once, outside the retry loop. +- **Rollback only runs on failure**, before result finalization, so `Result#rolled_back?` is already known when `on_failed` callbacks and `:task_executed` telemetry fire. +- **Teardown always runs** (via `ensure`), freezing the context/errors and clearing the fiber-local chain even when `execute!` re-raises. + ## Domain Driven Design CMDx makes business processes explicit and structural — a natural fit for Domain Driven Design (DDD). diff --git a/docs/tips_and_tricks.md b/docs/tips_and_tricks.md index 3cb08e8e8..6b955664e 100644 --- a/docs/tips_and_tricks.md +++ b/docs/tips_and_tricks.md @@ -192,10 +192,14 @@ Inherited registries (callbacks, middlewares, validators, coercions) accumulate - [Active Record Database Transaction](https://github.com/drexed/cmdx/blob/main/examples/active_record_database_transaction.md) - [Active Record Query Tagging](https://github.com/drexed/cmdx/blob/main/examples/active_record_query_tagging.md) - [Active Support Instrumentation](https://github.com/drexed/cmdx/blob/main/examples/active_support_instrumentation.md) +- [dry-monads Interop](https://github.com/drexed/cmdx/blob/main/examples/dry_monads_interop.md) - [Flipper Feature Flags](https://github.com/drexed/cmdx/blob/main/examples/flipper_feature_flags.md) +- [GraphQL Resolvers](https://github.com/drexed/cmdx/blob/main/examples/graphql_resolvers.md) - [OpenAPI Schema Generation](https://github.com/drexed/cmdx/blob/main/examples/openapi_schema_generation.md) +- [OpenTelemetry Tracing](https://github.com/drexed/cmdx/blob/main/examples/opentelemetry_tracing.md) - [Paper Trail Whatdunnit](https://github.com/drexed/cmdx/blob/main/examples/paper_trail_whatdunnit.md) - [PubSub Task Chaining](https://github.com/drexed/cmdx/blob/main/examples/pub_sub_task_chaining.md) +- [Pundit Authorization](https://github.com/drexed/cmdx/blob/main/examples/pundit_authorization.md) - [Redis Idempotency](https://github.com/drexed/cmdx/blob/main/examples/redis_idempotency.md) - [Sentry Error Tracking](https://github.com/drexed/cmdx/blob/main/examples/sentry_error_tracking.md) - [Sidekiq Async Execution](https://github.com/drexed/cmdx/blob/main/examples/sidekiq_async_execution.md) diff --git a/examples/dry_monads_interop.md b/examples/dry_monads_interop.md new file mode 100644 index 000000000..883ebaf43 --- /dev/null +++ b/examples/dry_monads_interop.md @@ -0,0 +1,68 @@ +# dry-monads Interop + +Convert CMDx `Result`s into [dry-monads](https://dry-rb.org/gems/dry-monads) values so tasks can participate in `Do` blocks or be chained with `bind` / `fmap`. + +## Result → `Success` / `Failure` + +```ruby +# lib/cmdx_monads.rb +require "dry/monads" + +module CMDxMonads + include Dry::Monads[:result] + + def self.for(result) + if result.success? || result.skipped? + Success(result.context) + else + Failure(reason: result.reason, metadata: result.metadata, result: result) + end + end +end +``` + +## Using `Do` Notation + +```ruby +class PurchaseFlow + include Dry::Monads[:result] + include Dry::Monads::Do.for(:call) + + def call(user:, product_id:) + reserved = yield CMDxMonads.for(ReserveInventory.execute(product_id:)) + charged = yield CMDxMonads.for(ChargeCard.execute(user:, amount: reserved.amount)) + yield CMDxMonads.for(SendReceipt.execute(user:, charge: charged.charge)) + + Success(charged.charge) + end +end + +case PurchaseFlow.new.call(user:, product_id: 42) +in Success(charge) + render json: { charge_id: charge.id } +in Failure(reason:, metadata:, result:) + render json: { error: reason, code: metadata[:code] }, status: :unprocessable_entity +end +``` + +## Failure → `fail!` + +The reverse direction: a task that receives a `Failure` from a dry-monads function halts cleanly. + +```ruby +class ChargeCard < CMDx::Task + required :user, :amount + + def work + payment_gateway.charge(amount).to_result # => Success | Failure + .fmap { |charge| context.charge = charge } + .or { |err| fail!(err.message, code: :gateway_error) } + end +end +``` + +## Notes + +!!! tip + + Use `result.deconstruct_keys` for native pattern matching instead of dry-monads when your only goal is destructuring — no gem required. dry-monads shines when you want railway-style composition across heterogeneous operations. diff --git a/examples/graphql_resolvers.md b/examples/graphql_resolvers.md new file mode 100644 index 000000000..9b4d4c5e1 --- /dev/null +++ b/examples/graphql_resolvers.md @@ -0,0 +1,76 @@ +# GraphQL Resolvers + +Use CMDx tasks as the business logic layer behind [graphql-ruby](https://graphql-ruby.org/) mutations and resolvers. The resolver stays thin: parse arguments, delegate, translate the `Result` into a payload. + +## Mutation Recipe + +```ruby +# app/graphql/mutations/application_mutation.rb +class Mutations::ApplicationMutation < GraphQL::Schema::Mutation + # Normalize a CMDx::Result into a mutation payload with + # `{ success:, errors:, ... }` — fields your schema expects. + def self.result_payload(result) + if result.success? + { success: true, errors: [] }.merge(result.context.to_h) + else + { + success: false, + errors: result.errors.full_messages.presence || + [{ message: result.reason, path: [] }] + } + end + end +end +``` + +```ruby +# app/graphql/mutations/create_invoice.rb +class Mutations::CreateInvoice < Mutations::ApplicationMutation + argument :customer_id, ID, required: true + argument :amount_cents, Integer, required: true + + field :invoice, Types::InvoiceType, null: true + field :success, Boolean, null: false + field :errors, [Types::UserErrorType], null: false + + def resolve(**args) + result = CreateInvoice.execute( + **args, + current_user: context[:current_user] + ) + + self.class.result_payload(result).tap do |payload| + payload[:invoice] = result.context.invoice + end + end +end +``` + +## Surfacing Validation Errors + +`Task#errors` aggregates coercion/validation/output errors per input key — perfect for GraphQL field-level user errors. + +```ruby +def self.result_payload(result) + return { success: true, errors: [] }.merge(result.context.to_h) if result.success? + + field_errors = result.errors.to_hash.flat_map do |field, messages| + messages.map { |m| { message: m, path: ["input", field.to_s] } } + end + + { + success: false, + errors: field_errors.presence || [{ message: result.reason, path: [] }] + } +end +``` + +## Notes + +!!! tip + + Expose `result.tid` (task id) and `result.chain.id` (correlation id) on a debug field or in an error extension in non-production environments — they turn a single log line into a full trace. + +!!! warning + + Don't raise `Fault` in a resolver (`execute!`). Schema-level raises become `InternalError` in GraphQL and hide the actual reason. Use `execute` and translate the `Result` yourself. diff --git a/examples/opentelemetry_tracing.md b/examples/opentelemetry_tracing.md new file mode 100644 index 000000000..4a694c3e4 --- /dev/null +++ b/examples/opentelemetry_tracing.md @@ -0,0 +1,69 @@ +# OpenTelemetry Tracing + +Emit an OTel span per CMDx task. Each span inherits the parent task's context, so nested `execute` calls produce a proper trace hierarchy that matches the `Chain`. + +## Tracing Middleware + +```ruby +# app/middlewares/cmdx_otel_middleware.rb +class CmdxOtelMiddleware + TRACER = OpenTelemetry.tracer_provider.tracer("cmdx", CMDx::VERSION) + + def call(task) + attrs = { + "cmdx.task" => task.class.name, + "cmdx.tid" => task.tid, + "cmdx.cid" => CMDx::Chain.current&.id, + "cmdx.type" => task.class.type + } + + TRACER.in_span(task.class.name, attributes: attrs, kind: :internal) do |span| + yield + rescue CMDx::Fault => e + span.set_attribute("cmdx.status", e.result.status) + span.set_attribute("cmdx.reason", e.result.reason.to_s) if e.result.reason + span.record_exception(e.result.cause) if e.result.cause + span.status = OpenTelemetry::Trace::Status.error(e.message) + raise + rescue StandardError => e + span.record_exception(e) + span.status = OpenTelemetry::Trace::Status.error(e.message) + raise + end + end +end +``` + +Middlewares rescue `Fault` themselves because CMDx converts raised `Fault`s back into echoed signals after the middleware chain — without the rescue the span wouldn't see the failure. + +```ruby +class ApplicationTask < CMDx::Task + register :middleware, CmdxOtelMiddleware.new +end +``` + +## Recording Logical Failures on the Span + +Middlewares don't see the finalized `Result`, so subscribe to `:task_executed` for `skip!` / `fail!` outcomes that never raised: + +```ruby +CMDx.configure do |config| + config.telemetry.subscribe(:task_executed) do |event| + result = event.payload[:result] + span = OpenTelemetry::Trace.current_span + next unless span&.recording? + + span.set_attribute("cmdx.state", result.state) + span.set_attribute("cmdx.status", result.status) + span.set_attribute("cmdx.duration", result.duration) + span.set_attribute("cmdx.reason", result.reason.to_s) if result.reason + span.status = OpenTelemetry::Trace::Status.error(result.reason.to_s) if result.failed? + end +end +``` + +## Notes + +!!! tip + + Add `result.origin` as a span link when chasing root-cause across workflows: it points at the leaf task that originated a propagated failure. Use `span.add_link(OpenTelemetry::Trace::Link.new(origin_span_context))` when you keep a task-id → span-context map. diff --git a/examples/pundit_authorization.md b/examples/pundit_authorization.md new file mode 100644 index 000000000..e4dc36438 --- /dev/null +++ b/examples/pundit_authorization.md @@ -0,0 +1,72 @@ +# Pundit Authorization + +Authorize CMDx tasks with [Pundit](https://github.com/varvet/pundit) policies by failing fast when the current user isn't permitted to execute the task. + +## Authorization Middleware + +A single middleware checks a policy for every task that opts in. + +```ruby +# app/middlewares/cmdx_pundit_middleware.rb +class CmdxPunditMiddleware + def initialize(action: :execute?) + @action = action + end + + def call(task) + policy = Pundit.policy!(task.context.current_user, task.class) + task.send(:fail!, "not authorized", code: :forbidden) unless policy.public_send(@action) + + yield + end +end +``` + +```ruby +class ApplicationTask < CMDx::Task + required :current_user +end + +class CreateInvoice < ApplicationTask + register :middleware, CmdxPunditMiddleware.new(action: :create?) + + required :customer_id, coerce: :integer + + def work + context.invoice = Invoice.create!(...) + end +end +``` + +## Per-task Policy Class + +Define a Pundit policy that mirrors the task, exactly like a model policy: + +```ruby +# app/policies/create_invoice_policy.rb +class CreateInvoicePolicy < ApplicationPolicy + def create? + user.admin? || user.billing_manager? + end +end +``` + +Pundit's `policy!` resolves `CreateInvoicePolicy` from `CreateInvoice` automatically. + +## Reacting to Denials + +Because the middleware halts with `fail!`, the `Result` is already a standard failure — no special casing in callers: + +```ruby +result = CreateInvoice.execute(current_user:, customer_id: 42) + +if result.failed? && result.metadata[:code] == :forbidden + redirect_to dashboard_path, alert: result.reason +end +``` + +## Notes + +!!! tip + + Put the middleware on `ApplicationTask` so every subclass inherits it automatically. Tasks that shouldn't be authorized can `deregister :middleware, CmdxPunditMiddleware` in their declaration. From 29cb1e57b9125b46debe02aff268ad3f2990ce1f Mon Sep 17 00:00:00 2001 From: Juan Gomez <drexed@users.noreply.github.com> Date: Wed, 22 Apr 2026 09:49:49 -0400 Subject: [PATCH 17/54] V2 --- CHANGELOG.md | 5 ++- docs/basics/context.md | 35 ++++++++++++++++ docs/configuration.md | 18 ++++++++- lib/cmdx/configuration.rb | 6 ++- lib/cmdx/context.rb | 19 ++++++++- lib/cmdx/settings.rb | 10 +++++ lib/cmdx/task.rb | 10 ++++- spec/cmdx/configuration_spec.rb | 3 ++ spec/cmdx/context_spec.rb | 46 +++++++++++++++++++++ spec/cmdx/settings_spec.rb | 11 +++++ spec/integration/tasks/settings_spec.rb | 54 +++++++++++++++++++++++++ 11 files changed, 208 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 62a219e07..91e1323b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,7 @@ Full runtime rewrite: the v1 state-machine plus Zeitwerk architecture is replace - Add `Context#keys`, `values`, `empty?`, `size`, `delete`, `clear`, `eql?` / `==`, `hash`, `deep_dup`, `respond_to_missing?`, and `Context#merge` that accepts any context-like object - Add `Coercions::Coerce` and `Validators::Validate` inline-callable handlers for `:coerce` / `:validate` hash entries; generic callables receive `(value, task)`, Symbol and Proc handlers still resolve against the task - Add `Configuration#backtrace_cleaner` and `Configuration#telemetry` +- Add `Configuration#strict_context` (defaults to `false`) and matching `Settings#strict_context` override, toggling `Context#strict`; when enabled, unknown dynamic reads (`ctx.missing`) raise `NoMethodError` instead of returning `nil` — `[]`, `fetch`, `dig`, `key?`, and `?` predicates stay lenient - Add `CMDx.reset_configuration!` which clears global registry ivars on `Task` for clean test setup/teardown; subclasses that already cloned their registries are unaffected - Add `:if` / `:unless` gates to `Callbacks#register` (Symbol, Proc, or any `#call`-able); per-event DSL helpers (`before_execution`, `on_success`, etc.) forward the options through - Add `:if` / `:unless` gates to `Middlewares#register` (Symbol, Proc, or any `#call`-able); evaluated per task in `Middlewares#process` — skipped middlewares are bypassed and the chain continues @@ -56,7 +57,7 @@ Full runtime rewrite: the v1 state-machine plus Zeitwerk architecture is replace - `Workflow` declares groups via `task` / `tasks` (still aliased) and supports `:strategy => :parallel`, `:pool_size`, `:if` / `:unless` per group; defining `#work` on a workflow raises `ImplementationError` - `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 - `Task.callbacks`, `Task.middlewares`, `Task.coercions`, `Task.validators`, `Task.telemetry`, `Task.inputs`, `Task.outputs` lazy-clone from the superclass (or global `Configuration`) on first access — subclasses extend rather than replace -- `Settings` is now a frozen value object holding only `logger`, `log_formatter`, `log_level`, `backtrace_cleaner`, `tags`; every getter falls back to `CMDx.configuration` +- `Settings` is now a frozen value object holding only `logger`, `log_formatter`, `log_level`, `backtrace_cleaner`, `tags`, `strict_context`; every getter falls back to `CMDx.configuration` - `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` - `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) - 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 @@ -71,7 +72,7 @@ Full runtime rewrite: the v1 state-machine plus Zeitwerk architecture is replace - `Middlewares` registry entries are now `[callable, options.freeze]` tuples — callers that read `Task.middlewares.registry` directly must map `.first` to recover the callable - 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 - Generators emit the new `def work` template; the install template documents the new middleware / callback / telemetry / coercion / validator registration shapes -- Slim `Configuration` to: `middlewares`, `callbacks`, `coercions`, `validators`, `telemetry`, `default_locale`, `backtrace_cleaner`, `logger`, `log_level`, `log_formatter` +- Slim `Configuration` to: `middlewares`, `callbacks`, `coercions`, `validators`, `telemetry`, `default_locale`, `strict_context`, `backtrace_cleaner`, `logger`, `log_level`, `log_formatter` ### Removed - **BREAKING**: Remove `Result::STATES = [INITIALIZED, EXECUTING, COMPLETE, INTERRUPTED]`, the `executed!` / `executing!` transitions, and the `executed?` / `initialized?` / `executing?` predicates diff --git a/docs/basics/context.md b/docs/basics/context.md index dbf0ffa7b..11ea7ddb5 100644 --- a/docs/basics/context.md +++ b/docs/basics/context.md @@ -125,6 +125,41 @@ CreateShippingLabel.execute(result) `context.to_h` exposes the backing hash by reference. `Context.build(context.to_h)` rebuilds a fresh top-level table (symbolized keys) but nested mutable values are still shared — use `deep_dup` for full isolation. +## Strict Mode + +By default, reading an unknown key via the dynamic method reader returns `nil`. Enable strict mode to raise `NoMethodError` for unknown reads instead — useful for catching typos in larger tasks. + +Strict mode can be set globally or per-task: + +```ruby +CMDx.configure do |config| + config.strict_context = true +end + +class CalculateShipping < CMDx::Task + settings(strict_context: true) + + def work + context.weight #=> reads fine when set + context.typoed_key #=> raises NoMethodError: unknown context key :typoed_key (strict mode) + end +end +``` + +Strict mode only affects the dynamic method reader. `[]`, `fetch`, `dig`, `key?`, and `ctx.foo?` predicates keep their lenient semantics so defaults and explicit presence checks still work: + +```ruby +context[:missing] #=> nil +context.fetch(:missing, :default) #=> :default +context.dig(:a, :b) #=> nil +context.missing? #=> false +context.missing = 1 #=> 1 (writes still allowed) +``` + +!!! note + + `strict_context` is read once in `Task#initialize` and applied to the built `Context`. Nested tasks that reuse the outer context inherit the outer task's strict flag — set it at the entry-point task, not mid-pipeline. + ## Pattern Matching `Context` supports both array and hash deconstruction (Ruby 3.0+). diff --git a/docs/configuration.md b/docs/configuration.md index 9f1917c90..8a4f4bc42 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -24,6 +24,7 @@ CMDx uses a two-tier configuration system: | `log_formatter` | `CMDx::LogFormatters::Line.new` | Formatter instance | | `default_locale` | `"en"` | Locale for built-in translation fallbacks | | `backtrace_cleaner` | `nil` | Callable to clean fault backtraces | +| `strict_context` | `false` | Raise `NoMethodError` on unknown `context.foo` reads | | `middlewares` | `Middlewares.new` (empty) | Middleware registry | | `callbacks` | `Callbacks.new` (empty) | Callback registry | | `coercions` | `Coercions.new` (13 built-ins) | Coercion registry | @@ -61,6 +62,18 @@ end Rails apps wire this automatically via `CMDx::Railtie`. +### Strict Context + +Raise `NoMethodError` for unknown dynamic context reads instead of returning `nil`. Applies to the `ctx.foo` reader only — `[]`, `fetch`, `dig`, `key?`, and predicate `ctx.foo?` keep their lenient behavior. See [Context - Strict Mode](basics/context.md#strict-mode) for usage. + +```ruby +CMDx.configure do |config| + config.strict_context = true +end +``` + +Override per-task via `settings(strict_context: true)`. + ### Logging ```ruby @@ -242,7 +255,8 @@ class GenerateInvoice < CMDx::Task log_formatter: CMDx::LogFormatters::JSON.new, log_level: Logger::DEBUG, backtrace_cleaner: ->(bt) { bt.first(8) }, - tags: ["billing", "financial"] + tags: ["billing", "financial"], + strict_context: true ) def work @@ -266,7 +280,7 @@ end !!! note - `Settings` only stores `:logger`, `:log_formatter`, `:log_level`, `:backtrace_cleaner`, and `:tags`. Other class-level config uses dedicated DSL (`retry_on`, `deprecation`, `register`, `before_execution`, …). + `Settings` only stores `:logger`, `:log_formatter`, `:log_level`, `:backtrace_cleaner`, `:tags`, and `:strict_context`. Other class-level config uses dedicated DSL (`retry_on`, `deprecation`, `register`, `before_execution`, …). ### Retry diff --git a/lib/cmdx/configuration.rb b/lib/cmdx/configuration.rb index 3fe60bda4..8204c0524 100644 --- a/lib/cmdx/configuration.rb +++ b/lib/cmdx/configuration.rb @@ -9,8 +9,9 @@ module CMDx # cached their copy yet. class Configuration - attr_accessor :middlewares, :callbacks, :coercions, :validators, :telemetry, - :default_locale, :backtrace_cleaner, :logger, :log_level, :log_formatter + attr_accessor :middlewares, :callbacks, :coercions, :validators, + :telemetry, :default_locale, :strict_context, :backtrace_cleaner, + :logger, :log_level, :log_formatter def initialize @middlewares = Middlewares.new @@ -20,6 +21,7 @@ def initialize @telemetry = Telemetry.new @default_locale = "en" + @strict_context = false @backtrace_cleaner = nil @log_formatter = LogFormatters::Line.new diff --git a/lib/cmdx/context.rb b/lib/cmdx/context.rb index 05d07fe92..1bac0439a 100644 --- a/lib/cmdx/context.rb +++ b/lib/cmdx/context.rb @@ -31,6 +31,13 @@ def build(context = EMPTY_HASH) end + # Enables strict mode — when true, dynamic readers via {#method_missing} + # raise `NoMethodError` for unknown keys instead of returning `nil`. + # Set by `Task#initialize` from `Task.settings.strict_context`. + # + # @return [Boolean] + attr_accessor :strict + # @param context [Hash, #to_h, #to_hash] source hash, keys are symbolized # @raise [ArgumentError] when `context` doesn't respond to `#to_h`/`#to_hash` def initialize(context = EMPTY_HASH) @@ -44,6 +51,12 @@ def initialize(context = EMPTY_HASH) end.transform_keys(&:to_sym) end + # @return [Boolean] whether dynamic reads for unknown keys raise instead + # of returning `nil` + def strict? + !!@strict + end + # Stores `value` under `key`, symbolizing the key. Overwrites any # existing entry. # @@ -226,16 +239,20 @@ def freeze # Provides dynamic read/write/predicate access to context keys. # - # - `ctx.name` — reads `@table[name]`, `nil` when absent. + # - `ctx.name` — reads `@table[name]`, `nil` when absent (raises + # `NoMethodError` when {#strict?} is true and the key is absent). # - `ctx.name = val` — stores `val` under `:name`. # - `ctx.name?` — truthy check for `@table[:name]`. # + # @raise [NoMethodError] when {#strict?} is true and the key is missing # @api private def method_missing(method_name, *args, **_kwargs, &) if method_name.end_with?("=") @table[method_name[..-2].to_sym] = args.first elsif method_name.end_with?("?") !!@table[method_name[..-2].to_sym] + elsif strict? && !@table.key?(method_name) + raise NoMethodError, "unknown context key #{method_name.inspect} (strict mode)" else @table[method_name] end diff --git a/lib/cmdx/settings.rb b/lib/cmdx/settings.rb index f43ccb2b3..c671cb4f5 100644 --- a/lib/cmdx/settings.rb +++ b/lib/cmdx/settings.rb @@ -12,6 +12,7 @@ class Settings # @option options [Integer] :log_level # @option options [#call] :backtrace_cleaner # @option options [Array<Symbol, String>] :tags + # @option options [Boolean] :strict_context def initialize(options = EMPTY_HASH) @options = options.freeze end @@ -65,5 +66,14 @@ def tags tags ? tags.dup : [] end + # @return [Boolean] whether this task's {Context} should raise on + # unknown dynamic reads; falls back to + # {Configuration#strict_context} + def strict_context + @options.fetch(:strict_context) do + CMDx.configuration.strict_context + end + end + end end diff --git a/lib/cmdx/task.rb b/lib/cmdx/task.rb index 52b231f56..ec1faab73 100644 --- a/lib/cmdx/task.rb +++ b/lib/cmdx/task.rb @@ -289,11 +289,17 @@ def undefine_input_reader(input) alias ctx context # @param context [Hash, Context, #context, #to_h] + # @note The built {Context} inherits `strict` mode from + # {Settings#strict_context} (falling back to + # {Configuration#strict_context}), so dynamic reads for unknown keys + # raise `NoMethodError` instead of returning `nil`. def initialize(context = EMPTY_HASH) + @metadata = {} @tid = SecureRandom.uuid_v7 - @context = Context.build(context) @errors = Errors.new - @metadata = {} + @context = Context.build(context).tap do |c| + c.strict = self.class.settings.strict_context + end end # Executes this task instance through {Runtime}. diff --git a/spec/cmdx/configuration_spec.rb b/spec/cmdx/configuration_spec.rb index 4b5c77d36..0c14d4360 100644 --- a/spec/cmdx/configuration_spec.rb +++ b/spec/cmdx/configuration_spec.rb @@ -19,6 +19,7 @@ expect(config).to have_attributes( default_locale: "en", backtrace_cleaner: nil, + strict_context: false, log_level: Logger::INFO ) expect(config.log_formatter).to be_a(CMDx::LogFormatters::Line) @@ -40,10 +41,12 @@ config.logger = logger config.backtrace_cleaner = cleaner config.default_locale = "fr" + config.strict_context = true expect(config.logger).to be(logger) expect(config.backtrace_cleaner).to be(cleaner) expect(config.default_locale).to eq("fr") + expect(config.strict_context).to be(true) end end end diff --git a/spec/cmdx/context_spec.rb b/spec/cmdx/context_spec.rb index 1a6e8cc60..81b7224f3 100644 --- a/spec/cmdx/context_spec.rb +++ b/spec/cmdx/context_spec.rb @@ -279,4 +279,50 @@ expect(ctx.respond_to?(:ready?)).to be(true) end end + + describe "strict mode" do + subject(:ctx) { described_class.new(name: "Jane") } + + it "strict? is false by default" do + expect(ctx.strict?).to be(false) + end + + it "strict? reflects any truthy/falsy assignment" do + ctx.strict = true + expect(ctx.strict?).to be(true) + + ctx.strict = nil + expect(ctx.strict?).to be(false) + end + + context "when strict is enabled" do + before { ctx.strict = true } + + it "raises NoMethodError for unknown dynamic reads" do + expect { ctx.missing } + .to raise_error(NoMethodError, /unknown context key :missing \(strict mode\)/) + end + + it "still returns existing keys via dynamic reader" do + expect(ctx.name).to eq("Jane") + end + + it "still assigns via foo=" do + ctx.age = 30 + expect(ctx[:age]).to eq(30) + end + + it "still allows foo? predicates for unknown keys without raising" do + expect(ctx.unknown?).to be(false) + end + + it "does not affect [] access for unknown keys" do + expect(ctx[:missing]).to be_nil + end + + it "does not affect fetch without default" do + expect { ctx.fetch(:missing) }.to raise_error(KeyError) + end + end + end end diff --git a/spec/cmdx/settings_spec.rb b/spec/cmdx/settings_spec.rb index 300ba4c6c..f8c8505ad 100644 --- a/spec/cmdx/settings_spec.rb +++ b/spec/cmdx/settings_spec.rb @@ -45,6 +45,13 @@ it "backtrace_cleaner falls back to the global configuration" do expect(described_class.new.backtrace_cleaner).to be(custom_cleaner) end + + it "strict_context falls back to the global configuration" do + CMDx.configuration.strict_context = true + expect(described_class.new.strict_context).to be(true) + ensure + CMDx.configuration.strict_context = false + end end describe "option overrides" do @@ -67,6 +74,10 @@ cleaner = ->(bt) { bt } expect(described_class.new(backtrace_cleaner: cleaner).backtrace_cleaner).to be(cleaner) end + + it "strict_context prefers the local option over the global default" do + expect(described_class.new(strict_context: true).strict_context).to be(true) + end end describe "#tags" do diff --git a/spec/integration/tasks/settings_spec.rb b/spec/integration/tasks/settings_spec.rb index efd112617..4b27c6b9d 100644 --- a/spec/integration/tasks/settings_spec.rb +++ b/spec/integration/tasks/settings_spec.rb @@ -131,4 +131,58 @@ expect(task.settings).to be(first) end end + + describe "strict_context" do + it "defaults to false and allows nil reads of unknown keys" do + task = create_successful_task do + define_method(:work) { context.missing } + end + + expect(task.execute).to have_attributes(status: CMDx::Signal::SUCCESS) + end + + it "propagates task-level strict_context to the instance context" do + task = create_task_class(name: "StrictTask") do + settings(strict_context: true) + define_method(:work) { nil } + end + + instance = task.new + expect(instance.context.strict?).to be(true) + end + + it "raises NoMethodError inside #work when a dynamic read hits an unknown key" do + task = create_task_class(name: "StrictReader") do + settings(strict_context: true) + define_method(:work) { context.missing } + end + + expect { task.execute! }.to raise_error(NoMethodError, /unknown context key :missing/) + end + + it "does not affect [] reads or fetch with defaults" do + task = create_task_class(name: "StrictSafeReader") do + settings(strict_context: true) + define_method(:work) do + context.seen = context[:missing] + context.defaulted = context.fetch(:missing, :fallback) + end + end + + result = task.execute + expect(result).to have_attributes(status: CMDx::Signal::SUCCESS) + expect(result.context).to have_attributes(seen: nil, defaulted: :fallback) + end + + it "falls back to the global configuration" do + CMDx.configuration.strict_context = true + task = create_task_class(name: "GlobalStrict") do + define_method(:work) { nil } + end + + expect(task.new.context.strict?).to be(true) + ensure + CMDx.configuration.strict_context = false + end + end end From 379f8d7d9ad279f4c2c00f1ae657c2d5ac142e20 Mon Sep 17 00:00:00 2001 From: Juan Gomez <drexed@users.noreply.github.com> Date: Wed, 22 Apr 2026 09:58:31 -0400 Subject: [PATCH 18/54] V2 --- CHANGELOG.md | 4 +- docs/workflows.md | 7 ++- lib/cmdx/pipeline.rb | 27 ++++++++--- lib/cmdx/workflow.rb | 2 + spec/cmdx/pipeline_spec.rb | 54 +++++++++++++++++++++ spec/integration/workflows/parallel_spec.rb | 32 ++++++++++++ 6 files changed, 115 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 91e1323b6..f1853bf2f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -54,8 +54,8 @@ Full runtime rewrite: the v1 state-machine plus Zeitwerk architecture is replace - **BREAKING**: `Result#chain` now returns the owning `Chain` object directly instead of its results array (use `result.chain.to_a` / `result.chain.results`, or iterate via `Chain`'s new Enumerable methods) - **BREAKING**: Drive `Result#caused_failure` / `threw_failure` / `caused_failure?` / `thrown_failure?` off `Signal#origin` instead of `signal.cause`; `caused_failure` walks `origin` recursively to the originating leaf, `threw_failure` returns `origin || self`, `caused_failure?` is true when the result originated the failure chain, `thrown_failure?` is true when the result re-threw an upstream failure - 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` -- `Workflow` declares groups via `task` / `tasks` (still aliased) and supports `:strategy => :parallel`, `:pool_size`, `:if` / `:unless` per group; defining `#work` on a workflow raises `ImplementationError` -- `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 +- `Workflow` declares groups via `task` / `tasks` (still aliased) and supports `:strategy => :parallel`, `:pool_size`, `:fail_fast`, `:if` / `:unless` per group; defining `#work` on a workflow raises `ImplementationError` +- `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; opt-in `:fail_fast` drains pending tasks on the first failure (in-flight tasks still finish and successful contexts still merge) - `Task.callbacks`, `Task.middlewares`, `Task.coercions`, `Task.validators`, `Task.telemetry`, `Task.inputs`, `Task.outputs` lazy-clone from the superclass (or global `Configuration`) on first access — subclasses extend rather than replace - `Settings` is now a frozen value object holding only `logger`, `log_formatter`, `log_level`, `backtrace_cleaner`, `tags`, `strict_context`; every getter falls back to `CMDx.configuration` - `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` diff --git a/docs/workflows.md b/docs/workflows.md index 632d28517..4669fcc46 100644 --- a/docs/workflows.md +++ b/docs/workflows.md @@ -64,6 +64,7 @@ Options apply to the entire group: |---------------|----------------|--------------------------------------------------------| | `strategy:` | `:sequential` | `:sequential` or `:parallel` | | `pool_size:` | `tasks.size` | Worker count when `strategy: :parallel` | +| `fail_fast:` | `false` | When `strategy: :parallel`, drain pending tasks on the first failure (in-flight tasks still finish) | | `if:` / `unless:` | — | Skip the entire group when the predicate isn't satisfied | ### Conditionals @@ -218,12 +219,16 @@ class SendWelcomeNotifications < CMDx::Task # Bounded thread pool tasks SendWelcomeEmail, SendWelcomeSms, SendWelcomePush, strategy: :parallel, pool_size: 2 + + # Abort pending parallel tasks once any sibling fails + tasks ChargeCard, ReserveInventory, EmitAnalytics, + strategy: :parallel, fail_fast: true end ``` !!! warning - Each parallel task receives its own deep-duplicated `context` copy, which is merged back into the workflow's context after execution. If multiple tasks write to the same key, the last merge wins non-deterministically. Use distinct keys per task to avoid conflicts. If any parallel task fails, the failed result is propagated through `throw!` (the other tasks still complete first; they just don't merge back). + Each parallel task receives its own deep-duplicated `context` copy, which is merged back into the workflow's context after execution. If multiple tasks write to the same key, the last merge wins non-deterministically. Use distinct keys per task to avoid conflicts. If any parallel task fails, the failed result is propagated through `throw!` (the other tasks still complete first; they just don't merge back). With `fail_fast: true`, tasks still queued when a sibling fails are skipped entirely; in-flight tasks run to completion and their successful contexts still merge. ## Task Generator diff --git a/lib/cmdx/pipeline.rb b/lib/cmdx/pipeline.rb index 39358ab67..9b635f975 100644 --- a/lib/cmdx/pipeline.rb +++ b/lib/cmdx/pipeline.rb @@ -60,12 +60,14 @@ def run_sequential(group) end def run_parallel(group) - tasks = group.tasks - chain = Chain.current - size = group.options[:pool_size] || tasks.size - queue = Queue.new - results = Array.new(tasks.size) - mutex = Mutex.new + tasks = group.tasks + chain = Chain.current + size = group.options[:pool_size] || tasks.size + fail_fast = group.options[:fail_fast] + queue = Queue.new + results = Array.new(tasks.size) + mutex = Mutex.new + failed = nil tasks.each_with_index { |tc, i| queue << [tc, i] } size.times { queue << nil } @@ -77,15 +79,24 @@ def run_parallel(group) task_class, index = entry ctx_copy = @workflow.context.deep_dup result = task_class.execute(ctx_copy) - mutex.synchronize { results[index] = result } + mutex.synchronize do + results[index] = result + + if fail_fast && result.failed? && failed.nil? + failed = result + queue.clear + size.times { queue << nil } + end + end end end end workers.each(&:join) - failed = nil results.each do |result| + next if result.nil? + if result.failed? failed ||= result else diff --git a/lib/cmdx/workflow.rb b/lib/cmdx/workflow.rb index 6662d8496..60b647603 100644 --- a/lib/cmdx/workflow.rb +++ b/lib/cmdx/workflow.rb @@ -29,6 +29,8 @@ def pipeline # @param options [Hash{Symbol => Object}] # @option options [:sequential, :parallel] :strategy (:sequential) # @option options [Integer] :pool_size parallel worker count + # @option options [Boolean] :fail_fast (false) when `:parallel`, drain + # pending tasks on the first failure (in-flight tasks still finish) # @option options [Symbol, Proc, #call] :if # @option options [Symbol, Proc, #call] :unless # @return [Array<ExecutionGroup>] the full pipeline diff --git a/spec/cmdx/pipeline_spec.rb b/spec/cmdx/pipeline_spec.rb index b7f620937..f8854b12a 100644 --- a/spec/cmdx/pipeline_spec.rb +++ b/spec/cmdx/pipeline_spec.rb @@ -132,6 +132,60 @@ expect(workflow_class.execute).to be_success end + + describe ":fail_fast" do + it "skips queued tasks after the first failure (pool_size: 1)" do + failing = create_failing_task(name: "First", reason: "stop") + never_run = create_task_class(name: "NeverRun") do + define_method(:work) { context.ran = true } + end + + workflow_class = create_workflow_class do + tasks failing, never_run, strategy: :parallel, pool_size: 1, fail_fast: true + end + + result = workflow_class.execute + expect(result).to be_failed + expect(result.reason).to eq("stop") + expect(result.context[:ran]).to be_nil + task_names = result.chain.map { |r| r.task.name } + expect(task_names.any? { |n| n.include?("NeverRun") }).to be(false) + end + + it "runs every task when fail_fast is false (default behavior preserved)" do + failing = create_failing_task(name: "First", reason: "stop") + other = create_task_class(name: "Other") do + define_method(:work) { context.ran = true } + end + + workflow_class = create_workflow_class do + tasks failing, other, strategy: :parallel, pool_size: 1 + end + + result = workflow_class.execute + expect(result).to be_failed + expect(result.context[:ran]).to be(true) + end + + it "merges context from tasks that completed before the failure was observed" do + ok = create_task_class(name: "Ok") do + define_method(:work) { context.ok = true } + end + failing = create_failing_task(name: "Fail", reason: "boom") + never_run = create_task_class(name: "NeverRun") do + define_method(:work) { context.ran = true } + end + + workflow_class = create_workflow_class do + tasks ok, failing, never_run, strategy: :parallel, pool_size: 1, fail_fast: true + end + + result = workflow_class.execute + expect(result).to be_failed + expect(result.context[:ok]).to be(true) + expect(result.context[:ran]).to be_nil + end + end end end end diff --git a/spec/integration/workflows/parallel_spec.rb b/spec/integration/workflows/parallel_spec.rb index b15a07540..9d0a5dd70 100644 --- a/spec/integration/workflows/parallel_spec.rb +++ b/spec/integration/workflows/parallel_spec.rb @@ -111,6 +111,38 @@ end end + describe "fail_fast" do + it "drains pending parallel tasks after the first failure" do + ok = create_task_class(name: "Ok") { define_method(:work) { context.ok = true } } + fl = create_failing_task(name: "Fail", reason: "fast boom") + never = create_task_class(name: "Never") { define_method(:work) { context.never_ran = true } } + + workflow = create_workflow_class do + tasks ok, fl, never, strategy: :parallel, pool_size: 1, fail_fast: true + end + + result = workflow.execute + + expect(result).to have_attributes(status: CMDx::Signal::FAILED, reason: "fast boom") + expect(result.context[:ok]).to be(true) + expect(result.context[:never_ran]).to be_nil + end + + it "still runs all tasks when fail_fast is omitted" do + fl = create_failing_task(name: "Fail", reason: "boom") + later = create_task_class(name: "Later") { define_method(:work) { context.later = true } } + + workflow = create_workflow_class do + tasks fl, later, strategy: :parallel, pool_size: 1 + end + + result = workflow.execute + + expect(result).to be_failed + expect(result.context[:later]).to be(true) + end + end + describe "concurrency" do it "actually runs tasks on separate threads" do a = create_task_class(name: "A") { define_method(:work) { context.a_thread = Thread.current.object_id } } From d44281bfce471da4140f7704dae0d4a55ee57147 Mon Sep 17 00:00:00 2001 From: Juan Gomez <drexed@users.noreply.github.com> Date: Wed, 22 Apr 2026 10:01:17 -0400 Subject: [PATCH 19/54] V2 --- CHANGELOG.md | 5 +++++ lib/cmdx/context.rb | 22 +++++++++++++++++---- lib/cmdx/errors.rb | 17 ++++++++++++++++ lib/cmdx/input.rb | 18 +++++++++++++++++ lib/cmdx/output.rb | 18 +++++++++++++++++ lib/cmdx/result.rb | 18 +++++++++++++++++ lib/cmdx/workflow.rb | 1 - spec/cmdx/context_spec.rb | 24 +++++++++++++++++++++++ spec/cmdx/errors_spec.rb | 25 ++++++++++++++++++++++++ spec/cmdx/input_spec.rb | 24 +++++++++++++++++++++++ spec/cmdx/output_spec.rb | 24 +++++++++++++++++++++++ spec/cmdx/result_spec.rb | 41 +++++++++++++++++++++++++++++++++++++++ 12 files changed, 232 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f1853bf2f..f262ad57a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), Full runtime rewrite: the v1 state-machine plus Zeitwerk architecture is replaced by an explicit signal-based runtime, immutable results, fiber-local chains, and a slimmer registry surface. See [docs/v2-migration.md](docs/v2-migration.md) for the full upgrade guide. ### Added +- Add `Context#as_json` / `Context#to_json` — JSON serialization delegating to `#to_h` +- Add `Errors#as_json` / `Errors#to_json` — JSON serialization delegating to `#to_h` +- Add `Result#as_json` / `Result#to_json` — JSON serialization delegating to the memoized `#to_h` +- Add `Input#as_json` / `Input#to_json` — JSON serialization delegating to `#to_h` +- Add `Output#as_json` / `Output#to_json` — JSON serialization delegating to `#to_h` - Add `CMDx::Signal` halt token thrown via `catch(Signal::TAG)` (`:cmdx_signal`) - Add `Signal#ok?` / `Signal#ko?` predicates - Add `CMDx::Runtime` orchestrating the full task lifecycle and building the final `Result` diff --git a/lib/cmdx/context.rb b/lib/cmdx/context.rb index 1bac0439a..cbe12ca87 100644 --- a/lib/cmdx/context.rb +++ b/lib/cmdx/context.rb @@ -195,6 +195,24 @@ def to_h @table end + # JSON-friendly hash view. Aliases {#to_h} for conventional `as_json` + # callers (e.g. Rails); values pass through unchanged — non-primitive + # entries rely on their own `as_json` / `to_json`. + # + # @return [Hash{Symbol => Object}] + def as_json(*) + to_h + end + + # Serializes the context to a JSON string. Symbol keys are emitted as + # strings by the `json` stdlib. + # + # @param args [Array] forwarded to `Hash#to_json` + # @return [String] + def to_json(*args) + to_h.to_json(*args) + end + # @return [String] space-separated `key=value.inspect` pairs def to_s @table.map { |k, v| "#{k}=#{v.inspect}" }.join(" ") @@ -262,10 +280,6 @@ def respond_to_missing?(method_name, include_private = false) @table.key?(method_name) || method_name.end_with?("=", "?") || super end - def deep_dup_hash(h) - h.each_with_object({}) { |(k, v), acc| acc[k] = deep_dup_value(v) } - end - def compute_deep_dup(value) case value when Numeric, Symbol, TrueClass, FalseClass, NilClass diff --git a/lib/cmdx/errors.rb b/lib/cmdx/errors.rb index b767eaf40..9ff814d77 100644 --- a/lib/cmdx/errors.rb +++ b/lib/cmdx/errors.rb @@ -128,6 +128,23 @@ def to_hash(full = false) full ? full_messages : to_h end + # JSON-friendly hash view. Aliases {#to_h} for conventional `as_json` + # callers (e.g. Rails). + # + # @return [Hash{Symbol => Array<String>}] + def as_json(*) + to_h + end + + # Serializes the error messages to a JSON string. Symbol keys are + # emitted as strings by the `json` stdlib. + # + # @param args [Array] forwarded to `Hash#to_json` + # @return [String] + def to_json(*args) + to_h.to_json(*args) + end + # @return [String] all full messages joined with `". "`, suitable as a # fail reason def to_s diff --git a/lib/cmdx/input.rb b/lib/cmdx/input.rb index 6f9ee5512..1f7f91010 100644 --- a/lib/cmdx/input.rb +++ b/lib/cmdx/input.rb @@ -156,6 +156,24 @@ def to_h } end + # JSON-friendly hash view. Aliases {#to_h} for conventional `as_json` + # callers (e.g. Rails). + # + # @return [Hash{Symbol => Object}] + def as_json(*) + to_h + end + + # Serializes the input schema to a JSON string. Non-primitive entries in + # `:options` (Procs, arbitrary callables) emit via their stdlib `to_json` + # defaults. + # + # @param args [Array] forwarded to `Hash#to_json` + # @return [String] + def to_json(*args) + to_h.to_json(*args) + end + private def run_pipeline(value, key_provided, task) diff --git a/lib/cmdx/output.rb b/lib/cmdx/output.rb index 941e32af9..cfe7a0b3b 100644 --- a/lib/cmdx/output.rb +++ b/lib/cmdx/output.rb @@ -79,6 +79,24 @@ def to_h } end + # JSON-friendly hash view. Aliases {#to_h} for conventional `as_json` + # callers (e.g. Rails). + # + # @return [Hash{Symbol => Object}] + def as_json(*) + to_h + end + + # Serializes the output schema to a JSON string. Non-primitive entries in + # `:options` (Procs, arbitrary callables) emit via their stdlib `to_json` + # defaults. + # + # @param args [Array] forwarded to `Hash#to_json` + # @return [String] + def to_json(*args) + to_h.to_json(*args) + end + # Enforces the output contract against `task.context[name]` after `work` runs. # # Steps, in order: diff --git a/lib/cmdx/result.rb b/lib/cmdx/result.rb index 50814ce9b..6530c3f7f 100644 --- a/lib/cmdx/result.rb +++ b/lib/cmdx/result.rb @@ -283,6 +283,24 @@ def to_h end end + # JSON-friendly hash view. Aliases the memoized {#to_h} for conventional + # `as_json` callers (e.g. Rails). + # + # @return [Hash{Symbol => Object}] + def as_json(*) + to_h + end + + # Serializes the result to a JSON string. Non-primitive entries (the + # `:task` Class, `:cause` Exception) emit via their stdlib `to_json` + # defaults; `:context` delegates to {Context#to_json}. + # + # @param args [Array] forwarded to `Hash#to_json` + # @return [String] + def to_json(*args) + to_h.to_json(*args) + end + # @return [String] space-separated `key=value.inspect` pairs; failure # references render as `<TaskClass uuid>`. def to_s diff --git a/lib/cmdx/workflow.rb b/lib/cmdx/workflow.rb index 60b647603..a6a326287 100644 --- a/lib/cmdx/workflow.rb +++ b/lib/cmdx/workflow.rb @@ -37,7 +37,6 @@ def pipeline # @raise [DefinitionError] when called with options but no tasks # @raise [TypeError] when any element isn't a `Task` subclass def tasks(*tasks, **options) - # return pipeline if tasks.empty? && options.empty? raise DefinitionError, "#{name}: cannot declare an empty task group" if tasks.empty? pipeline << ExecutionGroup.new( diff --git a/spec/cmdx/context_spec.rb b/spec/cmdx/context_spec.rb index 81b7224f3..fed419cee 100644 --- a/spec/cmdx/context_spec.rb +++ b/spec/cmdx/context_spec.rb @@ -325,4 +325,28 @@ end end end + + describe "#as_json" do + it "returns to_h" do + ctx = described_class.new(a: 1, b: "x") + expect(ctx.as_json).to eq(ctx.to_h) + end + end + + describe "#to_json" do + it "emits a JSON string with symbol keys stringified" do + ctx = described_class.new(a: 1, b: "x") + expect(JSON.parse(ctx.to_json)).to eq("a" => 1, "b" => "x") + end + + it "serializes nested contexts" do + inner = described_class.new(n: 2) + outer = described_class.new(inner: inner, label: "outer") + + expect(JSON.parse(outer.to_json)).to eq( + "inner" => { "n" => 2 }, + "label" => "outer" + ) + end + end end diff --git a/spec/cmdx/errors_spec.rb b/spec/cmdx/errors_spec.rb index 1a9578fc3..c5d4673c6 100644 --- a/spec/cmdx/errors_spec.rb +++ b/spec/cmdx/errors_spec.rb @@ -263,6 +263,31 @@ end end + describe "#as_json" do + it "returns to_h" do + errors.add(:name, "is required") + expect(errors.as_json).to eq(errors.to_h) + end + end + + describe "#to_json" do + it "emits a JSON string with symbol keys stringified and messages as arrays" do + errors.add(:name, "is required") + errors.add(:name, "is too short") + errors.add(:age, "too young") + + parsed = JSON.parse(errors.to_json) + + expect(parsed.keys).to contain_exactly("name", "age") + expect(parsed["name"]).to contain_exactly("is required", "is too short") + expect(parsed["age"]).to eq(["too young"]) + end + + it "emits an empty object when there are no messages" do + expect(errors.to_json).to eq("{}") + end + end + describe "#freeze" do it "freezes the messages hash and each message set" do errors.add(:name, "is required") diff --git a/spec/cmdx/input_spec.rb b/spec/cmdx/input_spec.rb index f5f36c5f2..0e8d89ad9 100644 --- a/spec/cmdx/input_spec.rb +++ b/spec/cmdx/input_spec.rb @@ -126,6 +126,30 @@ def inactive? = false end end + describe "#as_json" do + it "returns to_h" do + input = described_class.new(:user, description: "d", required: true) + expect(input.as_json).to eq(input.to_h) + end + end + + describe "#to_json" do + it "emits a JSON string with the schema shape" do + child = described_class.new(:inner) + input = described_class.new(:user, description: "d", required: true, children: [child]) + + parsed = JSON.parse(input.to_json) + + expect(parsed).to include( + "name" => "user", + "description" => "d", + "required" => true + ) + expect(parsed["options"]).to include("description" => "d", "required" => true) + expect(parsed["children"]).to eq([JSON.parse(child.to_json)]) + end + end + describe "#resolve" do context "when the value is present in context" do it "returns the coerced value" do diff --git a/spec/cmdx/output_spec.rb b/spec/cmdx/output_spec.rb index fa6730a4e..0275ddaeb 100644 --- a/spec/cmdx/output_spec.rb +++ b/spec/cmdx/output_spec.rb @@ -115,6 +115,30 @@ def inactive? = false end end + describe "#as_json" do + it "returns to_h" do + output = described_class.new(:user, description: "d", required: true) + expect(output.as_json).to eq(output.to_h) + end + end + + describe "#to_json" do + it "emits a JSON string with the schema shape" do + child = described_class.new(:id, type: :integer) + output = described_class.new(:user, description: "d", required: true, children: [child]) + + parsed = JSON.parse(output.to_json) + + expect(parsed).to include( + "name" => "user", + "description" => "d", + "required" => true + ) + expect(parsed["options"]).to include("description" => "d", "required" => true) + expect(parsed["children"]).to eq([JSON.parse(child.to_json)]) + end + end + describe "#verify" do let(:task) do create_task_class(name: "OutVerifyTask") do diff --git a/spec/cmdx/result_spec.rb b/spec/cmdx/result_spec.rb index a5971085f..271856d89 100644 --- a/spec/cmdx/result_spec.rb +++ b/spec/cmdx/result_spec.rb @@ -217,6 +217,47 @@ def build(signal, **opts) end end + describe "#as_json" do + it "returns the memoized to_h" do + chain << (result = build(CMDx::Signal.success, tid: "rid")) + expect(result.as_json).to be(result.to_h) + end + end + + describe "#to_json" do + it "emits a JSON string for a success result with expected top-level keys" do + chain << (result = build(CMDx::Signal.success, tid: "rid", duration: 0.1)) + + parsed = JSON.parse(result.to_json) + + expect(parsed).to include( + "cid" => chain.id, + "tid" => "rid", + "type" => "Task", + "state" => "complete", + "status" => "success", + "duration" => 0.1 + ) + expect(parsed).not_to have_key("cause") + end + + it "emits a JSON string for a failed result including failure fields" do + sig = CMDx::Signal.failed("boom", metadata: { k: 1 }) + chain << (result = build(sig, rolled_back: true)) + + parsed = JSON.parse(result.to_json) + + expect(parsed).to include( + "state" => "interrupted", + "status" => "failed", + "reason" => "boom", + "metadata" => { "k" => 1 }, + "rolled_back" => true + ) + expect(parsed.keys).to include("threw_failure", "caused_failure") + end + end + describe "pattern matching support" do let(:result) { build(CMDx::Signal.failed("boom", metadata: { k: 1 })) } From 2005fc307472fd3dcf7403360d064c133b57cdf0 Mon Sep 17 00:00:00 2001 From: Juan Gomez <drexed@users.noreply.github.com> Date: Wed, 22 Apr 2026 11:27:27 -0400 Subject: [PATCH 20/54] V2 --- .rubocop.yml | 2 + CHANGELOG.md | 6 +- benchmark/harness.rb | 12 +- benchmark/report.rb | 16 +-- docs/basics/context.md | 84 ++++-------- docs/basics/setup.md | 40 +----- docs/comparison.md | 18 +-- docs/configuration.md | 43 ++++++- docs/inputs/coercions.md | 36 +----- docs/inputs/defaults.md | 4 + docs/inputs/definitions.md | 10 +- docs/inputs/transformations.md | 2 +- docs/inputs/validations.md | 82 ++---------- docs/interruptions/exceptions.md | 4 + docs/interruptions/signals.md | 51 ++------ docs/logging.md | 45 +------ docs/outcomes/result.md | 3 +- docs/outcomes/states.md | 32 +---- docs/outcomes/statuses.md | 36 +----- docs/outputs.md | 2 +- docs/retries.md | 4 +- docs/testing.md | 17 ++- docs/tips_and_tricks.md | 6 +- docs/workflows.md | 88 ++++++++++++- examples/rate_limit.md | 122 ++++++++++++++++++ examples/timeout_guard.md | 75 +++++++++++ lib/cmdx.rb | 13 +- lib/cmdx/configuration.rb | 8 +- lib/cmdx/context.rb | 18 +++ lib/cmdx/executors.rb | 92 ++++++++++++++ lib/cmdx/executors/fiber.rb | 42 ++++++ lib/cmdx/executors/thread.rb | 36 ++++++ lib/cmdx/logger_proxy.rb | 2 +- lib/cmdx/mergers.rb | 92 ++++++++++++++ lib/cmdx/mergers/deep_merge.rb | 23 ++++ lib/cmdx/mergers/last_write_wins.rb | 23 ++++ lib/cmdx/mergers/no_merge.rb | 20 +++ lib/cmdx/output.rb | 22 +++- lib/cmdx/pipeline.rb | 43 +++---- lib/cmdx/task.rb | 34 ++++- lib/cmdx/workflow.rb | 11 +- skills/references/workflows.md | 56 +++++++- spec/cmdx/context_spec.rb | 32 ++++- spec/cmdx/executors_spec.rb | 99 +++++++++++++++ spec/cmdx/fault_spec.rb | 6 +- spec/cmdx/logger_proxy_spec.rb | 21 +++ spec/cmdx/mergers_spec.rb | 115 +++++++++++++++++ spec/cmdx/pipeline_spec.rb | 155 +++++++++++++++++++++++ spec/cmdx/signal_spec.rb | 8 +- spec/cmdx/task_spec.rb | 2 +- spec/integration/tasks/chain_spec.rb | 2 +- spec/integration/tasks/retry_spec.rb | 2 +- spec/integration/tasks/settings_spec.rb | 2 +- spec/integration/tasks/telemetry_spec.rb | 2 +- 54 files changed, 1390 insertions(+), 431 deletions(-) create mode 100644 examples/rate_limit.md create mode 100644 examples/timeout_guard.md create mode 100644 lib/cmdx/executors.rb create mode 100644 lib/cmdx/executors/fiber.rb create mode 100644 lib/cmdx/executors/thread.rb create mode 100644 lib/cmdx/mergers.rb create mode 100644 lib/cmdx/mergers/deep_merge.rb create mode 100644 lib/cmdx/mergers/last_write_wins.rb create mode 100644 lib/cmdx/mergers/no_merge.rb create mode 100644 spec/cmdx/executors_spec.rb create mode 100644 spec/cmdx/mergers_spec.rb diff --git a/.rubocop.yml b/.rubocop.yml index bb56ba6cf..3dc341ea1 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -96,6 +96,8 @@ Style/FrozenStringLiteralComment: Enabled: true EnforcedStyle: always_true SafeAutoCorrect: true +Style/HashSyntax: + EnforcedShorthandSyntax: always Style/ModuleFunction: EnforcedStyle: extend_self Style/OptionalBooleanParameter: diff --git a/CHANGELOG.md b/CHANGELOG.md index f262ad57a..401821dc2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,6 +49,10 @@ Full runtime rewrite: the v1 state-machine plus Zeitwerk architecture is replace - Add `Outputs#register` block DSL (`Outputs::ChildBuilder`) for nested outputs via `required` / `optional` / `output` / `outputs`, arbitrarily deep; `Output#children`, `Output#verify_from_parent`, and `:children` in `Output#to_h` / `Task.outputs_schema`. `Task.outputs` forwards the block - Add `Context#deconstruct` / `Context#deconstruct_keys` for pattern matching - Add `Errors#deconstruct` / `Errors#deconstruct_keys` for pattern matching +- Add `:executor` option to parallel task groups (`Workflow.tasks ..., strategy: :parallel, executor: :threads | :fibers | #call`); `:threads` is the default and preserves current behavior, `:fibers` dispatches via `Fiber.schedule` bounded by `:pool_size` (requires a `Fiber.scheduler` such as the `async` gem's — raises `RuntimeError` when none is installed), and a user-supplied callable matching `call(jobs:, concurrency:, on_job:)` is accepted. Unknown symbols raise `ArgumentError` +- Add `:merge_strategy` option to parallel task groups controlling how successful sibling contexts fold back into the workflow context: `:last_write_wins` (default, matches previous behavior), `:deep_merge` (recursive over `Hash` values), `:no_merge` (workflow context left untouched), or a callable `call(workflow_context, result)`. Merging always walks successful results in declaration order. Unknown symbols raise `ArgumentError` +- Add `CMDx::Executors` registry (built-ins: `:threads` → `Executors::Thread`, `:fibers` → `Executors::Fiber`) and `CMDx::Mergers` registry (built-ins: `:last_write_wins`, `:deep_merge`, `:no_merge`) exposed on `Configuration#executors` / `#mergers` and per-task via `Task.executors` / `Task.mergers` (dup-on-inherit); `Task.register(:executor, ...)` and `Task.register(:merger, ...)` (plus matching `deregister`) let apps plug in custom dispatch/merge strategies resolvable by name from `:executor` / `:merge_strategy` +- Add `Context#deep_merge` — in-place recursive `Hash`-value merge; scalar-vs-hash collisions follow last-write-wins. Used by the `:deep_merge` parallel merge strategy but also available directly ### Changed - **BREAKING**: Rename `#call` → `#work` on task subclasses; `Task.execute` / `Task.execute!` are the new entry points (`call` / `call!` kept as aliases) @@ -61,7 +65,7 @@ Full runtime rewrite: the v1 state-machine plus Zeitwerk architecture is replace - 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` - `Workflow` declares groups via `task` / `tasks` (still aliased) and supports `:strategy => :parallel`, `:pool_size`, `:fail_fast`, `:if` / `:unless` per group; defining `#work` on a workflow raises `ImplementationError` - `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; opt-in `:fail_fast` drains pending tasks on the first failure (in-flight tasks still finish and successful contexts still merge) -- `Task.callbacks`, `Task.middlewares`, `Task.coercions`, `Task.validators`, `Task.telemetry`, `Task.inputs`, `Task.outputs` lazy-clone from the superclass (or global `Configuration`) on first access — subclasses extend rather than replace +- `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 - `Settings` is now a frozen value object holding only `logger`, `log_formatter`, `log_level`, `backtrace_cleaner`, `tags`, `strict_context`; every getter falls back to `CMDx.configuration` - `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` - `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) diff --git a/benchmark/harness.rb b/benchmark/harness.rb index ee4ca19da..e50ce2459 100755 --- a/benchmark/harness.rb +++ b/benchmark/harness.rb @@ -38,7 +38,7 @@ require "get_process_mem" results = { - version: version, + version:, cmdx_version: CMDx::VERSION, ruby: RUBY_VERSION, yjit: defined?(RubyVM::YJIT) && RubyVM::YJIT.respond_to?(:enabled?) && RubyVM::YJIT.enabled?, @@ -73,7 +73,7 @@ def capture_ips(warmup: 1, time: 3) collected = {} job = nil Benchmark.ips do |x| - x.config(warmup: warmup, time: time, quiet: true) + x.config(warmup:, time:, quiet: true) yield(x) job = x end @@ -133,7 +133,7 @@ def memory_profile(label, task_class) task_class.execute rescue nil # rubocop:disable Style/RescueModifier report = MemoryProfiler.report(allow_files: "cmdx") { task_class.execute rescue nil } # rubocop:disable Style/RescueModifier { - label: label, + label:, total_allocated_memsize: report.total_allocated_memsize, total_allocated_objects: report.total_allocated, total_retained_memsize: report.total_retained_memsize, @@ -178,7 +178,7 @@ def allocation_trace(label, task_class) GC.enable ObjectSpace.trace_object_allocations_clear - { label: label, allocations: alloc_counts.sort_by { |_, c| -c }.first(15).to_h } + { label:, allocations: alloc_counts.sort_by { |_, c| -c }.first(15).to_h } end results[:suites][:allocations] = [ @@ -207,7 +207,7 @@ def allocation_trace(label, task_class) rss_after_workflows = mem.mb results[:suites][:rss] = { - iterations: iterations, + iterations:, before_mb: rss_before.round(2), after_tasks_mb: rss_after_tasks.round(2), after_workflows_mb: rss_after_workflows.round(2), @@ -232,7 +232,7 @@ def allocation_trace(label, task_class) gc_keys = %i[total_allocated_objects heap_live_slots major_gc_count minor_gc_count] results[:suites][:gc_stats] = { - iterations: iterations, + iterations:, after_tasks: gc_keys.to_h { |k| [k, gc_after_tasks[k] - gc_before[k]] }, after_workflows: gc_keys.to_h { |k| [k, gc_after_all[k] - gc_after_tasks[k]] } } diff --git a/benchmark/report.rb b/benchmark/report.rb index aa2f92dd4..a355aba29 100755 --- a/benchmark/report.rb +++ b/benchmark/report.rb @@ -86,7 +86,7 @@ def print_ips_table(title, v1_suite, v2_suite) section_header("IPS: #{title}") widths = [28, 14, 14, 10] - table_header("Benchmark", "v1 (i/s)", "v2 (i/s)", "Delta", widths: widths) + table_header("Benchmark", "v1 (i/s)", "v2 (i/s)", "Delta", widths:) v1_suite.each do |label, v1_entry| label_s = label.to_s @@ -120,7 +120,7 @@ def print_ips_table(title, v1_suite, v2_suite) section_header("Memory Profiling (per single execution)") widths = [22, 14, 14, 10] - table_header("Scenario", "v1 alloc", "v2 alloc", "Delta", widths: widths) + table_header("Scenario", "v1 alloc", "v2 alloc", "Delta", widths:) v1_mem = v1s[:memory].to_h { |m| [m[:label], m] } v2_mem = v2s[:memory].to_h { |m| [m[:label], m] } @@ -141,7 +141,7 @@ def print_ips_table(title, v1_suite, v2_suite) end puts - table_header("Scenario", "v1 objects", "v2 objects", "Delta", widths: widths) + table_header("Scenario", "v1 objects", "v2 objects", "Delta", widths:) v1_mem.each do |label, v1_entry| v2_entry = v2_mem[label] @@ -159,7 +159,7 @@ def print_ips_table(title, v1_suite, v2_suite) end puts - table_header("Scenario", "v1 retained", "v2 retained", "Delta", widths: widths) + table_header("Scenario", "v1 retained", "v2 retained", "Delta", widths:) v1_mem.each do |label, v1_entry| v2_entry = v2_mem[label] @@ -216,7 +216,7 @@ def print_ips_table(title, v1_suite, v2_suite) section_header("RSS (Resident Set Size) — #{v1s[:rss][:iterations]} iterations each") widths = [28, 12, 12, 10] - table_header("Metric", "v1 (MB)", "v2 (MB)", "Delta", widths: widths) + table_header("Metric", "v1 (MB)", "v2 (MB)", "Delta", widths:) [ ["Before", :before_mb], @@ -246,7 +246,7 @@ def print_ips_table(title, v1_suite, v2_suite) %i[after_tasks after_workflows].each do |phase| label = phase == :after_tasks ? "After Task Execution" : "After Workflow Execution" puts "\n #{bold(label)}:" - table_header("Metric", "v1", "v2", "Delta", widths: widths) + table_header("Metric", "v1", "v2", "Delta", widths:) v1_gc = v1s[:gc_stats][phase] || {} v2_gc = v2s[:gc_stats][phase] || {} @@ -276,7 +276,7 @@ def print_ips_table(title, v1_suite, v2_suite) if v1_yjit[:speedup] && v2_yjit[:speedup] widths = [28, 12, 12] - table_header("Benchmark", "v1 speedup", "v2 speedup", widths: widths) + table_header("Benchmark", "v1 speedup", "v2 speedup", widths:) v1_yjit[:speedup].each do |label, v1_ratio| v2_ratio = v2_yjit[:speedup][label] || v2_yjit[:speedup][label.to_s] @@ -290,7 +290,7 @@ def print_ips_table(title, v1_suite, v2_suite) elsif v1_yjit[:with_yjit] && v2_yjit[:with_yjit] puts "\n YJIT was enabled at process start; showing YJIT-on IPS only:" widths = [28, 14, 14, 10] - table_header("Benchmark", "v1 (i/s)", "v2 (i/s)", "Delta", widths: widths) + table_header("Benchmark", "v1 (i/s)", "v2 (i/s)", "Delta", widths:) v1_yjit[:with_yjit].each do |label, v1_entry| v2_entry = v2_yjit[:with_yjit][label] || v2_yjit[:with_yjit][label.to_s] diff --git a/docs/basics/context.md b/docs/basics/context.md index 11ea7ddb5..a1ecd36a3 100644 --- a/docs/basics/context.md +++ b/docs/basics/context.md @@ -14,79 +14,45 @@ CalculateShipping.execute(weight: 2.5, destination: "CA") `Context.build` passes an existing un-frozen `Context` through unchanged, unwraps anything that responds to `#context` (Task, Result), and wraps hash-likes in a fresh `Context`. -## Accessing Data +## Accessing and Modifying -Access context data using method notation, hash keys, or safe accessors. `ctx` is a shorthand alias for `context` on task instances. +Read with method, hash, or safe accessors; mutate freely inside `work` (the root context is frozen only after Runtime teardown). `ctx` is a shorthand alias for `context`. ```ruby class CalculateShipping < CMDx::Task def work - # Method style access (preferred) - weight = context.weight - destination = ctx.destination - - # Predicate style — truthy check, never raises on missing keys - context.weight? #=> true - context.missing_field? #=> false - - # Hash style access - service_type = context[:service_type] - options = context["options"] - - # Safe access with defaults - rush_delivery = context.fetch(:rush_delivery, false) - carrier = context.dig(:options, :carrier) - - # Fetch or set a default (returns existing value, or stores and returns the default) - context.retrieve(:attempt_count, 0) - context.retrieve(:correlation_id) { SecureRandom.uuid } - - # Inspection helpers - context.key?(:weight) #=> true - context.keys #=> [:weight, :destination, ...] - context.values #=> [2.5, "CA", ...] - context.size #=> 2 - context.empty? #=> false + # Reads + weight = context.weight # method style (nil for unknown keys) + service_type = context[:service_type] # hash style + rush_delivery = context.fetch(:rush, false) # safe default + carrier = context.dig(:options, :carrier) + attempt = context.retrieve(:attempt_count, 0) # fetch-or-set + context.weight? # truthy predicate + + # Writes + context.calculated_at = Time.now + context[:status] = "calculating" + context.insurance_included ||= false + context.merge(shipping_cost: calculate_cost) # top-level last-write-wins + context.deep_merge(options: { carrier: "ups" }) # recurses into Hash values + context.delete(:credit_card_token) end end ``` !!! note - Method-style access returns `nil` for unknown keys rather than raising. `Context` includes `Enumerable`, yielding `[key, value]` pairs through `each`; `each_key` and `each_value` iterate one side. + Method-style reads return `nil` for unknown keys. `Context` includes `Enumerable` and exposes the usual `keys`/`values`/`key?`/`each`/`each_key`/`each_value`. See YARD for the full surface. -## Modifying Context +## Serialization -Mutate freely inside `work` — the root task's context is frozen only after Runtime teardown: +`Context` serializes cleanly for logs, telemetry payloads, and Rails `render json:` callers: ```ruby -class CalculateShipping < CMDx::Task - def work - # Direct assignment - context.carrier = Carrier.find_by(code: context.carrier_code) - context.calculated_at = Time.now - - # Hash-style assignment - context[:status] = "calculating" - context["tracking_number"] = "SHIP#{SecureRandom.hex(6)}" - - # Conditional assignment - context.insurance_included ||= false - - # Batch updates (mutates in place; returns self) - context.merge( - status: "completed", - shipping_cost: calculate_cost, - estimated_delivery: Time.now + 3.days - ) - - # Remove a key - context.delete(:credit_card_token) - - # Clear all data - context.clear - end -end +context.to_h #=> { weight: 2.5, destination: "CA" } (the backing table, not a copy) +context.as_json #=> same as to_h (aliased for Rails/ActiveSupport callers) +context.to_json #=> '{"weight":2.5,"destination":"CA"}' (Symbol keys are emitted as strings) +context.to_s #=> 'weight=2.5 destination="CA"' (space-separated key=value.inspect) ``` ## Sharing Between Tasks @@ -158,7 +124,7 @@ context.missing = 1 #=> 1 (writes still allowed) !!! note - `strict_context` is read once in `Task#initialize` and applied to the built `Context`. Nested tasks that reuse the outer context inherit the outer task's strict flag — set it at the entry-point task, not mid-pipeline. + `strict_context` is re-applied on every `Task#initialize`: each nested task flips the shared context's flag to its own `settings.strict_context` for the duration of its execution, then the next task resets it. If you need consistent strict behavior across a pipeline, set it at the base class (e.g. `ApplicationTask`) so every subtask agrees. ## Pattern Matching diff --git a/docs/basics/setup.md b/docs/basics/setup.md index e0d4cf3c2..1882e1889 100644 --- a/docs/basics/setup.md +++ b/docs/basics/setup.md @@ -48,7 +48,7 @@ end ## Inheritance -Share configuration through inheritance. Every inheritable surface — `settings`, `retry_on`, `deprecation`, and the registries (`middlewares`, `callbacks`, `coercions`, `validators`, `telemetry`, `inputs`, `outputs`) — lazily clones from the superclass on first access, so subclasses extend rather than replace. +Share configuration through inheritance. Every inheritable surface — `settings`, `retry_on`, `deprecation`, and the registries (`middlewares`, `callbacks`, `coercions`, `validators`, `executors`, `mergers`, `telemetry`, `inputs`, `outputs`) — lazily clones from the superclass on first access, so subclasses extend rather than replace. ```ruby class ApplicationTask < CMDx::Task @@ -74,39 +74,7 @@ end ## Lifecycle -Tasks follow a predictable execution pattern. Halt primitives — `success!`, `skip!`, `fail!`, and `throw!` — are control-flow tokens: they `throw` a `Signal` caught by `Runtime`, so any code after a halt is unreachable. See [Signals](../interruptions/signals.md) for the full halt API. - -```mermaid -stateDiagram-v2 - [*] --> Instantiation - Instantiation --> BeforeExecution: execute - BeforeExecution --> BeforeValidation: callbacks - BeforeValidation --> Validating: callbacks - Validating --> Working: inputs ok - Validating --> Failed: input errors - Working --> VerifyOutputs: work returned - Working --> Success: success! - Working --> Skipped: skip! - Working --> Failed: fail! / throw! / Exception - VerifyOutputs --> Success: outputs ok - VerifyOutputs --> Failed: missing/invalid outputs - - Failed --> Rollback: rollback (if defined) - Rollback --> Terminal - - state Terminal { - Success - Skipped - Failed - } - - Terminal --> Freeze: completion callbacks + finalize - Freeze --> [*] -``` - -!!! danger "Caution" - - Tasks are single-use objects. After execution, the task, its root context, and its errors are frozen by `Runtime` teardown. +Tasks follow a predictable execution pattern. Halt primitives — `success!`, `skip!`, `fail!`, and `throw!` — are control-flow tokens: they `throw` a `Signal` caught by `Runtime`, so any code after a halt is unreachable. See [Signals](../interruptions/signals.md) for the full halt API and [Getting Started - Task Lifecycle](../getting_started.md#task-lifecycle) for the full flow diagram. | Stage | Description | |-------|-------------| @@ -119,3 +87,7 @@ stateDiagram-v2 | **Completion callbacks** | `on_<state>`, `on_<status>`, `on_ok`/`on_ko` fire in that order | | **Result finalization** | `Result` built and added to `Chain` (root is `unshift`ed; children are `push`ed) | | **Teardown** | Task, root context, errors, and chain are frozen; chain reference cleared from the fiber | + +!!! danger "Caution" + + Tasks are single-use objects. After execution, the task, its root context, and its errors are frozen by `Runtime` teardown. diff --git a/docs/comparison.md b/docs/comparison.md index 495c483f5..b7175a5e3 100644 --- a/docs/comparison.md +++ b/docs/comparison.md @@ -12,32 +12,26 @@ CMDx bundles structured logging, telemetry hooks, type coercion, middleware, and | Input validation | ✅ | ✅ | ❌ | ✅ | ❌ | | Built-in logging | ✅ | ❌ | ❌ | ❌ | ✅ | | Telemetry hooks | ✅ | ❌ | ❌ | ❌ | ❌ | -| Runtime metrics | ✅ | ❌ | ❌ | ❌ | ❌ | | Middleware system | ✅ | ❌ | ❌ | ❌ | ✅ | | Workflow execution | ✅ | ✅ | ✅ | ✅ | ✅ | | Fault tolerance | ✅ | ❌ | ❌ | ❌ | ❌ | | Lifecycle callbacks | ✅ | ✅ | ✅ | ✅ | ✅ | -**What you get in the box:** +**In the box:** - **Observability** — structured logging, telemetry events, and chain-aware result tracking, no extra instrumentation required. +- **Per-execution timing** — every `Result` carries `duration` (milliseconds) and is emitted on the `:task_executed` telemetry event, so attaching a metrics exporter is a few lines. + - **Type system** — 13 built-in coercers (primitives, dates, arrays, hashes, etc.) and 7 validators (`absence`, `exclusion`, `format`, `inclusion`, `length`, `numeric`, `presence`), both pluggable. - **Middleware** — wrap the task lifecycle for auth, caching, telemetry, etc., without touching `work`. - **Retries and faults** — declarative `retry_on` with configurable jitter, halt primitives (`success!` / `skip!` / `fail!`), and `throw!` for propagating peer failures. -- **Framework agnostic** — runs under Rails, Hanami, Sinatra, or plain Ruby. Runtime deps are limited to `bigdecimal` and `logger`; no ActiveSupport requirement. - -## Event Sourcing Replacement +- **Pluggable parallelism** — workflow groups can run tasks concurrently using registered executors (`:threads`, `:fibers`, or custom) and fold results with registered mergers (`:last_write_wins`, `:deep_merge`, `:no_merge`, or custom). See [Workflows - Parallel Groups](workflows.md#parallel-groups). -Full Event Sourcing requires an event store, snapshots, and rehydration logic. If you don't need strict replay guarantees, routing state changes through CMDx tasks and shipping the structured logs to a durable sink gets you most of the benefit for a fraction of the complexity. +- **Full telemetry surface** — `:task_started`, `:task_deprecated`, `:task_retried`, `:task_rolled_back`, and `:task_executed` events are emitted only when subscribers exist; subscribe from a single `CMDx.configure` block. -CMDx supplies the structured payload; your log sink and retention policy supply the durability. - -- **Audit trail** — every execution is logged with its inputs, status, and metadata, giving you a record of both intent (arguments) and outcome (status/reason). - -- **Reconstructability** — because tasks capture all inputs required for an action, you can rebuild past state or replay logic by walking the log stream. +- **Framework agnostic** — runs under Rails, Hanami, Sinatra, or plain Ruby. Runtime deps are limited to `bigdecimal` and `logger`; no ActiveSupport requirement. -- **Simpler architecture** — keep the relational database for the read model and treat the log stream as the write model. You get CQRS-style separation without maintaining bespoke projections. diff --git a/docs/configuration.md b/docs/configuration.md index 8a4f4bc42..9e8c6fe8c 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -11,7 +11,7 @@ CMDx uses a two-tier configuration system: !!! warning "Important" - Class-level registries (`middlewares`, `callbacks`, `coercions`, `validators`, `telemetry`) are **lazily duplicated** from the parent class (or from the global configuration at the top of the hierarchy) on first access. Configure globals before any task first touches a registry, or call `CMDx.reset_configuration!` in test setup to invalidate the cached copies on `Task`. + Class-level registries (`middlewares`, `callbacks`, `coercions`, `validators`, `executors`, `mergers`, `telemetry`) are **lazily duplicated** from the parent class (or from the global configuration at the top of the hierarchy) on first access. Configure globals before any task first touches a registry, or call `CMDx.reset_configuration!` in test setup to invalidate the cached copies on `Task`. ## Global Configuration @@ -29,6 +29,8 @@ CMDx uses a two-tier configuration system: | `callbacks` | `Callbacks.new` (empty) | Callback registry | | `coercions` | `Coercions.new` (13 built-ins) | Coercion registry | | `validators` | `Validators.new` (7 built-ins) | Validator registry | +| `executors` | `Executors.new` (`:threads`, `:fibers`) | Parallel-group executor registry | +| `mergers` | `Mergers.new` (`:last_write_wins`, `:deep_merge`, `:no_merge`) | Parallel-group merge-strategy registry | | `telemetry` | `Telemetry.new` (empty) | Telemetry pub/sub | ### Default Locale @@ -135,7 +137,7 @@ Callbacks fire at specific lifecycle points. Valid events: | `:on_skipped` | When `status == "skipped"` | | `:on_failed` | When `status == "failed"` | | `:on_ok` | Success or skipped (`signal.ok?`) | -| `:on_ko` | Skipped or failed (`signal.ko?`) | +| `:on_ko` | Failed only (runtime dispatches `on_ok` when `signal.ok?`, otherwise `on_ko`; skipped results run `on_ok`) | ```ruby CMDx.configure do |config| @@ -242,6 +244,43 @@ end See [Inputs - Validations](inputs/validations.md) for usage. +### Executors + +Named concurrency backends used by `Workflow` `:parallel` groups. An executor is any callable with signature `call(jobs:, concurrency:, on_job:)` that invokes `on_job.call(job)` for each job and blocks until all jobs complete. Built-ins: `:threads` (default), `:fibers`. + +```ruby +CMDx.configure do |config| + # Class or instance with #call(jobs:, concurrency:, on_job:) + config.executors.register :ractor, RactorExecutor + + # Proc / Lambda + config.executors.register(:inline, proc do |jobs:, concurrency:, on_job:| + jobs.each { |job| on_job.call(job) } + end) + + config.executors.deregister :fibers +end +``` + +See [Workflows - Parallel Groups](workflows.md#parallel-groups) for usage. + +### Mergers + +Named strategies for folding successful parallel task results back into the workflow context. A merger is any callable with signature `call(workflow_context, result)`. Built-ins: `:last_write_wins` (default), `:deep_merge`, `:no_merge`. + +```ruby +CMDx.configure do |config| + # Only merge specific keys + config.mergers.register(:whitelist, proc do |ctx, result| + result.context.to_h.slice(:user_id, :tenant_id).each { |k, v| ctx[k] = v } + end) + + config.mergers.deregister :no_merge +end +``` + +See [Workflows - Parallel Groups](workflows.md#parallel-groups) for usage. + ## Class-Level Configuration ### Settings diff --git a/docs/inputs/coercions.md b/docs/inputs/coercions.md index 976429405..e865eb67d 100644 --- a/docs/inputs/coercions.md +++ b/docs/inputs/coercions.md @@ -43,23 +43,23 @@ ParseMetrics.execute( |------|---------|-------------|----------| | `:array` | | Array conversion with JSON support; non-array JSON results fall back to wrapping | `"val"` → `["val"]`<br>`"[1,2,3]"` → `[1, 2, 3]` | | `:big_decimal` | `:precision` (default `14`) | High-precision decimal | `"123.456"` → `BigDecimal("123.456")` | -| `:boolean` | | Boolean with text patterns | `"yes"` → `true`, `"no"` → `false` | +| `:boolean` | | Match text form against truthy/falsey sets; `nil` and unknown strings fail | `"yes"` → `true`, `"no"` → `false` | | `:complex` | `:imaginary` (default `0`) | Complex numbers | `"1+2i"` → `Complex(1, 2)` | -| `:date` | `:strptime` | Date objects | `"2024-01-23"` → `Date.new(2024, 1, 23)` | -| `:date_time` | `:strptime` | DateTime objects | `"2024-01-23 10:30"` → `DateTime.new(2024, 1, 23, 10, 30)` | +| `:date` | `:strptime` | `Date.parse` on strings, `#to_date` on anything else that responds | `"2024-01-23"` → `Date.new(2024, 1, 23)` | +| `:date_time` | `:strptime` | `DateTime.parse` on strings, `#to_datetime` on anything else that responds | `"2024-01-23 10:30"` → `DateTime.new(2024, 1, 23, 10, 30)` | | `:float` | | Floating-point numbers | `"123.45"` → `123.45` | | `:hash` | | Hash conversion with JSON support (`nil` → `{}`) | `'{"a":1}'` → `{"a" => 1}` | | `:integer` | | Integer via `Kernel#Integer` (hex/octal with explicit prefix) | `"0xFF"` → `255`, `"0o77"` → `63` | | `:rational` | `:denominator` (default `1`) | Rational numbers | `"1/2"` → `Rational(1, 2)` | | `:string` | | String conversion | `123` → `"123"` | -| `:symbol` | | Symbol conversion | `"abc"` → `:abc` | +| `:symbol` | | `#to_s.to_sym`; fails when `value` has no `#to_s` (`BasicObject`) | `"abc"` → `:abc` | | `:time` | `:strptime` | Time objects; `Numeric` treated as epoch seconds | `"2024-01-23 10:30"` → `Time.new(2024, 1, 23, 10, 30)` | ## Declarations !!! warning "Important" - Custom coercions must return the coerced value on success or `CMDx::Coercions::Failure.new("message")` on failure. Returning a `Failure` records the message on `task.errors` under the input's name. + Custom coercions must return the coerced value on success or `CMDx::Coercions::Failure.new("message")` on failure. Returning a `Failure` records the message on `task.errors` keyed by the input's **accessor name** (post-`:as`/`:prefix`/`:suffix`) — not the original declaration name. !!! note "Call signatures" @@ -149,28 +149,4 @@ end ## Error Handling -Coercion failures accumulate on `task.errors`. When resolution finishes and errors exist, Runtime throws a failed signal: the joined sentence becomes `result.reason`; structured details live on `result.errors`. - -```ruby -class AnalyzePerformance < CMDx::Task - input :iterations, coerce: :integer - input :score, coerce: %i[float big_decimal] - - def work - # Your logic here... - end -end - -result = AnalyzePerformance.execute( - iterations: "not-a-number", - score: "invalid-float" -) - -result.state #=> "interrupted" -result.status #=> "failed" -result.reason #=> "iterations could not coerce into an integer. score could not coerce into one of: float, big decimal" -result.errors.to_h #=> { - # iterations: ["could not coerce into an integer"], - # score: ["could not coerce into one of: float, big decimal"] - # } -``` +Coercion failures accumulate on `task.errors` and surface as a failed result with the joined sentence as `result.reason`. See [Inputs - Error Handling](definitions.md#error-handling) for the full lifecycle. diff --git a/docs/inputs/defaults.md b/docs/inputs/defaults.md index b4abcbfd5..c30ccfe83 100644 --- a/docs/inputs/defaults.md +++ b/docs/inputs/defaults.md @@ -95,3 +95,7 @@ end !!! note Defaults only apply when the resolved value is `nil`. An explicitly provided `nil` is treated as missing and the default fires. + +!!! warning "Required + default" + + Defaults **do not** satisfy `required:`. A required input whose key is absent fails with `is required` before the default is consulted — so `required: true, default: ...` is effectively a contradiction. Use `optional ..., default:` instead when you want a fallback, and reserve `required:` for keys the caller must explicitly supply. diff --git a/docs/inputs/definitions.md b/docs/inputs/definitions.md index 3fc54c95f..5e4ac6b14 100644 --- a/docs/inputs/definitions.md +++ b/docs/inputs/definitions.md @@ -20,7 +20,7 @@ required :credentials, source: :database_config !!! warning "Important" - Input names that conflict with existing Ruby or CMDx methods will raise an error. Use `:as`, `:prefix`, or `:suffix` to resolve naming conflicts. See [Naming](naming.md). + Input names that conflict with existing Ruby or CMDx methods raise `CMDx::DefinitionError` at class-load time. Use `:as`, `:prefix`, or `:suffix` to resolve naming conflicts. See [Naming](naming.md). !!! tip @@ -144,6 +144,10 @@ CreateUser.inputs_schema Each entry exposes `:name` (the accessor name, post-`:as`/`:prefix`/`:suffix`), `:description`, `:required`, the raw declaration `:options`, and any nested `:children` recursively. +!!! note + + `:required` in the schema reflects the static flag only. Conditional `required: true` with `:if`/`:unless` still serializes as `required: true` because the schema is generated without a task instance — inspect `options[:if]` / `options[:unless]` when generating external documentation. + ## Sources Inputs read from any accessible object — not just context. The default source is `:context`; override with `source:` to pull data from a method, proc, callable class, or another already-defined input: @@ -242,7 +246,7 @@ end ## Nesting -Build complex structures with nested inputs. Children inherit their parent as source and support all input options: +Build complex structures with nested inputs. Children resolve from the parent's value (via `respond_to?`, `#[]`, or `#key?`) and support all input options except `:source` — nested children always read from the parent and ignore any `:source` on their own declaration. !!! note @@ -301,7 +305,7 @@ ConfigureServer.execute( !!! warning "Important" - Child requirements only apply when the parent is provided — perfect for optional structures. + Child requirements only apply when the parent is provided, which is what you want for optional structures. ## Error Handling diff --git a/docs/inputs/transformations.md b/docs/inputs/transformations.md index 3f84f417b..a8fbd3891 100644 --- a/docs/inputs/transformations.md +++ b/docs/inputs/transformations.md @@ -1,6 +1,6 @@ # Inputs - Transformations -Modify input values after coercion but before validation. Perfect for normalization, formatting, and data cleanup. +Modify input values after coercion but before validation — e.g. normalization, formatting, and data cleanup. ## Processing Pipeline diff --git a/docs/inputs/validations.md b/docs/inputs/validations.md index 4819d5f43..84b6a73d8 100644 --- a/docs/inputs/validations.md +++ b/docs/inputs/validations.md @@ -1,6 +1,6 @@ # Inputs - Validations -Ensure inputs meet requirements before execution. Validations run after coercions and transformations, giving you declarative data integrity checks. +Ensure inputs meet requirements before execution. Validations run after coercions and transformations. See [Global Configuration](../configuration.md#validators) for custom validator setup. @@ -98,10 +98,6 @@ This list of options is available to all built-in validators: class CreateUser < CMDx::Task input :honey_pot, absence: true # Or with a custom message: absence: { message: "must be empty" } - - def work - # Your logic here... - end end ``` @@ -114,10 +110,6 @@ end ```ruby class ProcessProduct < CMDx::Task input :status, exclusion: { in: %w[recalled archived] } - - def work - # Your logic here... - end end ``` @@ -136,10 +128,6 @@ class ProcessProduct < CMDx::Task input :sku, format: /\A[A-Z]{3}-[0-9]{4}\z/ input :sku, format: { with: /\A[A-Z]{3}-[0-9]{4}\z/ } - - def work - # Your logic here... - end end ``` @@ -153,19 +141,17 @@ end ```ruby class ProcessProduct < CMDx::Task input :availability, inclusion: { in: %w[available limited] } - - def work - # Your logic here... - end + # Enumerable members are matched with `===`, so Regex and Class members work too: + input :sku_or_code, inclusion: { in: [/\A[A-Z]{3}-\d{4}\z/, Integer] } end ``` | Options | Description | |---------|-------------| -| `:in` | The collection of allowed values or range | +| `:in` | Range (`#cover?`) or Enumerable (`===` per member — Regex/Class/Range members match accordingly) | | `:within` | Alias for :in option | -| `:of_message` | Custom message for discrete value inclusions | -| `:in_message` | Custom message for range-based inclusions | +| `:of_message` | Custom message for enumerable-member failures | +| `:in_message` | Custom message for range failures | | `:within_message` | Alias for :in_message option | ### Length @@ -173,10 +159,6 @@ end ```ruby class CreateBlogPost < CMDx::Task input :title, length: { within: 5..100 } - - def work - # Your logic here... - end end ``` @@ -201,10 +183,6 @@ Each rule supports a matching `<rule>_message` override (e.g. `:min_message`, `: ```ruby class CreateBlogPost < CMDx::Task input :word_count, numeric: { min: 100 } - - def work - # Your logic here... - end end ``` @@ -230,10 +208,6 @@ Each rule supports a matching `<rule>_message` override (e.g. `:min_message`, `: class CreateBlogPost < CMDx::Task input :content, presence: true # Or with a custom message: presence: { message: "cannot be blank" } - - def work - # Your logic here... - end end ``` @@ -245,7 +219,7 @@ end !!! warning "Important" - Return `CMDx::Validators::Failure.new("message")` to mark the value invalid; any other return value (including `nil`) is treated as success. Returning a `Failure` records the message on `task.errors` under the input's name. + Return `CMDx::Validators::Failure.new("message")` to mark the value invalid; any other return value (including `nil`, `true`, or `false`) is treated as success. The message is recorded on `task.errors` keyed by the input's **accessor name** (post-`:as`/`:prefix`/`:suffix`). ### Proc or Lambda @@ -316,8 +290,9 @@ end class SlugReservationCheck def self.call(value, task) - task.context.reserved_slugs.include?(value) && - CMDx::Validators::Failure.new("is reserved") + return unless task.context.reserved_slugs.include?(value) + + CMDx::Validators::Failure.new("is reserved") end end ``` @@ -338,39 +313,4 @@ end ## Error Handling -Validation failures accumulate on `task.errors`. When resolution finishes and errors exist, Runtime throws a failed signal: the joined sentence becomes `result.reason`; the structured map is exposed on `result.errors`. - -```ruby -class CreateProject < CMDx::Task - input :project_name, - presence: true, - length: { min: 3, max: 50 } - optional :budget, - numeric: { min: 1000, max: 1_000_000 } - required :priority, - inclusion: { in: %i[low medium high] } - input :contact_email, - format: /\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i - - def work - # Your logic here... - end -end - -result = CreateProject.execute( - project_name: "AB", # Too short - budget: 500, # Too low - priority: :urgent, # Not in allowed list - contact_email: "invalid-email" # Invalid format -) - -result.state #=> "interrupted" -result.status #=> "failed" -result.reason #=> "project_name length must be within 3 and 50. budget must be within 1000 and 1000000. priority must be one of: :low, :medium, :high. contact_email is an invalid format" -result.errors.to_h #=> { - # project_name: ["length must be within 3 and 50"], - # budget: ["must be within 1000 and 1000000"], - # priority: ["must be one of: :low, :medium, :high"], - # contact_email: ["is an invalid format"] - # } -``` +Validation failures accumulate on `task.errors` and surface as a failed result with the joined sentence as `result.reason`. See [Inputs - Error Handling](definitions.md#error-handling) for the full lifecycle. diff --git a/docs/interruptions/exceptions.md b/docs/interruptions/exceptions.md index 7f3628a98..d96299c3d 100644 --- a/docs/interruptions/exceptions.md +++ b/docs/interruptions/exceptions.md @@ -152,6 +152,10 @@ result.reason #=> "[ActiveRecord::RecordNotFound] Couldn't find Document with ' result.cause #=> #<ActiveRecord::RecordNotFound> ``` +!!! note "Framework errors inside `work`" + + `Runtime#perform_work` rescues in this order: `Fault` (echoes), then `CMDx::Error` (**re-raises**, never converts to a failed result), then `StandardError` (converts to a failed result with `cause` set). So raising any `CMDx::Error` subclass — `DefinitionError`, `DeprecationError`, `ImplementationError`, `MiddlewareError`, or a custom subclass — inside `work` propagates out of both `execute` and `execute!`. + ### Bang execution `execute!` re-raises on failure. When `Runtime` had captured an underlying exception (`result.cause` is set), that **original** exception is re-raised; otherwise a `CMDx::Fault` carrying the failed result is raised: diff --git a/docs/interruptions/signals.md b/docs/interruptions/signals.md index 1fd047b30..83b701627 100644 --- a/docs/interruptions/signals.md +++ b/docs/interruptions/signals.md @@ -88,7 +88,7 @@ result.reason #=> "Refund period has expired" ## Metadata Enrichment -Enrich halt calls with metadata for better debugging and error handling: +Enrich halt calls with metadata for better debugging and error handling. Keyword args passed to `success!` / `skip!` / `fail!` / `throw!` are merged into `Task#metadata` first, then the resulting hash is attached to the thrown `Signal` — so middlewares that pre-populated `task.metadata` (e.g. a request id) show up on the same result without the caller having to forward them. ```ruby class ProcessRenewal < CMDx::Task @@ -149,8 +149,10 @@ Halt methods trigger specific state and status transitions: | Method | State | Status | Outcome | |--------|-------|--------|---------| +| `success!` | `complete` | `success` | `ok? = true`, `ko? = false` | | `skip!` | `interrupted` | `skipped` | `ok? = true`, `ko? = true` | | `fail!` | `interrupted` | `failed` | `ok? = false`, `ko? = true` | +| `throw!(failed)` | `interrupted` | `failed` (mirrors upstream) | `ok? = false`, `ko? = true` | ```ruby result = ProcessRenewal.execute(license_id: 567) @@ -242,53 +244,16 @@ fail! ## Manual Errors -Add structured errors to `task.errors` to fail the task with a rich, multi-key error sentence. Errors accumulated during `work` are inspected after `work` returns; if any are present, `Runtime` automatically throws a failed signal whose reason is the joined error messages. - -!!! note - - You don't need to call `fail!` after `errors.add` — the post-`work` check does it for you. Calling `fail!` explicitly still works and short-circuits immediately. - -### Errors API - -The `errors` object is keyed by attribute name; each value is a deduplicating set of messages: - -```ruby -errors.add(:email, "is invalid") -errors.add(:email, "is required") -errors.add(:name, "is too short") - -errors.empty? #=> false -errors.any? #=> true (via Enumerable) -errors.size #=> 2 (number of keys) -errors.count #=> 3 (total messages across all keys) -errors.key?(:email) #=> true -errors.key?(:phone) #=> false -errors.added?(:email, "is invalid") #=> true -errors[:email] #=> ["is invalid", "is required"] - -errors.to_h #=> { email: ["is invalid", "is required"], name: ["is too short"] } -errors.full_messages #=> { email: ["email is invalid", "email is required"], name: ["name is too short"] } -errors.to_s #=> "email is invalid. email is required. name is too short" -``` - -### Usage +Accumulate structured errors on `task.errors` during `work`; if any are present when `work` returns, Runtime throws a failed signal whose reason is the joined messages — no explicit `fail!` required. ```ruby class ProcessRenewal < CMDx::Task def work document = Document.find(context.document_id) - - if document.nonrenewable? - errors.add(:document, "is not renewable") - return # Runtime sees errors and throws a failed signal automatically - end - - document.renew! + errors.add(:document, "is not renewable") if document.nonrenewable? + document.renew! if errors.empty? end end - -result = ProcessRenewal.execute(document_id: 42) -result.status #=> "failed" -result.reason #=> "document is not renewable" -result.errors.to_h #=> { document: ["is not renewable"] } ``` + +See [Outcomes - Errors](../outcomes/errors.md) for the full `errors` API. diff --git a/docs/logging.md b/docs/logging.md index 967bb47ad..cf64b1315 100644 --- a/docs/logging.md +++ b/docs/logging.md @@ -1,6 +1,6 @@ # Logging -CMDx logs every task execution at `INFO` with the full `Result#to_h` payload, giving you a structured event stream for free. Pick a formatter that matches your logging infrastructure. +CMDx logs every task execution at `INFO` with the full `Result#to_h` payload — a structured event stream suitable for log aggregators. Pick a formatter that matches your logging infrastructure. ## Formatters @@ -48,29 +48,15 @@ CMDx logs every task execution at `INFO` with the full `Result#to_h` payload, gi ## Sample Lifecycle -A single chain emitting a successful task, a skipped task, a failed leaf, and the workflow that propagated the failure: +A representative line showing a failed leaf with propagation fields: ```log -# Success -I, [2026-04-19T17:04:07.292614Z #20108] INFO -- cmdx: cid="019b4c2b-087b-79be-8ef2-96c11b659df5" index=0 root=true type="Task" task=GenerateInvoice tid="019b4c2b-0878-704d-ba0b-daa5410123ec" context=#<CMDx::Context ...> state="complete" status="success" reason=nil metadata={} strict=false deprecated=false retried=false retries=0 duration=12.34 tags=[] - -# Skipped -I, [2026-04-19T17:04:11.496881Z #20139] INFO -- cmdx: cid="019b4c2b-18e8-7af6-a38b-63b042c4fbed" index=0 root=true type="Task" task=ValidateCustomer tid="019b4c2b-18e5-7230-af7e-5b4a4bd7cda2" context=#<CMDx::Context ...> state="interrupted" status="skipped" reason="Customer already validated" metadata={} strict=false deprecated=false retried=false retries=0 duration=2.18 tags=[] - -# Failed (root cause via fail!) -I, [2026-04-19T17:04:15.875306Z #20173] INFO -- cmdx: cid="019b4c2b-2a02-7dbc-b713-b20a7379704f" index=1 root=false type="Task" task=CalculateTax tid="019b4c2b-2a00-70b7-9fab-2f14db9139ef" context=#<CMDx::Context ...> state="interrupted" status="failed" reason="tax service unavailable" metadata={error_code: "TAX_SERVICE_UNAVAILABLE"} strict=false deprecated=false retried=false retries=0 duration=8.92 tags=[] cause=nil origin=nil threw_failure=<CalculateTax 019b4c2b-2a00-...> caused_failure=<CalculateTax 019b4c2b-2a00-...> rolled_back=false - -# Failed workflow that propagated the above failure -I, [2026-04-19T17:04:15.876012Z #20173] INFO -- cmdx: cid="019b4c2b-2a02-7dbc-b713-b20a7379704f" index=0 root=true type="Workflow" task=BillingWorkflow tid="019b4c2b-3de6-70b9-9c16-5be13b1a463c" ... state="interrupted" status="failed" reason="tax service unavailable" cause=nil origin=<CalculateTax 019b4c2b-2a00-...> threw_failure=<CalculateTax 019b4c2b-2a00-...> caused_failure=<CalculateTax 019b4c2b-2a00-...> rolled_back=false +I, [2026-04-19T17:04:15.875306Z #20173] INFO -- cmdx: cid="019b4c2b-2a02-..." index=1 root=false type="Task" task=CalculateTax tid="019b4c2b-2a00-..." state="interrupted" status="failed" reason="tax service unavailable" metadata={error_code: "TAX_SERVICE_UNAVAILABLE"} duration=8.92 cause=nil origin=nil threw_failure=<CalculateTax ...> caused_failure=<CalculateTax ...> rolled_back=false ``` !!! tip - Use logging as a low-level event stream to track every task in a request. Pair `cid` with your APM's correlation field for distributed tracing. - -!!! note - - A rescued `StandardError` (not a `fail!` call) sets `cause=#<TheError: …>` and rewrites `reason` to `"[TheError] message"` with empty metadata. + Pair `cid` with your APM's correlation field for distributed tracing. A rescued `StandardError` (not a `fail!` call) sets `cause=#<TheError: …>` and rewrites `reason` to `"[TheError] message"`. ## Structure @@ -133,28 +119,7 @@ These are present **only** when `status == "failed"`: ## Configuration -Configure the formatter and level globally or per-task: - -=== "Global" - - ```ruby - CMDx.configure do |config| - config.log_formatter = CMDx::LogFormatters::JSON.new - config.log_level = Logger::DEBUG - config.logger = Logger.new($stdout, progname: "cmdx") - end - ``` - -=== "Per-task" - - ```ruby - class ProcessSubscription < CMDx::Task - settings( - log_formatter: CMDx::LogFormatters::JSON.new, - log_level: Logger::DEBUG - ) - end - ``` +Formatter, level, and logger are set globally on `CMDx.configure` or per-task via `settings(...)`. See [Configuration - Logging](configuration.md) for the full option list. !!! note diff --git a/docs/outcomes/result.md b/docs/outcomes/result.md index b2a70b76f..c33affe20 100644 --- a/docs/outcomes/result.md +++ b/docs/outcomes/result.md @@ -31,6 +31,7 @@ result.status #=> "failed" result.reason #=> "Build tool not found" result.metadata #=> { error_code: "BUILD_TOOL.NOT_FOUND" } result.cause #=> nil, the rescued StandardError, or the propagated Fault +result.backtrace #=> caller_locations captured by fail!/throw! (Array<String>), or nil # Lifecycle metadata result.duration #=> 12.34 (milliseconds, monotonic) @@ -89,7 +90,7 @@ For a nested workflow where leaf `ChargeCard` fails inside `PaymentWorkflow`, wh | `PaymentWorkflow` | `ChargeCard` | `ChargeCard` | `ChargeCard` | `false` | `true` | | `CheckoutWorkflow` | `PaymentWorkflow` | `PaymentWorkflow` | `ChargeCard` | `false` | `true` | -`threw_failure` always points one level up; `caused_failure` walks all the way down to the originator. +`threw_failure` is the nearest upstream failed result (`origin` if present, else `self` for the originator); `caused_failure` walks `origin` recursively down to the originator. ## Annotating a Successful Result diff --git a/docs/outcomes/states.md b/docs/outcomes/states.md index 430bf824a..9a8c667fd 100644 --- a/docs/outcomes/states.md +++ b/docs/outcomes/states.md @@ -6,8 +6,8 @@ States track the lifecycle dimension of a result: did `work` run end-to-end, or | State | Description | | ----- | ----------- | -| `complete` | Task finished `work` (and output verification) without any `skip!` / `fail!` / exception. | -| `interrupted` | Task halted via `skip!`, `fail!`, an unrescued `StandardError`, or accumulated `task.errors`. | +| `complete` | Task finished `work` (and output verification) without interruption. Includes both the implicit success path and an explicit `success!` halt. | +| `interrupted` | Task halted via `skip!`, `fail!`, `throw!`, an unrescued `StandardError`, or accumulated `task.errors`. | State-Status combinations: @@ -21,30 +21,6 @@ State-Status combinations: `complete` only ever pairs with `success`, and `interrupted` only ever pairs with `skipped` or `failed`. There is no `complete` + `skipped` or `interrupted` + `success` combination. -## Predicates +## Predicates and Handlers -```ruby -result = ProcessVideoUpload.execute - -result.complete? #=> true on success, false otherwise -result.interrupted? #=> true on skip or fail, false otherwise -``` - -## Handlers - -State-based dispatch with `on(:complete)` / `on(:interrupted)`. Pass multiple keys to one call when you want either to match: - -```ruby -result = ProcessVideoUpload.execute - -result - .on(:complete) { |r| send_upload_notification(r) } - .on(:interrupted) { |r| cleanup_temp_files(r) } - -# Run the same handler for either state — useful for cleanup/metrics -result.on(:complete, :interrupted) { |r| log_upload_metrics(r) } -``` - -!!! warning "Important" - - `on` raises `ArgumentError` for unknown event keys. Valid keys: `:complete`, `:interrupted`, `:success`, `:skipped`, `:failed`, `:ok`, `:ko`. +`result.complete?` / `result.interrupted?` are the state predicates; `result.on(:complete)` / `result.on(:interrupted)` dispatch on them. See [Result - Lifecycle Predicates](result.md#lifecycle-predicates) and [Result - Predicate Dispatch](result.md#predicate-dispatch) for the canonical list. diff --git a/docs/outcomes/statuses.md b/docs/outcomes/statuses.md index 1add2cef1..37624b348 100644 --- a/docs/outcomes/statuses.md +++ b/docs/outcomes/statuses.md @@ -6,7 +6,7 @@ Statuses represent the business outcome — did the task succeed, skip, or fail? | Status | Description | | ------ | ----------- | -| `success` | Task `work` ran to completion (and any declared outputs verified). Default outcome. | +| `success` | Task `work` ran to completion (and any declared outputs verified), or halted via `success!`. Default outcome. | | `skipped` | Task halted via `skip!`. Treated as a non-failure outcome. | | `failed` | Task halted via `fail!`, `throw!`, an unrescued `StandardError`, or accumulated `task.errors`. | @@ -29,40 +29,10 @@ end Calling `skip!` or `fail!` on a frozen task (after `Runtime` teardown) raises `FrozenError` — they can't mutate a finalized result. -## Predicates +## Predicates and Handlers -```ruby -result = ProcessNotification.execute - -# Direct status checks -result.success? #=> true / false -result.skipped? #=> true / false -result.failed? #=> true / false - -# Outcome categorization -result.ok? #=> true for success and skipped (anything but failed) -result.ko? #=> true for skipped and failed (anything but success) -``` +`result.success?` / `result.skipped?` / `result.failed?` check status; `result.ok?` (success or skipped) and `result.ko?` (skipped or failed) categorize the outcome. Dispatch with `result.on(:success | :skipped | :failed | :ok | :ko)`. See [Result - Lifecycle Predicates](result.md#lifecycle-predicates) and [Result - Predicate Dispatch](result.md#predicate-dispatch). !!! note `skipped` is intentionally both `ok?` and `ko?`. It's a valid outcome (`ok` — nothing broke) and a non-success (`ko` — work wasn't done). Use `success?` when you need a strict success check. - -## Handlers - -Branch business logic with status-based handlers. `:ok` and `:ko` are first-class event keys — not aliases of any combination: - -```ruby -result = ProcessNotification.execute - -# Direct status handlers -result - .on(:success) { |r| mark_notification_sent(r) } - .on(:skipped) { |r| log_notification_skipped(r) } - .on(:failed) { |r| queue_retry_notification(r) } - -# Outcome-based handlers -result - .on(:ok) { |r| update_message_stats(r) } # success or skipped - .on(:ko) { |r| track_delivery_failure(r) } # skipped or failed -``` diff --git a/docs/outputs.md b/docs/outputs.md index e17d86e48..386c299ee 100644 --- a/docs/outputs.md +++ b/docs/outputs.md @@ -142,7 +142,7 @@ Child verification only runs when the parent value is present and not a coercion !!! note - Children are *verified* against the parent value but written back to `task.context[<child>]` only if they were already there; nested outputs do not synthesize top-level context keys. Use inputs' [Nesting](inputs/definitions.md#nesting) when you need parent-backed accessors. + Coerced/transformed child values are written back **into the parent**, not `task.context[<child>]` — a `Hash` parent gets `parent[:child] = value` (assigning even when the key is absent), and any object exposing `#<child>=` gets its setter called. Read-only parents (e.g. `Data.define`) are left untouched; validation errors are still recorded on the task. Nested outputs do not synthesize top-level context keys — use inputs' [Nesting](inputs/definitions.md#nesting) when you need parent-backed accessors on the task. `deregister :output, :user` still removes the top-level declaration (and its children in one shot). diff --git a/docs/retries.md b/docs/retries.md index 0f7ddd2a8..4d07e25cb 100644 --- a/docs/retries.md +++ b/docs/retries.md @@ -183,10 +183,10 @@ end ## Behavior - **Same task instance** — retries reuse the same task object. `context` and any side effects from previous attempts persist. -- **Only `work` repeats** — input resolution, output verification, and `before_execution` / `before_validation` callbacks run once. Retries wrap `work` only. +- **Only `work` repeats** — input resolution, output verification, and `before_execution` / `before_validation` callbacks run once. Retries wrap `work` only. (This is intentional — flaky input sources are not retried here; wrap the source in a `retry_on` around its own fetcher or in a middleware.) - **Errors carry over** — `task.errors` accumulates across attempts; entries added during a previous attempt remain. Clear them at the start of `work` if you re-add per attempt, otherwise a successful retry will still finalize as failed once `signal_errors!` runs. - **Telemetry** — Runtime emits a `:task_retried` event for each retry (`attempt:` is zero-based; the initial call is `attempt = 0` and is not emitted). -- **Outside the middleware stack** — middlewares wrap the entire lifecycle (callbacks, inputs, retries, outputs, rollback). Each retried `work` call is *inside* every middleware; middlewares do not see individual attempts. +- **Inside the middleware stack** — middlewares wrap the entire lifecycle (callbacks, inputs, retries, outputs, rollback). Each retried `work` call is *inside* every middleware, so middlewares see the task once per execution, not once per attempt. Subscribe to the `:task_retried` telemetry event if you need per-attempt visibility. ## Inspecting Retries diff --git a/docs/testing.md b/docs/testing.md index 89e19e72d..3b61a6dab 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -270,12 +270,19 @@ end ```ruby RSpec.describe BuildApplication do - it "deconstructs to [type, task, state, status, reason, metadata, cause, origin]" do + it "deconstructs to [[key, value], ...] pairs" do result = BuildApplication.execute(version: "1.0") - expect(result.deconstruct).to match( - ["Task", BuildApplication, "complete", "success", nil, {}, nil, nil] + expect(result.deconstruct).to include( + [:type, "Task"], + [:task, BuildApplication], + [:state, "complete"], + [:status, "success"] ) + + case result + in [*, [:status, "success"], *] then :ok + end end it "matches a hash pattern on failure" do @@ -290,3 +297,7 @@ RSpec.describe BuildApplication do end end ``` + +!!! note + + `Result#deconstruct` returns `to_h.to_a` — an array of `[key, value]` pairs in insertion order, not a fixed-arity tuple. Use find patterns (`in [*, [:status, "success"], *]`) rather than positional arrays. diff --git a/docs/tips_and_tricks.md b/docs/tips_and_tricks.md index 6b955664e..174e7c72b 100644 --- a/docs/tips_and_tricks.md +++ b/docs/tips_and_tricks.md @@ -158,7 +158,7 @@ end ## Sharing Behavior via a Base Class -Pull cross-cutting concerns onto a base task. Subclasses inherit `settings`, `callbacks`, `middlewares`, `coercions`, `validators`, `telemetry`, and `retry_on` automatically. +Pull cross-cutting concerns onto a base task. Subclasses inherit `settings`, `callbacks`, `middlewares`, `coercions`, `validators`, `executors`, `mergers`, `telemetry`, and `retry_on` automatically. ```ruby class ApplicationTask < CMDx::Task @@ -184,7 +184,7 @@ class ProcessInvoice < ApplicationTask end ``` -Inherited registries (callbacks, middlewares, validators, coercions) accumulate — declaring more in a subclass appends to the parent's list. To opt out of an inherited entry, use `deregister` (e.g. `deregister :callback, :before_execution, :ensure_current_tenant!`). `retry_on` and `settings` likewise accumulate via merge: a subclass `retry_on` adds exception classes and overrides individual options (`limit:`, `delay:`, …) without dropping the parent's, and `settings` merges new keys on top. +Inherited registries (callbacks, middlewares, validators, coercions, executors, mergers) accumulate — declaring more in a subclass appends to (or overwrites by name in) the parent's list. To opt out of an inherited entry, use `deregister` (e.g. `deregister :callback, :before_execution, :ensure_current_tenant!`). `retry_on` and `settings` likewise accumulate via merge: a subclass `retry_on` adds exception classes and overrides individual options (`limit:`, `delay:`, …) without dropping the parent's, and `settings` merges new keys on top. ## Useful Examples @@ -200,7 +200,9 @@ Inherited registries (callbacks, middlewares, validators, coercions) accumulate - [Paper Trail Whatdunnit](https://github.com/drexed/cmdx/blob/main/examples/paper_trail_whatdunnit.md) - [PubSub Task Chaining](https://github.com/drexed/cmdx/blob/main/examples/pub_sub_task_chaining.md) - [Pundit Authorization](https://github.com/drexed/cmdx/blob/main/examples/pundit_authorization.md) +- [Rate Limit](https://github.com/drexed/cmdx/blob/main/examples/rate_limit.md) - [Redis Idempotency](https://github.com/drexed/cmdx/blob/main/examples/redis_idempotency.md) - [Sentry Error Tracking](https://github.com/drexed/cmdx/blob/main/examples/sentry_error_tracking.md) - [Sidekiq Async Execution](https://github.com/drexed/cmdx/blob/main/examples/sidekiq_async_execution.md) - [Stoplight Circuit Breaker](https://github.com/drexed/cmdx/blob/main/examples/stoplight_circuit_breaker.md) +- [Timeout Guard](https://github.com/drexed/cmdx/blob/main/examples/timeout_guard.md) diff --git a/docs/workflows.md b/docs/workflows.md index 4669fcc46..34dd38b2e 100644 --- a/docs/workflows.md +++ b/docs/workflows.md @@ -63,7 +63,9 @@ Options apply to the entire group: | Option | Default | Description | |---------------|----------------|--------------------------------------------------------| | `strategy:` | `:sequential` | `:sequential` or `:parallel` | -| `pool_size:` | `tasks.size` | Worker count when `strategy: :parallel` | +| `pool_size:` | `tasks.size` | Worker/fiber count when `strategy: :parallel` | +| `executor:` | `:threads` | Parallel dispatch backend: `:threads`, `:fibers`, or a callable. `:fibers` requires a `Fiber.scheduler` to be installed (e.g. inside `Async { ... }`) | +| `merge_strategy:` | `:last_write_wins` | How successful parallel contexts fold back into the workflow context: `:last_write_wins`, `:deep_merge`, `:no_merge`, or a callable `->(workflow_context, result) { ... }` | | `fail_fast:` | `false` | When `strategy: :parallel`, drain pending tasks on the first failure (in-flight tasks still finish) | | `if:` / `unless:` | — | Skip the entire group when the predicate isn't satisfied | @@ -230,6 +232,90 @@ end Each parallel task receives its own deep-duplicated `context` copy, which is merged back into the workflow's context after execution. If multiple tasks write to the same key, the last merge wins non-deterministically. Use distinct keys per task to avoid conflicts. If any parallel task fails, the failed result is propagated through `throw!` (the other tasks still complete first; they just don't merge back). With `fail_fast: true`, tasks still queued when a sibling fails are skipped entirely; in-flight tasks run to completion and their successful contexts still merge. +### Executors + +The `:executor` option swaps the concurrency backend while keeping the rest of the parallel semantics (context isolation, merge-on-success, fail-fast) identical. + +```ruby +# Default — native Ruby threads +tasks A, B, C, strategy: :parallel, executor: :threads + +# Fiber scheduler — requires Fiber.scheduler to be installed on the caller +tasks A, B, C, strategy: :parallel, executor: :fibers, pool_size: 10 + +# Custom callable +tasks A, B, C, strategy: :parallel, executor: MyPool.method(:run) +``` + +`:fibers` spawns one fiber per job bounded by `pool_size` (via a semaphore) and relies on whatever scheduler the caller has installed — most commonly the [`async`](https://github.com/socketry/async) gem: + +```ruby +require "async" + +Async do + SendWelcomeNotifications.execute! +end +``` + +Without a scheduler, `:fibers` raises at run time — the gem itself stays zero-dep. + +A user-supplied executor is any object responding to `call(jobs:, concurrency:, on_job:)`. It must invoke `on_job.call(job)` for each job and block until all jobs have completed. Chain propagation, cancellation, and context merging are already baked into `on_job`; the executor only decides how to dispatch. + +Executors are resolved from a per-task registry (`CMDx::Executors`). Built-ins ship with `:threads` and `:fibers`; register your own named executor once and reference it by symbol from `:executor`: + +```ruby +class ApplicationTask < CMDx::Task + register :executor, :bounded_pool, MyPool.method(:run) +end + +class ShipItAll < ApplicationTask + include CMDx::Workflow + + tasks A, B, C, strategy: :parallel, executor: :bounded_pool +end +``` + +The same registry is available globally via `CMDx.configuration.executors.register(...)`. + +### Merge strategies + +After every successful sibling completes, each sibling's duplicated context is folded back into the workflow context. The default is last-write-wins in declaration order — reliable and fast, but brittle when two tasks write a nested structure under the same key. `:merge_strategy` lets you pick the collision policy up front. + +```ruby +# Default — shallow, last declared task wins on conflicts +tasks A, B, C, strategy: :parallel, merge_strategy: :last_write_wins + +# Recursive hash merge — nested structures are combined instead of replaced +tasks A, B, C, strategy: :parallel, merge_strategy: :deep_merge + +# Don't touch the workflow context at all +tasks A, B, C, strategy: :parallel, merge_strategy: :no_merge + +# Custom — e.g. namespace each sibling's output under its class name +tasks A, B, C, strategy: :parallel, + merge_strategy: ->(ctx, result) { ctx[result.task.name] = result.context.to_h } +``` + +Behavior notes: + +- Merging always walks successful results in **declaration order**, never completion order — the fold is deterministic even though parallel execution isn't. +- `:deep_merge` recurses only into `Hash` values; non-hash collisions (Integer, String, Array, custom objects) still follow last-write-wins so a scalar on either side wins over a hash on the other. +- `:no_merge` keeps the parallel tasks' side effects (each sibling's `result.context` is still reachable via `result.chain`) but nothing is written back to the workflow context. Useful when you're only interested in per-task telemetry, or when tasks own their own persistence. +- A callable receives `(workflow_context, result)` and is free to write whatever shape you want. Failed results never reach the merger. +- Merge strategies are resolved from a per-task registry (`CMDx::Mergers`). Register your own named merger with `register :merger, :name, callable` (or on `CMDx.configuration.mergers`) and reference it by symbol from `:merge_strategy`. + +```ruby +class BuildDashboard < CMDx::Task + include CMDx::Workflow + + tasks FetchRevenue, FetchTraffic, FetchErrors, + strategy: :parallel, merge_strategy: :deep_merge + # FetchRevenue: context.metrics = { revenue: ... } + # FetchTraffic: context.metrics = { visitors: ... } + # After merge: context.metrics == { revenue: ..., visitors: ..., errors: ... } +end +``` + ## Task Generator Generate a workflow scaffold: diff --git a/examples/rate_limit.md b/examples/rate_limit.md new file mode 100644 index 000000000..117fd6471 --- /dev/null +++ b/examples/rate_limit.md @@ -0,0 +1,122 @@ +# Rate Limit + +Throttle a task so a burst of callers can't exceed `N` executions per window. The middleware increments a counter in a pluggable store; when the window is full it records a clear error and `yield`s, letting `signal_errors!` halt the task as **failed** during input resolution. + +## Setup + +```ruby +# app/middlewares/cmdx_rate_limit_middleware.rb +class CmdxRateLimitMiddleware + def initialize(max:, per:, key: :class, store: MemoryStore.new) + @max = max + @per = per + @key = key + @store = store + end + + def call(task) + bucket = resolve_key(task) + count = @store.increment(bucket, ttl: @per) + + if count > @max + task.errors.add(:base, "rate limited: #{count}/#{@max} per #{@per}s for #{bucket}") + end + + yield + end + + private + + def resolve_key(task) + case @key + when :class then task.class.name + when Symbol then task.send(@key).to_s + when Proc then @key.call(task).to_s + else @key.to_s + end + end + + class MemoryStore + def initialize + @mutex = Mutex.new + @data = {} + end + + def increment(key, ttl:) + @mutex.synchronize do + now = Process.clock_gettime(Process::CLOCK_MONOTONIC) + bucket = @data[key] + if bucket.nil? || bucket[:exp] < now + @data[key] = { count: 1, exp: now + ttl } + 1 + else + bucket[:count] += 1 + end + end + end + end +end +``` + +## Usage + +```ruby +class SendPasswordReset < CMDx::Task + register :middleware, CmdxRateLimitMiddleware.new( + max: 5, + per: 60, + key: ->(t) { t.context.email } + ) + + required :email + + def work + Mailer.password_reset(email).deliver_later + end +end + +5.times { SendPasswordReset.execute(email: "user@example.com") } # success +SendPasswordReset.execute(email: "user@example.com") # failed: +# reason => "rate limited: 6/5 per 60s for user@example.com" +``` + +## Redis store + +The in-memory store resets per process. For production (multi-worker / multi-host), back it with Redis using an atomic `INCR` + `EXPIRE` pair (Lua script avoids a race between the two commands): + +```ruby +class RedisRateStore + LUA = <<~LUA.freeze + local c = redis.call("INCR", KEYS[1]) + if c == 1 then redis.call("EXPIRE", KEYS[1], ARGV[1]) end + return c + LUA + + def initialize(redis: Redis.current, namespace: "cmdx:rl") + @redis = redis + @namespace = namespace + end + + def increment(key, ttl:) + @redis.eval(LUA, keys: ["#{@namespace}:#{key}"], argv: [ttl]) + end +end + +register :middleware, CmdxRateLimitMiddleware.new( + max: 100, per: 60, key: ->(t) { t.context.user_id }, store: RedisRateStore.new +) +``` + +## Notes + +!!! note "Fixed-window vs token-bucket" + + The example uses a fixed window: the counter resets when the TTL expires. That's simple and fast but allows brief 2× bursts at window boundaries (last second of window N + first second of window N+1). For smoother shaping, replace the store with a token-bucket implementation — the middleware contract is unchanged. + +!!! warning "Failed, not skipped" + + A middleware cannot emit a `skipped` signal — that originates inside `work`. This middleware surfaces throttling as **failed** via `task.errors` + `yield`, letting `signal_errors!` halt during input resolution. To treat excess calls as *skipped* instead, hoist the rate-limit check into `work` and call `skip!("rate limited")`. + +!!! tip "Keying strategies" + + Pick the `:key` to match your threat model: `:class` throttles globally per task, a Symbol reads an attribute (`:user_id`, `:ip`), a Proc composes multiple dimensions (`->(t) { "#{t.context.user_id}:#{t.context.endpoint}" }`). diff --git a/examples/timeout_guard.md b/examples/timeout_guard.md new file mode 100644 index 000000000..a10ef4bdc --- /dev/null +++ b/examples/timeout_guard.md @@ -0,0 +1,75 @@ +# Timeout Guard + +Cap how long a task is allowed to run by wrapping it in stdlib [`Timeout`](https://docs.ruby-lang.org/en/master/Timeout.html). When the deadline elapses, Runtime catches the raised exception and produces a **failed** result — no extra wiring needed. + +## Setup + +```ruby +# app/middlewares/cmdx_timeout_middleware.rb +require "timeout" + +class CmdxTimeoutMiddleware + def initialize(seconds:, message: nil) + @seconds = seconds + @message = message + end + + def call(task) + secs = resolve(task) + return yield if secs.nil? + + ::Timeout.timeout(secs, ::Timeout::Error, @message || "timed out after #{secs}s") { yield } + end + + private + + def resolve(task) + case @seconds + when Numeric then @seconds + when Symbol then task.send(@seconds) + when Proc then @seconds.call(task) + else @seconds.respond_to?(:call) ? @seconds.call(task) : @seconds + end + end +end +``` + +## Usage + +```ruby +class FetchReport < CMDx::Task + register :middleware, CmdxTimeoutMiddleware.new(seconds: 5) + + required :report_id + + def work + context.report = ReportClient.fetch(report_id) # slow network call + end +end + +result = FetchReport.execute(report_id: 42) +result.failed? #=> true when the fetch exceeds 5s +result.reason #=> "[Timeout::Error] timed out after 5s" +result.cause #=> #<Timeout::Error: ...> +``` + +Dynamic deadlines: + +```ruby +register :middleware, CmdxTimeoutMiddleware.new(seconds: :request_deadline) +register :middleware, CmdxTimeoutMiddleware.new(seconds: ->(t) { t.context.slo_ms / 1000.0 }) +``` + +## Notes + +!!! warning "stdlib Timeout caveats" + + `Timeout.timeout` on MRI is thread-based and interrupts the running code asynchronously. Operations inside `ensure` blocks — file handles, DB transactions, network sockets — can be left in partially cleaned-up states. Prefer explicit deadline APIs (`Net::HTTP#open_timeout` / `read_timeout`, `redis-rb` `:timeout`, `faraday` `:timeout`) for anything that owns external resources. Reach for this middleware only as a belt-and-suspenders safety net around code you already trust to clean up on its own. + +!!! tip "Failed vs raised" + + The timeout raises `Timeout::Error` inside `work`. Under `Task.execute`, Runtime's `rescue StandardError` converts it to a failed result (`result.reason` / `result.cause` populated, `#rollback` still runs). Under `Task.execute!`, the original `Timeout::Error` is re-raised after lifecycle finalization. + +!!! tip "Fiber scheduler alternative" + + Inside an `Async { ... }` block, prefer the [`async`](https://github.com/socketry/async) gem's `Task#with_timeout` — it cancels cooperatively via fiber scheduling instead of thread-level interrupts, sidestepping the `ensure`-block hazard entirely. diff --git a/lib/cmdx.rb b/lib/cmdx.rb index 0e81f48b1..a691920ca 100644 --- a/lib/cmdx.rb +++ b/lib/cmdx.rb @@ -42,9 +42,9 @@ module CMDx # accessor with the same name already exists. DefinitionError = Class.new(Error) - # Raised by {Deprecation} when a task configured with - # `settings(deprecate: :error)` is executed. Signals that the caller must - # migrate off the deprecated task before continuing. + # Raised by {Deprecation} when a task configured with `deprecation(:error)` + # is executed. Signals that the caller must migrate off the deprecated task + # before continuing. DeprecationError = Class.new(Error) # Raised when a subclass fails to fulfill an abstract contract — most @@ -92,6 +92,13 @@ module CMDx require_relative "cmdx/validators/presence" require_relative "cmdx/validators/validate" require_relative "cmdx/validators" +require_relative "cmdx/executors/thread" +require_relative "cmdx/executors/fiber" +require_relative "cmdx/executors" +require_relative "cmdx/mergers/last_write_wins" +require_relative "cmdx/mergers/deep_merge" +require_relative "cmdx/mergers/no_merge" +require_relative "cmdx/mergers" require_relative "cmdx/input" require_relative "cmdx/inputs" require_relative "cmdx/output" diff --git a/lib/cmdx/configuration.rb b/lib/cmdx/configuration.rb index 8204c0524..305daabf3 100644 --- a/lib/cmdx/configuration.rb +++ b/lib/cmdx/configuration.rb @@ -10,14 +10,16 @@ module CMDx class Configuration attr_accessor :middlewares, :callbacks, :coercions, :validators, - :telemetry, :default_locale, :strict_context, :backtrace_cleaner, - :logger, :log_level, :log_formatter + :executors, :mergers, :telemetry, :default_locale, :strict_context, + :backtrace_cleaner, :logger, :log_level, :log_formatter def initialize @middlewares = Middlewares.new @callbacks = Callbacks.new @coercions = Coercions.new @validators = Validators.new + @executors = Executors.new + @mergers = Mergers.new @telemetry = Telemetry.new @default_locale = "en" @@ -71,6 +73,8 @@ def reset_configuration! Task.instance_variable_set(:@callbacks, nil) Task.instance_variable_set(:@coercions, nil) Task.instance_variable_set(:@validators, nil) + Task.instance_variable_set(:@executors, nil) + Task.instance_variable_set(:@mergers, nil) Task.instance_variable_set(:@telemetry, nil) end diff --git a/lib/cmdx/context.rb b/lib/cmdx/context.rb index cbe12ca87..267578561 100644 --- a/lib/cmdx/context.rb +++ b/lib/cmdx/context.rb @@ -79,6 +79,18 @@ def merge(context = EMPTY_HASH) self end + # Like {#merge} but recursive into Hash values: a nested Hash key collision + # merges the two Hashes instead of replacing the left with the right. + # Non-Hash values follow last-write-wins (`context` wins). + # + # @param context [Context, Hash, #to_h, #to_hash] + # @return [Context] self for chaining + def deep_merge(context = EMPTY_HASH) + other = self.class.build(context) + @table = compute_deep_merge(@table, other.to_h) + self + end + # @param key [Symbol, String] # @return [Object, nil] def [](key) @@ -297,5 +309,11 @@ def compute_deep_dup(value) end end + def compute_deep_merge(lhs, rhs) + lhs.merge(rhs) do |_key, l, r| + l.is_a?(Hash) && r.is_a?(Hash) ? compute_deep_merge(l, r) : r + end + end + end end diff --git a/lib/cmdx/executors.rb b/lib/cmdx/executors.rb new file mode 100644 index 000000000..f3e0f682b --- /dev/null +++ b/lib/cmdx/executors.rb @@ -0,0 +1,92 @@ +# frozen_string_literal: true + +module CMDx + # Registry of named executors used by `:parallel` workflow groups to + # dispatch tasks concurrently. Ships with built-ins for `:threads` and + # `:fibers`. Executors are any callable accepting + # `call(jobs:, concurrency:, on_job:)` and must invoke `on_job.call(job)` + # for each job, blocking until every job is done. + class Executors + + attr_reader :registry + + def initialize + @registry = { + threads: Executors::Thread, + fibers: Executors::Fiber + } + end + + def initialize_copy(source) + @registry = source.registry.dup + end + + # Registers a named executor, overwriting any existing entry. + # + # @param name [Symbol] + # @param callable [#call, nil] pass either this or a block + # @yield executor body — `call(jobs:, concurrency:, on_job:)` + # @return [Executors] self for chaining + # @raise [ArgumentError] when both `callable` and a block are given, or + # when the resolved executor isn't callable + def register(name, callable = nil, &block) + executor = callable || block + + if callable && block + raise ArgumentError, "provide either a callable or a block, not both" + elsif !executor.respond_to?(:call) + raise ArgumentError, "executor must respond to #call" + end + + registry[name.to_sym] = executor + self + end + + # @param name [Symbol] + # @return [Executors] self for chaining + def deregister(name) + registry.delete(name.to_sym) + self + end + + # @param name [Symbol] + # @return [#call] the registered executor + # @raise [ArgumentError] when `name` isn't registered + def lookup(name) + registry[name] || begin + raise ArgumentError, "unknown executor: #{name.inspect}" + end + end + + # Resolves a declaration's `:executor` option to a concrete callable. + # Accepts `nil` (default `:threads`), a Symbol (registry lookup), or any + # object responding to `#call`. + # + # @param spec [Symbol, #call, nil] + # @return [#call] + # @raise [ArgumentError] when `spec` is an unknown symbol or not callable + def resolve(spec) + case spec + when NilClass + lookup(:threads) + when Symbol + lookup(spec) + else + return spec if spec.respond_to?(:call) + + raise ArgumentError, "unknown executor: #{spec.inspect}" + end + end + + # @return [Boolean] + def empty? + registry.empty? + end + + # @return [Integer] + def size + registry.size + end + + end +end diff --git a/lib/cmdx/executors/fiber.rb b/lib/cmdx/executors/fiber.rb new file mode 100644 index 000000000..50bb31c90 --- /dev/null +++ b/lib/cmdx/executors/fiber.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +module CMDx + class Executors + # Fiber-scheduler backed executor. Spawns one fiber per job, bounded by + # `concurrency` via a `SizedQueue` semaphore. Requires a Fiber scheduler to + # be installed on the current thread (e.g. inside `Async { ... }` from the + # `async` gem). `pool_size` caps in-flight fibers. + # + # @api private + module Fiber + + extend self + + # @param jobs [Array] + # @param concurrency [Integer] max in-flight fibers + # @param on_job [#call] + # @return [void] + # @raise [RuntimeError] when no `Fiber.scheduler` is installed + def call(jobs:, concurrency:, on_job:) + raise "executor: :fibers requires Fiber.scheduler; run the workflow inside a scheduler block (e.g. Async { ... })" unless ::Fiber.scheduler + + slots = SizedQueue.new(concurrency) + concurrency.times { slots << :slot } + done = Queue.new + + jobs.each do |job| + slots.pop + ::Fiber.schedule do + on_job.call(job) + ensure + slots << :slot + done << true + end + end + + jobs.size.times { done.pop } + end + + end + end +end diff --git a/lib/cmdx/executors/thread.rb b/lib/cmdx/executors/thread.rb new file mode 100644 index 000000000..3e90a0885 --- /dev/null +++ b/lib/cmdx/executors/thread.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module CMDx + class Executors + # Default executor. Uses a fixed-size `Thread` pool drained via a `Queue`; + # sentinel `nil`s terminate workers. Workers inherit the parent's chain via + # fiber-local storage. + # + # @api private + module Thread + + extend self + + # @param jobs [Array] opaque job objects forwarded to `on_job` + # @param concurrency [Integer] worker count + # @param on_job [#call] unary callable invoked per job + # @return [void] + def call(jobs:, concurrency:, on_job:) + queue = Queue.new + jobs.each { |job| queue << job } + concurrency.times { queue << nil } + + workers = Array.new(concurrency) do + ::Thread.new do + while (job = queue.pop) + on_job.call(job) + end + end + end + + workers.each(&:join) + end + + end + end +end diff --git a/lib/cmdx/logger_proxy.rb b/lib/cmdx/logger_proxy.rb index 611e9fe78..0eb8dd00b 100644 --- a/lib/cmdx/logger_proxy.rb +++ b/lib/cmdx/logger_proxy.rb @@ -17,7 +17,7 @@ def logger(task) formatter = settings.log_formatter change_level = level && level != logger.level - change_formatter = formatter && formatter != logger.formatter + change_formatter = formatter && !logger.formatter.equal?(formatter) return logger unless change_level || change_formatter logger = logger.dup diff --git a/lib/cmdx/mergers.rb b/lib/cmdx/mergers.rb new file mode 100644 index 000000000..568c0d7cd --- /dev/null +++ b/lib/cmdx/mergers.rb @@ -0,0 +1,92 @@ +# frozen_string_literal: true + +module CMDx + # Registry of named merge strategies used to fold successful parallel task + # results back into the workflow context. Ships with built-ins for + # `:last_write_wins` (default), `:deep_merge`, and `:no_merge`. A merger is + # any callable accepting `call(workflow_context, result)`. + class Mergers + + attr_reader :registry + + def initialize + @registry = { + last_write_wins: Mergers::LastWriteWins, + deep_merge: Mergers::DeepMerge, + no_merge: Mergers::NoMerge + } + end + + def initialize_copy(source) + @registry = source.registry.dup + end + + # Registers a named merger, overwriting any existing entry. + # + # @param name [Symbol] + # @param callable [#call, nil] pass either this or a block + # @yield merger body — `call(workflow_context, result)` + # @return [Mergers] self for chaining + # @raise [ArgumentError] when both `callable` and a block are given, or + # when the resolved merger isn't callable + def register(name, callable = nil, &block) + merger = callable || block + + if callable && block + raise ArgumentError, "provide either a callable or a block, not both" + elsif !merger.respond_to?(:call) + raise ArgumentError, "merger must respond to #call" + end + + registry[name.to_sym] = merger + self + end + + # @param name [Symbol] + # @return [Mergers] self for chaining + def deregister(name) + registry.delete(name.to_sym) + self + end + + # @param name [Symbol] + # @return [#call] the registered merger + # @raise [ArgumentError] when `name` isn't registered + def lookup(name) + registry[name] || begin + raise ArgumentError, "unknown merge_strategy: #{name.inspect}" + end + end + + # Resolves a declaration's `:merge_strategy` option to a concrete + # callable. Accepts `nil` (default `:last_write_wins`), a Symbol + # (registry lookup), or any object responding to `#call`. + # + # @param spec [Symbol, #call, nil] + # @return [#call] + # @raise [ArgumentError] when `spec` is an unknown symbol or not callable + def resolve(spec) + case spec + when NilClass + lookup(:last_write_wins) + when Symbol + lookup(spec) + else + return spec if spec.respond_to?(:call) + + raise ArgumentError, "unknown merge_strategy: #{spec.inspect}" + end + end + + # @return [Boolean] + def empty? + registry.empty? + end + + # @return [Integer] + def size + registry.size + end + + end +end diff --git a/lib/cmdx/mergers/deep_merge.rb b/lib/cmdx/mergers/deep_merge.rb new file mode 100644 index 000000000..869308a36 --- /dev/null +++ b/lib/cmdx/mergers/deep_merge.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module CMDx + class Mergers + # Recursively merges `Hash` values from the parallel task's context into + # the workflow context. Scalar-vs-hash collisions still follow + # last-write-wins. + # + # @api private + module DeepMerge + + extend self + + # @param ctx [Context] workflow context being folded into + # @param result [Result] successful parallel task result + # @return [void] + def call(ctx, result) + ctx.deep_merge(result.context) + end + + end + end +end diff --git a/lib/cmdx/mergers/last_write_wins.rb b/lib/cmdx/mergers/last_write_wins.rb new file mode 100644 index 000000000..8d10c3ce5 --- /dev/null +++ b/lib/cmdx/mergers/last_write_wins.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module CMDx + class Mergers + # Default merger. Shallow-merges the parallel task's context into the + # workflow context via `Hash#merge` semantics; on key conflicts, the + # later-declared task wins. + # + # @api private + module LastWriteWins + + extend self + + # @param ctx [Context] workflow context being folded into + # @param result [Result] successful parallel task result + # @return [void] + def call(ctx, result) + ctx.merge(result.context) + end + + end + end +end diff --git a/lib/cmdx/mergers/no_merge.rb b/lib/cmdx/mergers/no_merge.rb new file mode 100644 index 000000000..562c2df5c --- /dev/null +++ b/lib/cmdx/mergers/no_merge.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module CMDx + class Mergers + # No-op merger. Leaves the workflow context untouched; per-task results + # remain inspectable via `result.chain`. + # + # @api private + module NoMerge + + extend self + + # @param _ctx [Context] ignored + # @param _result [Result] ignored + # @return [void] + def call(_ctx, _result); end + + end + end +end diff --git a/lib/cmdx/output.rb b/lib/cmdx/output.rb index cfe7a0b3b..3e0f7b520 100644 --- a/lib/cmdx/output.rb +++ b/lib/cmdx/output.rb @@ -136,8 +136,11 @@ def verify(task) verify_children(value, task) end - # Verifies a child output against `parent_value` (read-only; child - # validation/coercion errors are still collected on the task). + # Verifies a child output against `parent_value` and writes the + # coerced/transformed value back into the parent when the parent supports + # mutation (Hash, or any object exposing `#{name}=`). Read-only parents + # (e.g. `Data.define`) are left untouched; child validation/coercion errors + # are still collected on the task. # # @param parent_value [#[], #key?, Object] the parent output's verified value # @param task [Task] @@ -164,11 +167,26 @@ def verify_from_parent(parent_value, task) validators = task.class.validators.extract(@options) task.class.validators.validate(task, name, value, validators) + writeback(parent_value, value) verify_children(value, task) end private + def writeback(parent_value, value) + if parent_value.is_a?(::Hash) + if parent_value.key?(name) + parent_value[name] = value + elsif parent_value.key?(name_str = name.to_s) + parent_value[name_str] = value + else + parent_value[name] = value + end + elsif parent_value.respond_to?(setter = :"#{name}=") + parent_value.public_send(setter, value) + end + end + def verify_children(value, task) return if children.empty? || value.nil? || value.is_a?(Coercions::Failure) diff --git a/lib/cmdx/pipeline.rb b/lib/cmdx/pipeline.rb index 9b635f975..7476935c0 100644 --- a/lib/cmdx/pipeline.rb +++ b/lib/cmdx/pipeline.rb @@ -64,35 +64,34 @@ def run_parallel(group) chain = Chain.current size = group.options[:pool_size] || tasks.size fail_fast = group.options[:fail_fast] - queue = Queue.new results = Array.new(tasks.size) mutex = Mutex.new failed = nil + cancelled = false - tasks.each_with_index { |tc, i| queue << [tc, i] } - size.times { queue << nil } - - workers = Array.new(size) do - Thread.new do - Fiber[Chain::STORAGE_KEY] = chain - while (entry = queue.pop) - task_class, index = entry - ctx_copy = @workflow.context.deep_dup - result = task_class.execute(ctx_copy) - mutex.synchronize do - results[index] = result - - if fail_fast && result.failed? && failed.nil? - failed = result - queue.clear - size.times { queue << nil } - end - end + jobs = tasks.each_with_index.to_a + + on_job = lambda do |(task_class, index)| + mutex.synchronize { return if cancelled } + + Fiber[Chain::STORAGE_KEY] ||= chain + ctx_copy = @workflow.context.deep_dup + result = task_class.execute(ctx_copy) + + mutex.synchronize do + results[index] = result + + if fail_fast && result.failed? && failed.nil? + failed = result + cancelled = true end end end - workers.each(&:join) + executor = @workflow.class.executors.resolve(group.options[:executor]) + merger = @workflow.class.mergers.resolve(group.options[:merge_strategy]) + + executor.call(jobs:, concurrency: size, on_job:) results.each do |result| next if result.nil? @@ -100,7 +99,7 @@ def run_parallel(group) if result.failed? failed ||= result else - @workflow.context.merge(result.context) + merger.call(@workflow.context, result) end end diff --git a/lib/cmdx/task.rb b/lib/cmdx/task.rb index ec1faab73..b53451de2 100644 --- a/lib/cmdx/task.rb +++ b/lib/cmdx/task.rb @@ -7,7 +7,7 @@ module CMDx # or {.execute!} (strict, raises on failure). # # Inheritance: every registry accessor (middlewares, callbacks, coercions, - # validators, telemetry, inputs, outputs) lazily clones from the + # validators, executors, mergers, telemetry, inputs, outputs) lazily clones from the # superclass's registry (or the global configuration at the top of the # hierarchy), so subclasses extend rather than replace. # @@ -110,9 +110,29 @@ def validators end end + # @return [Executors] cloned from superclass/configuration on first call + def executors + @executors ||= + if superclass.respond_to?(:executors) + superclass.executors.dup + else + CMDx.configuration.executors.dup + end + end + + # @return [Mergers] cloned from superclass/configuration on first call + def mergers + @mergers ||= + if superclass.respond_to?(:mergers) + superclass.mergers.dup + else + CMDx.configuration.mergers.dup + end + end + # Dispatches to the appropriate registry's `register` method. # - # @param type [:middleware, :callback, :coercion, :validator, :input, :output] + # @param type [:middleware, :callback, :coercion, :validator, :executor, :merger, :input, :output] # @return [Object] the registry's self # @raise [ArgumentError] when `type` is unknown def register(type, ...) @@ -125,6 +145,10 @@ def register(type, ...) coercions.register(...) when :validator validators.register(...) + when :executor + executors.register(...) + when :merger + mergers.register(...) when :input inputs.register(self, ...) when :output @@ -135,7 +159,7 @@ def register(type, ...) # Dispatches to the appropriate registry's `deregister` method. # - # @param type [:middleware, :callback, :coercion, :validator, :input, :output] + # @param type [:middleware, :callback, :coercion, :validator, :executor, :merger, :input, :output] # @return [Object] the registry's self # @raise [ArgumentError] when `type` is unknown def deregister(type, ...) @@ -148,6 +172,10 @@ def deregister(type, ...) coercions.deregister(...) when :validator validators.deregister(...) + when :executor + executors.deregister(...) + when :merger + mergers.deregister(...) when :input inputs.deregister(self, ...) when :output diff --git a/lib/cmdx/workflow.rb b/lib/cmdx/workflow.rb index a6a326287..88a6d969f 100644 --- a/lib/cmdx/workflow.rb +++ b/lib/cmdx/workflow.rb @@ -28,7 +28,16 @@ def pipeline # @param tasks [Array<Class<Task>>] # @param options [Hash{Symbol => Object}] # @option options [:sequential, :parallel] :strategy (:sequential) - # @option options [Integer] :pool_size parallel worker count + # @option options [Integer] :pool_size parallel worker/fiber count + # @option options [:threads, :fibers, #call] :executor (:threads) parallel + # dispatch backend. `:fibers` requires a `Fiber.scheduler` to be + # installed (e.g. `Async { ... }`). A custom callable accepting + # `jobs:, concurrency:, on_job:` may also be passed. + # @option options [:last_write_wins, :deep_merge, :no_merge, #call] :merge_strategy + # (:last_write_wins) how successful parallel contexts are folded back + # into the workflow context. Merging happens in declaration order. A + # callable `->(workflow_context, result) { ... }` may be passed to + # implement custom behavior (e.g. namespacing by task name). # @option options [Boolean] :fail_fast (false) when `:parallel`, drain # pending tasks on the first failure (in-flight tasks still finish) # @option options [Symbol, Proc, #call] :if diff --git a/skills/references/workflows.md b/skills/references/workflows.md index 59ddba44c..e071781bc 100644 --- a/skills/references/workflows.md +++ b/skills/references/workflows.md @@ -31,7 +31,10 @@ Defining `def work` on a workflow raises `CMDx::ImplementationError` — `#work` | Option | Description | |--------|-------------| | `strategy:` | `:sequential` (default) or `:parallel`. | -| `pool_size:` | Parallel worker thread count. Defaults to `tasks.size`. | +| `pool_size:` | Parallel worker/fiber count. Defaults to `tasks.size`. | +| `executor:` | `:threads` (default), `:fibers`, or any callable matching `call(jobs:, concurrency:, on_job:)`. `:fibers` requires `Fiber.scheduler` to be installed. | +| `merge_strategy:` | `:last_write_wins` (default), `:deep_merge`, `:no_merge`, or a callable `call(workflow_context, result)`. Applied in declaration order over successful results only. | +| `fail_fast:` | When `:parallel`, short-circuit pending tasks on the first failure (in-flight tasks still finish). | | `if:` / `unless:` | Gate the whole group. Signature `(workflow)` (Symbol → task method; Proc → `instance_exec`; `#call`-able → `callable.call(workflow)`). | Every task class must be a `CMDx::Task` subclass — otherwise registration raises `TypeError`. @@ -52,7 +55,7 @@ end ## Parallel groups -Runs the group's tasks concurrently on a Thread pool. +Runs the group's tasks concurrently. Default backend is a native Ruby Thread pool. ```ruby tasks SendReceipt, NotifyWarehouse, UpdateAnalytics, @@ -61,13 +64,60 @@ tasks SendReceipt, NotifyWarehouse, UpdateAnalytics, Behavior: -- Each task receives `context.deep_dup` — mutations are isolated per thread. +- Each task receives `context.deep_dup` — mutations are isolated per worker. - On success, each duplicated context is merged back into the workflow context. - The first failed result halts the workflow; successful siblings still merge. - Chain storage is propagated through fiber-local state so nested tasks see the same `CMDx::Chain`. Because parallel tasks receive deep-duplicated contexts, a task that relies on mutations performed by a sibling in the same group will not see them. Split such dependencies into separate groups. +### Pluggable executors (`executor:`) + +Swap the dispatch backend without changing the parallel semantics above: + +```ruby +tasks A, B, C, strategy: :parallel, executor: :threads # default +tasks A, B, C, strategy: :parallel, executor: :fibers, pool_size: 8 +tasks A, B, C, strategy: :parallel, executor: ->(jobs:, concurrency:, on_job:) { MyPool.run(jobs, concurrency, &on_job) } +``` + +- `:threads` — `Queue`-backed worker pool sized by `pool_size || tasks.size`. No external deps. +- `:fibers` — one fiber per job via `Fiber.schedule`, bounded by `pool_size` via a `SizedQueue` semaphore. Raises `RuntimeError` unless `Fiber.scheduler` is installed on the current thread (e.g. inside `Async { ... }` from the `async` gem). The gem ships no scheduler — callers supply one. +- Callable — any object responding to `call(jobs:, concurrency:, on_job:)`. Must invoke `on_job.call(job)` per job and block until all finish. Cancellation, chain propagation, and context merging are owned by `on_job`; the executor only schedules. + +Unknown executor symbols raise `ArgumentError` at execution time. + +Executors are resolved from a per-task `CMDx::Executors` registry (duplicated from `CMDx.configuration.executors` on first access). Register custom backends by name once and reference them by symbol: + +```ruby +class ApplicationTask < CMDx::Task + register :executor, :bounded_pool, MyPool.method(:run) +end + +# or globally +CMDx.configure { |c| c.executors.register(:bounded_pool, MyPool.method(:run)) } +``` + +### Merge strategies (`merge_strategy:`) + +Controls how successful sibling contexts fold back into the workflow context. Fold order is always declaration order (deterministic, independent of completion order). + +```ruby +tasks A, B, C, strategy: :parallel # :last_write_wins (default) +tasks A, B, C, strategy: :parallel, merge_strategy: :deep_merge +tasks A, B, C, strategy: :parallel, merge_strategy: :no_merge +tasks A, B, C, strategy: :parallel, merge_strategy: ->(ctx, result) { ctx[result.task.name] = result.context.to_h } +``` + +- `:last_write_wins` — shallow `Hash#merge!`; later-declared tasks overwrite earlier-declared on conflict. Matches previous behavior. +- `:deep_merge` — recursive over `Hash` values only; scalar-vs-hash still last-write-wins. +- `:no_merge` — the workflow context is not written to. Per-task results remain inspectable through `result.chain`. +- Callable — `call(workflow_context, result)` per successful result; failed results never reach the merger. + +Unknown merge strategy symbols raise `ArgumentError`. + +Merge strategies are resolved from a per-task `CMDx::Mergers` registry. Register custom named mergers via `register :merger, :name, callable` on a task class (or `CMDx.configuration.mergers.register(...)` globally) and reference them by symbol from `:merge_strategy`. + ## Conditional groups ```ruby diff --git a/spec/cmdx/context_spec.rb b/spec/cmdx/context_spec.rb index fed419cee..380e59038 100644 --- a/spec/cmdx/context_spec.rb +++ b/spec/cmdx/context_spec.rb @@ -134,6 +134,36 @@ end end + describe "#deep_merge" do + it "returns self" do + ctx = described_class.new(a: 1) + expect(ctx.deep_merge(b: 2)).to be(ctx) + end + + it "merges nested hashes recursively instead of replacing them" do + ctx = described_class.new(user: { name: "Ada", prefs: { theme: "dark" } }) + ctx.deep_merge(user: { age: 36, prefs: { lang: "en" } }) + expect(ctx.user).to eq(name: "Ada", age: 36, prefs: { theme: "dark", lang: "en" }) + end + + it "right-hand scalar replaces left-hand hash and vice versa" do + ctx = described_class.new(x: { a: 1 }) + ctx.deep_merge(x: 42) + expect(ctx.x).to eq(42) + + ctx = described_class.new(y: 5) + ctx.deep_merge(y: { b: 2 }) + expect(ctx.y).to eq(b: 2) + end + + it "does not mutate nested source hashes" do + source = { user: { name: "Ada" } } + ctx = described_class.new(user: { email: "a@b" }) + ctx.deep_merge(source) + expect(source[:user]).to eq(name: "Ada") + end + end + describe "predicates and introspection" do subject(:ctx) { described_class.new(a: 1) } @@ -341,7 +371,7 @@ it "serializes nested contexts" do inner = described_class.new(n: 2) - outer = described_class.new(inner: inner, label: "outer") + outer = described_class.new(inner:, label: "outer") expect(JSON.parse(outer.to_json)).to eq( "inner" => { "n" => 2 }, diff --git a/spec/cmdx/executors_spec.rb b/spec/cmdx/executors_spec.rb new file mode 100644 index 000000000..d2cbe4bad --- /dev/null +++ b/spec/cmdx/executors_spec.rb @@ -0,0 +1,99 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe CMDx::Executors do + subject(:executors) { described_class.new } + + describe "#initialize" do + it "registers the built-in executors" do + expect(executors.registry.keys).to contain_exactly(:threads, :fibers) + expect(executors.lookup(:threads)).to be(CMDx::Executors::Thread) + expect(executors.lookup(:fibers)).to be(CMDx::Executors::Fiber) + end + end + + describe "#initialize_copy" do + it "dups the registry" do + copy = executors.dup + copy.deregister(:threads) + expect(executors.registry).to have_key(:threads) + expect(copy.registry).not_to have_key(:threads) + end + end + + describe "#register" do + it "stores a callable" do + c = ->(jobs:, on_job:, **) { jobs.each { |j| on_job.call(j) } } + executors.register(:custom, c) + expect(executors.lookup(:custom)).to be(c) + end + + it "stores a block" do + executors.register(:b) { |jobs:, on_job:, **| jobs.each { |j| on_job.call(j) } } + expect(executors.lookup(:b)).to be_a(Proc) + end + + it "raises when both a callable and block are given" do + c = ->(**) {} + expect { executors.register(:x, c) { |**| nil } } + .to raise_error(ArgumentError, /either a callable or a block/) + end + + it "raises when the handler does not respond to call" do + expect { executors.register(:x, Object.new) } + .to raise_error(ArgumentError, /must respond to #call/) + end + + it "coerces string names to symbols" do + c = ->(**) {} + executors.register("custom", c) + expect(executors.lookup(:custom)).to be(c) + end + end + + describe "#deregister" do + it "removes a key" do + executors.deregister(:threads) + expect(executors.registry).not_to have_key(:threads) + end + end + + describe "#lookup" do + it "raises on unknown keys" do + expect { executors.lookup(:bogus) }.to raise_error(ArgumentError, "unknown executor: :bogus") + end + end + + describe "#resolve" do + it "defaults to :threads when spec is nil" do + expect(executors.resolve(nil)).to be(CMDx::Executors::Thread) + end + + it "looks up registered symbols" do + expect(executors.resolve(:fibers)).to be(CMDx::Executors::Fiber) + end + + it "passes through arbitrary callables" do + c = ->(**) {} + expect(executors.resolve(c)).to be(c) + end + + it "raises on unknown symbols" do + expect { executors.resolve(:bogus) } + .to raise_error(ArgumentError, "unknown executor: :bogus") + end + + it "raises on non-callable, non-symbol values" do + expect { executors.resolve(Object.new) } + .to raise_error(ArgumentError, /unknown executor/) + end + end + + describe "#empty? / #size" do + it "reports the registry size" do + expect(executors.size).to eq(2) + expect(executors).not_to be_empty + end + end +end diff --git a/spec/cmdx/fault_spec.rb b/spec/cmdx/fault_spec.rb index 725cf8065..dc0b5fd34 100644 --- a/spec/cmdx/fault_spec.rb +++ b/spec/cmdx/fault_spec.rb @@ -20,10 +20,10 @@ def build_result(signal, klass: task_class, **opts) fault = described_class.new(result) expect(fault).to have_attributes( - result: result, + result:, task: task_class, context: result.context, - chain: chain + chain: ) end @@ -61,7 +61,7 @@ def build_result(signal, klass: task_class, **opts) rescue StandardError => e e end - result = build_result(CMDx::Signal.failed("b", cause: cause)) + result = build_result(CMDx::Signal.failed("b", cause:)) expect(described_class.new(result).backtrace).to eq(cause.backtrace_locations.map(&:to_s)) end diff --git a/spec/cmdx/logger_proxy_spec.rb b/spec/cmdx/logger_proxy_spec.rb index 305075ae7..cd8c24610 100644 --- a/spec/cmdx/logger_proxy_spec.rb +++ b/spec/cmdx/logger_proxy_spec.rb @@ -45,6 +45,27 @@ expect(logger.formatter).to be(custom_formatter) end + it "compares formatters by identity, not by value" do + shared_formatter = proc { |*| "x" } + base_logger.formatter = shared_formatter + task_class.settings(log_level: base_logger.level, log_formatter: shared_formatter) + + expect(described_class.logger(task)).to be(base_logger) + end + + it "dups when a formatter with a custom == that returns true is supplied" do + weird = Class.new do + def call(*) = "" + def ==(_other) = true + end.new + + task_class.settings(log_formatter: weird) + + logger = described_class.logger(task) + expect(logger).not_to be(base_logger) + expect(logger.formatter).to be(weird) + end + it "does not mutate the settings logger" do original_level = base_logger.level original_formatter = base_logger.formatter diff --git a/spec/cmdx/mergers_spec.rb b/spec/cmdx/mergers_spec.rb new file mode 100644 index 000000000..4f50def24 --- /dev/null +++ b/spec/cmdx/mergers_spec.rb @@ -0,0 +1,115 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe CMDx::Mergers do + subject(:mergers) { described_class.new } + + describe "#initialize" do + it "registers the built-in merge strategies" do + expect(mergers.registry.keys).to contain_exactly( + :last_write_wins, :deep_merge, :no_merge + ) + expect(mergers.lookup(:last_write_wins)).to be(CMDx::Mergers::LastWriteWins) + expect(mergers.lookup(:deep_merge)).to be(CMDx::Mergers::DeepMerge) + expect(mergers.lookup(:no_merge)).to be(CMDx::Mergers::NoMerge) + end + end + + describe "#initialize_copy" do + it "dups the registry" do + copy = mergers.dup + copy.deregister(:no_merge) + expect(mergers.registry).to have_key(:no_merge) + expect(copy.registry).not_to have_key(:no_merge) + end + end + + describe "#register" do + it "stores a callable" do + c = ->(_ctx, _r) {} + mergers.register(:custom, c) + expect(mergers.lookup(:custom)).to be(c) + end + + it "stores a block" do + mergers.register(:b) { |_ctx, _r| nil } + expect(mergers.lookup(:b)).to be_a(Proc) + end + + it "raises when both a callable and block are given" do + c = ->(_, _) {} + expect { mergers.register(:x, c) { |_, _| nil } } + .to raise_error(ArgumentError, /either a callable or a block/) + end + + it "raises when the handler does not respond to call" do + expect { mergers.register(:x, Object.new) } + .to raise_error(ArgumentError, /must respond to #call/) + end + end + + describe "#deregister" do + it "removes a key" do + mergers.deregister(:deep_merge) + expect(mergers.registry).not_to have_key(:deep_merge) + end + end + + describe "#lookup" do + it "raises on unknown keys" do + expect { mergers.lookup(:bogus) } + .to raise_error(ArgumentError, "unknown merge_strategy: :bogus") + end + end + + describe "#resolve" do + it "defaults to :last_write_wins when spec is nil" do + expect(mergers.resolve(nil)).to be(mergers.lookup(:last_write_wins)) + end + + it "looks up registered symbols" do + expect(mergers.resolve(:deep_merge)).to be(mergers.lookup(:deep_merge)) + end + + it "passes through arbitrary callables" do + c = ->(_ctx, _r) {} + expect(mergers.resolve(c)).to be(c) + end + + it "raises on unknown symbols" do + expect { mergers.resolve(:bogus) } + .to raise_error(ArgumentError, "unknown merge_strategy: :bogus") + end + end + + describe "built-in behavior" do + let(:ctx) { CMDx::Context.build(a: 1, nested: { a: 1 }) } + let(:result) { instance_double(CMDx::Result, context: CMDx::Context.build(b: 2, nested: { b: 2 })) } + + it ":last_write_wins shallow-merges" do + mergers.lookup(:last_write_wins).call(ctx, result) + expect(ctx.a).to eq(1) + expect(ctx.b).to eq(2) + expect(ctx.nested).to eq(b: 2) + end + + it ":deep_merge recursively merges" do + mergers.lookup(:deep_merge).call(ctx, result) + expect(ctx.nested).to eq(a: 1, b: 2) + end + + it ":no_merge leaves context untouched" do + mergers.lookup(:no_merge).call(ctx, result) + expect(ctx.a).to eq(1) + expect(ctx.b).to be_nil + end + end + + describe "#empty? / #size" do + it "reports the registry size" do + expect(mergers.size).to eq(3) + expect(mergers).not_to be_empty + end + end +end diff --git a/spec/cmdx/pipeline_spec.rb b/spec/cmdx/pipeline_spec.rb index f8854b12a..c965b2e3f 100644 --- a/spec/cmdx/pipeline_spec.rb +++ b/spec/cmdx/pipeline_spec.rb @@ -167,6 +167,51 @@ expect(result.context[:ran]).to be(true) end + it "runs with executor: :threads (explicit, same as default)" do + t1 = create_successful_task(name: "E1") + t2 = create_successful_task(name: "E2") + workflow_class = create_workflow_class do + tasks t1, t2, strategy: :parallel, executor: :threads + end + + expect(workflow_class.execute).to be_success + end + + it "runs with a callable executor override" do + t1 = create_successful_task(name: "C1") + t2 = create_successful_task(name: "C2") + calls = [] + executor = lambda do |jobs:, concurrency:, on_job:| + calls << [jobs.size, concurrency] + jobs.each { |j| on_job.call(j) } + end + + workflow_class = create_workflow_class do + tasks(t1, t2, strategy: :parallel, executor:) + end + + expect(workflow_class.execute).to be_success + expect(calls).to eq([[2, 2]]) + end + + it "rejects an unknown executor symbol" do + t1 = create_successful_task(name: "U1") + workflow_class = create_workflow_class do + tasks t1, strategy: :parallel, executor: :bogus + end + + expect { workflow_class.execute! }.to raise_error(ArgumentError, /unknown executor: :bogus/) + end + + it "raises when executor: :fibers has no Fiber.scheduler installed" do + t1 = create_successful_task(name: "F1") + workflow_class = create_workflow_class do + tasks t1, strategy: :parallel, executor: :fibers + end + + expect { workflow_class.execute! }.to raise_error(RuntimeError, /Fiber\.scheduler/) + end + it "merges context from tasks that completed before the failure was observed" do ok = create_task_class(name: "Ok") do define_method(:work) { context.ok = true } @@ -186,6 +231,116 @@ expect(result.context[:ran]).to be_nil end end + + describe ":merge_strategy" do + let(:writer_a) do + create_task_class(name: "WA") do + define_method(:work) do + context.a = 1 + context.nested = { a: 1, shared: "a" } + end + end + end + let(:writer_b) do + create_task_class(name: "WB") do + define_method(:work) do + context.b = 2 + context.nested = { b: 2, shared: "b" } + end + end + end + + it "defaults to :last_write_wins (shallow, later-declared wins on conflict)" do + wa = writer_a + wb = writer_b + workflow_class = create_workflow_class do + tasks wa, wb, strategy: :parallel + end + + result = workflow_class.execute + expect(result.context.a).to eq(1) + expect(result.context.b).to eq(2) + expect(result.context.nested).to eq({ b: 2, shared: "b" }) + end + + it "recursively merges nested hashes under :deep_merge" do + wa = writer_a + wb = writer_b + workflow_class = create_workflow_class do + tasks wa, wb, strategy: :parallel, merge_strategy: :deep_merge + end + + result = workflow_class.execute + expect(result.context.nested).to eq({ a: 1, b: 2, shared: "b" }) + end + + it "leaves the workflow context untouched under :no_merge" do + wa = writer_a + wb = writer_b + workflow_class = create_workflow_class do + tasks wa, wb, strategy: :parallel, merge_strategy: :no_merge + end + + result = workflow_class.execute + expect(result).to be_success + expect(result.context.a).to be_nil + expect(result.context.b).to be_nil + expect(result.context.nested).to be_nil + end + + it "accepts a callable merger" do + wa = writer_a + wb = writer_b + seen = [] + collector = lambda { |ctx, result| + seen << result + ctx.merge_count = (ctx.merge_count || 0) + 1 + } + + workflow_class = create_workflow_class do + tasks wa, wb, strategy: :parallel, merge_strategy: collector + end + + result = workflow_class.execute + expect(result.context.merge_count).to eq(2) + expect(seen.map(&:task)).to contain_exactly(wa, wb) + end + + it "rejects unknown symbols" do + wa = writer_a + workflow_class = create_workflow_class do + tasks wa, strategy: :parallel, merge_strategy: :bogus + end + + expect { workflow_class.execute! }.to raise_error(ArgumentError, /unknown merge_strategy: :bogus/) + end + end + + describe "registry-based resolution" do + it "resolves executors registered on the workflow class" do + custom = ->(jobs:, on_job:, **) { jobs.each { |j| on_job.call(j) } } + t1 = create_successful_task(name: "R1") + workflow_class = create_workflow_class do + register :executor, :inline, custom + tasks t1, strategy: :parallel, executor: :inline + end + + expect(workflow_class.execute).to be_success + end + + it "resolves mergers registered on the workflow class" do + seen = [] + collector = ->(_ctx, result) { seen << result } + t1 = create_successful_task(name: "M1") + workflow_class = create_workflow_class do + register :merger, :collector, collector + tasks t1, strategy: :parallel, merge_strategy: :collector + end + + expect(workflow_class.execute).to be_success + expect(seen.size).to eq(1) + end + end end end end diff --git a/spec/cmdx/signal_spec.rb b/spec/cmdx/signal_spec.rb index 9d637311a..b11923b7b 100644 --- a/spec/cmdx/signal_spec.rb +++ b/spec/cmdx/signal_spec.rb @@ -20,11 +20,11 @@ context "with options" do it "stores metadata, cause, and backtrace" do cause = StandardError.new("inner") - signal = described_class.success(metadata: { code: 1 }, cause: cause, backtrace: %w[a b]) + signal = described_class.success(metadata: { code: 1 }, cause:, backtrace: %w[a b]) expect(signal).to have_attributes( metadata: { code: 1 }, - cause: cause, + cause:, backtrace: %w[a b] ) end @@ -135,7 +135,7 @@ described_class::FAILED, reason: "r", metadata: { k: :v }, - cause: cause, + cause:, backtrace: %w[line1 line2] ) end @@ -146,7 +146,7 @@ expect(signal).to have_attributes( reason: "r", metadata: { k: :v }, - cause: cause, + cause:, backtrace: %w[line1 line2] ) end diff --git a/spec/cmdx/task_spec.rb b/spec/cmdx/task_spec.rb index 34b3acad2..3f6e3b7cd 100644 --- a/spec/cmdx/task_spec.rb +++ b/spec/cmdx/task_spec.rb @@ -5,7 +5,7 @@ RSpec.describe CMDx::Task do describe "class-level registries" do let(:base) { create_task_class(name: "BaseTask") } - let(:child) { create_task_class(base: base, name: "ChildTask") } + let(:child) { create_task_class(base:, name: "ChildTask") } it "inherits settings from the superclass" do base.settings(tags: %w[root]) diff --git a/spec/integration/tasks/chain_spec.rb b/spec/integration/tasks/chain_spec.rb index b3438c8e3..16e984831 100644 --- a/spec/integration/tasks/chain_spec.rb +++ b/spec/integration/tasks/chain_spec.rb @@ -17,7 +17,7 @@ end describe "a nested execution" do - subject(:result) { create_nested_task(strategy: strategy, status: :success).execute } + subject(:result) { create_nested_task(strategy:, status: :success).execute } shared_examples "collects the full stack" do it "records inner/middle/outer in execution order" do diff --git a/spec/integration/tasks/retry_spec.rb b/spec/integration/tasks/retry_spec.rb index cb9f93e04..97c78a8ca 100644 --- a/spec/integration/tasks/retry_spec.rb +++ b/spec/integration/tasks/retry_spec.rb @@ -104,7 +104,7 @@ 0 end task = create_flaky_task(failures: 2) do - retry_on CMDx::TestError, limit: 3, delay: 0.0001, jitter: jitter + retry_on(CMDx::TestError, limit: 3, delay: 0.0001, jitter:) end task.execute diff --git a/spec/integration/tasks/settings_spec.rb b/spec/integration/tasks/settings_spec.rb index 4b27c6b9d..bae3e130e 100644 --- a/spec/integration/tasks/settings_spec.rb +++ b/spec/integration/tasks/settings_spec.rb @@ -91,7 +91,7 @@ let(:parent) do logger = custom_logger create_task_class(name: "Parent") do - settings(logger: logger, tags: %w[base]) + settings(logger:, tags: %w[base]) define_method(:work) { nil } end end diff --git a/spec/integration/tasks/telemetry_spec.rb b/spec/integration/tasks/telemetry_spec.rb index f5bf97321..b8f669fb8 100644 --- a/spec/integration/tasks/telemetry_spec.rb +++ b/spec/integration/tasks/telemetry_spec.rb @@ -69,7 +69,7 @@ def with_telemetry(task, *names) name: :task_executed, cid: result.cid, type: task.type, - task: task, + task:, tid: result.tid ) expect(event.payload[:result]).to be(result) From 73582de21a61995cedbe9a9658086a82a66c7c39 Mon Sep 17 00:00:00 2001 From: Juan Gomez <drexed@users.noreply.github.com> Date: Wed, 22 Apr 2026 13:13:03 -0400 Subject: [PATCH 21/54] V2 --- lib/generators/cmdx/templates/install.rb | 36 ++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/lib/generators/cmdx/templates/install.rb b/lib/generators/cmdx/templates/install.rb index c33f6c48e..8f1ff5561 100644 --- a/lib/generators/cmdx/templates/install.rb +++ b/lib/generators/cmdx/templates/install.rb @@ -9,6 +9,15 @@ # # config.default_locale = "en" + # =========================================================================== + # Strict context + # =========================================================================== + # When true, dynamic reads on `context` raise `NoMethodError` for unknown + # keys instead of returning `nil` (`[]`, `fetch`, `dig`, and `?` predicates + # stay lenient). Override per-task via `settings(strict_context: true)`. + # + # config.strict_context = true + # =========================================================================== # Logging # =========================================================================== @@ -87,4 +96,31 @@ # CMDx::Validators::Failure.new("is not a valid UUID") # end # end) + + # =========================================================================== + # Executors + # =========================================================================== + # Registered executors drive `:parallel` workflow groups. Built-ins: + # `:threads` (default), `:fibers`. A callable receives + # `call(jobs:, concurrency:, on_job:)` and must invoke `on_job.call(job)` + # for each job, blocking until every job is done. + # + # config.executors.register(:ractors, proc do |jobs:, concurrency:, on_job:| + # jobs.each_slice(concurrency) do |slice| + # slice.map { |job| Ractor.new(job) { |j| on_job.call(j) } }.each(&:take) + # end + # end) + + # =========================================================================== + # Mergers + # =========================================================================== + # Merge strategies fold successful parallel task contexts back into the + # workflow context. Built-ins: `:last_write_wins` (default), `:deep_merge`, + # `:no_merge`. A callable receives `call(workflow_context, result)`. + # + # config.mergers.register(:whitelist, proc do |workflow_context, result| + # result.context.to_h.slice(:order_id, :total).each do |key, value| + # workflow_context[key] = value + # end + # end) end From d128fae627093833015b1d7e956136ae33c829ad Mon Sep 17 00:00:00 2001 From: Juan Gomez <drexed@users.noreply.github.com> Date: Wed, 22 Apr 2026 14:18:19 -0400 Subject: [PATCH 22/54] Fix settings issue --- CHANGELOG.md | 1 + docs/configuration.md | 4 ++-- docs/v2-migration.md | 4 ++-- lib/cmdx/configuration.rb | 10 +++++----- spec/cmdx/configuration_spec.rb | 6 ++++-- 5 files changed, 14 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 401821dc2..2e6f039f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -82,6 +82,7 @@ Full runtime rewrite: the v1 state-machine plus Zeitwerk architecture is replace - 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 - Generators emit the new `def work` template; the install template documents the new middleware / callback / telemetry / coercion / validator registration shapes - Slim `Configuration` to: `middlewares`, `callbacks`, `coercions`, `validators`, `telemetry`, `default_locale`, `strict_context`, `backtrace_cleaner`, `logger`, `log_level`, `log_formatter` +- `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 ### Removed - **BREAKING**: Remove `Result::STATES = [INITIALIZED, EXECUTING, COMPLETE, INTERRUPTED]`, the `executed!` / `executing!` transitions, and the `executed?` / `initialized?` / `executing?` predicates diff --git a/docs/configuration.md b/docs/configuration.md index 9e8c6fe8c..09f07133a 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -20,8 +20,8 @@ CMDx uses a two-tier configuration system: | Setting | Default | Description | |---------|---------|-------------| | `logger` | `Logger.new($stdout, progname: "cmdx", formatter: Line.new, level: INFO)` | Logger instance | -| `log_level` | `Logger::INFO` | Logger severity | -| `log_formatter` | `CMDx::LogFormatters::Line.new` | Formatter instance | +| `log_level` | `nil` | Optional override applied on top of `logger.level` (nil = use the logger's own level) | +| `log_formatter` | `nil` | Optional override applied on top of `logger.formatter` (nil = use the logger's own formatter) | | `default_locale` | `"en"` | Locale for built-in translation fallbacks | | `backtrace_cleaner` | `nil` | Callable to clean fault backtraces | | `strict_context` | `false` | Raise `NoMethodError` on unknown `context.foo` reads | diff --git a/docs/v2-migration.md b/docs/v2-migration.md index 875afc77b..d51d95693 100644 --- a/docs/v2-migration.md +++ b/docs/v2-migration.md @@ -86,8 +86,8 @@ CMDx.configure do |config| config.default_locale # "en" config.backtrace_cleaner # ->(bt) { ... } or nil config.logger # Logger instance - config.log_level # Logger::INFO - config.log_formatter # CMDx::LogFormatters::Line.new + config.log_level # nil (optional override; defaults come from `logger.level`) + config.log_formatter # nil (optional override; defaults come from `logger.formatter`) end ``` diff --git a/lib/cmdx/configuration.rb b/lib/cmdx/configuration.rb index 305daabf3..98869230a 100644 --- a/lib/cmdx/configuration.rb +++ b/lib/cmdx/configuration.rb @@ -25,14 +25,14 @@ def initialize @default_locale = "en" @strict_context = false @backtrace_cleaner = nil + @log_formatter = nil + @log_level = nil - @log_formatter = LogFormatters::Line.new - @log_level = Logger::INFO - @logger = Logger.new( + @logger = Logger.new( $stdout, progname: "cmdx", - formatter: @log_formatter, - level: @log_level + formatter: LogFormatters::Line.new, + level: Logger::INFO ) end diff --git a/spec/cmdx/configuration_spec.rb b/spec/cmdx/configuration_spec.rb index 0c14d4360..922603eed 100644 --- a/spec/cmdx/configuration_spec.rb +++ b/spec/cmdx/configuration_spec.rb @@ -20,10 +20,12 @@ default_locale: "en", backtrace_cleaner: nil, strict_context: false, - log_level: Logger::INFO + log_level: nil, + log_formatter: nil ) - expect(config.log_formatter).to be_a(CMDx::LogFormatters::Line) expect(config.logger).to be_a(Logger) + expect(config.logger.level).to eq(Logger::INFO) + expect(config.logger.formatter).to be_a(CMDx::LogFormatters::Line) end it "gives each instance independent registries" do From 68b5c5c0cbdcb278cae3195d69b156db15288659 Mon Sep 17 00:00:00 2001 From: Juan Gomez <drexed@users.noreply.github.com> Date: Wed, 22 Apr 2026 14:47:06 -0400 Subject: [PATCH 23/54] V2 --- CHANGELOG.md | 3 ++- docs/configuration.md | 13 +++++++++---- docs/logging.md | 16 ++++++++++++++++ docs/v2-migration.md | 6 ++++-- lib/cmdx/configuration.rb | 3 ++- lib/cmdx/runtime.rb | 5 ++++- lib/cmdx/settings.rb | 8 ++++++++ lib/generators/cmdx/templates/install.rb | 7 ++++--- 8 files changed, 49 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e6f039f5..1bfbf8f78 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,7 @@ Full runtime rewrite: the v1 state-machine plus Zeitwerk architecture is replace - Add `Context#keys`, `values`, `empty?`, `size`, `delete`, `clear`, `eql?` / `==`, `hash`, `deep_dup`, `respond_to_missing?`, and `Context#merge` that accepts any context-like object - Add `Coercions::Coerce` and `Validators::Validate` inline-callable handlers for `:coerce` / `:validate` hash entries; generic callables receive `(value, task)`, Symbol and Proc handlers still resolve against the task - Add `Configuration#backtrace_cleaner` and `Configuration#telemetry` +- Add `Configuration#log_exclusions` (defaults to `[]`) and matching `Settings#log_exclusions` override — an array of `Result#to_h` keys to strip from the lifecycle log entry (e.g. `[:context, :metadata]`). When empty, `Runtime` logs the `Result` as before; otherwise it logs `result.to_h.except(*exclusions)`. Other consumers (telemetry, return values) see the full result - Add `Configuration#strict_context` (defaults to `false`) and matching `Settings#strict_context` override, toggling `Context#strict`; when enabled, unknown dynamic reads (`ctx.missing`) raise `NoMethodError` instead of returning `nil` — `[]`, `fetch`, `dig`, `key?`, and `?` predicates stay lenient - Add `CMDx.reset_configuration!` which clears global registry ivars on `Task` for clean test setup/teardown; subclasses that already cloned their registries are unaffected - Add `:if` / `:unless` gates to `Callbacks#register` (Symbol, Proc, or any `#call`-able); per-event DSL helpers (`before_execution`, `on_success`, etc.) forward the options through @@ -66,7 +67,7 @@ Full runtime rewrite: the v1 state-machine plus Zeitwerk architecture is replace - `Workflow` declares groups via `task` / `tasks` (still aliased) and supports `:strategy => :parallel`, `:pool_size`, `:fail_fast`, `:if` / `:unless` per group; defining `#work` on a workflow raises `ImplementationError` - `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; opt-in `:fail_fast` drains pending tasks on the first failure (in-flight tasks still finish and successful contexts still merge) - `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 -- `Settings` is now a frozen value object holding only `logger`, `log_formatter`, `log_level`, `backtrace_cleaner`, `tags`, `strict_context`; every getter falls back to `CMDx.configuration` +- `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` - `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` - `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) - 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 diff --git a/docs/configuration.md b/docs/configuration.md index 09f07133a..9b822d16d 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -22,6 +22,7 @@ CMDx uses a two-tier configuration system: | `logger` | `Logger.new($stdout, progname: "cmdx", formatter: Line.new, level: INFO)` | Logger instance | | `log_level` | `nil` | Optional override applied on top of `logger.level` (nil = use the logger's own level) | | `log_formatter` | `nil` | Optional override applied on top of `logger.formatter` (nil = use the logger's own formatter) | +| `log_exclusions` | `[]` | `Result#to_h` keys stripped from the lifecycle log entry (e.g. `[:context]`) | | `default_locale` | `"en"` | Locale for built-in translation fallbacks | | `backtrace_cleaner` | `nil` | Callable to clean fault backtraces | | `strict_context` | `false` | Raise `NoMethodError` on unknown `context.foo` reads | @@ -80,14 +81,17 @@ Override per-task via `settings(strict_context: true)`. ```ruby CMDx.configure do |config| - config.logger = Logger.new($stdout, progname: "cmdx") - config.log_level = Logger::DEBUG - config.log_formatter = CMDx::LogFormatters::JSON.new + config.logger = Logger.new($stdout, progname: "cmdx") + config.log_level = Logger::DEBUG + config.log_formatter = CMDx::LogFormatters::JSON.new + config.log_exclusions = [:context] end ``` Built-in formatters live under `CMDx::LogFormatters`: `Line` (default), `JSON`, `KeyValue`, `Logstash`, `Raw`. See [Logging](logging.md) for the emitted fields and sample output. +`log_exclusions` trims noisy keys from the logged `Result#to_h` (common targets: `:context`, `:metadata`, `:tags`). The returned `Result` and telemetry payloads still carry the full data — only the log line is filtered. + ### Middlewares Middlewares wrap the entire task lifecycle. The signature is `call(task) { ... }` — call `yield` (or `next_link.call` from a Proc) to invoke the next link. @@ -293,6 +297,7 @@ class GenerateInvoice < CMDx::Task logger: CustomLogger.new($stdout), log_formatter: CMDx::LogFormatters::JSON.new, log_level: Logger::DEBUG, + log_exclusions: [:context, :metadata], backtrace_cleaner: ->(bt) { bt.first(8) }, tags: ["billing", "financial"], strict_context: true @@ -319,7 +324,7 @@ end !!! note - `Settings` only stores `:logger`, `:log_formatter`, `:log_level`, `:backtrace_cleaner`, `:tags`, and `:strict_context`. Other class-level config uses dedicated DSL (`retry_on`, `deprecation`, `register`, `before_execution`, …). + `Settings` only stores `:logger`, `:log_formatter`, `:log_level`, `:log_exclusions`, `:backtrace_cleaner`, `:tags`, and `:strict_context`. Other class-level config uses dedicated DSL (`retry_on`, `deprecation`, `register`, `before_execution`, …). ### Retry diff --git a/docs/logging.md b/docs/logging.md index cf64b1315..169b4f1a0 100644 --- a/docs/logging.md +++ b/docs/logging.md @@ -150,6 +150,22 @@ class QuietTask < CMDx::Task end ``` +### Excluding Fields + +Strip specific keys from the logged `Result#to_h` with `log_exclusions`. Useful for dropping bulky or sensitive fields (`:context`, `:metadata`) from the log stream while keeping them on the returned `Result` and telemetry payloads. + +```ruby +CMDx.configure do |config| + config.log_exclusions = [:context, :metadata] +end + +class ImportPayroll < CMDx::Task + settings(log_exclusions: [:context]) +end +``` + +Exclusions match top-level `Result#to_h` keys only (no deep paths). When empty (the default), the full result is logged. + ## Log Levels CMDx logs each task result at `INFO` once the lifecycle completes. The framework itself emits no `WARN` or `ERROR` lines — use callbacks (`on_failed`, `on_skipped`) or telemetry subscribers (`:task_retried`, `:task_executed`) to log at higher severities. diff --git a/docs/v2-migration.md b/docs/v2-migration.md index d51d95693..3e45fe2da 100644 --- a/docs/v2-migration.md +++ b/docs/v2-migration.md @@ -88,6 +88,7 @@ CMDx.configure do |config| config.logger # Logger instance config.log_level # nil (optional override; defaults come from `logger.level`) config.log_formatter # nil (optional override; defaults come from `logger.formatter`) + config.log_exclusions # [] (Result#to_h keys stripped from the lifecycle log entry) end ``` @@ -323,14 +324,15 @@ See [Middlewares](middlewares.md) for the full surface. ## Settings -`Settings` is a frozen value object. Per-task overrides cover logger, log formatter, log level, backtrace cleaner, and tags — nothing else. Registries live on the `Task` class itself. +`Settings` is a frozen value object. Per-task overrides cover logger, log formatter, log level, log exclusions, backtrace cleaner, tags, and strict context — nothing else. Registries live on the `Task` class itself. ```ruby # v1 # v2 settings logger: MyLogger.new, settings logger: MyLogger.new, tags: %i[critical], log_formatter: CMDx::LogFormatters::JSON.new, task_breakpoints: %w[failed], # gone log_level: Logger::DEBUG, - freeze_results: false # gone backtrace_cleaner: ->(bt) { bt.reject { |l| l.include?("gems/") } }, + freeze_results: false # gone log_exclusions: %i[context metadata], + backtrace_cleaner: ->(bt) { bt.reject { |l| l.include?("gems/") } }, tags: %i[critical] ``` diff --git a/lib/cmdx/configuration.rb b/lib/cmdx/configuration.rb index 98869230a..b5322339d 100644 --- a/lib/cmdx/configuration.rb +++ b/lib/cmdx/configuration.rb @@ -11,7 +11,7 @@ class Configuration attr_accessor :middlewares, :callbacks, :coercions, :validators, :executors, :mergers, :telemetry, :default_locale, :strict_context, - :backtrace_cleaner, :logger, :log_level, :log_formatter + :backtrace_cleaner, :log_exclusions, :log_formatter, :log_level, :logger def initialize @middlewares = Middlewares.new @@ -25,6 +25,7 @@ def initialize @default_locale = "en" @strict_context = false @backtrace_cleaner = nil + @log_exclusions = EMPTY_ARRAY @log_formatter = nil @log_level = nil diff --git a/lib/cmdx/runtime.rb b/lib/cmdx/runtime.rb index 635257f17..8cf56587b 100644 --- a/lib/cmdx/runtime.rb +++ b/lib/cmdx/runtime.rb @@ -120,7 +120,10 @@ def finalize_result ).tap do |result| @root ? Chain.current.unshift(result) : Chain.current.push(result) emit_telemetry(:task_executed, result:) - @task.logger.info { result } + @task.logger.info do + exclusions = @task.settings.log_exclusions + exclusions.empty? ? result : result.to_h.except(*exclusions) + end end end diff --git a/lib/cmdx/settings.rb b/lib/cmdx/settings.rb index c671cb4f5..e72cde4b8 100644 --- a/lib/cmdx/settings.rb +++ b/lib/cmdx/settings.rb @@ -11,6 +11,7 @@ class Settings # @option options [#call] :log_formatter # @option options [Integer] :log_level # @option options [#call] :backtrace_cleaner + # @option options [Array<Symbol>] :log_exclusions # @option options [Array<Symbol, String>] :tags # @option options [Boolean] :strict_context def initialize(options = EMPTY_HASH) @@ -56,6 +57,13 @@ def backtrace_cleaner end end + # @return [Array<Symbol>] keys to exclude from logging + def log_exclusions + @options.fetch(:log_exclusions) do + CMDx.configuration.log_exclusions + end + end + # Returns a fresh array each call so callers can mutate the result # without affecting other tasks (or hitting `FrozenError` on the # shared sentinel). diff --git a/lib/generators/cmdx/templates/install.rb b/lib/generators/cmdx/templates/install.rb index 8f1ff5561..d3ca81e86 100644 --- a/lib/generators/cmdx/templates/install.rb +++ b/lib/generators/cmdx/templates/install.rb @@ -26,10 +26,11 @@ # # Formatters: Line (default), Json, KeyValue, Logstash, Raw # - # config.logger = Logger.new($stdout, progname: "cmdx") - # config.log_level = Logger::INFO - # config.log_formatter = CMDx::LogFormatters::Line.new # config.backtrace_cleaner = ->(bt) { Rails.backtrace_cleaner.clean(bt) } + # config.log_exclusions = [:context] + # config.log_formatter = CMDx::LogFormatters::Line.new + # config.log_level = Logger::INFO + # config.logger = Logger.new($stdout, progname: "cmdx") # =========================================================================== # Middlewares From beb16be49ca284e220d562d72aa7fc07762eb5d8 Mon Sep 17 00:00:00 2001 From: Juan Gomez <drexed@users.noreply.github.com> Date: Wed, 22 Apr 2026 14:49:36 -0400 Subject: [PATCH 24/54] Update output.rb --- lib/cmdx/output.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/cmdx/output.rb b/lib/cmdx/output.rb index 3e0f7b520..0f9a8b142 100644 --- a/lib/cmdx/output.rb +++ b/lib/cmdx/output.rb @@ -179,10 +179,10 @@ def writeback(parent_value, value) parent_value[name] = value elsif parent_value.key?(name_str = name.to_s) parent_value[name_str] = value - else + else # rubocop:disable Lint/DuplicateBranch parent_value[name] = value end - elsif parent_value.respond_to?(setter = :"#{name}=") + elsif parent_value.respond_to?(setter = :"#{name}=") # rubocop:disable Lint/LiteralAssignmentInCondition parent_value.public_send(setter, value) end end From fd75c3e5da07450f3dc9c454b850cad5de8b5960 Mon Sep 17 00:00:00 2001 From: Juan Gomez <drexed@users.noreply.github.com> Date: Wed, 22 Apr 2026 14:54:18 -0400 Subject: [PATCH 25/54] Update runtime.rb --- lib/cmdx/runtime.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/cmdx/runtime.rb b/lib/cmdx/runtime.rb index 8cf56587b..19983fdf5 100644 --- a/lib/cmdx/runtime.rb +++ b/lib/cmdx/runtime.rb @@ -121,7 +121,7 @@ def finalize_result @root ? Chain.current.unshift(result) : Chain.current.push(result) emit_telemetry(:task_executed, result:) @task.logger.info do - exclusions = @task.settings.log_exclusions + exclusions = @task.class.settings.log_exclusions exclusions.empty? ? result : result.to_h.except(*exclusions) end end From 01f3286d0119f4092fa928a8f0f48b08b6267e8b Mon Sep 17 00:00:00 2001 From: Juan Gomez <drexed@users.noreply.github.com> Date: Wed, 22 Apr 2026 21:27:15 -0400 Subject: [PATCH 26/54] Update runtime.rb --- lib/cmdx/runtime.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/cmdx/runtime.rb b/lib/cmdx/runtime.rb index 19983fdf5..c30052df3 100644 --- a/lib/cmdx/runtime.rb +++ b/lib/cmdx/runtime.rb @@ -138,10 +138,10 @@ def run_teardown end def measure_duration - start = Process.clock_gettime(Process::CLOCK_MONOTONIC, :float_millisecond) + start = Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond) yield ensure - @duration = Process.clock_gettime(Process::CLOCK_MONOTONIC, :float_millisecond) - start + @duration = Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond) - start end def run_callbacks(event) From 8c02f6a5ec1e15f2e835cfdf377c1bb91e868950 Mon Sep 17 00:00:00 2001 From: Juan Gomez <drexed@users.noreply.github.com> Date: Wed, 22 Apr 2026 21:27:46 -0400 Subject: [PATCH 27/54] Update runtime.rb --- lib/cmdx/runtime.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/cmdx/runtime.rb b/lib/cmdx/runtime.rb index c30052df3..19983fdf5 100644 --- a/lib/cmdx/runtime.rb +++ b/lib/cmdx/runtime.rb @@ -138,10 +138,10 @@ def run_teardown end def measure_duration - start = Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond) + start = Process.clock_gettime(Process::CLOCK_MONOTONIC, :float_millisecond) yield ensure - @duration = Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond) - start + @duration = Process.clock_gettime(Process::CLOCK_MONOTONIC, :float_millisecond) - start end def run_callbacks(event) From 15206acfb24dca9fcdc15c041ac353488a7e94f8 Mon Sep 17 00:00:00 2001 From: Juan Gomez <drexed@users.noreply.github.com> Date: Wed, 22 Apr 2026 23:56:26 -0400 Subject: [PATCH 28/54] v2 --- CHANGELOG.md | 1 + docs/basics/chain.md | 20 ++++++- docs/configuration.md | 25 +++++++- docs/logging.md | 3 +- docs/outcomes/result.md | 6 +- docs/v2-migration.md | 2 +- lib/cmdx/chain.rb | 8 ++- lib/cmdx/configuration.rb | 5 +- lib/cmdx/result.rb | 6 ++ lib/cmdx/runtime.rb | 6 +- lib/cmdx/settings.rb | 21 ++++--- lib/cmdx/telemetry.rb | 2 +- lib/generators/cmdx/templates/install.rb | 14 ++++- spec/cmdx/chain_spec.rb | 8 +++ spec/cmdx/configuration_spec.rb | 6 +- spec/cmdx/result_spec.rb | 13 ++++ spec/cmdx/runtime_spec.rb | 75 ++++++++++++++++++++++++ spec/cmdx/telemetry_spec.rb | 11 ++++ 18 files changed, 209 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1bfbf8f78..e087b7b56 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), Full runtime rewrite: the v1 state-machine plus Zeitwerk architecture is replaced by an explicit signal-based runtime, immutable results, fiber-local chains, and a slimmer registry surface. See [docs/v2-migration.md](docs/v2-migration.md) for the full upgrade guide. ### Added +- Add `xid` correlation id on `Chain`, `Result`, and `Telemetry::Event`, sourced once per root execution from `CMDx.configuration.correlation_id` (a callable). Useful for threading external ids like Rails `request_id` through every task in a chain so they can be filtered together in logs/telemetry. - Add `Context#as_json` / `Context#to_json` — JSON serialization delegating to `#to_h` - Add `Errors#as_json` / `Errors#to_json` — JSON serialization delegating to `#to_h` - Add `Result#as_json` / `Result#to_json` — JSON serialization delegating to the memoized `#to_h` diff --git a/docs/basics/chain.md b/docs/basics/chain.md index cc94be77e..e5dc0110e 100644 --- a/docs/basics/chain.md +++ b/docs/basics/chain.md @@ -12,6 +12,7 @@ From a result, reach the chain via: |--------|---------| | `result.chain` | The owning `CMDx::Chain` (Enumerable; `id`, `size`, `first`, `last`, etc.) | | `result.cid` | The chain's UUID v7 (`String`) | +| `result.xid` | External correlation id (`String`, `nil` when no resolver is configured) | | `result.index` | This result's zero-based position in the chain (`Integer`, `nil` if absent) | | `result.root?` | `true` when this result is the chain's root | | `CMDx::Chain.current` | The live `Chain` object (only inside execution) | @@ -31,7 +32,7 @@ result.chain.each_with_index do |r, idx| end ``` -The `Chain` instance exposes `id`, `results`, `push` (aliased `<<`), `unshift`, `index`, `size`, `empty?`, `each`, `last`, plus root delegators: +The `Chain` instance exposes `id`, `xid`, `results`, `push` (aliased `<<`), `unshift`, `index`, `size`, `empty?`, `each`, `last`, plus root delegators: | Method | Returns | |--------|---------| @@ -72,6 +73,23 @@ result.chain.map(&:task) Chain lifecycle is automatic: Runtime installs a fresh chain when the top-level task starts and clears it on teardown. +## Correlation ID (xid) + +`Chain#xid` is an optional external correlation id (e.g. Rails `request_id`) — distinct from `cid` (the per-execution chain UUID). It's resolved **once** per root chain creation from `CMDx.configuration.correlation_id` (a callable; per-task override via `settings(correlation_id: ->{ ... })`). Every nested subtask inherits the same `xid` via the shared chain, so a single request id ties together every task it touches in logs and telemetry. + +```ruby +CMDx.configure do |config| + config.correlation_id = -> { Current.request_id } # e.g. Rails ActionDispatch::RequestId +end + +result = ImportDataset.execute(dataset_id: 456) +result.xid #=> "abc-123-..." +result.chain.xid #=> "abc-123-..." +result.chain.map(&:xid).uniq #=> ["abc-123-..."] +``` + +See [Configuration - Correlation ID](../configuration.md#correlation-id-xid) for resolver semantics. + ## Fiber Storage The active chain lives on `Fiber[]` (fiber-local), so each `Thread`'s root fiber and every explicit `Fiber.new` sees its own chain. `Workflow` parallel groups intentionally propagate the parent chain into their worker threads so their results roll up under the same trace; the chain's internal mutex makes concurrent pushes safe. diff --git a/docs/configuration.md b/docs/configuration.md index 9b822d16d..50fae0cdf 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -26,6 +26,7 @@ CMDx uses a two-tier configuration system: | `default_locale` | `"en"` | Locale for built-in translation fallbacks | | `backtrace_cleaner` | `nil` | Callable to clean fault backtraces | | `strict_context` | `false` | Raise `NoMethodError` on unknown `context.foo` reads | +| `correlation_id` | `nil` | Callable resolved once per root execution; surfaced as `xid` on `Chain`/`Result`/`Telemetry::Event` | | `middlewares` | `Middlewares.new` (empty) | Middleware registry | | `callbacks` | `Callbacks.new` (empty) | Callback registry | | `coercions` | `Coercions.new` (13 built-ins) | Coercion registry | @@ -77,6 +78,26 @@ end Override per-task via `settings(strict_context: true)`. +### Correlation ID (xid) + +Thread an external correlation id (e.g. Rails `request_id`) through every task in a chain so they can be filtered together in logs and telemetry. Provide a callable; Runtime invokes it **once per root execution** when it builds the chain. The resolved value is stored on the `Chain` and surfaced as `xid` on every `Result#to_h` and `Telemetry::Event`. + +```ruby +CMDx.configure do |config| + config.correlation_id = -> { Current.request_id } +end + +result = ProcessOrder.execute(order_id: 42) +result.xid #=> "abc-123-..." +result.chain.map(&:xid).uniq #=> ["abc-123-..."] (every task in the chain shares it) +``` + +Override per-task via `settings(correlation_id: ->{ ... })` to give a specific task family its own resolver. Returning `nil` from the callable leaves `xid` as `nil`. Resolver exceptions propagate (so misconfigured resolvers fail loudly). + +!!! note + + The resolver fires only on the **root** chain creation. Nested subtasks inherit the same xid via the shared chain — even if the underlying thread-local mutates mid-flight, every result in the same execution sees a stable value. + ### Logging ```ruby @@ -174,7 +195,7 @@ See [Callbacks](callbacks.md) for class-level usage. ### Telemetry -Pub/sub for runtime lifecycle events. Subscribers receive a `Telemetry::Event` data object with `cid`, `root`, `type`, `task`, `tid`, `name`, `payload`, and `timestamp`. +Pub/sub for runtime lifecycle events. Subscribers receive a `Telemetry::Event` data object with `cid`, `xid`, `root`, `type`, `task`, `tid`, `name`, `payload`, and `timestamp`. | Event | Payload | |-------|---------| @@ -324,7 +345,7 @@ end !!! note - `Settings` only stores `:logger`, `:log_formatter`, `:log_level`, `:log_exclusions`, `:backtrace_cleaner`, `:tags`, and `:strict_context`. Other class-level config uses dedicated DSL (`retry_on`, `deprecation`, `register`, `before_execution`, …). + `Settings` only stores `:logger`, `:log_formatter`, `:log_level`, `:log_exclusions`, `:backtrace_cleaner`, `:tags`, `:strict_context`, and `:correlation_id`. Other class-level config uses dedicated DSL (`retry_on`, `deprecation`, `register`, `before_execution`, …). ### Retry diff --git a/docs/logging.md b/docs/logging.md index 169b4f1a0..40e1bcaaa 100644 --- a/docs/logging.md +++ b/docs/logging.md @@ -56,7 +56,7 @@ I, [2026-04-19T17:04:15.875306Z #20173] INFO -- cmdx: cid="019b4c2b-2a02-..." in !!! tip - Pair `cid` with your APM's correlation field for distributed tracing. A rescued `StandardError` (not a `fail!` call) sets `cause=#<TheError: …>` and rewrites `reason` to `"[TheError] message"`. + Pair `cid` with your APM's correlation field for distributed tracing. To carry an external request id end-to-end, configure a `correlation_id` resolver and filter logs on `xid` — see [Configuration - Correlation ID](configuration.md#correlation-id-xid). A rescued `StandardError` (not a `fail!` call) sets `cause=#<TheError: …>` and rewrites `reason` to `"[TheError] message"`. ## Structure @@ -78,6 +78,7 @@ Every log entry is built from `Result#to_h`. Available fields: | Field | Description | Example | |-------|-------------|---------| | `cid` | Chain UUID (uuid_v7) | `"018c2b95-b764-7615-..."` | +| `xid` | External correlation id (e.g. Rails `request_id`); `nil` unless `config.correlation_id` is set | `"req-abc-123"` | | `index` | Position in chain (root is 0) | `0`, `1`, `2` | | `root` | `true` for the root task's result | `true`, `false` | | `type` | `"Task"` or `"Workflow"` | `"Task"` | diff --git a/docs/outcomes/result.md b/docs/outcomes/result.md index c33affe20..56d9d19f9 100644 --- a/docs/outcomes/result.md +++ b/docs/outcomes/result.md @@ -22,6 +22,7 @@ result.errors #=> #<CMDx::Errors ...> (frozen on teardown) # Chain placement result.chain #=> #<CMDx::Chain ...> result.cid #=> "0190..." +result.xid #=> "abc-123-..." or nil (external correlation id, see Configuration) result.index #=> 0 (root is always 0; children are 1+ in completion order) result.root? #=> true when this result is the root of its chain @@ -175,7 +176,7 @@ end ### Hash Pattern -`deconstruct_keys(keys)` delegates to `#to_h` — `nil` returns the full hash, a key list slices it (unknown keys are omitted). Keys always present: `:cid, :index, :root, :type, :task, :tid, :context, :state, :status, :reason, :metadata, :strict, :deprecated, :retried, :retries, :duration, :tags`. Failure-only keys (`:cause`, `:origin`, `:threw_failure`, `:caused_failure`, `:rolled_back`) appear only on `failed?` results. +`deconstruct_keys(keys)` delegates to `#to_h` — `nil` returns the full hash, a key list slices it (unknown keys are omitted). Keys always present: `:xid, :cid, :index, :root, :type, :task, :tid, :context, :state, :status, :reason, :metadata, :strict, :deprecated, :retried, :retries, :duration, :tags`. Failure-only keys (`:cause`, `:origin`, `:threw_failure`, `:caused_failure`, `:rolled_back`) appear only on `failed?` results. ```ruby result = BuildApplication.execute(version: "1.2.3") @@ -212,6 +213,7 @@ end ```ruby result.to_h #=> { +# xid: "abc-123-..." or nil, # cid: "0190...", index: 0, root: true, # type: "Task", task: BuildApplication, tid: "0190...", # context: #<CMDx::Context ...>, @@ -223,7 +225,7 @@ result.to_h # } result.to_s -#=> "cid=\"0190...\" index=0 ... state=\"complete\" status=\"success\" ..." +#=> "xid=nil cid=\"0190...\" index=0 ... state=\"complete\" status=\"success\" ..." ``` On `failed?` results, `to_h` additionally includes `:cause`, `:origin`, `:threw_failure`, `:caused_failure`, and `:rolled_back`. The `_failure` and `:origin` entries are compact `{ task:, tid: }` hashes (and render as `<TaskClass uuid>` in `to_s`) to avoid serializing entire upstream results. `:origin` is `nil` when the failure is locally originated. diff --git a/docs/v2-migration.md b/docs/v2-migration.md index 3e45fe2da..3dc127de1 100644 --- a/docs/v2-migration.md +++ b/docs/v2-migration.md @@ -305,7 +305,7 @@ The registry no longer auto-instantiates classes or forwards `**options`. Pass a | Removed | Replacement | |---|---| -| `CMDx::Middlewares::Correlate` | 5-line proc that sets `context.correlation_id`, or a `:task_started` Telemetry subscriber | +| `CMDx::Middlewares::Correlate` | Built-in: configure `CMDx.configuration.correlation_id = -> { ... }` to surface `xid` on `Chain`/`Result`/`Telemetry::Event` (see [Configuration - Correlation ID](configuration.md#correlation-id-xid)) | | `CMDx::Middlewares::Runtime` | `result.duration` is built in; `:task_executed` Telemetry for richer payloads | | `CMDx::Middlewares::Timeout` | wrap `yield` in your own `Timeout.timeout(n)` middleware | diff --git a/lib/cmdx/chain.rb b/lib/cmdx/chain.rb index 9b972866e..488db0614 100644 --- a/lib/cmdx/chain.rb +++ b/lib/cmdx/chain.rb @@ -34,9 +34,13 @@ def clear end - attr_reader :id, :results + attr_reader :xid, :id, :results - def initialize + # @param xid [String, nil] external correlation id (e.g. Rails `request_id`) + # shared across every {Result} in this chain. Resolved once by Runtime + # from {Configuration#xid} when the root chain is created. + def initialize(xid = nil) + @xid = xid @id = SecureRandom.uuid_v7 @mutex = Mutex.new @results = [] diff --git a/lib/cmdx/configuration.rb b/lib/cmdx/configuration.rb index b5322339d..a7830d966 100644 --- a/lib/cmdx/configuration.rb +++ b/lib/cmdx/configuration.rb @@ -9,8 +9,8 @@ module CMDx # cached their copy yet. class Configuration - attr_accessor :middlewares, :callbacks, :coercions, :validators, - :executors, :mergers, :telemetry, :default_locale, :strict_context, + attr_accessor :middlewares, :callbacks, :coercions, :validators, :executors, + :mergers, :telemetry, :correlation_id, :default_locale, :strict_context, :backtrace_cleaner, :log_exclusions, :log_formatter, :log_level, :logger def initialize @@ -22,6 +22,7 @@ def initialize @mergers = Mergers.new @telemetry = Telemetry.new + @correlation_id = nil @default_locale = "en" @strict_context = false @backtrace_cleaner = nil diff --git a/lib/cmdx/result.rb b/lib/cmdx/result.rb index 6530c3f7f..44c36e431 100644 --- a/lib/cmdx/result.rb +++ b/lib/cmdx/result.rb @@ -52,6 +52,11 @@ def type task.type end + # @return [String, nil] correlation id or the global configuration's correlation id + def xid + chain.xid + end + # @return [String] uuid_v7 identifier for the chain this result belongs to def cid chain.id @@ -255,6 +260,7 @@ def tags # on failure. def to_h @to_h ||= { + xid:, cid:, index:, root: root?, diff --git a/lib/cmdx/runtime.rb b/lib/cmdx/runtime.rb index 19983fdf5..9dd035c83 100644 --- a/lib/cmdx/runtime.rb +++ b/lib/cmdx/runtime.rb @@ -64,7 +64,10 @@ def execute def acquire_chain @root = Chain.current.nil? - Chain.current = Chain.new if @root + return unless @root + + xid = @task.class.settings.correlation_id&.call + Chain.current = Chain.new(xid) end def run_middlewares(&) @@ -211,6 +214,7 @@ def emit_telemetry(name, payload = EMPTY_HASH) return unless telemetry.subscribed?(name) event = Telemetry::Event.new( + xid: Chain.current.xid, cid: Chain.current.id, root: @root, type: @task.class.type, diff --git a/lib/cmdx/settings.rb b/lib/cmdx/settings.rb index e72cde4b8..63337f1c4 100644 --- a/lib/cmdx/settings.rb +++ b/lib/cmdx/settings.rb @@ -50,13 +50,6 @@ def log_level end end - # @return [#call, nil] callable that cleans fault backtrace frames - def backtrace_cleaner - @options.fetch(:backtrace_cleaner) do - CMDx.configuration.backtrace_cleaner - end - end - # @return [Array<Symbol>] keys to exclude from logging def log_exclusions @options.fetch(:log_exclusions) do @@ -64,6 +57,13 @@ def log_exclusions end end + # @return [#call, nil] callable that cleans fault backtrace frames + def backtrace_cleaner + @options.fetch(:backtrace_cleaner) do + CMDx.configuration.backtrace_cleaner + end + end + # Returns a fresh array each call so callers can mutate the result # without affecting other tasks (or hitting `FrozenError` on the # shared sentinel). @@ -83,5 +83,12 @@ def strict_context end end + # @return [String, nil] correlation id or the global configuration's correlation id + def correlation_id + @options.fetch(:correlation_id) do + CMDx.configuration.correlation_id + end + end + end end diff --git a/lib/cmdx/telemetry.rb b/lib/cmdx/telemetry.rb index 6fe0b4c1d..541e2555b 100644 --- a/lib/cmdx/telemetry.rb +++ b/lib/cmdx/telemetry.rb @@ -7,7 +7,7 @@ module CMDx class Telemetry # Immutable event payload passed to subscribers. - Event = Data.define(:cid, :root, :type, :task, :tid, :name, :payload, :timestamp) + Event = Data.define(:xid, :cid, :root, :type, :task, :tid, :name, :payload, :timestamp) # Lifecycle event names Runtime emits. EVENTS = %i[ diff --git a/lib/generators/cmdx/templates/install.rb b/lib/generators/cmdx/templates/install.rb index d3ca81e86..0652ca15f 100644 --- a/lib/generators/cmdx/templates/install.rb +++ b/lib/generators/cmdx/templates/install.rb @@ -18,6 +18,16 @@ # # config.strict_context = true + # =========================================================================== + # Correlation ID (xid) + # =========================================================================== + # Resolves an external correlation id (e.g. Rails `request_id`) once per + # root execution. The value is stored on the Chain and surfaces on every + # Result (`result.xid`, `result.to_h[:xid]`) and Telemetry::Event (`event.xid`), + # so all tasks within the same request can be filtered together in logs. + # + # config.correlation_id = -> { Current.request_id } + # =========================================================================== # Logging # =========================================================================== @@ -70,8 +80,8 @@ # :task_rolled_back payload: {} # :task_executed payload: { result: CMDx::Result } # - # Every event also carries: event.cid, event.tid, event.task, event.type, - # event.root, event.timestamp. + # Every event also carries: event.cid, event.xid, event.tid, event.task, + # event.type, event.root, event.timestamp. # # config.telemetry.subscribe(:task_executed, proc do |event| # StatsD.timing("cmdx.task", event.payload[:result].duration) diff --git a/spec/cmdx/chain_spec.rb b/spec/cmdx/chain_spec.rb index 311cfe72d..0f2911a01 100644 --- a/spec/cmdx/chain_spec.rb +++ b/spec/cmdx/chain_spec.rb @@ -63,6 +63,14 @@ def build_result(signal = CMDx::Signal.success, **opts) it "generates a unique id per instance" do expect(chain.id).not_to eq(described_class.new.id) end + + it "defaults xid to nil" do + expect(chain.xid).to be_nil + end + + it "stores an explicit xid" do + expect(described_class.new("req-123").xid).to eq("req-123") + end end describe "#push" do diff --git a/spec/cmdx/configuration_spec.rb b/spec/cmdx/configuration_spec.rb index 922603eed..d9552a7fb 100644 --- a/spec/cmdx/configuration_spec.rb +++ b/spec/cmdx/configuration_spec.rb @@ -21,7 +21,8 @@ backtrace_cleaner: nil, strict_context: false, log_level: nil, - log_formatter: nil + log_formatter: nil, + correlation_id: nil ) expect(config.logger).to be_a(Logger) expect(config.logger.level).to eq(Logger::INFO) @@ -39,16 +40,19 @@ it "allows reading and writing each configuration attribute" do logger = Logger.new(nil) cleaner = ->(bt) { bt } + correlation_id = -> { "req-1" } config.logger = logger config.backtrace_cleaner = cleaner config.default_locale = "fr" config.strict_context = true + config.correlation_id = correlation_id expect(config.logger).to be(logger) expect(config.backtrace_cleaner).to be(cleaner) expect(config.default_locale).to eq("fr") expect(config.strict_context).to be(true) + expect(config.correlation_id).to be(correlation_id) end end end diff --git a/spec/cmdx/result_spec.rb b/spec/cmdx/result_spec.rb index 271856d89..1dc51f5a1 100644 --- a/spec/cmdx/result_spec.rb +++ b/spec/cmdx/result_spec.rb @@ -179,6 +179,18 @@ def build(signal, **opts) end end + describe "#xid" do + it "delegates to the chain's xid" do + chain = CMDx::Chain.new("req-9") + result = described_class.new(chain, task, CMDx::Signal.success) + expect(result.xid).to eq("req-9") + end + + it "is nil when the chain has no xid" do + expect(build(CMDx::Signal.success).xid).to be_nil + end + end + describe "#to_h" do it "includes core fields for a success result" do chain << (result = build(CMDx::Signal.success, tid: "rid", duration: 0.1)) @@ -186,6 +198,7 @@ def build(signal, **opts) expect(hash).to include( cid: chain.id, + xid: chain.xid, index: 0, root: false, type: "Task", diff --git a/spec/cmdx/runtime_spec.rb b/spec/cmdx/runtime_spec.rb index 40d0d2b1e..1fabd4be0 100644 --- a/spec/cmdx/runtime_spec.rb +++ b/spec/cmdx/runtime_spec.rb @@ -107,6 +107,81 @@ described_class.execute(task_class.new) expect(events).to eq(%i[task_started task_executed]) end + + it "passes the chain xid through every event" do + CMDx.configuration.correlation_id = -> { "req-xyz" } + events = [] + task_class = create_successful_task + task_class.telemetry.subscribe(:task_started) { |e| events << e } + task_class.telemetry.subscribe(:task_executed) { |e| events << e } + + described_class.execute(task_class.new) + expect(events.map(&:xid)).to eq(%w[req-xyz req-xyz]) + end + end + + describe "xid (correlation id)" do + it "leaves xid nil when no resolver is configured" do + result = described_class.execute(create_successful_task.new) + expect(result.xid).to be_nil + end + + it "resolves xid from the configured callable on root chain creation" do + CMDx.configuration.correlation_id = -> { "req-abc" } + result = described_class.execute(create_successful_task.new) + expect(result.xid).to eq("req-abc") + end + + it "invokes the resolver exactly once per root execution" do + calls = 0 + CMDx.configuration.correlation_id = lambda do + calls += 1 + "req-#{calls}" + end + + inner = create_task_class(name: "InnerXidTask") do + define_method(:work) { context.inner_xid = CMDx::Chain.current.xid } + end + outer = create_task_class(name: "OuterXidTask") do + define_method(:work) do + inner.execute(context) + context.outer_xid = CMDx::Chain.current.xid + end + end + + result = described_class.execute(outer.new) + expect(calls).to eq(1) + expect(result.xid).to eq("req-1") + expect(result.context.inner_xid).to eq("req-1") + expect(result.context.outer_xid).to eq("req-1") + end + + it "shares the xid across every result in the chain" do + CMDx.configuration.correlation_id = -> { "shared" } + + inner = create_task_class(name: "InnerSharedXidTask") do + define_method(:work) { nil } + end + outer = create_task_class(name: "OuterSharedXidTask") do + define_method(:work) { inner.execute(context) } + end + + result = described_class.execute(outer.new) + expect(result.chain.map(&:xid).uniq).to eq(["shared"]) + end + + it "lets the resolver return nil" do + CMDx.configuration.correlation_id = -> {} + result = described_class.execute(create_successful_task.new) + expect(result.xid).to be_nil + end + + it "propagates exceptions raised by the resolver and leaves no chain behind" do + CMDx.configuration.correlation_id = -> { raise "bad resolver" } + expect { described_class.execute(create_successful_task.new) } + .to raise_error(RuntimeError, "bad resolver") + expect(CMDx::Chain.current).to be_nil + end end describe "rollback" do diff --git a/spec/cmdx/telemetry_spec.rb b/spec/cmdx/telemetry_spec.rb index 54b00e59d..fcb118534 100644 --- a/spec/cmdx/telemetry_spec.rb +++ b/spec/cmdx/telemetry_spec.rb @@ -12,6 +12,17 @@ end end + describe "Event" do + it "carries cid, xid, root, type, task, tid, name, payload, timestamp" do + event = described_class::Event.new( + cid: "cid", xid: "req-1", root: true, type: "Task", task: Object, + tid: "tid", name: :task_started, payload: {}, timestamp: Time.now.utc + ) + + expect(event).to have_attributes(cid: "cid", xid: "req-1", root: true, tid: "tid") + end + end + describe "#initialize_copy" do it "deep-dups each event's subscriber list" do sub = ->(_p) {} From e687ca667978e329703414784d579a7d05708e67 Mon Sep 17 00:00:00 2001 From: Juan Gomez <drexed@users.noreply.github.com> Date: Thu, 23 Apr 2026 00:14:00 -0400 Subject: [PATCH 29/54] Minimal output --- CHANGELOG.md | 3 +- docs/basics/setup.md | 2 +- .../blog/posts/cmdx-v2-the-runtime-rewrite.md | 14 +- .../posts/real-world-cmdx-background-jobs.md | 2 +- .../posts/real-world-cmdx-external-apis.md | 6 +- .../real-world-cmdx-multi-tenant-saas.md | 10 +- .../posts/real-world-cmdx-user-onboarding.md | 8 +- docs/configuration.md | 2 +- docs/index.md | 2 +- docs/outputs.md | 109 +----- docs/tips_and_tricks.md | 2 +- docs/v2-migration.md | 27 +- lib/cmdx/output.rb | 165 +-------- lib/cmdx/outputs.rb | 64 +--- lib/cmdx/task.rb | 5 +- skills/SKILL.md | 10 +- skills/references/outputs.md | 48 +-- spec/cmdx/output_spec.rb | 313 ++++-------------- spec/cmdx/outputs_spec.rb | 54 +-- spec/cmdx/task_spec.rb | 4 +- spec/integration/tasks/output_spec.rb | 98 +++--- 21 files changed, 210 insertions(+), 738 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e087b7b56..f9bd9bbdb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,7 +21,7 @@ Full runtime rewrite: the v1 state-machine plus Zeitwerk architecture is replace - 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` - Add `CMDx::Deprecation` for declarative class-level deprecation (`:log`, `:warn`, `:error`, Symbol, Proc, callable) with `:if` / `:unless` gating - Add `CMDx::Input` / `CMDx::Inputs` (replaces `Attribute` / `AttributeRegistry` / `AttributeValue`) supporting `:source`, `:default`, `:transform`, `:as`, `:prefix` / `:suffix`, and nested children via DSL block -- Add `CMDx::Output` / `CMDx::Outputs` for first-class declared outputs verified against `task.context` after `work` (required-presence, default application, coercion, transformation, validation, write-back of final value); `:default` and `:transform` mirror input semantics — defaults fire for nil/absent values and can satisfy `:required`, transforms run between coerce and validate +- 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 - Add `CMDx::Util` single conditional-evaluation module (`evaluate`, `if?`, `unless?`, `satisfied?`) consolidating the v1 `Utils::*` modules - Add `CMDx::I18nProxy` translation façade that delegates to `I18n` when available, otherwise loads the bundled YAML and percent-interpolates with memoization - Add `CMDx::LoggerProxy` returning a per-task logger, `dup`-ing the base only when the task overrides `log_level` or `log_formatter` @@ -48,7 +48,6 @@ Full runtime rewrite: the v1 state-machine plus Zeitwerk architecture is replace - Add `:if` / `:unless` gates to `Callbacks#register` (Symbol, Proc, or any `#call`-able); per-event DSL helpers (`before_execution`, `on_success`, etc.) forward the options through - Add `:if` / `:unless` gates to `Middlewares#register` (Symbol, Proc, or any `#call`-able); evaluated per task in `Middlewares#process` — skipped middlewares are bypassed and the chain continues - Add `:if` / `:unless` gates to `Retry` / `Task.retry_on`; gate receives `(task, error, attempt)` and, when falsy, re-raises the exception instead of retrying (no further wait). Adds `Retry#condition_if` / `Retry#condition_unless` readers -- Add `Outputs#register` block DSL (`Outputs::ChildBuilder`) for nested outputs via `required` / `optional` / `output` / `outputs`, arbitrarily deep; `Output#children`, `Output#verify_from_parent`, and `:children` in `Output#to_h` / `Task.outputs_schema`. `Task.outputs` forwards the block - Add `Context#deconstruct` / `Context#deconstruct_keys` for pattern matching - Add `Errors#deconstruct` / `Errors#deconstruct_keys` for pattern matching - Add `:executor` option to parallel task groups (`Workflow.tasks ..., strategy: :parallel, executor: :threads | :fibers | #call`); `:threads` is the default and preserves current behavior, `:fibers` dispatches via `Fiber.schedule` bounded by `:pool_size` (requires a `Fiber.scheduler` such as the `async` gem's — raises `RuntimeError` when none is installed), and a user-supplied callable matching `call(jobs:, concurrency:, on_job:)` is accepted. Unknown symbols raise `ArgumentError` diff --git a/docs/basics/setup.md b/docs/basics/setup.md index 1882e1889..cb756af37 100644 --- a/docs/basics/setup.md +++ b/docs/basics/setup.md @@ -82,7 +82,7 @@ Tasks follow a predictable execution pattern. Halt primitives — `success!`, `s | **Before validation** | `before_validation` callbacks run next | | **Validation** | Inputs are coerced/validated; failures halt with `failed` | | **Work** | `work` runs inside `catch(:cmdx_signal)`, wrapped in retries | -| **Output verification** | Declared `output` keys are coerced/validated | +| **Output verification** | Declared `output` keys are checked on `context` (every declared output is implicitly required); `:default` fills nils, missing keys fail the task | | **Rollback** | `rollback` runs when the signal is `failed` (before completion callbacks) | | **Completion callbacks** | `on_<state>`, `on_<status>`, `on_ok`/`on_ko` fire in that order | | **Result finalization** | `Result` built and added to `Chain` (root is `unshift`ed; children are `push`ed) | diff --git a/docs/blog/posts/cmdx-v2-the-runtime-rewrite.md b/docs/blog/posts/cmdx-v2-the-runtime-rewrite.md index 91b358b45..51c96fdfe 100644 --- a/docs/blog/posts/cmdx-v2-the-runtime-rewrite.md +++ b/docs/blog/posts/cmdx-v2-the-runtime-rewrite.md @@ -88,7 +88,7 @@ A condensed cheat sheet. The [full migration guide](https://drexed.github.io/cmd |---|---|---| | Entry point | `MyTask.call` / `def call` | `MyTask.execute` / `def work` (old names aliased) | | Inputs | `attribute :email, type: :string` | `input :email, coerce: :string` (`required` / `optional` unchanged) | -| Outputs | `returns :user` (presence only) | `output :user, required: true, coerce: :..., validate: ...` | +| Outputs | `returns :user` (presence only) | `output :user, default: ..., if: ...` (implicit required + optional defaults/guards) | | Middleware | `def call(task, options, &block)` | `def call(task); yield; end` (one arg, must `yield`) | | Built-in middlewares | `Correlate`, `Runtime`, `Timeout` auto-registered | removed — subscribe to Telemetry or register your own | | Callbacks | `on_good`, `on_bad`, `on_executed` | `on_ok`, `on_ko` (`on_executed` removed) | @@ -165,18 +165,18 @@ end `Result` implements `deconstruct` and `deconstruct_keys`, so controllers and job handlers can dispatch on outcome without brittle `if result.success? && result.metadata[:code] == ...` ladders. -### The full output pipeline +### Output verification ```ruby # v1 — presence check only returns :user, :token -# v2 — required + coerce + validate + default + transform -output :user, required: true -output :token, required: true, coerce: :string, length: { min: 32 } +# v2 — implicit required + optional default / :if / :unless guards +output :user +output :token, default: -> { JwtService.encode(user_id: context.user.id) } ``` -`output` reuses the input pipeline. Missing required outputs record `outputs.missing` on `task.errors` and become a failed signal automatically. +Every declared output is implicitly required. Outputs verify each declared key on `task.context` after `work` succeeds: `:default` fills in `nil` values (and satisfies the check), and a missing key without a default records `outputs.missing` on `task.errors` and becomes a failed signal automatically. For coercion, transformation, or validation, use [Inputs](https://drexed.github.io/cmdx/inputs/definitions/) or post-`work` code. ## Performance @@ -196,7 +196,7 @@ gem "cmdx", "~> 2.0" Then, across your task classes: - Rename `attribute :x, type: :string` → `input :x, coerce: :string` -- Rename `returns :user` → `output :user, required: true` +- Rename `returns :user` → `output :user` (implicit required) - Update middlewares from `call(task, options, &block)` to `call(task) { yield }`, and register instances (`register :middleware, Foo.new`) instead of classes - Replace `result.chain_id` with `result.cid`, `result.good?` with `result.ok?`, `result.bad?` with `result.ko?` - Drop `task_breakpoints` / `workflow_breakpoints` settings — use `execute!` where you want strict mode diff --git a/docs/blog/posts/real-world-cmdx-background-jobs.md b/docs/blog/posts/real-world-cmdx-background-jobs.md index 99d5cc497..b64ed9d6b 100644 --- a/docs/blog/posts/real-world-cmdx-background-jobs.md +++ b/docs/blog/posts/real-world-cmdx-background-jobs.md @@ -81,7 +81,7 @@ class Reports::GenerateMonthly < ApplicationTask required :month, coerce: :integer, numeric: { within: 1..12 } required :year, coerce: :integer, numeric: { min: 2020 } - output :report, required: true + output :report def work account = Account.find(account_id) diff --git a/docs/blog/posts/real-world-cmdx-external-apis.md b/docs/blog/posts/real-world-cmdx-external-apis.md index 2c21280f4..e18c0139e 100644 --- a/docs/blog/posts/real-world-cmdx-external-apis.md +++ b/docs/blog/posts/real-world-cmdx-external-apis.md @@ -152,7 +152,7 @@ With infrastructure handled, the tasks themselves are pure business logic. class Stripe::CreateCustomer < Stripe::BaseTask required :user - output :stripe_customer, required: true + output :stripe_customer def work if user.stripe_customer_id.present? @@ -190,7 +190,7 @@ class Stripe::ChargeCard < Stripe::BaseTask optional :description optional :idempotency_key, default: -> { SecureRandom.uuid } - output :charge, required: true + output :charge def work context.charge = ::Stripe::Charge.create( @@ -232,7 +232,7 @@ class Payments::Record < ApplicationTask required :amount_cents, coerce: :integer required :currency - output :payment, required: true + output :payment def work context.payment = Payment.create!( diff --git a/docs/blog/posts/real-world-cmdx-multi-tenant-saas.md b/docs/blog/posts/real-world-cmdx-multi-tenant-saas.md index 5f1fcaaff..286b841e5 100644 --- a/docs/blog/posts/real-world-cmdx-multi-tenant-saas.md +++ b/docs/blog/posts/real-world-cmdx-multi-tenant-saas.md @@ -138,7 +138,7 @@ For tasks that behave differently per tenant: class Reports::Generate < TenantTask required :report_type, inclusion: { in: %w[summary detailed] } - output :report, required: true + output :report def work context.report = case report_type @@ -189,7 +189,7 @@ class Tenants::Create < ApplicationTask required :plan, inclusion: { in: %w[starter growth enterprise] } required :owner_email, format: { with: URI::MailTo::EMAIL_REGEXP } - output :tenant, required: true + output :tenant def work fail!("Slug already taken", code: :slug_taken) if Tenant.exists?(slug: slug) @@ -208,7 +208,7 @@ end ```ruby class Tenants::SeedDefaultData < TenantTask - output :seed_summary, required: true + output :seed_summary def work roles = Role.insert_all([ @@ -247,7 +247,7 @@ end class Tenants::CreateAdminUser < TenantTask required :owner_email - output :admin_user, required: true + output :admin_user def work context.admin_user = User.create!( @@ -276,7 +276,7 @@ class Admin::GenerateUsageReport < Admin::BaseTask settings(tags: ["admin", "billing"]) - output :report, required: true + output :report def work context.report = Tenant.active.map do |tenant| diff --git a/docs/blog/posts/real-world-cmdx-user-onboarding.md b/docs/blog/posts/real-world-cmdx-user-onboarding.md index 246fec25f..91e15c370 100644 --- a/docs/blog/posts/real-world-cmdx-user-onboarding.md +++ b/docs/blog/posts/real-world-cmdx-user-onboarding.md @@ -46,7 +46,7 @@ class Users::Register < CMDx::Task optional :referral_code optional :invite_token - output :user, required: true + output :user def work fail!("Email already taken", code: :duplicate) if User.exists?(email: email) @@ -61,7 +61,7 @@ class Users::Register < CMDx::Task end ``` -Three layers of defense here: input validations catch malformed inputs, the `fail!` catches business rule violations, and `output :user, required: true` guarantees downstream tasks always have the user available — Runtime verifies it's present after `work` returns and fails the task otherwise. +Three layers of defense here: input validations catch malformed inputs, the `fail!` catches business rule violations, and `output :user` guarantees downstream tasks always have the user available — every declared output is implicitly required, so Runtime verifies it's present after `work` returns and fails the task otherwise. ### Send Verification Email @@ -69,7 +69,7 @@ Three layers of defense here: input validations catch malformed inputs, the `fai class Users::SendVerification < CMDx::Task required :user - output :verification_token, required: true + output :verification_token def work context.verification_token = user.generate_verification_token! @@ -90,7 +90,7 @@ This task only runs for trial plans. That conditional logic lives in the workflo class Users::ActivateTrial < CMDx::Task required :user - output :trial_ends_at, required: true + output :trial_ends_at def work trial_duration = case user.plan diff --git a/docs/configuration.md b/docs/configuration.md index 50fae0cdf..894ccd610 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -402,7 +402,7 @@ class SendCampaignEmail < CMDx::Task # Inputs / outputs (per-class schemas) register :input, :recipient_id, coerce: :integer, presence: true - register :output, :delivered_at, presence: true + register :output, :delivered_at end ``` diff --git a/docs/index.md b/docs/index.md index 10b8ad87a..efce9960e 100644 --- a/docs/index.md +++ b/docs/index.md @@ -16,7 +16,7 @@ class ApproveLoan < CMDx::Task on_success :notify_applicant! - output :approved_at, presence: true + output :approved_at def work if application.nil? diff --git a/docs/outputs.md b/docs/outputs.md index 386c299ee..e24395d99 100644 --- a/docs/outputs.md +++ b/docs/outputs.md @@ -1,6 +1,6 @@ # Outputs -Outputs declare keys the task is expected to write to `context`. After `work` succeeds, the runtime verifies each declared output: it can enforce presence, coerce the value, and run validators against it. Coerced values are written back to context. +Outputs declare keys the task is expected to write to `context`. Every declared output is implicitly required: after `work` succeeds, the runtime checks each declared key on `task.context`, applies a default when the value is absent or `nil`, and fails the task if the key is still missing. Outputs are intentionally minimal — for coercion, transformation, validation, or nested resolution use [Inputs](inputs/definitions.md) (or post-`work` code). ## Declaration @@ -11,7 +11,7 @@ class AuthenticateUser < CMDx::Task required :email, :password output :source - output :user, :token, required: true + output :user, :token def work context.source = email.include?("@mycompany.com") ? :admin_portal : :user_portal @@ -25,32 +25,24 @@ end | Option | Default | Description | |---------------|---------|----------------------------------------------------------------------------| -| `required:` | `false` | When `true`, fails the task if the key is missing from context after `work`| | `default:` | — | Fallback value, Symbol, Proc, or `#call(task)`-able; applied when `context[name]` is `nil` or the key is absent | -| `coerce:` | — | Same as inputs — single coercer or array applied to the written value | -| `transform:` | — | Symbol, Proc, or `#call(value, task)`-able; applied after coercion and before validation | -| `validate:` | — | Inline callable validator (Symbol, Proc, lambda, or `#call`-able) | -| `if:` / `unless:` | — | Skip verification (and required-ness) when the predicate isn't satisfied | +| `if:` / `unless:` | — | Skip verification entirely when the predicate isn't satisfied | | `description:` (alias `desc:`) | — | Documentation surfaced via `outputs_schema` | -Validator keys (`presence:`, `absence:`, `numeric:`, `format:`, `inclusion:`, `exclusion:`, `length:`) and any custom validators registered via `register :validator, ...` work the same as on inputs — see [Inputs - Validations](inputs/validations.md) for the full list. - ```ruby -output :total, coerce: :big_decimal, numeric: { min: 0.01 } -output :report_path, required: true, presence: true -output :exported_at, required: true, if: ->(t) { t.context.persist? } +output :report_path +output :exported_at, if: ->(t) { t.context.persist? } ``` ### Defaults -Defaults let you declare constants or derived values alongside the output instead of computing them inside `work`. A default fires during verification whenever the resolved value is `nil` — both "key absent" and "task wrote nil" — flows through coercion and transform, and can satisfy `:required`. +Defaults let you declare constants or derived values alongside the output instead of computing them inside `work`. A default fires during verification whenever the resolved value is `nil` — both "key absent" and "task wrote nil" — and satisfies the implicit required check. ```ruby class ComputeRecommendations < CMDx::Task output :version, default: "v2" # literal output :source, default: :default_source # Symbol → task#default_source output :generated_at, default: -> { Time.now } # Proc → instance_exec on task - output :retention_days, default: "7", coerce: :integer # defaults flow through coerce output :tenant, default: TenantDefaults # anything responding to #call(task) def work @@ -66,40 +58,14 @@ end See [Inputs - Defaults](inputs/defaults.md) for the long-form treatment of each shape — the resolution rules are identical. -### Transformations - -Transformations normalize whatever `work` wrote before validators run. Same pipeline as inputs: - -```mermaid -flowchart LR - Write --> Default --> Coerce --> Transform --> Validate --> WriteBack -``` - -```ruby -class CreateUser < CMDx::Task - output :email, coerce: :string, transform: :downcase - output :tags, coerce: :array, transform: proc { |v| v.uniq.sort } - output :days, coerce: :integer, transform: DayClamper, - numeric: { min: 1, max: 30 } - - def work - context.email = params[:email] - context.tags = params[:tags] - context.days = params[:days] - end -end -``` - -Symbol transforms try `value.send(sym)` first, then fall back to `task.send(sym, value)`. Proc transforms run via `instance_exec(value)` — lambdas must accept exactly one argument. Anything else must respond to `#call(value, task)` (use this path when you need access to the task). See [Inputs - Transformations](inputs/transformations.md) for the same semantics applied to inputs. - ## Removals Outputs inherit through subclasses. Remove inherited declarations with `deregister` — pass one or more keys per call: ```ruby class ApplicationTask < CMDx::Task - output :audit_log, required: true - output :request_id, required: true + output :audit_log + output :request_id end class LightweightTask < ApplicationTask @@ -111,41 +77,6 @@ class LightweightTask < ApplicationTask end ``` -## Nesting - -Declare child outputs against a parent value with a block. Children are verified against the resolved parent — any object that responds to the child's name, `#[]`, or `#key?` (Hash, Struct, Data, OpenStruct, Context, ...). All output options (`:required`, `:default`, `:coerce`, `:transform`, validators, `:if`/`:unless`) work per-child and nest arbitrarily deep. - -```ruby -class CreateUser < CMDx::Task - output :user do - required :id, coerce: :integer - optional :email - end - - output :billing do - required :plan - - optional :address do - required :city - optional :postal_code - end - end - - def work - context.user = User.create!(params) - context.billing = { plan: "pro", address: { city: "Lisbon" } } - end -end -``` - -Child verification only runs when the parent value is present and not a coercion failure — missing optional parents skip their children entirely. Required children on a present parent that doesn't expose the child key add `cmdx.outputs.missing` to `task.errors` under the child's name. - -!!! note - - Coerced/transformed child values are written back **into the parent**, not `task.context[<child>]` — a `Hash` parent gets `parent[:child] = value` (assigning even when the key is absent), and any object exposing `#<child>=` gets its setter called. Read-only parents (e.g. `Data.define`) are left untouched; validation errors are still recorded on the task. Nested outputs do not synthesize top-level context keys — use inputs' [Nesting](inputs/definitions.md#nesting) when you need parent-backed accessors on the task. - -`deregister :output, :user` still removes the top-level declaration (and its children in one shot). - ## Verification Behavior Verification runs **after** `work` completes successfully. If `work` threw a `skip!`, `fail!`, or `throw!` signal, outputs are not verified. @@ -160,7 +91,7 @@ flowchart LR E -->|yes| F[Signal.failed reason=errors.to_s] ``` -For each output, in declaration order: if `:if`/`:unless` excludes it, skip entirely; otherwise follow the diagram. When the resolved value is `nil`, `:default` fires before the required check. A coercion failure adds an error and short-circuits `:transform` and validation; otherwise the pipeline runs `:transform`, then validators, then writes the final value back to `context[name]`. +For each output, in declaration order: if `:if`/`:unless` excludes it, skip entirely; otherwise read `context[name]`, fall back to `:default` when `nil`, fail with `cmdx.outputs.missing` when the key was never written and no default produced a value, otherwise write the resolved value back to `context[name]`. Verification errors fold into the same failed signal that input/validation failures use — `result.reason` is `task.errors.to_s` and `result.errors` exposes the structured map. Under `execute!`, the same failure raises `CMDx::Fault`. @@ -168,7 +99,7 @@ Verification errors fold into the same failed signal that input/validation failu ```ruby class CreateUser < CMDx::Task - output :user, required: true + output :user def work # Forgot to set context.user @@ -201,27 +132,11 @@ end ```ruby class CreateUser < CMDx::Task - output :user, required: true, description: "the persisted user" + output :user, description: "the persisted user" end CreateUser.outputs_schema # => { user: { name: :user, # description: "the persisted user", -# required: true, -# options: { required: true, description: "the persisted user" }, -# children: [] } } -``` - -Nested declarations serialize their children recursively: - -```ruby -class CreateUser < CMDx::Task - output :user do - required :id, coerce: :integer - end -end - -CreateUser.outputs_schema[:user][:children] -# => [{ name: :id, description: nil, required: true, -# options: { required: true, coerce: :integer }, children: [] }] +# options: { description: "the persisted user" } } } ``` diff --git a/docs/tips_and_tricks.md b/docs/tips_and_tricks.md index 174e7c72b..df8603044 100644 --- a/docs/tips_and_tricks.md +++ b/docs/tips_and_tricks.md @@ -99,7 +99,7 @@ class ExportReport < CMDx::Task optional :format_type, coerce: :string, inclusion: { in: %w[pdf csv] } # 5. Declare outputs (the contract) - output :exported_at, required: true + output :exported_at # 6. Define work def work diff --git a/docs/v2-migration.md b/docs/v2-migration.md index 3dc127de1..ed3571ef9 100644 --- a/docs/v2-migration.md +++ b/docs/v2-migration.md @@ -4,7 +4,7 @@ CMDx 2.0 is a full runtime rewrite. The public DSL — `required`, `optional`, c !!! warning "Not a drop-in upgrade" - Plan to touch every task class. Halt is now `throw`/`catch` instead of `Result` mutation, attributes became inputs (`type:` → `coerce:`), returns became outputs (with a full pipeline), middleware takes one argument and `yield`s, and the built-in middleware trio (`Correlate`, `Runtime`, `Timeout`) is gone. + Plan to touch every task class. Halt is now `throw`/`catch` instead of `Result` mutation, attributes became inputs (`type:` → `coerce:`), returns became outputs (with optional `:default` / `:if` / `:unless`), middleware takes one argument and `yield`s, and the built-in middleware trio (`Correlate`, `Runtime`, `Timeout`) is gone. !!! tip "Benchmarks" @@ -30,7 +30,7 @@ CMDx 2.0 is a full runtime rewrite. The public DSL — `required`, `optional`, c | `Result` mutability | mutable (`initialized → executing → complete`) | read-only; options frozen on construction | | Lifecycle owner | `CMDx::Executor` | `CMDx::Runtime` | | Inputs | `attribute` / `attributes` with `type:` | `input` / `inputs` with `coerce:` | -| Outputs | `returns :user, :token` (presence check only) | `output :user, required: true, coerce: ...` (full pipeline) | +| Outputs | `returns :user, :token` (presence check only) | `output :user, default: ..., if: ...` (every declared output is implicitly required; defaults + guards are optional) | | Callbacks | `on_executed`, `on_good`, `on_bad` | drops `on_executed`; renames to `on_ok` / `on_ko` | | Middleware signature | `call(task, options, &block)` | `call(task) { yield }` | | Built-in middlewares | `Correlate`, `Runtime`, `Timeout` | removed — register your own | @@ -187,30 +187,28 @@ See [Inputs - Definitions](inputs/definitions.md). ## Outputs (was Returns) -`returns` was a presence check. `output` runs through the same required/coerce/validate pipeline as inputs. +`returns` was a presence check. `output` keeps the same implicit-required semantics and adds optional `:default` and `:if`/`:unless` gates. Outputs are intentionally minimal — for coercion, transformation, or validation use [Inputs](inputs/definitions.md) (or compute in `work`). ```ruby # v1 returns :user, :token # v2 -output :user, required: true -output :token, required: true, - coerce: :string, - length: { min: 32 } +output :user +output :token, default: -> { JwtService.encode(user_id: context.user.id) } ``` -Outputs run **after** `work` returns successfully (skipped if the task halted). A missing required output adds `outputs.missing` to `task.errors`, which Runtime converts into a failed signal. +Outputs run **after** `work` returns successfully (skipped if the task halted). Every declared output is implicitly required: a missing key adds `outputs.missing` to `task.errors`, which Runtime converts into a failed signal. `:default` satisfies the check when it produces a non-nil value. ### Removed | Removed | Replacement | |---|---| -| `returns :name` | `output :name, required: true` | +| `returns :name` | `output :name` | | `remove_returns :name` | `deregister :output, :name` | | `cmdx.returns.missing` locale key | `cmdx.outputs.missing` | -See [Outputs](outputs.md) for the full pipeline. +See [Outputs](outputs.md) for the full surface. --- @@ -836,10 +834,13 @@ by the rules here, stop and surface the file:line so a human can resolve it. ## Pass 2 — Outputs -- Replace `returns :a, :b` with one `output :a, required: true` / - `output :b, required: true` per key. Leave room for future `coerce:` / - validator options. +- Replace `returns :a, :b` with one `output :a` / `output :b` per key. + Every declared output is implicitly required — drop any leftover + `required: true` option. - Replace `remove_returns :name` with `deregister :output, :name`. +- Outputs only support `:default`, `:if`/`:unless`, and `:description`. + Move any coercion / transformation / validation onto inputs or compute + the value in `work` before assigning to context. ## Pass 3 — Locale files diff --git a/lib/cmdx/output.rb b/lib/cmdx/output.rb index 0f9a8b142..cc4f20f16 100644 --- a/lib/cmdx/output.rb +++ b/lib/cmdx/output.rb @@ -2,27 +2,21 @@ module CMDx # A single declared output. Runtime calls {#verify} after `work` to enforce - # `:required`, apply `:default`, run `:coerce` types, apply `:transform`, - # and run validators against the value the task wrote to `task.context[name]`. + # presence on `task.context[name]` (every declared output is implicitly + # required) and to apply `:default`. `:if`/`:unless` gate verification entirely. class Output - attr_reader :name, :children + attr_reader :name # @param name [Symbol, String] output key (symbolized) - # @param children [Array<Output>] nested child outputs verified against this - # output's resolved value # @param options [Hash{Symbol => Object}] declaration options # @option options [String] :description (also accepts `:desc`) - # @option options [Boolean] :required # @option options [Symbol, Proc, #call] :if # @option options [Symbol, Proc, #call] :unless - # @option options [Symbol, Array, Hash, Proc] :coerce # @option options [Object, Symbol, Proc, #call] :default - # @option options [Symbol, Proc, #call] :transform mutator applied after coercion - def initialize(name, children: EMPTY_ARRAY, **options) - @name = name.to_sym - @children = children.freeze - @options = options.freeze + def initialize(name, **options) + @name = name.to_sym + @options = options.freeze end # @return [String, nil] @@ -35,11 +29,6 @@ def default @options[:default] end - # @return [Symbol, Proc, #call, nil] - def transform - @options[:transform] - end - # @return [Symbol, Proc, #call, nil] def condition_if @options[:if] @@ -50,32 +39,12 @@ def condition_unless @options[:unless] end - # @return [Boolean] - def required - @options.fetch(:required, false) - end - - # Evaluates required-ness against `task`, respecting `:if`/`:unless`. - # When called without a task, returns the static `:required` flag. - # - # @param task [Task, nil] - # @return [Boolean] - def required?(task = nil) - return false unless required - return true if task.nil? - return false unless Util.satisfied?(condition_if, condition_unless, task) - - true - end - # @return [Hash{Symbol => Object}] serialized schema for `outputs_schema` def to_h { name:, description:, - required: required?, - options: @options, - children: children.map(&:to_h) + options: @options } end @@ -100,118 +69,31 @@ def to_json(*args) # Enforces the output contract against `task.context[name]` after `work` runs. # # Steps, in order: - # 1. Reads the value from `task.context` and falls back to `:default` when nil. - # 2. Adds a `cmdx.outputs.missing` error when required (respecting `:if`/`:unless`) - # and neither the key nor a default supplied a value. - # 3. Short-circuits when the key was never written and no default exists. - # 4. Runs `:coerce` types; aborts silently on {Coercions::Failure} (the coercion - # layer records its own error). - # 5. Applies `:transform` to the coerced value. - # 6. Runs validators, then writes the final value back to `task.context[name]`. + # 1. Skips entirely when `:if`/`:unless` excludes it. + # 2. Reads the value from `task.context` and falls back to `:default` when nil. + # 3. Adds a `cmdx.outputs.missing` error when neither the key nor a default + # supplied a value (every declared output is implicitly required). + # 4. Writes the resolved value back to `task.context[name]`. # # @param task [Task] the running task whose context is inspected and mutated # @return [void] def verify(task) + return unless Util.satisfied?(condition_if, condition_unless, task) + key_provided = task.context.key?(name) value = task.context[name] value = apply_default(task) if value.nil? - if required?(task) && !key_provided && value.nil? + if !key_provided && value.nil? task.errors.add(name, I18nProxy.t("cmdx.outputs.missing")) return end - return if !key_provided && value.nil? - - coercions = task.class.coercions.extract(@options) - value = task.class.coercions.coerce(task, name, value, coercions) - return if value.is_a?(Coercions::Failure) - - value = apply_transform(value, task) if transform - - validators = task.class.validators.extract(@options) - task.class.validators.validate(task, name, value, validators) - task.context[name] = value - verify_children(value, task) - end - - # Verifies a child output against `parent_value` and writes the - # coerced/transformed value back into the parent when the parent supports - # mutation (Hash, or any object exposing `#{name}=`). Read-only parents - # (e.g. `Data.define`) are left untouched; child validation/coercion errors - # are still collected on the task. - # - # @param parent_value [#[], #key?, Object] the parent output's verified value - # @param task [Task] - # @return [void] - def verify_from_parent(parent_value, task) - return if parent_value.nil? || parent_value.is_a?(Coercions::Failure) - - value, key_provided = fetch_by_name(parent_value) - value = apply_default(task) if value.nil? - - if required?(task) && !key_provided && value.nil? - task.errors.add(name, I18nProxy.t("cmdx.outputs.missing")) - return - end - - return if !key_provided && value.nil? - - coercions = task.class.coercions.extract(@options) - value = task.class.coercions.coerce(task, name, value, coercions) - return if value.is_a?(Coercions::Failure) - - value = apply_transform(value, task) if transform - - validators = task.class.validators.extract(@options) - task.class.validators.validate(task, name, value, validators) - - writeback(parent_value, value) - verify_children(value, task) end private - def writeback(parent_value, value) - if parent_value.is_a?(::Hash) - if parent_value.key?(name) - parent_value[name] = value - elsif parent_value.key?(name_str = name.to_s) - parent_value[name_str] = value - else # rubocop:disable Lint/DuplicateBranch - parent_value[name] = value - end - elsif parent_value.respond_to?(setter = :"#{name}=") # rubocop:disable Lint/LiteralAssignmentInCondition - parent_value.public_send(setter, value) - end - end - - def verify_children(value, task) - return if children.empty? || value.nil? || value.is_a?(Coercions::Failure) - - children.each { |child| child.verify_from_parent(value, task) } - end - - def fetch_by_name(obj) - if obj.respond_to?(name, true) - [obj.send(name), true] - elsif obj.respond_to?(:key?) - if obj.key?(name) - [obj[name], true] - elsif obj.respond_to?(:[]) && obj.key?(name_str = name.to_s) - [obj[name_str], true] - else - [nil, false] - end - elsif obj.respond_to?(:[]) - value = obj[name] || obj[name.to_s] - [value, !value.nil?] - else - [nil, false] - end - end - def apply_default(task) return if default.nil? @@ -227,22 +109,5 @@ def apply_default(task) end end - def apply_transform(value, task) - case transform - when Symbol - if value.respond_to?(transform, true) - value.send(transform) - else - task.send(transform, value) - end - when Proc - task.instance_exec(value, &transform) - else - return transform.call(value, task) if transform.respond_to?(:call) - - raise ArgumentError, "transform must be a Symbol, Proc, or respond to #call" - end - end - end end diff --git a/lib/cmdx/outputs.rb b/lib/cmdx/outputs.rb index 9c42041f9..3adda000a 100644 --- a/lib/cmdx/outputs.rb +++ b/lib/cmdx/outputs.rb @@ -2,8 +2,8 @@ module CMDx # Registry of declared task outputs. Runtime verifies each output after - # `work` completes: presence, coercion, and validation run against values - # the task wrote to context. + # `work` completes: presence and defaults run against values the task wrote + # to context. class Outputs attr_reader :registry @@ -16,18 +16,14 @@ def initialize_copy(source) @registry = source.registry.dup end - # Declares one or more output keys. All share the same `options`. A block - # nests child outputs under each declared key (see {ChildBuilder}). + # Declares one or more output keys. All share the same `options`. # # @param keys [Array<Symbol>] # @param options [Hash{Symbol => Object}] passed through to {Output#initialize} - # @yield block evaluated in a {ChildBuilder} for nested outputs # @return [Outputs] self for chaining - def register(*keys, **options, &block) - children = block ? ChildBuilder.build(&block) : EMPTY_ARRAY - + def register(*keys, **options) keys.each do |key| - output = Output.new(key, children:, **options) + output = Output.new(key, **options) registry[output.name] = output end @@ -60,55 +56,5 @@ def verify(task) registry.each_value { |output| output.verify(task) } end - # DSL receiver for the block passed to {Outputs#register}. Builds a frozen - # list of child {Output}s. Supports arbitrary nesting: every DSL method - # accepts its own block. - class ChildBuilder - - class << self - - # @yield (see Outputs#register) - # @return [Array<Output>] frozen list of built children - def build(&) - builder = new - builder.instance_eval(&) - builder.children.freeze - end - - end - - attr_reader :children - - def initialize - @children = [] - end - - # @param names [Array<Symbol>] - # @param options [Hash{Symbol => Object}] - # @return [Array<Output>] - def outputs(*names, **options, &) - build(*names, **options, &) - end - alias output outputs - - # Declares optional child outputs (equivalent to `outputs ..., required: false`). - def optional(*names, **options, &) - build(*names, required: false, **options, &) - end - - # Declares required child outputs (equivalent to `outputs ..., required: true`). - def required(*names, **options, &) - build(*names, required: true, **options, &) - end - - private - - def build(*names, **options, &block) - nested = block ? self.class.build(&block) : EMPTY_ARRAY - names.map { |name| children << Output.new(name, children: nested, **options) } - end - - end - end end diff --git a/lib/cmdx/task.rb b/lib/cmdx/task.rb index b53451de2..5c4eca709 100644 --- a/lib/cmdx/task.rb +++ b/lib/cmdx/task.rb @@ -242,9 +242,8 @@ def inputs_schema # # @param keys [Array<Symbol>] # @param options [Hash{Symbol => Object}] see {Output#initialize} - # @yield nested-output DSL block (see {Outputs::ChildBuilder}) # @return [Outputs] - def outputs(*keys, **options, &) + def outputs(*keys, **options) @outputs ||= if superclass.respond_to?(:outputs) superclass.outputs.dup @@ -254,7 +253,7 @@ def outputs(*keys, **options, &) return @outputs if keys.empty? - @outputs.register(*keys, **options, &) + @outputs.register(*keys, **options) end alias output outputs diff --git a/skills/SKILL.md b/skills/SKILL.md index fbb6d7b41..2106fd2c7 100644 --- a/skills/SKILL.md +++ b/skills/SKILL.md @@ -72,8 +72,8 @@ class ProcessPayment < CMDx::Task required :amount, coerce: :big_decimal, numeric: { gt: 0 } optional :currency, coerce: :string, default: "USD", inclusion: { in: %w[USD EUR GBP] } - output :charge_id, required: true - output :receipt_url, required: true + output :charge_id + output :receipt_url def work charge = Gateway.charge!(amount: amount, currency: currency) @@ -162,8 +162,8 @@ Declare keys the task must write to `context`. Verification runs after `work` su class CreateUser < CMDx::Task required :email, coerce: :string - output :user, required: true - output :token, required: true, presence: true + output :user + output :token def work context.user = User.create!(email: email) @@ -172,7 +172,7 @@ class CreateUser < CMDx::Task end ``` -Outputs share the `coerce:`, `default:`, `transform:`, `validate:`, and `if:` / `unless:` options with inputs. Missing required outputs fail the task with `result.errors[name]`. +Every declared output is implicitly required. Outputs support `default:`, `if:`/`unless:`, and `description:` only. A missing output fails the task with `result.errors[name]`. For coercion, transformation, or validation use inputs (or write derived values directly inside `work`). Full reference: [references/outputs.md](references/outputs.md). diff --git a/skills/references/outputs.md b/skills/references/outputs.md index 337af06c0..883d72323 100644 --- a/skills/references/outputs.md +++ b/skills/references/outputs.md @@ -1,12 +1,12 @@ # Outputs Reference -Docs: [docs/outputs.md](../../docs/outputs.md). Outputs share coercions, validators, transforms, and defaults with inputs — see [inputs.md](inputs.md). +Docs: [docs/outputs.md](../../docs/outputs.md). Outputs are intentionally minimal — every declared output is implicitly required, and only `:default`, `:if`/`:unless`, and `:description` are configurable. For coercion, transformation, validation, or nested resolution use [inputs.md](inputs.md) (or compute in `work`). ## Declaration ```ruby -output :source # single, optional -output :user, :token, required: true # multiple, required +output :source # single +output :user, :token # multiple outputs :generated_at, default: -> { Time.now } deregister :output, :audit_log, :request_id @@ -18,13 +18,8 @@ deregister :output, :audit_log, :request_id | Option | Description | |--------|-------------| -| `required:` | Adds `cmdx.outputs.missing` error if `context[name]` is absent/nil after `work` and no default resolves. | -| `default:` | Static value, Symbol (task method), Proc (`instance_exec`), or `#call(task)`-able. Applied when `context[name]` is nil. | -| `coerce:` | Same as inputs (single Symbol, array, Hash, or callable). | -| `transform:` | Symbol, Proc (`instance_exec(value)`), or `#call(value, task)`. Applied post-coerce, pre-validate. | -| `validate:` | Inline validator (Symbol/Proc/`#call`-able or Array chain). | -| `if:` / `unless:` | Skip the entire check (including `required:`) when the gate fails. Signature `(task)`. | -| Validator shorthands | `presence:`, `absence:`, `format:`, `length:`, `numeric:`, `inclusion:`, `exclusion:`, plus custom validators. | +| `default:` | Static value, Symbol (task method), Proc (`instance_exec`), or `#call(task)`-able. Applied when `context[name]` is nil. Satisfies the implicit required check. | +| `if:` / `unless:` | Skip the entire check (including the implicit required check) when the gate fails. Signature `(task)`. | | `description:` / `desc:` | Metadata for `outputs_schema`. | ## Verification @@ -33,17 +28,14 @@ Runs **after `work` succeeds**. Skipped entirely when `work` threw `skip!`, `fai 1. Evaluate `if:`/`unless:` — skip if gated. 2. Read `task.context[name]`; apply `:default` when value is `nil`. -3. If `required?` and nothing resolved, add `cmdx.outputs.missing`. -4. Coerce; a `Coercions::Failure` short-circuits transform/validate. -5. Apply `:transform`. -6. Run validators. -7. Write the final value back to `task.context[name]`. +3. If the key was never written and no default produced a value, add `cmdx.outputs.missing`. +4. Write the resolved value back to `task.context[name]`. Failures fold into the same terminal "auto-fail" behavior as input errors: `result.reason` = `task.errors.to_s`, `result.errors[name]` exposes messages. `execute!` raises `CMDx::Fault`. ```ruby class CreateUser < CMDx::Task - output :user, required: true + output :user def work # forgot to set context.user end @@ -55,29 +47,25 @@ result.reason #=> "user must be set in the context" result.errors.to_h #=> { user: ["must be set in the context"] } ``` -## Defaults, transforms, validators - -Same mechanisms as inputs — refer to [inputs.md](inputs.md). +## Defaults ```ruby -output :version, default: "v2" -output :source, default: :default_source # method -output :generated_at, default: -> { Time.now } # instance_exec -output :retention_days, default: "7", coerce: :integer # flows through coerce - -output :email, coerce: :string, transform: :downcase -output :tags, coerce: :array, transform: proc { |v| v.uniq.sort } -output :total, coerce: :big_decimal, numeric: { min: 0.01 } +output :version, default: "v2" # literal +output :source, default: :default_source # task method +output :generated_at, default: -> { Time.now } # instance_exec on task +output :tenant, default: TenantDefaults # #call(task)-able ``` +A default that produces a non-nil value satisfies the implicit required check. + ## Inheritance Subclasses inherit parent outputs via a lazy `dup`. Remove inherited outputs with `deregister :output, :name`. ```ruby class ApplicationTask < CMDx::Task - output :audit_log, required: true - output :request_id, required: true + output :audit_log + output :request_id end class LightweightTask < ApplicationTask @@ -89,5 +77,5 @@ end ```ruby MyTask.outputs_schema -# => { user: { name: :user, description: "...", required: true, options: {...} } } +# => { user: { name: :user, description: "...", options: {...} } } ``` diff --git a/spec/cmdx/output_spec.rb b/spec/cmdx/output_spec.rb index 0275ddaeb..99cfcf82d 100644 --- a/spec/cmdx/output_spec.rb +++ b/spec/cmdx/output_spec.rb @@ -9,7 +9,7 @@ end it "freezes the options hash" do - output = described_class.new(:user, required: true) + output = described_class.new(:user, default: 1) expect(output.instance_variable_get(:@options)).to be_frozen end end @@ -37,145 +37,124 @@ end end - describe "#required" do - it "defaults to false" do - expect(described_class.new(:user).required).to be(false) + describe "#default" do + it "exposes the :default option" do + expect(described_class.new(:level, default: 7).default).to eq(7) end - it "reflects the :required option" do - expect(described_class.new(:user, required: true).required).to be(true) - end - end - - describe "simple option accessors" do - it "exposes :default and :transform" do - output = described_class.new(:level, default: 7, transform: :upcase) - expect(output).to have_attributes(default: 7, transform: :upcase) - end - - it "returns nil when unset" do - output = described_class.new(:level) - expect(output).to have_attributes(default: nil, transform: nil) - end - end - - describe "#required?" do - it "is false when not required" do - expect(described_class.new(:user).required?).to be(false) - end - - it "is true when required and no task is given" do - expect(described_class.new(:user, required: true).required?).to be(true) - end - - context "with a task" do - let(:task_class) do - Class.new do - def active? = true - def inactive? = false - end - end - let(:task) { task_class.new } - - it "is false when the if-guard is false" do - output = described_class.new(:user, required: true, if: :inactive?) - expect(output.required?(task)).to be(false) - end - - it "is false when the unless-guard is true" do - output = described_class.new(:user, required: true, unless: :active?) - expect(output.required?(task)).to be(false) - end - - it "is true when guards pass" do - output = described_class.new(:user, required: true, if: :active?) - expect(output.required?(task)).to be(true) - end + it "is nil when unset" do + expect(described_class.new(:level).default).to be_nil end end describe "#to_h" do - it "returns name, description, required, and the raw options" do - output = described_class.new(:user, description: "d", required: true, type: :string) + it "returns name, description, and the raw options" do + output = described_class.new(:user, description: "d", default: "x") expect(output.to_h).to eq( name: :user, description: "d", - required: true, - options: { description: "d", required: true, type: :string }, - children: [] + options: { description: "d", default: "x" } ) end - - it "serializes children recursively" do - child = described_class.new(:id, type: :integer) - output = described_class.new(:user, children: [child], type: :hash) - - expect(output.to_h[:children]).to eq([child.to_h]) - end end describe "#as_json" do it "returns to_h" do - output = described_class.new(:user, description: "d", required: true) + output = described_class.new(:user, description: "d") expect(output.as_json).to eq(output.to_h) end end describe "#to_json" do it "emits a JSON string with the schema shape" do - child = described_class.new(:id, type: :integer) - output = described_class.new(:user, description: "d", required: true, children: [child]) + output = described_class.new(:user, description: "d") parsed = JSON.parse(output.to_json) expect(parsed).to include( "name" => "user", - "description" => "d", - "required" => true + "description" => "d" ) - expect(parsed["options"]).to include("description" => "d", "required" => true) - expect(parsed["children"]).to eq([JSON.parse(child.to_json)]) + expect(parsed["options"]).to include("description" => "d") end end describe "#verify" do - let(:task) do - create_task_class(name: "OutVerifyTask") do - output :user, required: true - end.new - end + it "adds a missing error when the context key is absent" do + task_class = create_task_class(name: "OutVerifyTask") do + output :user + end + task = task_class.new - it "adds a missing error when required and the context key is absent" do - task.class.outputs.registry[:user].verify(task) + task_class.outputs.registry[:user].verify(task) expect(task.errors[:user]).to include(CMDx::I18nProxy.t("cmdx.outputs.missing")) end - it "coerces and stores the value when the key is present" do - task_class = create_task_class(name: "CoerceTask") do - output :count, coerce: :integer + it "writes the value back when the key is present" do + task_class = create_task_class(name: "WritebackTask") do + output :note end task = task_class.new - task.context[:count] = "42" + task.context[:note] = "hello" - task_class.outputs.registry[:count].verify(task) + task_class.outputs.registry[:note].verify(task) - expect(task.context[:count]).to eq(42) + expect(task.context[:note]).to eq("hello") + expect(task.errors).to be_empty end - it "does not modify context when the key is absent and not required" do - task_class = create_task_class(name: "OptionalTask") do + it "treats an explicit nil context value as set" do + task_class = create_task_class(name: "OutNilSet") do output :note end task = task_class.new + task.context[:note] = nil task_class.outputs.registry[:note].verify(task) - expect(task.context).to be_empty expect(task.errors).to be_empty end + context "with :if / :unless" do + it "skips verification when :if is false" do + task_class = create_task_class(name: "OutIfFalse") do + output :user, if: :inactive? + define_method(:inactive?) { false } + end + task = task_class.new + + task_class.outputs.registry[:user].verify(task) + + expect(task.errors).to be_empty + end + + it "skips verification when :unless is true" do + task_class = create_task_class(name: "OutUnlessTrue") do + output :user, unless: :active? + define_method(:active?) { true } + end + task = task_class.new + + task_class.outputs.registry[:user].verify(task) + + expect(task.errors).to be_empty + end + + it "runs verification when guards pass" do + task_class = create_task_class(name: "OutGuardsPass") do + output :user, if: :active? + define_method(:active?) { true } + end + task = task_class.new + + task_class.outputs.registry[:user].verify(task) + + expect(task.errors[:user]).to include(CMDx::I18nProxy.t("cmdx.outputs.missing")) + end + end + context "with :default" do it "applies a literal default when the key is absent" do task_class = create_task_class(name: "OutDefaultLiteral") do @@ -186,6 +165,7 @@ def inactive? = false task_class.outputs.registry[:version].verify(task) expect(task.context[:version]).to eq("v2") + expect(task.errors).to be_empty end it "applies a Symbol default by sending to the task" do @@ -239,163 +219,6 @@ def call(task) expect(task.context[:version]).to eq("v2") end - - it "satisfies :required when the default produces a value" do - task_class = create_task_class(name: "OutDefaultRequired") do - output :version, required: true, default: "v2" - end - task = task_class.new - - task_class.outputs.registry[:version].verify(task) - - expect(task.errors).to be_empty - expect(task.context[:version]).to eq("v2") - end - - it "flows through coercion and validation" do - task_class = create_task_class(name: "OutDefaultCoerced") do - output :retention_days, default: "7", coerce: :integer - end - task = task_class.new - - task_class.outputs.registry[:retention_days].verify(task) - - expect(task.context[:retention_days]).to eq(7) - end - end - - context "with :transform" do - it "applies a Symbol transform on the value" do - task_class = create_task_class(name: "OutTransformSym") do - output :email, transform: :downcase - end - task = task_class.new - task.context[:email] = "Alice@Example.COM" - - task_class.outputs.registry[:email].verify(task) - - expect(task.context[:email]).to eq("alice@example.com") - end - - it "falls back to the task when value doesn't respond" do - task_class = create_task_class(name: "OutTransformTaskMethod") do - output :name, transform: :shout - define_method(:shout) { |v| "#{v}!!!" } - end - task = task_class.new - task.context[:name] = 42 - - task_class.outputs.registry[:name].verify(task) - - expect(task.context[:name]).to eq("42!!!") - end - - it "resolves a private method on the value" do - klass = Class.new do - def initialize(v) = (@v = v) - - private - - def squish = @v.gsub(/\s+/, " ").strip - end - - task_class = create_task_class(name: "OutTransformPrivate") do - output :line, transform: :squish - end - task = task_class.new - task.context[:line] = klass.new(" hi there ") - - task_class.outputs.registry[:line].verify(task) - - expect(task.context[:line]).to eq("hi there") - end - - it "applies a Proc transform via instance_exec" do - task_class = create_task_class(name: "OutTransformProc") do - output :tags, transform: proc { |v| v.uniq.sort } - end - task = task_class.new - task.context[:tags] = %w[b a a c] - - task_class.outputs.registry[:tags].verify(task) - - expect(task.context[:tags]).to eq(%w[a b c]) - end - - it "applies a callable transform with (value, task)" do - callable = Class.new do - def call(value, _task) - value.to_s.upcase - end - end.new - task_class = create_task_class(name: "OutTransformCallable") do - output :code, transform: callable - end - task = task_class.new - task.context[:code] = "abc" - - task_class.outputs.registry[:code].verify(task) - - expect(task.context[:code]).to eq("ABC") - end - - it "runs after coerce and before validation" do - task_class = create_task_class(name: "OutTransformPipeline") do - output :days, coerce: :integer, transform: proc { |v| v.clamp(1, 5) }, - numeric: { min: 1, max: 5 } - end - task = task_class.new - task.context[:days] = "42" - - task_class.outputs.registry[:days].verify(task) - - expect(task.context[:days]).to eq(5) - expect(task.errors).to be_empty - end - end - - context "with nested children" do - it "verifies required children against the parent value" do - task_class = create_task_class(name: "OutChildRequired") do - output :user do - required :id - optional :email - end - end - task = task_class.new - task.context[:user] = { email: "x@y" } - - task_class.outputs.registry[:user].verify(task) - - expect(task.errors[:id]).to include(CMDx::I18nProxy.t("cmdx.outputs.missing")) - end - - it "skips child verification when parent is nil" do - task_class = create_task_class(name: "OutChildSkip") do - output :user do - required :id - end - end - task = task_class.new - - task_class.outputs.registry[:user].verify(task) - - expect(task.errors).to be_empty - end - - it "validates child values against the parent" do - task_class = create_task_class(name: "OutChildValidate") do - output :user do - required :age, coerce: :integer, numeric: { min: 18 } - end - end - task = task_class.new - task.context[:user] = { age: "12" } - - task_class.outputs.registry[:user].verify(task) - - expect(task.errors.keys).to include(:age) - end end end end diff --git a/spec/cmdx/outputs_spec.rb b/spec/cmdx/outputs_spec.rb index 3cdaddb0f..3391b5bab 100644 --- a/spec/cmdx/outputs_spec.rb +++ b/spec/cmdx/outputs_spec.rb @@ -26,18 +26,18 @@ describe "#register" do it "adds each key as an Output entry and returns self" do - expect(outputs.register(:user, :token, required: true)).to be(outputs) + expect(outputs.register(:user, :token, description: "x")).to be(outputs) expect(outputs.size).to eq(2) expect(outputs.registry[:user]).to be_a(CMDx::Output) - expect(outputs.registry[:user].required).to be(true) + expect(outputs.registry[:user].description).to eq("x") end it "overwrites an existing entry with the same name" do - outputs.register(:user, required: false) - outputs.register(:user, required: true) + outputs.register(:user, default: "a") + outputs.register(:user, default: "b") - expect(outputs.registry[:user].required).to be(true) + expect(outputs.registry[:user].default).to eq("b") expect(outputs.size).to eq(1) end end @@ -75,8 +75,8 @@ describe "#verify" do it "invokes verify on each registered Output" do task_class = create_task_class(name: "VerifyAllTask") do - output :user, required: true - output :token, required: true + output :user + output :token end task = task_class.new @@ -85,44 +85,4 @@ expect(task.errors.keys).to contain_exactly(:user, :token) end end - - describe "nested outputs" do - it "builds child outputs from a block" do - outputs.register(:user) do - required :id, type: :integer - optional :email - end - - user = outputs.registry[:user] - expect(user.children.map(&:name)).to eq(%i[id email]) - expect(user.children.first.required).to be(true) - expect(user.children.last.required).to be(false) - end - - it "supports arbitrary nesting" do - outputs.register(:user) do - output :address do - required :city - end - end - - address = outputs.registry[:user].children.first - expect(address.name).to eq(:address) - expect(address.children.map(&:name)).to eq([:city]) - end - - it "freezes the children list" do - outputs.register(:user) { required :id } - expect(outputs.registry[:user].children).to be_frozen - end - - it "exposes children through to_h (schema export)" do - outputs.register(:user) do - required :id - end - - schema = outputs.registry[:user].to_h - expect(schema[:children].first).to include(name: :id, required: true) - end - end end diff --git a/spec/cmdx/task_spec.rb b/spec/cmdx/task_spec.rb index 3f6e3b7cd..b4c2a28c2 100644 --- a/spec/cmdx/task_spec.rb +++ b/spec/cmdx/task_spec.rb @@ -93,7 +93,7 @@ create_task_class(name: "SchemaTask") do required :name optional :age - output :id, required: true + output :id, description: "the persisted id" end end @@ -105,7 +105,7 @@ it "outputs_schema returns a flat hash of output descriptors" do expect(klass.outputs_schema.keys).to eq([:id]) - expect(klass.outputs_schema[:id][:required]).to be(true) + expect(klass.outputs_schema[:id][:description]).to eq("the persisted id") end end diff --git a/spec/integration/tasks/output_spec.rb b/spec/integration/tasks/output_spec.rb index e6c552aab..0d0129ca7 100644 --- a/spec/integration/tasks/output_spec.rb +++ b/spec/integration/tasks/output_spec.rb @@ -3,11 +3,11 @@ require "spec_helper" RSpec.describe "Task output verification", type: :feature do - describe "required outputs" do + describe "presence" do context "when all declared keys are set" do subject(:result) do create_task_class(name: "AllOutputsSet") do - output :user, :token, required: true + output :user, :token define_method(:work) do context.user = "alice" context.token = "abc123" @@ -21,10 +21,10 @@ end end - context "when one required key is missing" do + context "when one declared key is missing" do subject(:result) do create_task_class(name: "OneMissing") do - output :user, :token, required: true + output :user, :token define_method(:work) { context.user = "alice" } end.execute end @@ -34,10 +34,10 @@ end end - context "when multiple required keys are missing" do + context "when multiple declared keys are missing" do subject(:result) do create_task_class(name: "MultiMissing") do - output :user, :token, :session, required: true + output :user, :token, :session define_method(:work) { context.user = "alice" } end.execute end @@ -50,10 +50,10 @@ end end - context "when a required key is set to nil" do + context "when a declared key is set to nil" do subject(:result) do create_task_class(name: "NilValue") do - output :user, required: true + output :user define_method(:work) { context.user = nil } end.execute end @@ -64,21 +64,10 @@ end end - describe "non-required outputs" do - it "succeeds even when the key is missing" do - task = create_task_class(name: "OptionalOutputs") do - output :user, :token - define_method(:work) { context.user = "alice" } - end - - expect(task.execute).to have_attributes(status: CMDx::Signal::SUCCESS) - end - end - describe "skipped/failed outcomes" do it "does not verify outputs when the task skips" do task = create_task_class(name: "SkipOutputs") do - output :user, required: true + output :user define_method(:work) { skip!("not needed") } end @@ -90,7 +79,7 @@ it "does not verify outputs when the task fails" do task = create_task_class(name: "FailOutputs") do - output :user, required: true + output :user define_method(:work) { fail!("broken") } end @@ -101,16 +90,36 @@ end end + describe "if / unless guards" do + it "skips verification when :unless is true" do + task = create_task_class(name: "OutGuardUnless") do + output :user, unless: -> { true } + define_method(:work) { nil } + end + + expect(task.execute).to have_attributes(status: CMDx::Signal::SUCCESS) + end + + it "still requires the key when :if is true" do + task = create_task_class(name: "OutGuardIf") do + output :user, if: -> { true } + define_method(:work) { nil } + end + + expect(task.execute.errors.to_h).to eq(user: ["must be set in the context"]) + end + end + describe "inheritance" do let(:parent) do create_task_class(name: "ParentOutputs") do - output :user, required: true + output :user end end it "inherits parent outputs alongside its own" do child = create_task_class(base: parent, name: "ChildOutputs") do - output :token, required: true + output :token define_method(:work) do context.user = "alice" context.token = "abc" @@ -123,7 +132,7 @@ it "fails when a parent-declared output is missing in the child" do child = create_task_class(base: parent, name: "MissingParent") do - output :token, required: true + output :token define_method(:work) { context.token = "abc" } end @@ -142,9 +151,9 @@ end describe "defaults" do - it "fills in a required output via default when work doesn't set it" do + it "fills in an output via default when work doesn't set it" do task = create_task_class(name: "DefaultVersion") do - output :version, required: true, default: "v2" + output :version, default: "v2" define_method(:work) { nil } end @@ -171,45 +180,12 @@ expect(task.execute.context.version).to eq("v2") end - - it "flows defaults through coercion" do - task = create_task_class(name: "DefaultCoerced") do - output :retention_days, default: "7", coerce: :integer - define_method(:work) { nil } - end - - expect(task.execute.context.retention_days).to eq(7) - end - end - - describe "transform" do - it "normalizes values the task wrote" do - task = create_task_class(name: "TransformEmail") do - output :email, coerce: :string, transform: :downcase - define_method(:work) { context.email = "Alice@Example.COM" } - end - - expect(task.execute.context.email).to eq("alice@example.com") - end - - it "runs after coerce and before validation" do - task = create_task_class(name: "TransformClamp") do - output :days, coerce: :integer, transform: proc { |v| v.clamp(1, 5) }, - numeric: { min: 1, max: 5 } - define_method(:work) { context.days = "42" } - end - - result = task.execute - - expect(result).to have_attributes(status: CMDx::Signal::SUCCESS) - expect(result.context.days).to eq(5) - end end describe "blocking execute!" do - it "raises a Fault for a missing required output" do + it "raises a Fault for a missing output" do task = create_task_class(name: "BangOutput") do - output :user, required: true + output :user define_method(:work) { nil } end From 2f6406a142034e03f899f1b1c3d1902fea1d308b Mon Sep 17 00:00:00 2001 From: Juan Gomez <drexed@users.noreply.github.com> Date: Thu, 23 Apr 2026 09:37:11 -0400 Subject: [PATCH 30/54] Bump ruby --- .ruby-version | 2 +- spec/cmdx/chain_spec.rb | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.ruby-version b/.ruby-version index 4d54daddb..c4e41f945 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -4.0.2 +4.0.3 diff --git a/spec/cmdx/chain_spec.rb b/spec/cmdx/chain_spec.rb index 0f2911a01..420a54b85 100644 --- a/spec/cmdx/chain_spec.rb +++ b/spec/cmdx/chain_spec.rb @@ -278,3 +278,4 @@ def build_result(signal = CMDx::Signal.success, **opts) end end end + From 1735626ad1151eb4794a4a13f0686fca79641a33 Mon Sep 17 00:00:00 2001 From: Juan Gomez <drexed@users.noreply.github.com> Date: Thu, 23 Apr 2026 09:43:33 -0400 Subject: [PATCH 31/54] ok ko diff --- docs/callbacks.md | 14 ++++++++++++++ docs/configuration.md | 4 ++-- lib/cmdx/runtime.rb | 3 ++- spec/integration/tasks/callbacks_spec.rb | 4 ++-- 4 files changed, 20 insertions(+), 5 deletions(-) diff --git a/docs/callbacks.md b/docs/callbacks.md index a2f3dd2f8..fe0ac8cb5 100644 --- a/docs/callbacks.md +++ b/docs/callbacks.md @@ -28,6 +28,20 @@ Callbacks execute in a predictable lifecycle order: 5. on_[ok|ko] # Outcome-based (success/skip vs fail) ``` +!!! note "Callbacks are additive, not exclusive" + + Status callbacks (`on_success` / `on_skipped` / `on_failed`) and outcome + callbacks (`on_ok` / `on_ko`) are dispatched independently — if you define + both kinds, both will fire. The outcome pair also overlaps on skipped + results: `on_ok` runs for success **and** skipped, `on_ko` runs for skipped + **and** failed, so a skipped task fires **both** `on_ok` and `on_ko`. + + | Status | Fires | + |----------|----------------------------------------| + | success | `on_success`, `on_ok` | + | skipped | `on_skipped`, `on_ok`, `on_ko` | + | failed | `on_failed`, `on_ko` | + ## Declarations ### Symbol References diff --git a/docs/configuration.md b/docs/configuration.md index 894ccd610..72cfa24ac 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -161,8 +161,8 @@ Callbacks fire at specific lifecycle points. Valid events: | `:on_success` | When `status == "success"` | | `:on_skipped` | When `status == "skipped"` | | `:on_failed` | When `status == "failed"` | -| `:on_ok` | Success or skipped (`signal.ok?`) | -| `:on_ko` | Failed only (runtime dispatches `on_ok` when `signal.ok?`, otherwise `on_ko`; skipped results run `on_ok`) | +| `:on_ok` | Success or skipped (`signal.ok?` — not failed) | +| `:on_ko` | Skipped or failed (`signal.ko?` — not success) | ```ruby CMDx.configure do |config| diff --git a/lib/cmdx/runtime.rb b/lib/cmdx/runtime.rb index 9dd035c83..454b9f170 100644 --- a/lib/cmdx/runtime.rb +++ b/lib/cmdx/runtime.rb @@ -95,7 +95,8 @@ def run_lifecycle perform_rollback if @signal.failed? run_callbacks(:"on_#{@signal.state}") run_callbacks(:"on_#{@signal.status}") - run_callbacks(@signal.ok? ? :on_ok : :on_ko) + run_callbacks(:on_ok) if @signal.ok? + run_callbacks(:on_ko) if @signal.ko? end end diff --git a/spec/integration/tasks/callbacks_spec.rb b/spec/integration/tasks/callbacks_spec.rb index 35c48243e..6c6ee899d 100644 --- a/spec/integration/tasks/callbacks_spec.rb +++ b/spec/integration/tasks/callbacks_spec.rb @@ -36,10 +36,10 @@ def callback_log_task(status: :success) expect(result.context[:log]).to eq(%i[before_execution before_validation on_complete on_success on_ok]) end - it "fires before_execution, before_validation, on_interrupted, on_skipped, on_ok for a skipped task" do + it "fires before_execution, before_validation, on_interrupted, on_skipped, on_ok, on_ko for a skipped task" do result = callback_log_task(status: :skip).execute - expect(result.context[:log]).to eq(%i[before_execution before_validation on_interrupted on_skipped on_ok]) + expect(result.context[:log]).to eq(%i[before_execution before_validation on_interrupted on_skipped on_ok on_ko]) end it "fires before_execution, before_validation, on_interrupted, on_failed, on_ko for a failed task" do From a4c13a5080ad245889de4c674c99acedb90ced4e Mon Sep 17 00:00:00 2001 From: Juan Gomez <drexed@users.noreply.github.com> Date: Thu, 23 Apr 2026 10:04:27 -0400 Subject: [PATCH 32/54] Update docs --- docs/basics/chain.md | 8 ++++-- docs/basics/context.md | 26 +++++++++++-------- docs/basics/execution.md | 8 ++++++ docs/basics/setup.md | 2 +- docs/configuration.md | 2 +- docs/inputs/defaults.md | 4 +-- docs/inputs/transformations.md | 4 +++ docs/inputs/validations.md | 14 +++++++++++ docs/interruptions/exceptions.md | 40 +++++++++++++---------------- docs/interruptions/faults.md | 2 +- docs/interruptions/signals.md | 43 ++++---------------------------- docs/middlewares.md | 8 ++++-- docs/outcomes/errors.md | 2 ++ docs/outcomes/result.md | 13 +++++++--- docs/retries.md | 10 +++++--- docs/tips_and_tricks.md | 2 +- docs/v2-migration.md | 13 +++++----- 17 files changed, 108 insertions(+), 93 deletions(-) diff --git a/docs/basics/chain.md b/docs/basics/chain.md index e5dc0110e..239a60db6 100644 --- a/docs/basics/chain.md +++ b/docs/basics/chain.md @@ -71,7 +71,7 @@ result.chain.map(&:task) !!! note - Chain lifecycle is automatic: Runtime installs a fresh chain when the top-level task starts and clears it on teardown. + Chain lifecycle is automatic: Runtime installs a fresh chain when the top-level task starts, freezes it (and its results array) on root teardown, and clears the fiber-local reference. `result.index` is `nil` on a result that was never appended to the chain — every finalized `Result` is always on its chain, so this only happens in test doubles. ## Correlation ID (xid) @@ -109,5 +109,9 @@ end # Inspect or clear the current fiber's chain (rarely needed) CMDx::Chain.current #=> Returns current chain or nil -CMDx::Chain.clear #=> Clears current fiber's chain +CMDx::Chain.clear #=> Clears current fiber's chain (Runtime does this on teardown) ``` + +!!! warning + + Runtime freezes the chain on root teardown, so `CMDx::Chain.clear` is really only useful in test setup before the next execution. Mutating a frozen chain raises `FrozenError`. diff --git a/docs/basics/context.md b/docs/basics/context.md index a1ecd36a3..76e4b5850 100644 --- a/docs/basics/context.md +++ b/docs/basics/context.md @@ -28,21 +28,24 @@ class CalculateShipping < CMDx::Task carrier = context.dig(:options, :carrier) attempt = context.retrieve(:attempt_count, 0) # fetch-or-set context.weight? # truthy predicate + context.empty? # false when any key stored + context.size # number of top-level keys # Writes context.calculated_at = Time.now - context[:status] = "calculating" + context[:status] = "calculating" # alias: context.store(:status, "...") context.insurance_included ||= false - context.merge(shipping_cost: calculate_cost) # top-level last-write-wins + context.merge(shipping_cost: calculate_cost) # top-level last-write-wins (mutates in place) context.deep_merge(options: { carrier: "ups" }) # recurses into Hash values context.delete(:credit_card_token) + context.clear # wipes every entry end end ``` !!! note - Method-style reads return `nil` for unknown keys. `Context` includes `Enumerable` and exposes the usual `keys`/`values`/`key?`/`each`/`each_key`/`each_value`. See YARD for the full surface. + Method-style reads return `nil` for unknown keys. `Context` includes `Enumerable` and exposes the usual `keys`/`values`/`key?`/`each`/`each_key`/`each_value`. `#merge` / `#deep_merge` mutate in place and return `self`. `#store` (aliased `[]=`) symbolizes the key. See YARD for the full surface. ## Serialization @@ -112,15 +115,16 @@ class CalculateShipping < CMDx::Task end ``` -Strict mode only affects the dynamic method reader. `[]`, `fetch`, `dig`, `key?`, and `ctx.foo?` predicates keep their lenient semantics so defaults and explicit presence checks still work: +Strict mode only affects the dynamic method reader. Every other access channel keeps its lenient semantics so defaults and explicit presence checks still work: -```ruby -context[:missing] #=> nil -context.fetch(:missing, :default) #=> :default -context.dig(:a, :b) #=> nil -context.missing? #=> false -context.missing = 1 #=> 1 (writes still allowed) -``` +| Access | Behavior in strict mode | +|--------|-------------------------| +| `context.missing` | raises `NoMethodError` | +| `context[:missing]` | returns `nil` | +| `context.fetch(:missing, :default)` | returns `:default` | +| `context.dig(:a, :b)` | returns `nil` | +| `context.missing?` | returns `false` | +| `context.missing = 1` | writes succeed | !!! note diff --git a/docs/basics/execution.md b/docs/basics/execution.md index 945fb6bc3..f20847412 100644 --- a/docs/basics/execution.md +++ b/docs/basics/execution.md @@ -78,3 +78,11 @@ rescue StandardError => e ErrorTracker.capture(unhandled_exception: e) end ``` + +!!! note "Strict re-raise order" + + When `work` raises a non-framework `StandardError`, Runtime captures it on `result.cause` **and** re-raises the **original** exception under strict mode — not a `Fault`. Put `rescue CMDx::Fault` before `rescue StandardError` (Fault is a `StandardError` subclass). A `fail!` / `throw!` / validation / output failure has no captured `cause`, so it raises `Fault` carrying the caused-failure leaf as `fault.result`. + +!!! note "Teardown ordering" + + Result finalization runs **before** the strict re-raise, and teardown (freeze + chain clear) runs in an `ensure` after. This means `fault.result`, `fault.context`, and `fault.chain` are all safe to read inside any `rescue` — and a lifecycle log line / `:task_executed` telemetry event still fires on strict failures. diff --git a/docs/basics/setup.md b/docs/basics/setup.md index cb756af37..4f8ac1fb0 100644 --- a/docs/basics/setup.md +++ b/docs/basics/setup.md @@ -82,7 +82,7 @@ Tasks follow a predictable execution pattern. Halt primitives — `success!`, `s | **Before validation** | `before_validation` callbacks run next | | **Validation** | Inputs are coerced/validated; failures halt with `failed` | | **Work** | `work` runs inside `catch(:cmdx_signal)`, wrapped in retries | -| **Output verification** | Declared `output` keys are checked on `context` (every declared output is implicitly required); `:default` fills nils, missing keys fail the task | +| **Output verification** | Declared `output` keys are checked on `context` when `work` returned without halting; `:default` fills nils, missing keys fail the task. Skipped when `work` halts via `success!` / `skip!` / `fail!` / `throw!` | | **Rollback** | `rollback` runs when the signal is `failed` (before completion callbacks) | | **Completion callbacks** | `on_<state>`, `on_<status>`, `on_ok`/`on_ko` fire in that order | | **Result finalization** | `Result` built and added to `Chain` (root is `unshift`ed; children are `push`ed) | diff --git a/docs/configuration.md b/docs/configuration.md index 72cfa24ac..efc66a81c 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -451,4 +451,4 @@ end !!! warning "Important" - `reset_configuration!` clears `@middlewares`, `@callbacks`, `@coercions`, `@validators`, and `@telemetry` on `Task` only — subclasses that already cached their own copy keep them. In tests, prefer letting each example use freshly defined task classes (e.g. via `stub_const` or anonymous classes). + `reset_configuration!` clears `@middlewares`, `@callbacks`, `@coercions`, `@validators`, `@executors`, `@mergers`, and `@telemetry` on `Task` only — subclasses that already cached their own copy keep them. In tests, prefer letting each example use freshly defined task classes (e.g. via `stub_const` or anonymous classes). diff --git a/docs/inputs/defaults.md b/docs/inputs/defaults.md index c30ccfe83..9ce54e8e8 100644 --- a/docs/inputs/defaults.md +++ b/docs/inputs/defaults.md @@ -4,7 +4,7 @@ Provide fallback values for optional inputs. Defaults kick in when values aren't ## Declarations -Defaults work seamlessly with coercions, validations, and nested inputs: +Defaults compose with coercions, validations, and nested inputs: ### Static Values @@ -94,7 +94,7 @@ end !!! note - Defaults only apply when the resolved value is `nil`. An explicitly provided `nil` is treated as missing and the default fires. + Defaults only apply when the resolved value is `nil`. An explicitly provided `nil` is treated as missing and the default fires. This applies equally whether the key was absent from `context` or the caller sent `nil` explicitly — both are "not provided". !!! warning "Required + default" diff --git a/docs/inputs/transformations.md b/docs/inputs/transformations.md index a8fbd3891..780e51247 100644 --- a/docs/inputs/transformations.md +++ b/docs/inputs/transformations.md @@ -80,3 +80,7 @@ class ScheduleBackup < CMDx::Task optional :frequency, transform: :downcase, inclusion: { in: %w[hourly daily weekly monthly] } end ``` + +!!! warning "Optional + nil value" + + Transforms run only when the coerced value is non-`nil`. When an optional input's key is absent from `context` and no default is declared, the pipeline short-circuits after the default step — transforms (and validators) don't fire. Declare a `default:` or `required` if you need the transform to always run. diff --git a/docs/inputs/validations.md b/docs/inputs/validations.md index 84b6a73d8..3477f6ae1 100644 --- a/docs/inputs/validations.md +++ b/docs/inputs/validations.md @@ -311,6 +311,20 @@ class SetupApplication < CMDx::Task end ``` +## `required` vs `presence: true` + +These two aren't interchangeable: + +| Declaration | When the caller omits the key | +|-------------|------------------------------| +| `required :email` | `email is required` — the missing-key error is added; validators don't run | +| `input :email, presence: true` | **No error.** Validators (including `presence`) are skipped when an optional key is absent | +| `required :email, presence: true` | Both: missing-key gate first; `presence:` then re-runs on the resolved value | + +!!! danger "Optional + validator" + + Validators (including `presence`) do **not** run when an optional input's final resolved value is `nil` — the pipeline short-circuits after the default step. `input :email, presence: true` by itself enforces nothing when the caller omits `email`: declare it with `required :email` (or `required :email, presence: true`) whenever the caller must supply the key. + ## Error Handling Validation failures accumulate on `task.errors` and surface as a failed result with the joined sentence as `result.reason`. See [Inputs - Error Handling](definitions.md#error-handling) for the full lifecycle. diff --git a/docs/interruptions/exceptions.md b/docs/interruptions/exceptions.md index d96299c3d..c1eddd27a 100644 --- a/docs/interruptions/exceptions.md +++ b/docs/interruptions/exceptions.md @@ -38,13 +38,22 @@ end ### CMDx::DefinitionError -Raised at class-load time when an input declaration would clash with an existing accessor on the task (e.g. `:context`, `:errors`, or any user-defined method). +Raised at class-load time when a declaration is structurally invalid: + +- An input name clashes with an existing accessor on the task (e.g. `:context`, `:errors`, or any user-defined method). +- A workflow `task` / `tasks` declaration is called with options but no tasks. ```ruby class ConflictingTask < CMDx::Task required :context #=> raises CMDx::DefinitionError # "cannot define input :context: #context is already defined on ConflictingTask" end + +class EmptyGroupWorkflow < CMDx::Task + include CMDx::Workflow + tasks strategy: :parallel #=> raises CMDx::DefinitionError + # "EmptyGroupWorkflow: cannot declare an empty task group" +end ``` ### CMDx::DeprecationError @@ -134,9 +143,7 @@ end ## Execute vs Execute! -### Non-bang execution - -`Runtime` rescues every non-framework `StandardError` raised inside `work` and converts it into a failed result. The exception is preserved on `result.cause`; its class and message become `result.reason`. Any `CMDx::Error` subclass propagates instead—framework errors are never swallowed: +`Runtime#perform_work` rescues in a strict order: `Fault` (echoes) → `CMDx::Error` (**re-raises**, never converts to a failed result) → `StandardError` (converts to a failed result with `cause` set). `execute!` then re-raises: if `result.cause` holds a captured exception, the **original** exception bubbles up; otherwise a `CMDx::Fault` wrapping the failed result is raised. ```ruby class CompressDocument < CMDx::Task @@ -146,21 +153,12 @@ class CompressDocument < CMDx::Task end end -result = CompressDocument.execute(document_id: "unknown-doc-id") -result.failed? #=> true -result.reason #=> "[ActiveRecord::RecordNotFound] Couldn't find Document with 'id'=unknown-doc-id" -result.cause #=> #<ActiveRecord::RecordNotFound> -``` - -!!! note "Framework errors inside `work`" - - `Runtime#perform_work` rescues in this order: `Fault` (echoes), then `CMDx::Error` (**re-raises**, never converts to a failed result), then `StandardError` (converts to a failed result with `cause` set). So raising any `CMDx::Error` subclass — `DefinitionError`, `DeprecationError`, `ImplementationError`, `MiddlewareError`, or a custom subclass — inside `work` propagates out of both `execute` and `execute!`. - -### Bang execution - -`execute!` re-raises on failure. When `Runtime` had captured an underlying exception (`result.cause` is set), that **original** exception is re-raised; otherwise a `CMDx::Fault` carrying the failed result is raised: +CompressDocument.execute(document_id: "unknown-doc-id").then do |r| + r.failed? #=> true + r.reason #=> "[ActiveRecord::RecordNotFound] Couldn't find Document with 'id'=unknown-doc-id" + r.cause #=> #<ActiveRecord::RecordNotFound> +end -```ruby begin CompressDocument.execute!(document_id: "unknown-doc-id") rescue ActiveRecord::RecordNotFound => e @@ -168,10 +166,6 @@ rescue ActiveRecord::RecordNotFound => e end ``` -See [Faults](faults.md) for `Fault.for?` / `Fault.matches?` matchers. - -## When Each Path Raises - | Trigger | `execute` (safe) | `execute!` (strict) | |---------|------------------|---------------------| | `success!` | success result | success result | @@ -185,6 +179,8 @@ See [Faults](faults.md) for `Fault.for?` / `Fault.matches?` matchers. | `DefinitionError` from a conflicting input declaration | propagates at class-load time | propagates at class-load time | | Non-`StandardError` (e.g. `Interrupt`, `SignalException`) | propagates | propagates | +See [Faults](faults.md) for `Fault.for?` / `Fault.matches?` matchers. + ## Backtrace Cleaning `Fault` backtraces are passed through the configured `backtrace_cleaner` (set on `CMDx.configuration.backtrace_cleaner` or per-task via `settings`). This is useful for stripping framework frames in Rails apps: diff --git a/docs/interruptions/faults.md b/docs/interruptions/faults.md index cc5a21612..0c1b0d6ef 100644 --- a/docs/interruptions/faults.md +++ b/docs/interruptions/faults.md @@ -134,7 +134,7 @@ rescue CMDx::Fault => e end # Or via non-bang execute: -result = DocumentWorkflow.execute(invalid_data) +result = DocumentWorkflow.execute(document_data: data) if result.failed? origin = result.caused_failure puts "Originated by #{origin.task}: #{origin.reason}" diff --git a/docs/interruptions/signals.md b/docs/interruptions/signals.md index 83b701627..1d9e0a762 100644 --- a/docs/interruptions/signals.md +++ b/docs/interruptions/signals.md @@ -170,44 +170,7 @@ result.ko? #=> true for both skipped and failed ## Execution Behavior -Halt methods behave differently depending on the entry point used: - -### Non-bang execution - -Returns the result object regardless of outcome; nothing raises: - -```ruby -result = ProcessRefund.execute(refund_id: 789) - -case result.status -when "success" - puts "Refund processed: $#{result.context.refund.amount}" -when "skipped" - puts "Refund skipped: #{result.reason}" -when "failed" - puts "Refund failed: #{result.reason}" - handle_refund_error(result.metadata[:error_code]) -end -``` - -### Bang execution - -Raises `CMDx::Fault` only when the result is `failed?`. Skipped results return normally: - -```ruby -begin - result = ProcessRefund.execute!(refund_id: 789) - - if result.skipped? - puts "Skipped: #{result.reason}" - else - puts "Success: Refund processed" - end -rescue CMDx::Fault => e - puts "Failed: #{e.message}" - handle_refund_failure(e.result.metadata[:error_code]) -end -``` +`execute` always returns a `Result`, regardless of whether `work` finished normally or halted via a signal. `execute!` only raises on `failed?` — `skip!` and `success!` return normally. See [Basics - Execution](../basics/execution.md) for the full entry-point contract and [Interruptions - Faults](faults.md) for the rescued exception hierarchy. ## Rethrowing a Peer Failure @@ -226,6 +189,10 @@ end The resulting `Result` carries the upstream failure in `result.origin`; `result.thrown_failure?` is `true`. See [Result - Chain Analysis](../outcomes/result.md#chain-analysis). +!!! note + + `throw!` accepts either a `Result` or a raw `CMDx::Signal`. Passing a non-failed result (or a non-failed signal) is a no-op — the caller continues past the `throw!` line. Use this whenever you're forwarding another task's halt state without unwrapping it yourself. + ## Best Practices Prefer specific reasons — they become `result.reason`, `Fault#message`, and end up in logs and telemetry: diff --git a/docs/middlewares.md b/docs/middlewares.md index a776aaa80..5eb29f93c 100644 --- a/docs/middlewares.md +++ b/docs/middlewares.md @@ -130,8 +130,8 @@ end ```ruby class ProcessCampaign < CMDx::Task register :middleware, AuditMiddleware, if: :audited? - register :middleware, CacheMiddleware, unless: ->(task) { task.context.skip_cache } - register :middleware, TracingMiddleware, if: TracingSampler # #call(task) + register :middleware, CacheMiddleware, unless: -> { context.skip_cache } + register :middleware, TracingMiddleware, if: TracingSampler.new # #call(task) def work # ... @@ -143,6 +143,10 @@ class ProcessCampaign < CMDx::Task end ``` +!!! note + + Procs are `instance_exec`'d on the task with zero args, so `self` is the task — a 1-arity lambda like `->(task) { ... }` raises `ArgumentError`. For `#call`-ables, passing a class dispatches to `Klass.call(task)` (needs `def self.call`); passing an instance dispatches to `instance.call(task)`. + When a gate is falsy, the middleware is skipped and the chain walks straight to the next link — inner middlewares still run. Gates do not need to yield; only the middleware itself does. !!! note diff --git a/docs/outcomes/errors.md b/docs/outcomes/errors.md index b1cd7f2ba..a6925e091 100644 --- a/docs/outcomes/errors.md +++ b/docs/outcomes/errors.md @@ -47,6 +47,8 @@ result.errors.frozen? #=> true | `errors.size` | Number of distinct keys. | | `errors.count` | Total messages across all keys. | | `errors.each` | Yields `[Symbol, Set<String>]` pairs. `each_key` and `each_value` are also available. | +| `errors.as_json` | Alias for `to_h` — for Rails/ActiveSupport callers. | +| `errors.to_json` | Serializes `to_h` via the `json` stdlib (Symbol keys emitted as strings). | ```ruby def work diff --git a/docs/outcomes/result.md b/docs/outcomes/result.md index 56d9d19f9..c46d106d2 100644 --- a/docs/outcomes/result.md +++ b/docs/outcomes/result.md @@ -32,13 +32,14 @@ result.status #=> "failed" result.reason #=> "Build tool not found" result.metadata #=> { error_code: "BUILD_TOOL.NOT_FOUND" } result.cause #=> nil, the rescued StandardError, or the propagated Fault -result.backtrace #=> caller_locations captured by fail!/throw! (Array<String>), or nil +result.backtrace #=> caller_locations captured by fail!/throw! (Array<Thread::Backtrace::Location>), or nil + # (Fault#backtrace stringifies these through the configured backtrace_cleaner) # Lifecycle metadata result.duration #=> 12.34 (milliseconds, monotonic) result.retries #=> 2 result.retried? #=> true -result.strict? #=> false (true when produced via execute!) +result.strict? #=> false (true when produced via execute! or execute(strict: true)) result.deprecated? #=> false result.rolled_back? #=> false result.tags #=> [] (from settings(tags: [...])) @@ -208,7 +209,7 @@ end ## Serialization -`to_h` returns a memoized hash suitable for telemetry sinks and structured logs. `to_s` is the space-separated `key=value.inspect` rendering that `Runtime` writes to the task logger after `task_executed`. +`to_h` returns a memoized hash suitable for telemetry sinks and structured logs. `as_json` aliases `to_h` for Rails/ActiveSupport callers; `to_json` serializes via the `json` stdlib. `to_s` is the space-separated `key=value.inspect` rendering that `Runtime` writes to the task logger after `task_executed`. ```ruby result.to_h @@ -224,8 +225,14 @@ result.to_h # duration: 12.34, tags: [] # } +result.as_json #=> same hash as to_h +result.to_json #=> '{"xid":null,"cid":"0190...",...}' result.to_s #=> "xid=nil cid=\"0190...\" index=0 ... state=\"complete\" status=\"success\" ..." ``` On `failed?` results, `to_h` additionally includes `:cause`, `:origin`, `:threw_failure`, `:caused_failure`, and `:rolled_back`. The `_failure` and `:origin` entries are compact `{ task:, tid: }` hashes (and render as `<TaskClass uuid>` in `to_s`) to avoid serializing entire upstream results. `:origin` is `nil` when the failure is locally originated. + +!!! note + + `to_json` emits the task Class and any `:cause` Exception via their stdlib `to_json` defaults; the embedded `:context` delegates to `Context#to_json`. Symbol keys are emitted as strings. diff --git a/docs/retries.md b/docs/retries.md index 4d07e25cb..f39cbcf9c 100644 --- a/docs/retries.md +++ b/docs/retries.md @@ -153,16 +153,20 @@ end ## Conditional Retries -`:if` / `:unless` gate each retry attempt. The gate receives `(task, error, attempt)` — when falsy (`if`) or truthy (`unless`), the rescued exception is re-raised instead of retried, skipping any remaining budget and the `wait` between attempts. +`:if` / `:unless` gate each retry attempt. When the gate is falsy (`if`) or truthy (`unless`), the rescued exception is re-raised instead of retried, skipping any remaining budget and the `wait` between attempts. -Symbol, Proc, and any `#call`-able resolve against the task (Procs via `instance_exec`, Symbols via `task.send(sym, error, attempt)`, callables via `gate.call(task, error, attempt)`). +| Gate form | How it's invoked | Effective signature | +|-----------|------------------|---------------------| +| `Symbol` | `task.send(sym, error, attempt)` | `def sym(error, attempt)` | +| `Proc` / lambda | `task.instance_exec(error, attempt, &gate)` (`self` is the task) | `->(error, attempt) { ... }` | +| `#call`-able | `gate.call(task, error, attempt)` | `def call(task, error, attempt)` | ```ruby class FetchProfile < CMDx::Task retry_on ApiError, limit: 5, delay: 1.0, - if: ->(_task, error, _attempt) { error.retryable? } + if: ->(error, _attempt) { error.retryable? } retry_on Net::ReadTimeout, if: :transient?, limit: 3 diff --git a/docs/tips_and_tricks.md b/docs/tips_and_tricks.md index df8603044..4a5474391 100644 --- a/docs/tips_and_tricks.md +++ b/docs/tips_and_tricks.md @@ -91,7 +91,7 @@ class ExportReport < CMDx::Task # 3. Define callbacks before_execution :find_report - on_complete :track_export_metrics, if: ->(task) { Current.tenant.analytics? } + on_complete :track_export_metrics, if: -> { Current.tenant.analytics? } # 4. Declare inputs optional :user_id diff --git a/docs/v2-migration.md b/docs/v2-migration.md index ed3571ef9..80ca296e9 100644 --- a/docs/v2-migration.md +++ b/docs/v2-migration.md @@ -39,7 +39,7 @@ CMDx 2.0 is a full runtime rewrite. The public DSL — `required`, `optional`, c | Chain storage | thread-local | fiber-local (parallel-safe) | | Breakpoints | `task_breakpoints` / `workflow_breakpoints` | removed — use `execute!` for strict mode | | Loader | Zeitwerk | explicit `require_relative` | -| Pattern matching | n/a | `case result in [_, _, "complete", "success", *]` | +| Pattern matching | n/a | `case result in [*, [:status, "success"], *]` | | `result.task` | task **instance** | task **class** | | `result.chain` | results `Array` | `Chain` object (`Enumerable`) | @@ -94,7 +94,7 @@ end !!! note - `CMDx.reset_configuration!` is new — call it in test setup/teardown to wipe the global config and invalidate `Task`'s cached registries. + `CMDx.reset_configuration!` is new — call it in test setup/teardown to replace the global config and invalidate the cached registries (`@middlewares`, `@callbacks`, `@coercions`, `@validators`, `@executors`, `@mergers`, `@telemetry`) **on `Task`** only. Subclass caches aren't cleared — prefer freshly defined task classes (or `stub_const`/anonymous classes) per example. See [Configuration](configuration.md) for the full surface. @@ -381,8 +381,8 @@ result.on(:success) { |r| deliver(r.context) } # predicate dispatch # Accepted keys: :complete :interrupted :success :skipped :failed :ok :ko case result # pattern matching -in [_, _, "complete", "success", *] then ok! -in [_, _, _, "failed", reason, *] then alert(reason) +in [*, [:status, "success"], *] then ok! +in [*, [:status, "failed"], *, [:reason, reason], *] then alert(reason) in { task:, status: "failed", cause: } then ... end @@ -433,7 +433,8 @@ end - Each parallel worker `deep_dup`s the workflow context, runs its task, then merges its successful child context back into the workflow (on the parent thread, after all workers join). - All workers share the parent's fiber-local `Chain` — each worker sets `Fiber[Chain::STORAGE_KEY]` on thread entry, and each result is pushed under a `Mutex`. -- After all workers finish, the first **by declaration index** failed result halts the pipeline via `throw!`. Successful contexts merge in index order; failed ones are discarded. +- With the default `fail_fast: false`, all workers run to completion, successful contexts merge in declaration order, and the first failure **by declaration index** halts the pipeline via `throw!`. With `fail_fast: true`, queued workers are drained as soon as any sibling fails (in-flight tasks still finish), and the first failure **by completion time** is propagated. +- Additional knobs: `:executor` (`:threads` default, `:fibers`, or a callable), `:merge_strategy` (`:last_write_wins` default, `:deep_merge`, `:no_merge`, or a callable), and `:fail_fast`. See [Workflows - Parallel Execution](workflows.md#parallel-execution). ### Behavioral Changes @@ -477,7 +478,7 @@ New in v2: CMDx::Error = CMDx::Exception (StandardError) ├── CMDx::Fault ├── CMDx::DeprecationError -├── CMDx::DefinitionError (NEW — duplicate input/output accessor) +├── CMDx::DefinitionError (NEW — conflicting input accessor, or empty workflow task group) ├── CMDx::ImplementationError (NEW — Task#work unoverridden, or Workflow#work defined) └── CMDx::MiddlewareError (NEW — middleware didn't yield) ``` From a195301ba04889c099a249379c1949a9729cf13f Mon Sep 17 00:00:00 2001 From: Juan Gomez <drexed@users.noreply.github.com> Date: Thu, 23 Apr 2026 10:22:28 -0400 Subject: [PATCH 33/54] CLean up --- docs/inputs/definitions.md | 6 +++++- lib/cmdx/input.rb | 8 +++++--- lib/cmdx/inputs.rb | 2 +- spec/integration/tasks/inputs_spec.rb | 25 +++++++++++++++++++++++++ 4 files changed, 36 insertions(+), 5 deletions(-) diff --git a/docs/inputs/definitions.md b/docs/inputs/definitions.md index 5e4ac6b14..27c6b17c3 100644 --- a/docs/inputs/definitions.md +++ b/docs/inputs/definitions.md @@ -146,7 +146,11 @@ Each entry exposes `:name` (the accessor name, post-`:as`/`:prefix`/`:suffix`), !!! note - `:required` in the schema reflects the static flag only. Conditional `required: true` with `:if`/`:unless` still serializes as `required: true` because the schema is generated without a task instance — inspect `options[:if]` / `options[:unless]` when generating external documentation. + `:required` in the schema reflects the static flag only — it's `Input#required?` evaluated without a task, so `:if` / `:unless` gates are ignored at schema time. Inspect `options[:if]` / `options[:unless]` when generating external documentation. + +!!! note + + A failed coercion or validator leaves the input's backing ivar at `nil`. The `Failure#message` (coercion) or validator message is recorded on `task.errors` under the input's accessor name, nested children are not resolved, and `Runtime` throws a failed signal before `work` runs. ## Sources diff --git a/lib/cmdx/input.rb b/lib/cmdx/input.rb index 1f7f91010..c955413c2 100644 --- a/lib/cmdx/input.rb +++ b/lib/cmdx/input.rb @@ -125,10 +125,11 @@ def required?(task = nil) # Fetches + coerces + transforms + validates the value from its # configured `:source` on `task`. Missing-but-required inputs add a - # validation error to `task.errors`. + # validation error to `task.errors`. Returns `nil` when coercion or any + # validator fails (the failure message is recorded on `task.errors`). # # @param task [Task] - # @return [Object, nil, Coercions::Failure] the resolved value + # @return [Object, nil] the resolved value (`nil` on failure) def resolve(task) value, key_provided = resolve_with_key(task) run_pipeline(value, key_provided, task) @@ -139,7 +140,7 @@ def resolve(task) # # @param parent_value [#[], #key?, Object] the parent input's resolved value # @param task [Task] - # @return [Object, nil, Coercions::Failure] + # @return [Object, nil] def resolve_from_parent(parent_value, task) value, key_provided = resolve_from_parent_with_key(parent_value) run_pipeline(value, key_provided, task) @@ -192,6 +193,7 @@ def run_pipeline(value, key_provided, task) value = apply_transform(value, task) if transform @validators ||= task.class.validators.extract(@options) task.class.validators.validate(task, accessor_name, value, @validators) + return if task.errors.for?(accessor_name) value end diff --git a/lib/cmdx/inputs.rb b/lib/cmdx/inputs.rb index 3e6a0c056..420269aa8 100644 --- a/lib/cmdx/inputs.rb +++ b/lib/cmdx/inputs.rb @@ -76,7 +76,7 @@ def resolve(task) private def resolve_children(input, parent_value, task) - return if input.children.empty? || parent_value.nil? || parent_value.is_a?(Coercions::Failure) + return if input.children.empty? || parent_value.nil? input.children.each do |child| child_value = child.resolve_from_parent(parent_value, task) diff --git a/spec/integration/tasks/inputs_spec.rb b/spec/integration/tasks/inputs_spec.rb index 8e2726bf5..087331902 100644 --- a/spec/integration/tasks/inputs_spec.rb +++ b/spec/integration/tasks/inputs_spec.rb @@ -91,6 +91,18 @@ expect(result.errors.to_h[:count].first).to include("integer") end + it "leaves the backing ivar at nil when coercion fails" do + klass = create_task_class(name: "CoerceFailIvar") do + input :count, coerce: :integer + define_method(:work) { nil } + end + + instance = klass.new(count: "abc") + instance.execute + + expect(instance.instance_variable_get(:@_input_count)).to be_nil + end + it "falls back through a multi-type list" do task = create_task_class(name: "MultiCoerce") do input :value, coerce: %i[integer float] @@ -255,6 +267,19 @@ def self.call(value, task) expect(task.execute(frequency: "BIWEEKLY").errors.to_h).to include(:frequency) end + it "leaves the backing ivar at nil when a validator fails" do + klass = create_task_class(name: "ValidateFailIvar") do + input :age, coerce: :integer, numeric: { min: 18 } + define_method(:work) { nil } + end + + instance = klass.new(age: "5") + instance.execute + + expect(instance.instance_variable_get(:@_input_age)).to be_nil + expect(instance.errors.to_h[:age]).not_to be_empty + end + describe "inline :validate callables" do it "invokes a Symbol handler with the value on the task" do task = create_task_class(name: "InlineSymbolValidate") do From dfec964ea668861023a45729362cc6e8cf068cd0 Mon Sep 17 00:00:00 2001 From: Juan Gomez <drexed@users.noreply.github.com> Date: Mon, 4 May 2026 21:46:59 -0400 Subject: [PATCH 34/54] Add continue on failure --- CHANGELOG.md | 4 +- docs/v2-migration.md | 4 +- docs/workflows.md | 35 ++++- lib/cmdx/pipeline.rb | 51 ++++++-- lib/cmdx/workflow.rb | 15 ++- skills/references/workflows.md | 2 +- spec/cmdx/chain_spec.rb | 1 - spec/cmdx/pipeline_spec.rb | 138 ++++++++++++++++++-- spec/integration/workflows/parallel_spec.rb | 20 +-- 9 files changed, 232 insertions(+), 38 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f9bd9bbdb..67934f0e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -64,8 +64,8 @@ Full runtime rewrite: the v1 state-machine plus Zeitwerk architecture is replace - **BREAKING**: `Result#chain` now returns the owning `Chain` object directly instead of its results array (use `result.chain.to_a` / `result.chain.results`, or iterate via `Chain`'s new Enumerable methods) - **BREAKING**: Drive `Result#caused_failure` / `threw_failure` / `caused_failure?` / `thrown_failure?` off `Signal#origin` instead of `signal.cause`; `caused_failure` walks `origin` recursively to the originating leaf, `threw_failure` returns `origin || self`, `caused_failure?` is true when the result originated the failure chain, `thrown_failure?` is true when the result re-threw an upstream failure - 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` -- `Workflow` declares groups via `task` / `tasks` (still aliased) and supports `:strategy => :parallel`, `:pool_size`, `:fail_fast`, `:if` / `:unless` per group; defining `#work` on a workflow raises `ImplementationError` -- `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; opt-in `:fail_fast` drains pending tasks on the first failure (in-flight tasks still finish and successful contexts still merge) +- `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` +- `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. - `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 - `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` - `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` diff --git a/docs/v2-migration.md b/docs/v2-migration.md index 80ca296e9..eb1361fab 100644 --- a/docs/v2-migration.md +++ b/docs/v2-migration.md @@ -433,8 +433,8 @@ end - Each parallel worker `deep_dup`s the workflow context, runs its task, then merges its successful child context back into the workflow (on the parent thread, after all workers join). - All workers share the parent's fiber-local `Chain` — each worker sets `Fiber[Chain::STORAGE_KEY]` on thread entry, and each result is pushed under a `Mutex`. -- With the default `fail_fast: false`, all workers run to completion, successful contexts merge in declaration order, and the first failure **by declaration index** halts the pipeline via `throw!`. With `fail_fast: true`, queued workers are drained as soon as any sibling fails (in-flight tasks still finish), and the first failure **by completion time** is propagated. -- Additional knobs: `:executor` (`:threads` default, `:fibers`, or a callable), `:merge_strategy` (`:last_write_wins` default, `:deep_merge`, `:no_merge`, or a callable), and `:fail_fast`. See [Workflows - Parallel Execution](workflows.md#parallel-execution). +- By default (`continue_on_failure: false`), pending workers are drained as soon as any sibling fails (in-flight tasks still finish, successful contexts still merge), and the first failure **by completion time** is propagated. With `continue_on_failure: true`, every worker runs to completion and all failures are aggregated into the workflow's `errors` (keyed `"TaskClass.input"` for input/validation errors and `"TaskClass.<status>"` for bare `fail!` reasons); the first failure **by declaration index** is propagated via `throw!`. +- Additional knobs: `:executor` (`:threads` default, `:fibers`, or a callable), `:merge_strategy` (`:last_write_wins` default, `:deep_merge`, `:no_merge`, or a callable), and `:continue_on_failure`. See [Workflows - Parallel Execution](workflows.md#parallel-execution). ### Behavioral Changes diff --git a/docs/workflows.md b/docs/workflows.md index 34dd38b2e..98e60060a 100644 --- a/docs/workflows.md +++ b/docs/workflows.md @@ -66,7 +66,7 @@ Options apply to the entire group: | `pool_size:` | `tasks.size` | Worker/fiber count when `strategy: :parallel` | | `executor:` | `:threads` | Parallel dispatch backend: `:threads`, `:fibers`, or a callable. `:fibers` requires a `Fiber.scheduler` to be installed (e.g. inside `Async { ... }`) | | `merge_strategy:` | `:last_write_wins` | How successful parallel contexts fold back into the workflow context: `:last_write_wins`, `:deep_merge`, `:no_merge`, or a callable `->(workflow_context, result) { ... }` | -| `fail_fast:` | `false` | When `strategy: :parallel`, drain pending tasks on the first failure (in-flight tasks still finish) | +| `continue_on_failure:` | `false` | When `true`, run every task in the group to completion even after a failure, and aggregate all failures into the workflow's `errors` (keyed `"TaskClass.input"` for input/validation errors and `"TaskClass.<status>"` for bare `fail!` reasons). Applies to both strategies. When `false` (default), `:sequential` halts on the first failure and `:parallel` cancels pending tasks (in-flight tasks still finish) | | `if:` / `unless:` | — | Skip the entire group when the predicate isn't satisfied | ### Conditionals @@ -222,15 +222,40 @@ class SendWelcomeNotifications < CMDx::Task tasks SendWelcomeEmail, SendWelcomeSms, SendWelcomePush, strategy: :parallel, pool_size: 2 - # Abort pending parallel tasks once any sibling fails - tasks ChargeCard, ReserveInventory, EmitAnalytics, - strategy: :parallel, fail_fast: true + # Default behavior: pending parallel tasks are cancelled once any sibling fails + tasks ChargeCard, ReserveInventory, EmitAnalytics, strategy: :parallel + + # Batch processing: run every task and collect every failure into result.errors + tasks ProcessOrder1, ProcessOrder2, ProcessOrder3, + strategy: :parallel, continue_on_failure: true end ``` !!! warning - Each parallel task receives its own deep-duplicated `context` copy, which is merged back into the workflow's context after execution. If multiple tasks write to the same key, the last merge wins non-deterministically. Use distinct keys per task to avoid conflicts. If any parallel task fails, the failed result is propagated through `throw!` (the other tasks still complete first; they just don't merge back). With `fail_fast: true`, tasks still queued when a sibling fails are skipped entirely; in-flight tasks run to completion and their successful contexts still merge. + Each parallel task receives its own deep-duplicated `context` copy, which is merged back into the workflow's context after execution. If multiple tasks write to the same key, the last merge wins non-deterministically. Use distinct keys per task to avoid conflicts. By default, when any parallel task fails, pending tasks are cancelled (in-flight tasks still finish and successful contexts still merge) and the failed result is propagated through `throw!`. With `continue_on_failure: true`, every task runs to completion and all failures are aggregated into the workflow's `errors` keyed `"TaskClass.input"` (validation errors) or `"TaskClass.<status>"` (bare `fail!` reasons); the first failure (declaration order) is still propagated through `throw!`. + +### Batch processing with `continue_on_failure` + +For batch-style groups where you want to know about every failure rather than stopping at the first one, set `continue_on_failure: true`. Failures are aggregated into the workflow's `errors` collection. + +```ruby +class ProcessOrders < CMDx::Task + include CMDx::Workflow + + tasks ProcessOrderA, ProcessOrderB, ProcessOrderC, continue_on_failure: true +end + +result = ProcessOrders.execute +result.failed? # => true (any task in the group failed) +result.errors.to_h +# => { +# :"ProcessOrderA.failed" => ["card declined"], +# :"ProcessOrderC.amount" => ["amount must be greater than 0"] +# } +``` + +The pipeline still halts after the failed group — subsequent groups do not run. The first failure (declaration order) is the signal origin. ### Executors diff --git a/lib/cmdx/pipeline.rb b/lib/cmdx/pipeline.rb index 7476935c0..934749104 100644 --- a/lib/cmdx/pipeline.rb +++ b/lib/cmdx/pipeline.rb @@ -6,6 +6,13 @@ module CMDx # pipeline by echoing the failed result's signal through `throw!`, which # bubbles up through Runtime as the workflow's own failure. # + # Groups may opt into batch semantics with `continue_on_failure: true`, + # in which case every task in the group runs to completion and all + # failures are aggregated into the workflow's `errors` (keyed as + # `"TaskClass.input"` for input/validation errors and `"TaskClass.<status>"` + # for bare `fail!` reasons) before the pipeline halts on the first + # failure (declaration order). + # # @see Workflow class Pipeline @@ -51,22 +58,26 @@ def execute private def run_sequential(group) - group.tasks.each do |task| + continue = group.options[:continue_on_failure] + failures = group.tasks.each_with_object([]) do |task, bucket| result = task.execute(@workflow.context) - return result if result.failed? + next unless result.failed? + + bucket << result + break bucket unless continue end - nil + aggregate(failures, continue:) end def run_parallel(group) tasks = group.tasks chain = Chain.current size = group.options[:pool_size] || tasks.size - fail_fast = group.options[:fail_fast] + continue = group.options[:continue_on_failure] results = Array.new(tasks.size) mutex = Mutex.new - failed = nil + seen_fail = false cancelled = false jobs = tasks.each_with_index.to_a @@ -81,8 +92,8 @@ def run_parallel(group) mutex.synchronize do results[index] = result - if fail_fast && result.failed? && failed.nil? - failed = result + if result.failed? && !continue && !seen_fail + seen_fail = true cancelled = true end end @@ -93,17 +104,39 @@ def run_parallel(group) executor.call(jobs:, concurrency: size, on_job:) + failures = [] results.each do |result| next if result.nil? if result.failed? - failed ||= result + failures << result else merger.call(@workflow.context, result) end end - failed + aggregate(failures, continue:) + end + + def aggregate(failures, continue:) + return if failures.empty? + return failures.first unless continue + + failures.each do |result| + prefix = result.task.name + + if result.errors.empty? + message = result.reason || I18nProxy.t("cmdx.reasons.unspecified") + @workflow.errors.add(:"#{prefix}.#{result.status}", message) + else + result.errors.each do |key, messages| + namespaced = :"#{prefix}.#{key}" + messages.each { |message| @workflow.errors.add(namespaced, message) } + end + end + end + + failures.first end end diff --git a/lib/cmdx/workflow.rb b/lib/cmdx/workflow.rb index 88a6d969f..f9e4a0c0e 100644 --- a/lib/cmdx/workflow.rb +++ b/lib/cmdx/workflow.rb @@ -38,8 +38,19 @@ def pipeline # into the workflow context. Merging happens in declaration order. A # callable `->(workflow_context, result) { ... }` may be passed to # implement custom behavior (e.g. namespacing by task name). - # @option options [Boolean] :fail_fast (false) when `:parallel`, drain - # pending tasks on the first failure (in-flight tasks still finish) + # @option options [Boolean] :continue_on_failure (false) when `true`, + # run every task in the group to completion (even after a failure) + # and aggregate all failures into the workflow's `errors`. Each + # failed result's `errors` are merged in with keys namespaced as + # `"TaskClass.input"`; failures with no errors entries (bare + # `fail!("reason")`) record under `"TaskClass.<status>"` (e.g. + # `"MyTask.failed"`) with `result.reason` as the message (falling + # back to the localized `cmdx.reasons.unspecified` string when + # `reason` is nil). The pipeline still halts after the group with + # the first failure (declaration order) as the signal origin. + # Applies to both `:sequential` and `:parallel` strategies. When + # `false` (default), `:sequential` halts on the first failure and + # `:parallel` cancels pending tasks (in-flight tasks still finish). # @option options [Symbol, Proc, #call] :if # @option options [Symbol, Proc, #call] :unless # @return [Array<ExecutionGroup>] the full pipeline diff --git a/skills/references/workflows.md b/skills/references/workflows.md index e071781bc..94ff541bc 100644 --- a/skills/references/workflows.md +++ b/skills/references/workflows.md @@ -34,7 +34,7 @@ Defining `def work` on a workflow raises `CMDx::ImplementationError` — `#work` | `pool_size:` | Parallel worker/fiber count. Defaults to `tasks.size`. | | `executor:` | `:threads` (default), `:fibers`, or any callable matching `call(jobs:, concurrency:, on_job:)`. `:fibers` requires `Fiber.scheduler` to be installed. | | `merge_strategy:` | `:last_write_wins` (default), `:deep_merge`, `:no_merge`, or a callable `call(workflow_context, result)`. Applied in declaration order over successful results only. | -| `fail_fast:` | When `:parallel`, short-circuit pending tasks on the first failure (in-flight tasks still finish). | +| `continue_on_failure:` | When `true`, run every task in the group even after a failure and aggregate failures into `workflow.errors` keyed `"TaskClass.input"` / `"TaskClass.<status>"`. When `false` (default), `:sequential` halts on first failure and `:parallel` cancels pending tasks. | | `if:` / `unless:` | Gate the whole group. Signature `(workflow)` (Symbol → task method; Proc → `instance_exec`; `#call`-able → `callable.call(workflow)`). | Every task class must be a `CMDx::Task` subclass — otherwise registration raises `TypeError`. diff --git a/spec/cmdx/chain_spec.rb b/spec/cmdx/chain_spec.rb index 420a54b85..0f2911a01 100644 --- a/spec/cmdx/chain_spec.rb +++ b/spec/cmdx/chain_spec.rb @@ -278,4 +278,3 @@ def build_result(signal = CMDx::Signal.success, **opts) end end end - diff --git a/spec/cmdx/pipeline_spec.rb b/spec/cmdx/pipeline_spec.rb index c965b2e3f..c6443d994 100644 --- a/spec/cmdx/pipeline_spec.rb +++ b/spec/cmdx/pipeline_spec.rb @@ -78,6 +78,128 @@ end end + describe "continue_on_failure aggregation" do + let(:input_validation_task) do + klass = Class.new(CMDx::Task) + klass.define_singleton_method(:name) { "ValidatesAmount" } + klass.required(:amount) + klass.define_method(:work) { context.executed_validation = true } + klass + end + + let(:bare_failing_task) do + klass = Class.new(CMDx::Task) + klass.define_singleton_method(:name) { "BareFailer" } + klass.define_method(:work) { fail!("bare boom") } + klass + end + + let(:reasonless_failing_task) do + klass = Class.new(CMDx::Task) + klass.define_singleton_method(:name) { "Reasonless" } + klass.define_method(:work) { fail! } + klass + end + + let(:succeeding_task) do + klass = Class.new(CMDx::Task) + klass.define_singleton_method(:name) { "Winner" } + klass.define_method(:work) { context.winner_ran = true } + klass + end + + describe "sequential" do + it "runs every task and aggregates failures into result.errors" do + a = bare_failing_task + b = input_validation_task + c = succeeding_task + + workflow_class = create_workflow_class do + tasks a, b, c, continue_on_failure: true + end + + result = workflow_class.execute + + expect(result).to be_failed + expect(result.context[:winner_ran]).to be(true) + expect(result.errors[:"BareFailer.failed"]).to eq(["bare boom"]) + expect(result.errors[:"ValidatesAmount.amount"]).to eq([CMDx::I18nProxy.t("cmdx.attributes.required")]) + end + + it "uses the localized unspecified message when fail! has no reason" do + a = reasonless_failing_task + + workflow_class = create_workflow_class do + tasks a, continue_on_failure: true + end + + result = workflow_class.execute + + expect(result).to be_failed + expect(result.errors[:"Reasonless.failed"]).to eq([CMDx::I18nProxy.t("cmdx.reasons.unspecified")]) + end + + it "the first failure (declaration order) becomes the signal origin" do + first_fail = create_failing_task(name: "First", reason: "first") + second_fail = create_failing_task(name: "Second", reason: "second") + + workflow_class = create_workflow_class do + tasks first_fail, second_fail, continue_on_failure: true + end + + result = workflow_class.execute + + expect(result.reason).to eq("first") + end + + it "halts the pipeline after the failed group (subsequent groups do not run)" do + a = create_failing_task(name: "Halter", reason: "stop") + after = create_task_class(name: "After") { define_method(:work) { context.after_ran = true } } + + workflow_class = create_workflow_class do + tasks a, continue_on_failure: true + task after + end + + result = workflow_class.execute + + expect(result).to be_failed + expect(result.context[:after_ran]).to be_nil + end + + it "does not aggregate when continue_on_failure is false (default)" do + a = create_failing_task(name: "OnlyFail", reason: "stop") + b = create_successful_task(name: "Skipped") + + workflow_class = create_workflow_class do + tasks a, b + end + + result = workflow_class.execute + + expect(result).to be_failed + expect(result.errors).to be_empty + end + end + + describe "parallel" do + it "runs every task, merges successes, and aggregates failures" do + ok_task = succeeding_task + fail_task = bare_failing_task + + workflow_class = create_workflow_class do + tasks ok_task, fail_task, strategy: :parallel, continue_on_failure: true + end + + result = workflow_class.execute + + expect(result).to be_failed + expect(result.context[:winner_ran]).to be(true) + expect(result.errors[:"BareFailer.failed"]).to eq(["bare boom"]) + end + end + end + describe "sequential strategy" do it "runs each task in order" do task1 = create_successful_task(name: "T1") @@ -105,13 +227,13 @@ end describe "parallel strategy" do - it "runs every task regardless of failure" do + it "runs every task regardless of failure when continue_on_failure is true" do task1 = create_failing_task(name: "Failing1", reason: "f1") task2 = create_successful_task(name: "Succ2") task3 = create_successful_task(name: "Succ3") workflow_class = create_workflow_class do - tasks task1, task2, task3, strategy: :parallel + tasks task1, task2, task3, strategy: :parallel, continue_on_failure: true end result = workflow_class.execute @@ -133,15 +255,15 @@ expect(workflow_class.execute).to be_success end - describe ":fail_fast" do - it "skips queued tasks after the first failure (pool_size: 1)" do + describe ":continue_on_failure" do + it "skips queued tasks after the first failure by default (pool_size: 1)" do failing = create_failing_task(name: "First", reason: "stop") never_run = create_task_class(name: "NeverRun") do define_method(:work) { context.ran = true } end workflow_class = create_workflow_class do - tasks failing, never_run, strategy: :parallel, pool_size: 1, fail_fast: true + tasks failing, never_run, strategy: :parallel, pool_size: 1 end result = workflow_class.execute @@ -152,14 +274,14 @@ expect(task_names.any? { |n| n.include?("NeverRun") }).to be(false) end - it "runs every task when fail_fast is false (default behavior preserved)" do + it "runs every task when continue_on_failure is true" do failing = create_failing_task(name: "First", reason: "stop") other = create_task_class(name: "Other") do define_method(:work) { context.ran = true } end workflow_class = create_workflow_class do - tasks failing, other, strategy: :parallel, pool_size: 1 + tasks failing, other, strategy: :parallel, pool_size: 1, continue_on_failure: true end result = workflow_class.execute @@ -222,7 +344,7 @@ end workflow_class = create_workflow_class do - tasks ok, failing, never_run, strategy: :parallel, pool_size: 1, fail_fast: true + tasks ok, failing, never_run, strategy: :parallel, pool_size: 1 end result = workflow_class.execute diff --git a/spec/integration/workflows/parallel_spec.rb b/spec/integration/workflows/parallel_spec.rb index 9d0a5dd70..1beb096f0 100644 --- a/spec/integration/workflows/parallel_spec.rb +++ b/spec/integration/workflows/parallel_spec.rb @@ -111,14 +111,14 @@ end end - describe "fail_fast" do - it "drains pending parallel tasks after the first failure" do + describe "continue_on_failure" do + it "drains pending parallel tasks after the first failure by default" do ok = create_task_class(name: "Ok") { define_method(:work) { context.ok = true } } fl = create_failing_task(name: "Fail", reason: "fast boom") never = create_task_class(name: "Never") { define_method(:work) { context.never_ran = true } } workflow = create_workflow_class do - tasks ok, fl, never, strategy: :parallel, pool_size: 1, fail_fast: true + tasks ok, fl, never, strategy: :parallel, pool_size: 1 end result = workflow.execute @@ -128,18 +128,22 @@ expect(result.context[:never_ran]).to be_nil end - it "still runs all tasks when fail_fast is omitted" do - fl = create_failing_task(name: "Fail", reason: "boom") - later = create_task_class(name: "Later") { define_method(:work) { context.later = true } } + it "runs every task and aggregates failures when continue_on_failure is true" do + ok = create_task_class(name: "Ok") { define_method(:work) { context.ok = true } } + a = create_failing_task(name: "A", reason: "a-boom") + b = create_failing_task(name: "B", reason: "b-boom") workflow = create_workflow_class do - tasks fl, later, strategy: :parallel, pool_size: 1 + tasks ok, a, b, strategy: :parallel, continue_on_failure: true end result = workflow.execute expect(result).to be_failed - expect(result.context[:later]).to be(true) + expect(result.context[:ok]).to be(true) + keys = result.errors.to_h.keys + expect(keys.any? { |k| k.to_s.end_with?(".failed") && k.to_s.start_with?("A") }).to be(true) + expect(keys.any? { |k| k.to_s.end_with?(".failed") && k.to_s.start_with?("B") }).to be(true) end end From 2dee20eb128fc1070870525e891f3a3f46ff792d Mon Sep 17 00:00:00 2001 From: Juan Gomez <drexed@users.noreply.github.com> Date: Mon, 4 May 2026 23:30:17 -0400 Subject: [PATCH 35/54] Add saga style compensation --- CHANGELOG.md | 1 + docs/workflows.md | 29 ++-- lib/cmdx/pipeline.rb | 48 +++++- spec/integration/workflows/rollback_spec.rb | 174 ++++++++++++++++++++ 4 files changed, 234 insertions(+), 18 deletions(-) create mode 100644 spec/integration/workflows/rollback_spec.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index 67934f0e0..a54bb814b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ Full runtime rewrite: the v1 state-machine plus Zeitwerk architecture is replace - Add new exception classes: `DefinitionError`, `DeprecationError`, `ImplementationError`, `MiddlewareError` - Add `Task#work` abstract method (raises `ImplementationError` when not defined) - 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 +- Add saga-style pipeline rollback: when a `Workflow` halts, `Pipeline` walks every previously executed task instance whose result is `success?` in reverse execution order and invokes `#rollback` (when defined), then flips that result's `rolled_back?` to `true`. Skipped tasks are excluded; the failing task is rolled back by Runtime and not re-invoked. Exceptions raised inside a compensator propagate — handling them is the developer's responsibility. Applies across groups, within `continue_on_failure: true` groups, and to `:parallel` groups (compensators see the per-task `deep_dup`'d context) - Add `Task#success!` for signaling a successful halt, joining `skip!` / `fail!` / `throw!` - Add `Task.execute` / `Task.execute!` as the execution entry points (aliased as `call` / `call!` for backward compatibility) - Add `Task#execute(strict:)` instance method (aliased as `#call`) diff --git a/docs/workflows.md b/docs/workflows.md index 98e60060a..dafe14322 100644 --- a/docs/workflows.md +++ b/docs/workflows.md @@ -134,7 +134,9 @@ To make a "soft" failure non-halting, have the task `skip!` instead of `fail!`. ## Rollback in Workflows -When a task fails, Runtime calls its `#rollback` method (if defined) immediately after `work` returns and *before* the failure is `throw!`n up to the workflow. Concretely, the failed leaf task's lifecycle is: `perform_work` → `perform_rollback` → `on_*` callbacks → result finalization → throw to workflow. Rollback is **per-task**: previously successful tasks in the same workflow are not rolled back automatically. +When a task fails, Runtime calls its `#rollback` method (if defined) immediately after `work` returns and *before* the failure is `throw!`n up to the workflow. Concretely, the failed leaf task's lifecycle is: `perform_work` → `perform_rollback` → `on_*` callbacks → result finalization → throw to workflow. + +When a workflow's pipeline halts, `Pipeline` then walks every previously executed task instance whose result is `success?` in **reverse** execution order and invokes `#rollback` on any that defines it — saga-style compensation across the whole pipeline. Each compensated result's `#rolled_back?` becomes `true`. Skipped tasks are excluded; the failing task itself is rolled back by Runtime and is not re-invoked. Exceptions raised inside a compensator propagate to the caller — handling them is the developer's responsibility. ```ruby class PaymentWorkflow < CMDx::Task @@ -157,27 +159,34 @@ class ChargeCard < CMDx::Task end ``` -!!! warning "Compensation across tasks" +!!! note "Compensation across tasks" - To undo earlier successful tasks when a later one fails, handle it on the workflow itself with an `on_failed` callback. The callback runs after the pipeline halts but before teardown, with full access to `context`. + Pipeline rollback covers the common saga case automatically: define `#rollback` on each task that has side effects to undo, and the workflow will compensate them in reverse order on failure. Use a workflow-level `on_failed` callback only when compensation logic doesn't belong to any single task (e.g. it spans multiple contexts or external systems). ```ruby class PaymentWorkflow < CMDx::Task include CMDx::Workflow - on_failed :compensate! - - task ReserveInventory - task ChargeCard + task ReserveInventory # rolled back second on failure (in reverse) + task ChargeCard # rolled back first if it succeeded; Runtime rolls it back if it failed + task SendConfirmation +end - private +class ReserveInventory < CMDx::Task + def work + context.reservation_id = Inventory.reserve(context.sku, context.qty) + end - def compensate! - ReleaseInventory.execute(context) if context.reservation_id + def rollback + Inventory.release(context.reservation_id) end end ``` +!!! warning "Parallel groups" + + Tasks in a `:parallel` group run on a `deep_dup`'d context. Their `#rollback` sees that per-task copy, not the merged workflow context. Keep parallel compensators self-contained (e.g. external API calls keyed off values captured during `work`) rather than relying on shared workflow state. + ## Nested Workflows Workflows are tasks, so they nest naturally: diff --git a/lib/cmdx/pipeline.rb b/lib/cmdx/pipeline.rb index 934749104..375ada878 100644 --- a/lib/cmdx/pipeline.rb +++ b/lib/cmdx/pipeline.rb @@ -29,14 +29,24 @@ def execute(workflow) # @param workflow [Task & Workflow] def initialize(workflow) @workflow = workflow + @executed = [] end # Iterates every group in the workflow's pipeline, respecting # `:if`/`:unless` and the `:strategy` key. Any group that produces a # failed result halts execution by throwing through the workflow. # + # On halt, every previously executed task instance whose result is + # `success?` is sent `#rollback` (when defined) in reverse execution + # order, providing saga-style compensation. Each compensated result + # has its `:rolled_back` option flipped to `true`. Skipped tasks are + # excluded; the failing task itself is rolled back by {Runtime} and + # is not re-invoked here. Exceptions raised inside a compensator + # propagate — handling them is the developer's responsibility. + # # @return [void] # @raise [ArgumentError] for an unknown strategy + # @raise [StandardError] anything raised by a task's `#rollback` def execute @workflow.class.pipeline.each do |group| next unless Util.satisfied?(group.options[:if], group.options[:unless], @workflow) @@ -51,7 +61,10 @@ def execute raise ArgumentError, "invalid strategy: #{strategy.inspect}" end - @workflow.send(:throw!, halt) if halt + next unless halt + + rollback_executed! + @workflow.send(:throw!, halt) end end @@ -59,8 +72,10 @@ def execute def run_sequential(group) continue = group.options[:continue_on_failure] - failures = group.tasks.each_with_object([]) do |task, bucket| - result = task.execute(@workflow.context) + failures = group.tasks.each_with_object([]) do |task_class, bucket| + instance = task_class.new(@workflow.context) + result = instance.execute(strict: false) + @executed << [instance, result] next unless result.failed? bucket << result @@ -75,7 +90,7 @@ def run_parallel(group) chain = Chain.current size = group.options[:pool_size] || tasks.size continue = group.options[:continue_on_failure] - results = Array.new(tasks.size) + entries = Array.new(tasks.size) mutex = Mutex.new seen_fail = false cancelled = false @@ -87,10 +102,11 @@ def run_parallel(group) Fiber[Chain::STORAGE_KEY] ||= chain ctx_copy = @workflow.context.deep_dup - result = task_class.execute(ctx_copy) + instance = task_class.new(ctx_copy) + result = instance.execute(strict: false) mutex.synchronize do - results[index] = result + entries[index] = [instance, result] if result.failed? && !continue && !seen_fail seen_fail = true @@ -105,8 +121,11 @@ def run_parallel(group) executor.call(jobs:, concurrency: size, on_job:) failures = [] - results.each do |result| - next if result.nil? + entries.each do |entry| + next if entry.nil? + + @executed << entry + _instance, result = entry if result.failed? failures << result @@ -118,6 +137,19 @@ def run_parallel(group) aggregate(failures, continue:) end + def rollback_executed! + @executed.reverse_each do |instance, result| + next unless result.success? + next unless instance.respond_to?(:rollback) + + instance.rollback + + old_opts = result.instance_variable_get(:@options) + new_opts = old_opts.merge(rolled_back: true).freeze + result.instance_variable_set(:@options, new_opts) + end + end + def aggregate(failures, continue:) return if failures.empty? return failures.first unless continue diff --git a/spec/integration/workflows/rollback_spec.rb b/spec/integration/workflows/rollback_spec.rb new file mode 100644 index 000000000..a685d10cc --- /dev/null +++ b/spec/integration/workflows/rollback_spec.rb @@ -0,0 +1,174 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe "Workflow saga rollback", type: :feature do + after { CMDx::Chain.clear } + + def task_with_rollback(name:, work_label: name.downcase.to_sym, base: CMDx::Task) + create_task_class(base:, name:) do + define_method(:work) { (context.executed ||= []) << work_label } + define_method(:rollback) { (context.rolled ||= []) << work_label } + end + end + + describe "sequential pipeline" do + it "rolls back previously successful tasks in reverse order; failing task handled by Runtime" do + a = task_with_rollback(name: "A", work_label: :a) + b = task_with_rollback(name: "B", work_label: :b) + c = create_task_class(name: "C") do + define_method(:work) do + (context.executed ||= []) << :c + fail!("boom") + end + define_method(:rollback) { (context.rolled ||= []) << :c } + end + + workflow = create_workflow_class { tasks a, b, c } + result = workflow.execute + + expect(result).to be_failed + expect(result.context.executed).to eq(%i[a b c]) + expect(result.context.rolled).to eq(%i[c b a]) + end + + it "excludes skipped tasks from rollback" do + a = task_with_rollback(name: "A", work_label: :a) + s = create_task_class(name: "S") do + define_method(:work) do + (context.executed ||= []) << :s + skip!("nope") + end + define_method(:rollback) { (context.rolled ||= []) << :s } + end + b = task_with_rollback(name: "B", work_label: :b) + f = create_task_class(name: "F") do + define_method(:work) { fail!("boom") } + end + + workflow = create_workflow_class { tasks a, s, b, f } + result = workflow.execute + + expect(result).to be_failed + expect(result.context.rolled).to eq(%i[b a]) + end + + it "does not invoke rollback when the workflow succeeds" do + a = task_with_rollback(name: "A", work_label: :a) + b = task_with_rollback(name: "B", work_label: :b) + + workflow = create_workflow_class { tasks a, b } + result = workflow.execute + + expect(result).to be_success + expect(result.context).not_to respond_to(:rolled) + end + + it "is a no-op for tasks without #rollback" do + a = create_task_class(name: "A") { define_method(:work) { context.a = true } } + f = create_task_class(name: "F") { define_method(:work) { fail!("boom") } } + + workflow = create_workflow_class { tasks a, f } + expect { workflow.execute }.not_to raise_error + end + end + + describe "continue_on_failure" do + it "rolls back successful tasks within the failing group in reverse order" do + a = task_with_rollback(name: "A", work_label: :a) + b = create_task_class(name: "B") do + define_method(:work) { fail!("boom") } + end + c = task_with_rollback(name: "C", work_label: :c) + + workflow = create_workflow_class { tasks a, b, c, continue_on_failure: true } + result = workflow.execute + + expect(result).to be_failed + expect(result.context.rolled).to eq(%i[c a]) + end + end + + describe "across groups" do + it "rolls back successes from earlier groups when a later group fails" do + a = task_with_rollback(name: "A", work_label: :a) + b = task_with_rollback(name: "B", work_label: :b) + f = create_task_class(name: "F") { define_method(:work) { fail!("boom") } } + + workflow = create_workflow_class do + tasks a + tasks b + tasks f + end + result = workflow.execute + + expect(result).to be_failed + expect(result.context.rolled).to eq(%i[b a]) + end + end + + describe "parallel groups" do + it "invokes rollback on each successful instance when the group fails" do + mailbox = [] + mutex = Mutex.new + record = ->(label) { mutex.synchronize { mailbox << label } } + + a = create_task_class(name: "A") { define_method(:work) { context.a = true } } + a.define_method(:rollback) { record.call(:a) } + b = create_task_class(name: "B") { define_method(:work) { fail!("boom") } } + c = create_task_class(name: "C") { define_method(:work) { context.c = true } } + c.define_method(:rollback) { record.call(:c) } + + workflow = create_workflow_class { tasks a, b, c, strategy: :parallel, continue_on_failure: true } + result = workflow.execute + + expect(result).to be_failed + expect(mailbox.sort).to eq(%i[a c]) + end + end + + describe "rollback raising" do + it "propagates as the workflow's failure cause (developer's responsibility)" do + b = create_task_class(name: "B") do + define_method(:work) { context.b = true } + define_method(:rollback) { raise "compensator boom" } + end + f = create_task_class(name: "F") do + define_method(:work) { fail!("orig") } + end + + workflow = create_workflow_class { tasks b, f } + + result = workflow.execute + expect(result).to be_failed + expect(result.cause).to be_a(RuntimeError).and have_attributes(message: "compensator boom") + end + + it "re-raises under execute!" do + b = create_task_class(name: "B") do + define_method(:work) { context.b = true } + define_method(:rollback) { raise "compensator boom" } + end + f = create_task_class(name: "F") do + define_method(:work) { fail!("orig") } + end + + workflow = create_workflow_class { tasks b, f } + + expect { workflow.execute! }.to raise_error(RuntimeError, "compensator boom") + end + end + + describe "result#rolled_back?" do + it "is true for compensated successful tasks" do + a = task_with_rollback(name: "A", work_label: :a) + f = create_task_class(name: "F") { define_method(:work) { fail!("boom") } } + + workflow = create_workflow_class { tasks a, f } + result = workflow.execute + + a_result = result.chain.find { |r| r.task == a } + expect(a_result).to have_attributes(success?: true, rolled_back?: true) + end + end +end From 59b1e146b0e622a57aa8ba420609c5e287f1f2e9 Mon Sep 17 00:00:00 2001 From: Juan Gomez <drexed@users.noreply.github.com> Date: Mon, 4 May 2026 23:40:22 -0400 Subject: [PATCH 36/54] Add more retry strategies --- CHANGELOG.md | 1 + docs/retries.md | 17 +++++++++++++ lib/cmdx/coercions.rb | 9 ++++--- lib/cmdx/retry.rb | 34 ++++++++++++++++++++++--- lib/cmdx/validators.rb | 9 ++++--- spec/cmdx/retry_spec.rb | 55 +++++++++++++++++++++++++++++++++++++++++ 6 files changed, 115 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a54bb814b..db21a7578 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -55,6 +55,7 @@ Full runtime rewrite: the v1 state-machine plus Zeitwerk architecture is replace - Add `:merge_strategy` option to parallel task groups controlling how successful sibling contexts fold back into the workflow context: `:last_write_wins` (default, matches previous behavior), `:deep_merge` (recursive over `Hash` values), `:no_merge` (workflow context left untouched), or a callable `call(workflow_context, result)`. Merging always walks successful results in declaration order. Unknown symbols raise `ArgumentError` - Add `CMDx::Executors` registry (built-ins: `:threads` → `Executors::Thread`, `:fibers` → `Executors::Fiber`) and `CMDx::Mergers` registry (built-ins: `:last_write_wins`, `:deep_merge`, `:no_merge`) exposed on `Configuration#executors` / `#mergers` and per-task via `Task.executors` / `Task.mergers` (dup-on-inherit); `Task.register(:executor, ...)` and `Task.register(:merger, ...)` (plus matching `deregister`) let apps plug in custom dispatch/merge strategies resolvable by name from `:executor` / `:merge_strategy` - Add `Context#deep_merge` — in-place recursive `Hash`-value merge; scalar-vs-hash collisions follow last-write-wins. Used by the `:deep_merge` parallel merge strategy but also available directly +- Add `:linear`, `:fibonacci`, and `:decorrelated_jitter` built-in retry jitter strategies on `Retry` / `Task.retry_on`. `:linear` sleeps `delay * (attempt + 1)`; `:fibonacci` sleeps `delay * fib(attempt + 1)`; `:decorrelated_jitter` is the AWS-recommended stateful strategy `next ∈ [delay, prev_sleep * 3]` (threaded across attempts inside a single `process` call, falls back to `delay` when no prior sleep). `Retry#wait` now accepts an optional `prev_delay` argument and returns the computed delay so callers can thread state ### Changed - **BREAKING**: Rename `#call` → `#work` on task subclasses; `Task.execute` / `Task.execute!` are the new entry points (`call` / `call!` kept as aliases) diff --git a/docs/retries.md b/docs/retries.md index f39cbcf9c..6d4fab668 100644 --- a/docs/retries.md +++ b/docs/retries.md @@ -74,8 +74,25 @@ retry_on TransientError, delay: 2.0, jitter: :full_random retry_on TransientError, delay: 2.0, jitter: :bounded_random # delay .. 2*delay → 2.0s .. 4.0s + +retry_on TransientError, delay: 1.0, jitter: :linear +# attempt 0 → 1s, attempt 1 → 2s, attempt 2 → 3s, ... + +retry_on TransientError, delay: 1.0, jitter: :fibonacci +# attempt 0 → 1s, attempt 1 → 1s, attempt 2 → 2s, attempt 3 → 3s, attempt 4 → 5s, ... + +retry_on TransientError, delay: 1.0, jitter: :decorrelated_jitter +# AWS-style: next sleep ∈ [delay, prev_sleep * 3], starting from prev = delay +# attempt 0 → 1.0s..3.0s, then each subsequent attempt's upper bound is 3× the +# previous sleep (clamped by :max_delay if set) ``` +!!! note + + `:decorrelated_jitter` is stateful — the previous sleep is threaded across + retries inside a single `process` call. Calling `#wait` directly without + passing `prev_delay` falls back to the base delay each time. + ### Symbol (Instance Method) A `Symbol` resolves to an instance method on the task. The method receives `(attempt, delay)` and must return the desired sleep duration in seconds. diff --git a/lib/cmdx/coercions.rb b/lib/cmdx/coercions.rb index 14c715136..189750e1e 100644 --- a/lib/cmdx/coercions.rb +++ b/lib/cmdx/coercions.rb @@ -46,10 +46,13 @@ def initialize_copy(source) # @raise [ArgumentError] when both `callable` and a block are given, or # when the resolved coercion isn't callable def register(name, callable = nil, &block) - raise ArgumentError, "provide either a callable or a block, not both" if callable && block - coercion = callable || block - raise ArgumentError, "coercion must respond to #call" unless coercion.respond_to?(:call) + + if callable && block + raise ArgumentError, "provide either a callable or a block, not both" + elsif !coercion.respond_to?(:call) + raise ArgumentError, "coercion must respond to #call" + end registry[name.to_sym] = coercion self diff --git a/lib/cmdx/retry.rb b/lib/cmdx/retry.rb index f2e34aff0..f274e715b 100644 --- a/lib/cmdx/retry.rb +++ b/lib/cmdx/retry.rb @@ -15,7 +15,8 @@ class Retry # @option options [Float] :delay (0.5) base delay in seconds between attempts # @option options [Float] :max_delay clamp for computed delays # @option options [Symbol, Proc, #call] :jitter built-in strategy (`:exponential`, - # `:half_random`, `:full_random`, `:bounded_random`) or custom + # `:half_random`, `:full_random`, `:bounded_random`, `:linear`, `:fibonacci`, + # `:decorrelated_jitter`) or custom # @yield [attempt, delay] optional custom jitter block, used when `:jitter` isn't set def initialize(exceptions, options = EMPTY_HASH, &block) @exceptions = exceptions.flatten @@ -64,8 +65,11 @@ def jitter # # @param attempt [Integer] zero-based retry attempt number # @param task [Task, nil] used as receiver for Symbol/Proc jitter strategies - # @return [void] - def wait(attempt, task = nil) + # @param prev_delay [Float, nil] previous computed delay; only consumed by + # `:decorrelated_jitter` to thread state across attempts + # @return [Float, nil] the computed (and possibly clamped) delay, or `nil` when + # `delay` is zero + def wait(attempt, task = nil, prev_delay = nil) return unless delay.positive? d = @@ -78,6 +82,13 @@ def wait(attempt, task = nil) rand * delay when :bounded_random delay + (rand * delay) + when :linear + delay * (attempt + 1) + when :fibonacci + delay * fibonacci(attempt + 1) + when :decorrelated_jitter + base = prev_delay || delay + delay + (rand * ((base * 3) - delay)) when Symbol task.send(jitter, attempt, delay) when Proc @@ -92,6 +103,7 @@ def wait(attempt, task = nil) d = d.clamp(0, max_delay) if max_delay Kernel.sleep(d) if d.positive? + d end # Executes the block up to `limit + 1` times. Re-raises the last @@ -105,15 +117,29 @@ def wait(attempt, task = nil) def process(task = nil, &) return yield(0) if exceptions.empty? || !limit.positive? + prev_delay = nil (limit + 1).times do |attempt| return yield(attempt) rescue *exceptions => e raise(e) if attempt >= limit raise(e) unless Util.satisfied?(@options[:if], @options[:unless], task, e, attempt) - wait(attempt, task) + prev_delay = wait(attempt, task, prev_delay) end end + private + + # Iterative Fibonacci. `fib(1) == 1`, `fib(2) == 1`, `fib(3) == 2`, ... + # + # @param n [Integer] one-based index into the Fibonacci sequence + # @return [Integer] + def fibonacci(n) + a = 0 + b = 1 + n.times { a, b = b, a + b } + a + end + end end diff --git a/lib/cmdx/validators.rb b/lib/cmdx/validators.rb index 48a4f357a..20fd8efaa 100644 --- a/lib/cmdx/validators.rb +++ b/lib/cmdx/validators.rb @@ -39,10 +39,13 @@ def initialize_copy(source) # @raise [ArgumentError] when both `callable` and a block are given, or # when the resolved validator isn't callable def register(name, callable = nil, &block) - raise ArgumentError, "provide either a callable or a block, not both" if callable && block - validator = callable || block - raise ArgumentError, "validator must respond to #call" unless validator.respond_to?(:call) + + if callable && block + raise ArgumentError, "provide either a callable or a block, not both" + elsif !validator.respond_to?(:call) + raise ArgumentError, "validator must respond to #call" + end registry[name.to_sym] = validator self diff --git a/spec/cmdx/retry_spec.rb b/spec/cmdx/retry_spec.rb index f4173c67c..bc48b59c3 100644 --- a/spec/cmdx/retry_spec.rb +++ b/spec/cmdx/retry_spec.rb @@ -131,6 +131,42 @@ expect(sleeps).to eq([3.0]) end + it "linear produces delay * (attempt + 1)" do + described_class.new([error_class], delay: 0.25, jitter: :linear).wait(3) + expect(sleeps).to eq([1.0]) + end + + it "fibonacci produces delay * fib(attempt + 1) across attempts" do + retry_ = described_class.new([error_class], delay: 1.0, jitter: :fibonacci) + 6.times { |i| retry_.wait(i) } + expect(sleeps).to eq([1.0, 1.0, 2.0, 3.0, 5.0, 8.0]) + end + + it "decorrelated_jitter falls back to base delay when prev_delay is nil" do + retry_ = described_class.new([error_class], delay: 1.0, jitter: :decorrelated_jitter) + allow(retry_).to receive(:rand).and_return(0.0, 1.0) + + retry_.wait(0) + retry_.wait(0) + expect(sleeps).to eq([1.0, 3.0]) + end + + it "decorrelated_jitter uses prev_delay to widen the upper bound" do + retry_ = described_class.new([error_class], delay: 1.0, jitter: :decorrelated_jitter) + allow(retry_).to receive(:rand).and_return(1.0) + + retry_.wait(0, nil, 4.0) + expect(sleeps).to eq([12.0]) + end + + it "decorrelated_jitter returns the computed delay so it can be threaded" do + retry_ = described_class.new([error_class], delay: 1.0, jitter: :decorrelated_jitter) + allow(retry_).to receive(:rand).and_return(0.5) + + d = retry_.wait(0) + expect(d).to eq(2.0) + end + it "calls a Symbol jitter on the task" do task = Class.new { def jitter_calc(attempt, delay) = delay * attempt }.new described_class.new([error_class], delay: 1.0, jitter: :jitter_calc).wait(4, task) @@ -194,6 +230,25 @@ .to raise_error(RuntimeError, "other") end + it "threads prev_delay across attempts for :decorrelated_jitter" do + sleeps = [] + allow(Kernel).to receive(:sleep) { |d| sleeps << d } + + retry_ = described_class.new([error_class], limit: 3, delay: 1.0, jitter: :decorrelated_jitter) + allow(retry_).to receive(:rand).and_return(1.0) + + attempts = 0 + expect do + retry_.process do + attempts += 1 + raise error_class, "boom" + end + end.to raise_error(error_class) + + expect(sleeps).to eq([3.0, 9.0, 27.0]) + expect(attempts).to eq(4) + end + it "passes the attempt number to the block" do retry_ = described_class.new([error_class], limit: 2, delay: 0) attempts = [] From 808009e3eeffe1a0c0bc4bd5d8402c6b1920ac6d Mon Sep 17 00:00:00 2001 From: Juan Gomez <drexed@users.noreply.github.com> Date: Mon, 4 May 2026 23:48:34 -0400 Subject: [PATCH 37/54] Add retriers class --- CHANGELOG.md | 1 + docs/retries.md | 25 ++++ lib/cmdx.rb | 8 ++ lib/cmdx/configuration.rb | 4 +- lib/cmdx/retriers.rb | 103 +++++++++++++++ lib/cmdx/retriers/bounded_random.rb | 24 ++++ lib/cmdx/retriers/decorrelated_jitter.rb | 28 ++++ lib/cmdx/retriers/exponential.rb | 23 ++++ lib/cmdx/retriers/fibonacci.rb | 37 ++++++ lib/cmdx/retriers/full_random.rb | 23 ++++ lib/cmdx/retriers/half_random.rb | 24 ++++ lib/cmdx/retriers/linear.rb | 23 ++++ lib/cmdx/retry.rb | 45 +++---- lib/cmdx/task.rb | 18 ++- spec/cmdx/retriers_spec.rb | 159 +++++++++++++++++++++++ spec/cmdx/retry_spec.rb | 59 +++------ 16 files changed, 536 insertions(+), 68 deletions(-) create mode 100644 lib/cmdx/retriers.rb create mode 100644 lib/cmdx/retriers/bounded_random.rb create mode 100644 lib/cmdx/retriers/decorrelated_jitter.rb create mode 100644 lib/cmdx/retriers/exponential.rb create mode 100644 lib/cmdx/retriers/fibonacci.rb create mode 100644 lib/cmdx/retriers/full_random.rb create mode 100644 lib/cmdx/retriers/half_random.rb create mode 100644 lib/cmdx/retriers/linear.rb create mode 100644 spec/cmdx/retriers_spec.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index db21a7578..5f481bcf9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -56,6 +56,7 @@ Full runtime rewrite: the v1 state-machine plus Zeitwerk architecture is replace - Add `CMDx::Executors` registry (built-ins: `:threads` → `Executors::Thread`, `:fibers` → `Executors::Fiber`) and `CMDx::Mergers` registry (built-ins: `:last_write_wins`, `:deep_merge`, `:no_merge`) exposed on `Configuration#executors` / `#mergers` and per-task via `Task.executors` / `Task.mergers` (dup-on-inherit); `Task.register(:executor, ...)` and `Task.register(:merger, ...)` (plus matching `deregister`) let apps plug in custom dispatch/merge strategies resolvable by name from `:executor` / `:merge_strategy` - Add `Context#deep_merge` — in-place recursive `Hash`-value merge; scalar-vs-hash collisions follow last-write-wins. Used by the `:deep_merge` parallel merge strategy but also available directly - Add `:linear`, `:fibonacci`, and `:decorrelated_jitter` built-in retry jitter strategies on `Retry` / `Task.retry_on`. `:linear` sleeps `delay * (attempt + 1)`; `:fibonacci` sleeps `delay * fib(attempt + 1)`; `:decorrelated_jitter` is the AWS-recommended stateful strategy `next ∈ [delay, prev_sleep * 3]` (threaded across attempts inside a single `process` call, falls back to `delay` when no prior sleep). `Retry#wait` now accepts an optional `prev_delay` argument and returns the computed delay so callers can thread state +- Add `CMDx::Retriers` registry (built-ins: `:exponential`, `:half_random`, `:full_random`, `:bounded_random`, `:linear`, `:fibonacci`, `:decorrelated_jitter`) exposed on `Configuration#retriers` and per-task via `Task.retriers` (dup-on-inherit). `Task.register(:retrier, ...)` / `deregister(:retrier, ...)` let apps plug in custom jitter strategies resolvable by name from `:jitter`. Strategies are pure callables matching `call(attempt, delay, prev_delay)`. Symbols not present in the registry still fall through to task instance methods, preserving existing `jitter: :method_name` semantics ### Changed - **BREAKING**: Rename `#call` → `#work` on task subclasses; `Task.execute` / `Task.execute!` are the new entry points (`call` / `call!` kept as aliases) diff --git a/docs/retries.md b/docs/retries.md index 6d4fab668..3fbe65201 100644 --- a/docs/retries.md +++ b/docs/retries.md @@ -93,6 +93,31 @@ retry_on TransientError, delay: 1.0, jitter: :decorrelated_jitter retries inside a single `process` call. Calling `#wait` directly without passing `prev_delay` falls back to the base delay each time. +### Custom Strategies via the `Retriers` Registry + +Built-in strategies live in the `CMDx::Retriers` registry, mirroring `Mergers` +and `Executors`. Strategies are any callable matching +`call(attempt, delay, prev_delay)` returning the next delay in seconds. Register +custom strategies globally on the configuration or per-task: + +```ruby +CMDx.configure do |config| + config.retriers.register(:capped_exponential) do |attempt, delay, _prev| + [delay * (2**attempt), 30.0].min + end +end + +class FetchExternalData < CMDx::Task + retry_on Net::ReadTimeout, jitter: :capped_exponential + + # Or scoped to the task class only: + register :retrier, :doubled, ->(_a, delay, _p) { delay * 2 } +end +``` + +Symbols not present in the registry fall through to a task instance method, so +existing `jitter: :exponential_backoff` declarations keep working. + ### Symbol (Instance Method) A `Symbol` resolves to an instance method on the task. The method receives `(attempt, delay)` and must return the desired sleep duration in seconds. diff --git a/lib/cmdx.rb b/lib/cmdx.rb index a691920ca..f68df98ca 100644 --- a/lib/cmdx.rb +++ b/lib/cmdx.rb @@ -99,6 +99,14 @@ module CMDx require_relative "cmdx/mergers/deep_merge" require_relative "cmdx/mergers/no_merge" require_relative "cmdx/mergers" +require_relative "cmdx/retriers/exponential" +require_relative "cmdx/retriers/half_random" +require_relative "cmdx/retriers/full_random" +require_relative "cmdx/retriers/bounded_random" +require_relative "cmdx/retriers/linear" +require_relative "cmdx/retriers/fibonacci" +require_relative "cmdx/retriers/decorrelated_jitter" +require_relative "cmdx/retriers" require_relative "cmdx/input" require_relative "cmdx/inputs" require_relative "cmdx/output" diff --git a/lib/cmdx/configuration.rb b/lib/cmdx/configuration.rb index a7830d966..ab828175b 100644 --- a/lib/cmdx/configuration.rb +++ b/lib/cmdx/configuration.rb @@ -10,7 +10,7 @@ module CMDx class Configuration attr_accessor :middlewares, :callbacks, :coercions, :validators, :executors, - :mergers, :telemetry, :correlation_id, :default_locale, :strict_context, + :mergers, :retriers, :telemetry, :correlation_id, :default_locale, :strict_context, :backtrace_cleaner, :log_exclusions, :log_formatter, :log_level, :logger def initialize @@ -20,6 +20,7 @@ def initialize @validators = Validators.new @executors = Executors.new @mergers = Mergers.new + @retriers = Retriers.new @telemetry = Telemetry.new @correlation_id = nil @@ -77,6 +78,7 @@ def reset_configuration! Task.instance_variable_set(:@validators, nil) Task.instance_variable_set(:@executors, nil) Task.instance_variable_set(:@mergers, nil) + Task.instance_variable_set(:@retriers, nil) Task.instance_variable_set(:@telemetry, nil) end diff --git a/lib/cmdx/retriers.rb b/lib/cmdx/retriers.rb new file mode 100644 index 000000000..4ca59a1fb --- /dev/null +++ b/lib/cmdx/retriers.rb @@ -0,0 +1,103 @@ +# frozen_string_literal: true + +module CMDx + # Registry of named retry/jitter strategies used by `Retry` to compute the + # sleep duration between attempts. Ships with built-ins for `:exponential`, + # `:half_random`, `:full_random`, `:bounded_random`, `:linear`, `:fibonacci`, + # and `:decorrelated_jitter`. A retrier is any callable accepting + # `call(attempt, delay, prev_delay)` that returns the next delay in seconds. + class Retriers + + attr_reader :registry + + def initialize + @registry = { + exponential: Retriers::Exponential, + half_random: Retriers::HalfRandom, + full_random: Retriers::FullRandom, + bounded_random: Retriers::BoundedRandom, + linear: Retriers::Linear, + fibonacci: Retriers::Fibonacci, + decorrelated_jitter: Retriers::DecorrelatedJitter + } + end + + def initialize_copy(source) + @registry = source.registry.dup + end + + # Registers a named retrier, overwriting any existing entry. + # + # @param name [Symbol] + # @param callable [#call, nil] pass either this or a block + # @yield retrier body — `call(attempt, delay, prev_delay)` + # @return [Retriers] self for chaining + # @raise [ArgumentError] when both `callable` and a block are given, or + # when the resolved retrier isn't callable + def register(name, callable = nil, &block) + retrier = callable || block + + if callable && block + raise ArgumentError, "provide either a callable or a block, not both" + elsif !retrier.respond_to?(:call) + raise ArgumentError, "retrier must respond to #call" + end + + registry[name.to_sym] = retrier + self + end + + # @param name [Symbol] + # @return [Retriers] self for chaining + def deregister(name) + registry.delete(name.to_sym) + self + end + + # @param name [Symbol] + # @return [Boolean] whether a retrier is registered under `name` + def key?(name) + registry.key?(name.to_sym) + end + + # @param name [Symbol] + # @return [#call] the registered retrier + # @raise [ArgumentError] when `name` isn't registered + def lookup(name) + registry[name] || begin + raise ArgumentError, "unknown retrier: #{name.inspect}" + end + end + + # Resolves a `:jitter` spec to a concrete callable. Accepts a Symbol + # (registry lookup) or any object responding to `#call`. `nil` resolves + # to `nil` so callers can fall back to the unjittered base delay. + # + # @param spec [Symbol, #call, nil] + # @return [#call, nil] + # @raise [ArgumentError] when `spec` is an unknown symbol or not callable + def resolve(spec) + case spec + when NilClass + nil + when Symbol + lookup(spec) + else + return spec if spec.respond_to?(:call) + + raise ArgumentError, "unknown retrier: #{spec.inspect}" + end + end + + # @return [Boolean] + def empty? + registry.empty? + end + + # @return [Integer] + def size + registry.size + end + + end +end diff --git a/lib/cmdx/retriers/bounded_random.rb b/lib/cmdx/retriers/bounded_random.rb new file mode 100644 index 000000000..837b73e1b --- /dev/null +++ b/lib/cmdx/retriers/bounded_random.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module CMDx + class Retriers + # Bounded-random jitter. Produces a uniform delay in `[delay, 2*delay]`. + # Guarantees at least the base delay between attempts while still + # decorrelating retry timing. + # + # @api private + module BoundedRandom + + extend self + + # @param _attempt [Integer] ignored + # @param delay [Float] base delay in seconds + # @param _prev_delay [Float, nil] ignored + # @return [Float] computed delay + def call(_attempt, delay, _prev_delay = nil) + delay + (rand * delay) + end + + end + end +end diff --git a/lib/cmdx/retriers/decorrelated_jitter.rb b/lib/cmdx/retriers/decorrelated_jitter.rb new file mode 100644 index 000000000..d43595e15 --- /dev/null +++ b/lib/cmdx/retriers/decorrelated_jitter.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module CMDx + class Retriers + # AWS-recommended decorrelated jitter. Produces a uniform delay in + # `[delay, prev_delay * 3]`, threading state across attempts via + # `prev_delay`. When no previous delay exists the upper bound collapses + # to `3 * delay`, matching the AWS reference implementation. + # + # @see https://aws.amazon.com/blogs/architecture/exponential-backoff-and-jitter/ + # @api private + module DecorrelatedJitter + + extend self + + # @param _attempt [Integer] ignored + # @param delay [Float] base delay in seconds (also the lower bound) + # @param prev_delay [Float, nil] previous computed delay; falls back to + # `delay` so the first call samples in `[delay, 3*delay]` + # @return [Float] computed delay + def call(_attempt, delay, prev_delay = nil) + base = prev_delay || delay + delay + (rand * ((base * 3) - delay)) + end + + end + end +end diff --git a/lib/cmdx/retriers/exponential.rb b/lib/cmdx/retriers/exponential.rb new file mode 100644 index 000000000..fe342df25 --- /dev/null +++ b/lib/cmdx/retriers/exponential.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module CMDx + class Retriers + # Exponential backoff. Doubles the base delay every attempt: + # `delay * (2 ** attempt)`. + # + # @api private + module Exponential + + extend self + + # @param attempt [Integer] zero-based retry attempt + # @param delay [Float] base delay in seconds + # @param _prev_delay [Float, nil] ignored + # @return [Float] computed delay + def call(attempt, delay, _prev_delay = nil) + delay * (2**attempt) + end + + end + end +end diff --git a/lib/cmdx/retriers/fibonacci.rb b/lib/cmdx/retriers/fibonacci.rb new file mode 100644 index 000000000..ca885bb79 --- /dev/null +++ b/lib/cmdx/retriers/fibonacci.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module CMDx + class Retriers + # Fibonacci backoff. Sleeps `delay * fib(attempt + 1)` where `fib(1) == 1`, + # `fib(2) == 1`, `fib(3) == 2`, ... Multipliers grow as 1, 1, 2, 3, 5, 8. + # Slower-growing than exponential, faster-growing than linear. + # + # @api private + module Fibonacci + + extend self + + # @param attempt [Integer] zero-based retry attempt + # @param delay [Float] base delay in seconds + # @param _prev_delay [Float, nil] ignored + # @return [Float] computed delay + def call(attempt, delay, _prev_delay = nil) + delay * sequence(attempt + 1) + end + + # Iterative Fibonacci. `sequence(1) == 1`, `sequence(2) == 1`, + # `sequence(3) == 2`, ... + # + # @param n [Integer] one-based index into the Fibonacci sequence + # @return [Integer] + # @api private + def sequence(n) + a = 0 + b = 1 + n.times { a, b = b, a + b } + a + end + + end + end +end diff --git a/lib/cmdx/retriers/full_random.rb b/lib/cmdx/retriers/full_random.rb new file mode 100644 index 000000000..f2a4935aa --- /dev/null +++ b/lib/cmdx/retriers/full_random.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module CMDx + class Retriers + # Full-random jitter. Produces a uniform delay in `[0, delay]`. Maximizes + # spread at the cost of occasional very-fast retries. + # + # @api private + module FullRandom + + extend self + + # @param _attempt [Integer] ignored + # @param delay [Float] base delay in seconds + # @param _prev_delay [Float, nil] ignored + # @return [Float] computed delay + def call(_attempt, delay, _prev_delay = nil) + rand * delay + end + + end + end +end diff --git a/lib/cmdx/retriers/half_random.rb b/lib/cmdx/retriers/half_random.rb new file mode 100644 index 000000000..fdcf61804 --- /dev/null +++ b/lib/cmdx/retriers/half_random.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module CMDx + class Retriers + # Half-random jitter. Produces a uniform delay in `[delay/2, delay]`. + # Useful when you want a tighter spread than `:full_random` while still + # decorrelating retries from synchronized clients. + # + # @api private + module HalfRandom + + extend self + + # @param _attempt [Integer] ignored + # @param delay [Float] base delay in seconds + # @param _prev_delay [Float, nil] ignored + # @return [Float] computed delay + def call(_attempt, delay, _prev_delay = nil) + (delay / 2.0) + (rand * delay / 2.0) + end + + end + end +end diff --git a/lib/cmdx/retriers/linear.rb b/lib/cmdx/retriers/linear.rb new file mode 100644 index 000000000..f428d54ce --- /dev/null +++ b/lib/cmdx/retriers/linear.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module CMDx + class Retriers + # Linear backoff. Sleeps `delay * (attempt + 1)` — multiples of the base + # delay grow arithmetically (1x, 2x, 3x, ...). + # + # @api private + module Linear + + extend self + + # @param attempt [Integer] zero-based retry attempt + # @param delay [Float] base delay in seconds + # @param _prev_delay [Float, nil] ignored + # @return [Float] computed delay + def call(attempt, delay, _prev_delay = nil) + delay * (attempt + 1) + end + + end + end +end diff --git a/lib/cmdx/retry.rb b/lib/cmdx/retry.rb index f274e715b..b3bff4e5e 100644 --- a/lib/cmdx/retry.rb +++ b/lib/cmdx/retry.rb @@ -74,23 +74,16 @@ def wait(attempt, task = nil, prev_delay = nil) d = case jitter - when :exponential - delay * (2**attempt) - when :half_random - (delay / 2.0) + (rand * delay / 2.0) - when :full_random - rand * delay - when :bounded_random - delay + (rand * delay) - when :linear - delay * (attempt + 1) - when :fibonacci - delay * fibonacci(attempt + 1) - when :decorrelated_jitter - base = prev_delay || delay - delay + (rand * ((base * 3) - delay)) + when NilClass + delay when Symbol - task.send(jitter, attempt, delay) + registry = retriers_registry(task) + + if registry.key?(jitter) + registry.lookup(jitter).call(attempt, delay, prev_delay) + else + task.send(jitter, attempt, delay) + end when Proc task.instance_exec(attempt, delay, &jitter) else @@ -130,15 +123,19 @@ def process(task = nil, &) private - # Iterative Fibonacci. `fib(1) == 1`, `fib(2) == 1`, `fib(3) == 2`, ... + # Resolves the retriers registry to consult for built-in jitter strategies. + # Prefers the task class's registry (so per-task `register(:retrier, ...)` + # overrides take effect) and falls back to the global configuration when + # no task is supplied. # - # @param n [Integer] one-based index into the Fibonacci sequence - # @return [Integer] - def fibonacci(n) - a = 0 - b = 1 - n.times { a, b = b, a + b } - a + # @param task [Task, nil] + # @return [Retriers] + def retriers_registry(task) + if task && task.class.respond_to?(:retriers) + task.class.retriers + else + CMDx.configuration.retriers + end end end diff --git a/lib/cmdx/task.rb b/lib/cmdx/task.rb index 5c4eca709..f82176b62 100644 --- a/lib/cmdx/task.rb +++ b/lib/cmdx/task.rb @@ -130,9 +130,19 @@ def mergers end end + # @return [Retriers] cloned from superclass/configuration on first call + def retriers + @retriers ||= + if superclass.respond_to?(:retriers) + superclass.retriers.dup + else + CMDx.configuration.retriers.dup + end + end + # Dispatches to the appropriate registry's `register` method. # - # @param type [:middleware, :callback, :coercion, :validator, :executor, :merger, :input, :output] + # @param type [:middleware, :callback, :coercion, :validator, :executor, :merger, :retrier, :input, :output] # @return [Object] the registry's self # @raise [ArgumentError] when `type` is unknown def register(type, ...) @@ -149,6 +159,8 @@ def register(type, ...) executors.register(...) when :merger mergers.register(...) + when :retrier + retriers.register(...) when :input inputs.register(self, ...) when :output @@ -159,7 +171,7 @@ def register(type, ...) # Dispatches to the appropriate registry's `deregister` method. # - # @param type [:middleware, :callback, :coercion, :validator, :executor, :merger, :input, :output] + # @param type [:middleware, :callback, :coercion, :validator, :executor, :merger, :retrier, :input, :output] # @return [Object] the registry's self # @raise [ArgumentError] when `type` is unknown def deregister(type, ...) @@ -176,6 +188,8 @@ def deregister(type, ...) executors.deregister(...) when :merger mergers.deregister(...) + when :retrier + retriers.deregister(...) when :input inputs.deregister(self, ...) when :output diff --git a/spec/cmdx/retriers_spec.rb b/spec/cmdx/retriers_spec.rb new file mode 100644 index 000000000..d298b0790 --- /dev/null +++ b/spec/cmdx/retriers_spec.rb @@ -0,0 +1,159 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe CMDx::Retriers do + subject(:retriers) { described_class.new } + + describe "#initialize" do + it "registers the built-in retry strategies" do + expect(retriers.registry.keys).to contain_exactly( + :exponential, :half_random, :full_random, :bounded_random, + :linear, :fibonacci, :decorrelated_jitter + ) + expect(retriers.lookup(:exponential)).to be(CMDx::Retriers::Exponential) + expect(retriers.lookup(:half_random)).to be(CMDx::Retriers::HalfRandom) + expect(retriers.lookup(:full_random)).to be(CMDx::Retriers::FullRandom) + expect(retriers.lookup(:bounded_random)).to be(CMDx::Retriers::BoundedRandom) + expect(retriers.lookup(:linear)).to be(CMDx::Retriers::Linear) + expect(retriers.lookup(:fibonacci)).to be(CMDx::Retriers::Fibonacci) + expect(retriers.lookup(:decorrelated_jitter)).to be(CMDx::Retriers::DecorrelatedJitter) + end + end + + describe "#initialize_copy" do + it "dups the registry" do + copy = retriers.dup + copy.deregister(:exponential) + expect(retriers.registry).to have_key(:exponential) + expect(copy.registry).not_to have_key(:exponential) + end + end + + describe "#register" do + it "stores a callable" do + c = ->(_a, _d, _p) { 1.0 } + retriers.register(:custom, c) + expect(retriers.lookup(:custom)).to be(c) + end + + it "stores a block" do + retriers.register(:b) { |_a, _d, _p| 0.0 } + expect(retriers.lookup(:b)).to be_a(Proc) + end + + it "raises when both a callable and block are given" do + c = ->(_, _, _) {} + expect { retriers.register(:x, c) { |_, _, _| nil } } + .to raise_error(ArgumentError, /either a callable or a block/) + end + + it "raises when the handler does not respond to call" do + expect { retriers.register(:x, Object.new) } + .to raise_error(ArgumentError, /must respond to #call/) + end + end + + describe "#deregister" do + it "removes a key" do + retriers.deregister(:exponential) + expect(retriers.registry).not_to have_key(:exponential) + end + end + + describe "#key?" do + it "reports membership" do + expect(retriers.key?(:exponential)).to be(true) + expect(retriers.key?(:bogus)).to be(false) + end + end + + describe "#lookup" do + it "raises on unknown keys" do + expect { retriers.lookup(:bogus) } + .to raise_error(ArgumentError, "unknown retrier: :bogus") + end + end + + describe "#resolve" do + it "returns nil when spec is nil" do + expect(retriers.resolve(nil)).to be_nil + end + + it "looks up registered symbols" do + expect(retriers.resolve(:linear)).to be(retriers.lookup(:linear)) + end + + it "passes through arbitrary callables" do + c = ->(_a, _d, _p) { 0.0 } + expect(retriers.resolve(c)).to be(c) + end + + it "raises on unknown symbols" do + expect { retriers.resolve(:bogus) } + .to raise_error(ArgumentError, "unknown retrier: :bogus") + end + + it "raises on non-callable specs" do + expect { retriers.resolve(Object.new) } + .to raise_error(ArgumentError, /unknown retrier/) + end + end + + describe "built-in behavior" do + it ":exponential doubles each attempt" do + expect(retriers.lookup(:exponential).call(0, 0.5)).to eq(0.5) + expect(retriers.lookup(:exponential).call(3, 0.5)).to eq(4.0) + end + + it ":half_random samples within [delay/2, delay]" do + strategy = retriers.lookup(:half_random) + allow(strategy).to receive(:rand).and_return(0.0, 1.0) + expect(strategy.call(0, 2.0)).to eq(1.0) + expect(strategy.call(0, 2.0)).to eq(2.0) + end + + it ":full_random samples within [0, delay]" do + strategy = retriers.lookup(:full_random) + allow(strategy).to receive(:rand).and_return(0.25) + expect(strategy.call(0, 2.0)).to eq(0.5) + end + + it ":bounded_random samples within [delay, 2*delay]" do + strategy = retriers.lookup(:bounded_random) + allow(strategy).to receive(:rand).and_return(0.5) + expect(strategy.call(0, 2.0)).to eq(3.0) + end + + it ":linear scales arithmetically" do + expect(retriers.lookup(:linear).call(0, 1.0)).to eq(1.0) + expect(retriers.lookup(:linear).call(3, 0.25)).to eq(1.0) + end + + it ":fibonacci scales by the Fibonacci sequence" do + strategy = retriers.lookup(:fibonacci) + expect((0..5).map { |a| strategy.call(a, 1.0) }) + .to eq([1.0, 1.0, 2.0, 3.0, 5.0, 8.0]) + end + + it ":decorrelated_jitter falls back to base delay when prev is nil" do + strategy = retriers.lookup(:decorrelated_jitter) + allow(strategy).to receive(:rand).and_return(0.0, 1.0) + expect(strategy.call(0, 1.0)).to eq(1.0) + expect(strategy.call(0, 1.0)).to eq(3.0) + end + + it ":decorrelated_jitter widens upper bound from prev_delay" do + strategy = retriers.lookup(:decorrelated_jitter) + allow(strategy).to receive(:rand).and_return(1.0) + expect(strategy.call(0, 1.0, 4.0)).to eq(12.0) + end + end + + describe "#empty? / #size" do + it "reports the registry size" do + expect(retriers.size).to eq(7) + expect(retriers).not_to be_empty + end + end +end diff --git a/spec/cmdx/retry_spec.rb b/spec/cmdx/retry_spec.rb index bc48b59c3..cc9136a56 100644 --- a/spec/cmdx/retry_spec.rb +++ b/spec/cmdx/retry_spec.rb @@ -106,31 +106,6 @@ expect(sleeps).to eq([1.0]) end - it "half_random produces delays in [delay/2, delay]" do - retry_ = described_class.new([error_class], delay: 1.0, jitter: :half_random) - allow(retry_).to receive(:rand).and_return(0.0, 1.0) - - retry_.wait(0) - retry_.wait(0) - expect(sleeps).to eq([0.5, 1.0]) - end - - it "full_random produces a delay in [0, delay]" do - retry_ = described_class.new([error_class], delay: 2.0, jitter: :full_random) - allow(retry_).to receive(:rand).and_return(0.25) - - retry_.wait(0) - expect(sleeps).to eq([0.5]) - end - - it "bounded_random produces a delay in [delay, 2*delay]" do - retry_ = described_class.new([error_class], delay: 2.0, jitter: :bounded_random) - allow(retry_).to receive(:rand).and_return(0.5) - - retry_.wait(0) - expect(sleeps).to eq([3.0]) - end - it "linear produces delay * (attempt + 1)" do described_class.new([error_class], delay: 0.25, jitter: :linear).wait(3) expect(sleeps).to eq([1.0]) @@ -142,32 +117,34 @@ expect(sleeps).to eq([1.0, 1.0, 2.0, 3.0, 5.0, 8.0]) end - it "decorrelated_jitter falls back to base delay when prev_delay is nil" do + it "decorrelated_jitter threads prev_delay through wait's third argument" do + allow(CMDx::Retriers::DecorrelatedJitter).to receive(:rand).and_return(1.0) retry_ = described_class.new([error_class], delay: 1.0, jitter: :decorrelated_jitter) - allow(retry_).to receive(:rand).and_return(0.0, 1.0) - retry_.wait(0) - retry_.wait(0) - expect(sleeps).to eq([1.0, 3.0]) + retry_.wait(0, nil, 4.0) + expect(sleeps).to eq([12.0]) end - it "decorrelated_jitter uses prev_delay to widen the upper bound" do + it "wait returns the computed delay so process can thread it" do + allow(CMDx::Retriers::DecorrelatedJitter).to receive(:rand).and_return(0.5) retry_ = described_class.new([error_class], delay: 1.0, jitter: :decorrelated_jitter) - allow(retry_).to receive(:rand).and_return(1.0) - retry_.wait(0, nil, 4.0) - expect(sleeps).to eq([12.0]) + expect(retry_.wait(0)).to eq(2.0) end - it "decorrelated_jitter returns the computed delay so it can be threaded" do - retry_ = described_class.new([error_class], delay: 1.0, jitter: :decorrelated_jitter) - allow(retry_).to receive(:rand).and_return(0.5) + it "delegates Symbol jitter to the retriers registry" do + strategy = ->(attempt, delay, _prev) { delay * (attempt + 10) } + task_class = Class.new + task_class.singleton_class.define_method(:retriers) do + @retriers ||= CMDx::Retriers.new.tap { |r| r.register(:custom, strategy) } + end + task = task_class.new - d = retry_.wait(0) - expect(d).to eq(2.0) + described_class.new([error_class], delay: 1.0, jitter: :custom).wait(2, task) + expect(sleeps).to eq([12.0]) end - it "calls a Symbol jitter on the task" do + it "falls back to a task instance method when Symbol is not in the registry" do task = Class.new { def jitter_calc(attempt, delay) = delay * attempt }.new described_class.new([error_class], delay: 1.0, jitter: :jitter_calc).wait(4, task) @@ -233,9 +210,9 @@ it "threads prev_delay across attempts for :decorrelated_jitter" do sleeps = [] allow(Kernel).to receive(:sleep) { |d| sleeps << d } + allow(CMDx::Retriers::DecorrelatedJitter).to receive(:rand).and_return(1.0) retry_ = described_class.new([error_class], limit: 3, delay: 1.0, jitter: :decorrelated_jitter) - allow(retry_).to receive(:rand).and_return(1.0) attempts = 0 expect do From f91d990808145c35e70bbc3e4c8fbe115d95a57f Mon Sep 17 00:00:00 2001 From: Juan Gomez <drexed@users.noreply.github.com> Date: Mon, 4 May 2026 23:50:30 -0400 Subject: [PATCH 38/54] Update fibonacci.rb --- lib/cmdx/retriers/fibonacci.rb | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/cmdx/retriers/fibonacci.rb b/lib/cmdx/retriers/fibonacci.rb index ca885bb79..774fe09c3 100644 --- a/lib/cmdx/retriers/fibonacci.rb +++ b/lib/cmdx/retriers/fibonacci.rb @@ -19,6 +19,8 @@ def call(attempt, delay, _prev_delay = nil) delay * sequence(attempt + 1) end + private + # Iterative Fibonacci. `sequence(1) == 1`, `sequence(2) == 1`, # `sequence(3) == 2`, ... # From f714db7899d293d5f53979a6a8ab48e05e78f6c5 Mon Sep 17 00:00:00 2001 From: Juan Gomez <drexed@users.noreply.github.com> Date: Mon, 4 May 2026 23:56:30 -0400 Subject: [PATCH 39/54] Rename to merger strategy --- CHANGELOG.md | 4 ++-- docs/v2-migration.md | 2 +- docs/workflows.md | 16 ++++++++-------- lib/cmdx/mergers.rb | 6 +++--- lib/cmdx/pipeline.rb | 2 +- lib/cmdx/workflow.rb | 2 +- skills/references/workflows.md | 12 ++++++------ spec/cmdx/mergers_spec.rb | 4 ++-- spec/cmdx/pipeline_spec.rb | 14 +++++++------- 9 files changed, 31 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f481bcf9..a6bd73bd3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,8 +52,8 @@ Full runtime rewrite: the v1 state-machine plus Zeitwerk architecture is replace - Add `Context#deconstruct` / `Context#deconstruct_keys` for pattern matching - Add `Errors#deconstruct` / `Errors#deconstruct_keys` for pattern matching - Add `:executor` option to parallel task groups (`Workflow.tasks ..., strategy: :parallel, executor: :threads | :fibers | #call`); `:threads` is the default and preserves current behavior, `:fibers` dispatches via `Fiber.schedule` bounded by `:pool_size` (requires a `Fiber.scheduler` such as the `async` gem's — raises `RuntimeError` when none is installed), and a user-supplied callable matching `call(jobs:, concurrency:, on_job:)` is accepted. Unknown symbols raise `ArgumentError` -- Add `:merge_strategy` option to parallel task groups controlling how successful sibling contexts fold back into the workflow context: `:last_write_wins` (default, matches previous behavior), `:deep_merge` (recursive over `Hash` values), `:no_merge` (workflow context left untouched), or a callable `call(workflow_context, result)`. Merging always walks successful results in declaration order. Unknown symbols raise `ArgumentError` -- Add `CMDx::Executors` registry (built-ins: `:threads` → `Executors::Thread`, `:fibers` → `Executors::Fiber`) and `CMDx::Mergers` registry (built-ins: `:last_write_wins`, `:deep_merge`, `:no_merge`) exposed on `Configuration#executors` / `#mergers` and per-task via `Task.executors` / `Task.mergers` (dup-on-inherit); `Task.register(:executor, ...)` and `Task.register(:merger, ...)` (plus matching `deregister`) let apps plug in custom dispatch/merge strategies resolvable by name from `:executor` / `:merge_strategy` +- Add `:merger` option to parallel task groups controlling how successful sibling contexts fold back into the workflow context: `:last_write_wins` (default, matches previous behavior), `:deep_merge` (recursive over `Hash` values), `:no_merge` (workflow context left untouched), or a callable `call(workflow_context, result)`. Merging always walks successful results in declaration order. Unknown symbols raise `ArgumentError` +- Add `CMDx::Executors` registry (built-ins: `:threads` → `Executors::Thread`, `:fibers` → `Executors::Fiber`) and `CMDx::Mergers` registry (built-ins: `:last_write_wins`, `:deep_merge`, `:no_merge`) exposed on `Configuration#executors` / `#mergers` and per-task via `Task.executors` / `Task.mergers` (dup-on-inherit); `Task.register(:executor, ...)` and `Task.register(:merger, ...)` (plus matching `deregister`) let apps plug in custom dispatch/merge strategies resolvable by name from `:executor` / `:merger` - Add `Context#deep_merge` — in-place recursive `Hash`-value merge; scalar-vs-hash collisions follow last-write-wins. Used by the `:deep_merge` parallel merge strategy but also available directly - Add `:linear`, `:fibonacci`, and `:decorrelated_jitter` built-in retry jitter strategies on `Retry` / `Task.retry_on`. `:linear` sleeps `delay * (attempt + 1)`; `:fibonacci` sleeps `delay * fib(attempt + 1)`; `:decorrelated_jitter` is the AWS-recommended stateful strategy `next ∈ [delay, prev_sleep * 3]` (threaded across attempts inside a single `process` call, falls back to `delay` when no prior sleep). `Retry#wait` now accepts an optional `prev_delay` argument and returns the computed delay so callers can thread state - Add `CMDx::Retriers` registry (built-ins: `:exponential`, `:half_random`, `:full_random`, `:bounded_random`, `:linear`, `:fibonacci`, `:decorrelated_jitter`) exposed on `Configuration#retriers` and per-task via `Task.retriers` (dup-on-inherit). `Task.register(:retrier, ...)` / `deregister(:retrier, ...)` let apps plug in custom jitter strategies resolvable by name from `:jitter`. Strategies are pure callables matching `call(attempt, delay, prev_delay)`. Symbols not present in the registry still fall through to task instance methods, preserving existing `jitter: :method_name` semantics diff --git a/docs/v2-migration.md b/docs/v2-migration.md index eb1361fab..2f4a4378d 100644 --- a/docs/v2-migration.md +++ b/docs/v2-migration.md @@ -434,7 +434,7 @@ end - Each parallel worker `deep_dup`s the workflow context, runs its task, then merges its successful child context back into the workflow (on the parent thread, after all workers join). - All workers share the parent's fiber-local `Chain` — each worker sets `Fiber[Chain::STORAGE_KEY]` on thread entry, and each result is pushed under a `Mutex`. - By default (`continue_on_failure: false`), pending workers are drained as soon as any sibling fails (in-flight tasks still finish, successful contexts still merge), and the first failure **by completion time** is propagated. With `continue_on_failure: true`, every worker runs to completion and all failures are aggregated into the workflow's `errors` (keyed `"TaskClass.input"` for input/validation errors and `"TaskClass.<status>"` for bare `fail!` reasons); the first failure **by declaration index** is propagated via `throw!`. -- Additional knobs: `:executor` (`:threads` default, `:fibers`, or a callable), `:merge_strategy` (`:last_write_wins` default, `:deep_merge`, `:no_merge`, or a callable), and `:continue_on_failure`. See [Workflows - Parallel Execution](workflows.md#parallel-execution). +- Additional knobs: `:executor` (`:threads` default, `:fibers`, or a callable), `:merger` (`:last_write_wins` default, `:deep_merge`, `:no_merge`, or a callable), and `:continue_on_failure`. See [Workflows - Parallel Execution](workflows.md#parallel-execution). ### Behavioral Changes diff --git a/docs/workflows.md b/docs/workflows.md index dafe14322..e3fbf96c6 100644 --- a/docs/workflows.md +++ b/docs/workflows.md @@ -65,7 +65,7 @@ Options apply to the entire group: | `strategy:` | `:sequential` | `:sequential` or `:parallel` | | `pool_size:` | `tasks.size` | Worker/fiber count when `strategy: :parallel` | | `executor:` | `:threads` | Parallel dispatch backend: `:threads`, `:fibers`, or a callable. `:fibers` requires a `Fiber.scheduler` to be installed (e.g. inside `Async { ... }`) | -| `merge_strategy:` | `:last_write_wins` | How successful parallel contexts fold back into the workflow context: `:last_write_wins`, `:deep_merge`, `:no_merge`, or a callable `->(workflow_context, result) { ... }` | +| `merger:` | `:last_write_wins` | How successful parallel contexts fold back into the workflow context: `:last_write_wins`, `:deep_merge`, `:no_merge`, or a callable `->(workflow_context, result) { ... }` | | `continue_on_failure:` | `false` | When `true`, run every task in the group to completion even after a failure, and aggregate all failures into the workflow's `errors` (keyed `"TaskClass.input"` for input/validation errors and `"TaskClass.<status>"` for bare `fail!` reasons). Applies to both strategies. When `false` (default), `:sequential` halts on the first failure and `:parallel` cancels pending tasks (in-flight tasks still finish) | | `if:` / `unless:` | — | Skip the entire group when the predicate isn't satisfied | @@ -313,21 +313,21 @@ The same registry is available globally via `CMDx.configuration.executors.regist ### Merge strategies -After every successful sibling completes, each sibling's duplicated context is folded back into the workflow context. The default is last-write-wins in declaration order — reliable and fast, but brittle when two tasks write a nested structure under the same key. `:merge_strategy` lets you pick the collision policy up front. +After every successful sibling completes, each sibling's duplicated context is folded back into the workflow context. The default is last-write-wins in declaration order — reliable and fast, but brittle when two tasks write a nested structure under the same key. `:merger` lets you pick the collision policy up front. ```ruby # Default — shallow, last declared task wins on conflicts -tasks A, B, C, strategy: :parallel, merge_strategy: :last_write_wins +tasks A, B, C, strategy: :parallel, merger: :last_write_wins # Recursive hash merge — nested structures are combined instead of replaced -tasks A, B, C, strategy: :parallel, merge_strategy: :deep_merge +tasks A, B, C, strategy: :parallel, merger: :deep_merge # Don't touch the workflow context at all -tasks A, B, C, strategy: :parallel, merge_strategy: :no_merge +tasks A, B, C, strategy: :parallel, merger: :no_merge # Custom — e.g. namespace each sibling's output under its class name tasks A, B, C, strategy: :parallel, - merge_strategy: ->(ctx, result) { ctx[result.task.name] = result.context.to_h } + merger: ->(ctx, result) { ctx[result.task.name] = result.context.to_h } ``` Behavior notes: @@ -336,14 +336,14 @@ Behavior notes: - `:deep_merge` recurses only into `Hash` values; non-hash collisions (Integer, String, Array, custom objects) still follow last-write-wins so a scalar on either side wins over a hash on the other. - `:no_merge` keeps the parallel tasks' side effects (each sibling's `result.context` is still reachable via `result.chain`) but nothing is written back to the workflow context. Useful when you're only interested in per-task telemetry, or when tasks own their own persistence. - A callable receives `(workflow_context, result)` and is free to write whatever shape you want. Failed results never reach the merger. -- Merge strategies are resolved from a per-task registry (`CMDx::Mergers`). Register your own named merger with `register :merger, :name, callable` (or on `CMDx.configuration.mergers`) and reference it by symbol from `:merge_strategy`. +- Merge strategies are resolved from a per-task registry (`CMDx::Mergers`). Register your own named merger with `register :merger, :name, callable` (or on `CMDx.configuration.mergers`) and reference it by symbol from `:merger`. ```ruby class BuildDashboard < CMDx::Task include CMDx::Workflow tasks FetchRevenue, FetchTraffic, FetchErrors, - strategy: :parallel, merge_strategy: :deep_merge + strategy: :parallel, merger: :deep_merge # FetchRevenue: context.metrics = { revenue: ... } # FetchTraffic: context.metrics = { visitors: ... } # After merge: context.metrics == { revenue: ..., visitors: ..., errors: ... } diff --git a/lib/cmdx/mergers.rb b/lib/cmdx/mergers.rb index 568c0d7cd..f2a115cb1 100644 --- a/lib/cmdx/mergers.rb +++ b/lib/cmdx/mergers.rb @@ -54,11 +54,11 @@ def deregister(name) # @raise [ArgumentError] when `name` isn't registered def lookup(name) registry[name] || begin - raise ArgumentError, "unknown merge_strategy: #{name.inspect}" + raise ArgumentError, "unknown merger: #{name.inspect}" end end - # Resolves a declaration's `:merge_strategy` option to a concrete + # Resolves a declaration's `:merger` option to a concrete # callable. Accepts `nil` (default `:last_write_wins`), a Symbol # (registry lookup), or any object responding to `#call`. # @@ -74,7 +74,7 @@ def resolve(spec) else return spec if spec.respond_to?(:call) - raise ArgumentError, "unknown merge_strategy: #{spec.inspect}" + raise ArgumentError, "unknown merger: #{spec.inspect}" end end diff --git a/lib/cmdx/pipeline.rb b/lib/cmdx/pipeline.rb index 375ada878..4161beb9a 100644 --- a/lib/cmdx/pipeline.rb +++ b/lib/cmdx/pipeline.rb @@ -116,7 +116,7 @@ def run_parallel(group) end executor = @workflow.class.executors.resolve(group.options[:executor]) - merger = @workflow.class.mergers.resolve(group.options[:merge_strategy]) + merger = @workflow.class.mergers.resolve(group.options[:merger]) executor.call(jobs:, concurrency: size, on_job:) diff --git a/lib/cmdx/workflow.rb b/lib/cmdx/workflow.rb index f9e4a0c0e..f4df36074 100644 --- a/lib/cmdx/workflow.rb +++ b/lib/cmdx/workflow.rb @@ -33,7 +33,7 @@ def pipeline # dispatch backend. `:fibers` requires a `Fiber.scheduler` to be # installed (e.g. `Async { ... }`). A custom callable accepting # `jobs:, concurrency:, on_job:` may also be passed. - # @option options [:last_write_wins, :deep_merge, :no_merge, #call] :merge_strategy + # @option options [:last_write_wins, :deep_merge, :no_merge, #call] :merger # (:last_write_wins) how successful parallel contexts are folded back # into the workflow context. Merging happens in declaration order. A # callable `->(workflow_context, result) { ... }` may be passed to diff --git a/skills/references/workflows.md b/skills/references/workflows.md index 94ff541bc..d2ecdb345 100644 --- a/skills/references/workflows.md +++ b/skills/references/workflows.md @@ -33,7 +33,7 @@ Defining `def work` on a workflow raises `CMDx::ImplementationError` — `#work` | `strategy:` | `:sequential` (default) or `:parallel`. | | `pool_size:` | Parallel worker/fiber count. Defaults to `tasks.size`. | | `executor:` | `:threads` (default), `:fibers`, or any callable matching `call(jobs:, concurrency:, on_job:)`. `:fibers` requires `Fiber.scheduler` to be installed. | -| `merge_strategy:` | `:last_write_wins` (default), `:deep_merge`, `:no_merge`, or a callable `call(workflow_context, result)`. Applied in declaration order over successful results only. | +| `merger:` | `:last_write_wins` (default), `:deep_merge`, `:no_merge`, or a callable `call(workflow_context, result)`. Applied in declaration order over successful results only. | | `continue_on_failure:` | When `true`, run every task in the group even after a failure and aggregate failures into `workflow.errors` keyed `"TaskClass.input"` / `"TaskClass.<status>"`. When `false` (default), `:sequential` halts on first failure and `:parallel` cancels pending tasks. | | `if:` / `unless:` | Gate the whole group. Signature `(workflow)` (Symbol → task method; Proc → `instance_exec`; `#call`-able → `callable.call(workflow)`). | @@ -98,15 +98,15 @@ end CMDx.configure { |c| c.executors.register(:bounded_pool, MyPool.method(:run)) } ``` -### Merge strategies (`merge_strategy:`) +### Merge strategies (`merger:`) Controls how successful sibling contexts fold back into the workflow context. Fold order is always declaration order (deterministic, independent of completion order). ```ruby tasks A, B, C, strategy: :parallel # :last_write_wins (default) -tasks A, B, C, strategy: :parallel, merge_strategy: :deep_merge -tasks A, B, C, strategy: :parallel, merge_strategy: :no_merge -tasks A, B, C, strategy: :parallel, merge_strategy: ->(ctx, result) { ctx[result.task.name] = result.context.to_h } +tasks A, B, C, strategy: :parallel, merger: :deep_merge +tasks A, B, C, strategy: :parallel, merger: :no_merge +tasks A, B, C, strategy: :parallel, merger: ->(ctx, result) { ctx[result.task.name] = result.context.to_h } ``` - `:last_write_wins` — shallow `Hash#merge!`; later-declared tasks overwrite earlier-declared on conflict. Matches previous behavior. @@ -116,7 +116,7 @@ tasks A, B, C, strategy: :parallel, merge_strategy: ->(ctx, result) { ctx[result Unknown merge strategy symbols raise `ArgumentError`. -Merge strategies are resolved from a per-task `CMDx::Mergers` registry. Register custom named mergers via `register :merger, :name, callable` on a task class (or `CMDx.configuration.mergers.register(...)` globally) and reference them by symbol from `:merge_strategy`. +Merge strategies are resolved from a per-task `CMDx::Mergers` registry. Register custom named mergers via `register :merger, :name, callable` on a task class (or `CMDx.configuration.mergers.register(...)` globally) and reference them by symbol from `:merger`. ## Conditional groups diff --git a/spec/cmdx/mergers_spec.rb b/spec/cmdx/mergers_spec.rb index 4f50def24..1042a064b 100644 --- a/spec/cmdx/mergers_spec.rb +++ b/spec/cmdx/mergers_spec.rb @@ -59,7 +59,7 @@ describe "#lookup" do it "raises on unknown keys" do expect { mergers.lookup(:bogus) } - .to raise_error(ArgumentError, "unknown merge_strategy: :bogus") + .to raise_error(ArgumentError, "unknown merger: :bogus") end end @@ -79,7 +79,7 @@ it "raises on unknown symbols" do expect { mergers.resolve(:bogus) } - .to raise_error(ArgumentError, "unknown merge_strategy: :bogus") + .to raise_error(ArgumentError, "unknown merger: :bogus") end end diff --git a/spec/cmdx/pipeline_spec.rb b/spec/cmdx/pipeline_spec.rb index c6443d994..c829c2b37 100644 --- a/spec/cmdx/pipeline_spec.rb +++ b/spec/cmdx/pipeline_spec.rb @@ -354,7 +354,7 @@ end end - describe ":merge_strategy" do + describe ":merger" do let(:writer_a) do create_task_class(name: "WA") do define_method(:work) do @@ -389,7 +389,7 @@ wa = writer_a wb = writer_b workflow_class = create_workflow_class do - tasks wa, wb, strategy: :parallel, merge_strategy: :deep_merge + tasks wa, wb, strategy: :parallel, merger: :deep_merge end result = workflow_class.execute @@ -400,7 +400,7 @@ wa = writer_a wb = writer_b workflow_class = create_workflow_class do - tasks wa, wb, strategy: :parallel, merge_strategy: :no_merge + tasks wa, wb, strategy: :parallel, merger: :no_merge end result = workflow_class.execute @@ -420,7 +420,7 @@ } workflow_class = create_workflow_class do - tasks wa, wb, strategy: :parallel, merge_strategy: collector + tasks wa, wb, strategy: :parallel, merger: collector end result = workflow_class.execute @@ -431,10 +431,10 @@ it "rejects unknown symbols" do wa = writer_a workflow_class = create_workflow_class do - tasks wa, strategy: :parallel, merge_strategy: :bogus + tasks wa, strategy: :parallel, merger: :bogus end - expect { workflow_class.execute! }.to raise_error(ArgumentError, /unknown merge_strategy: :bogus/) + expect { workflow_class.execute! }.to raise_error(ArgumentError, /unknown merger: :bogus/) end end @@ -456,7 +456,7 @@ t1 = create_successful_task(name: "M1") workflow_class = create_workflow_class do register :merger, :collector, collector - tasks t1, strategy: :parallel, merge_strategy: :collector + tasks t1, strategy: :parallel, merger: :collector end expect(workflow_class.execute).to be_success From 9c20bd13b2fa8cba9e7c5f90284e1e6897afca6a Mon Sep 17 00:00:00 2001 From: Juan Gomez <drexed@users.noreply.github.com> Date: Tue, 5 May 2026 00:02:59 -0400 Subject: [PATCH 40/54] Add deprecators --- CHANGELOG.md | 1 + docs/deprecation.md | 25 +++++++ lib/cmdx.rb | 4 ++ lib/cmdx/configuration.rb | 7 +- lib/cmdx/deprecation.rb | 29 ++++++-- lib/cmdx/deprecators.rb | 98 ++++++++++++++++++++++++++ lib/cmdx/deprecators/error.rb | 22 ++++++ lib/cmdx/deprecators/log.rb | 22 ++++++ lib/cmdx/deprecators/warn.rb | 21 ++++++ lib/cmdx/task.rb | 18 ++++- spec/cmdx/deprecators_spec.rb | 128 ++++++++++++++++++++++++++++++++++ 11 files changed, 364 insertions(+), 11 deletions(-) create mode 100644 lib/cmdx/deprecators.rb create mode 100644 lib/cmdx/deprecators/error.rb create mode 100644 lib/cmdx/deprecators/log.rb create mode 100644 lib/cmdx/deprecators/warn.rb create mode 100644 spec/cmdx/deprecators_spec.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index a6bd73bd3..18324054b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -57,6 +57,7 @@ Full runtime rewrite: the v1 state-machine plus Zeitwerk architecture is replace - Add `Context#deep_merge` — in-place recursive `Hash`-value merge; scalar-vs-hash collisions follow last-write-wins. Used by the `:deep_merge` parallel merge strategy but also available directly - Add `:linear`, `:fibonacci`, and `:decorrelated_jitter` built-in retry jitter strategies on `Retry` / `Task.retry_on`. `:linear` sleeps `delay * (attempt + 1)`; `:fibonacci` sleeps `delay * fib(attempt + 1)`; `:decorrelated_jitter` is the AWS-recommended stateful strategy `next ∈ [delay, prev_sleep * 3]` (threaded across attempts inside a single `process` call, falls back to `delay` when no prior sleep). `Retry#wait` now accepts an optional `prev_delay` argument and returns the computed delay so callers can thread state - Add `CMDx::Retriers` registry (built-ins: `:exponential`, `:half_random`, `:full_random`, `:bounded_random`, `:linear`, `:fibonacci`, `:decorrelated_jitter`) exposed on `Configuration#retriers` and per-task via `Task.retriers` (dup-on-inherit). `Task.register(:retrier, ...)` / `deregister(:retrier, ...)` let apps plug in custom jitter strategies resolvable by name from `:jitter`. Strategies are pure callables matching `call(attempt, delay, prev_delay)`. Symbols not present in the registry still fall through to task instance methods, preserving existing `jitter: :method_name` semantics +- Add `CMDx::Deprecators` registry (built-ins: `:log`, `:warn`, `:error`) exposed on `Configuration#deprecators` and per-task via `Task.deprecators` (dup-on-inherit). `Task.register(:deprecator, ...)` / `deregister(:deprecator, ...)` let apps plug in custom deprecation actions resolvable by name from `Task.deprecation`. Actions are callables matching `call(task)`. Symbols not present in the registry still fall through to task instance methods, preserving existing `deprecation :method_name` semantics. The built-in `:log`, `:warn`, `:error` arms are now strategy modules under `lib/cmdx/deprecators/` instead of inline branches in `Deprecation#execute` ### Changed - **BREAKING**: Rename `#call` → `#work` on task subclasses; `Task.execute` / `Task.execute!` are the new entry points (`call` / `call!` kept as aliases) diff --git a/docs/deprecation.md b/docs/deprecation.md index 530c02941..7a7da2449 100644 --- a/docs/deprecation.md +++ b/docs/deprecation.md @@ -176,6 +176,31 @@ class Excluded < BaseLegacyTask end ``` +## Custom Actions via the `Deprecators` Registry + +Built-in actions (`:log`, `:warn`, `:error`) live in the `CMDx::Deprecators` +registry, mirroring `Retriers` and `Mergers`. Actions are any callable matching +`call(task)`; the return value is discarded. Register custom actions globally +on the configuration or per-task: + +```ruby +CMDx.configure do |config| + config.deprecators.register(:bugsnag) do |task| + Bugsnag.notify("DEPRECATED: #{task.class}", severity: "warning") + end +end + +class OutdatedConnector < CMDx::Task + deprecation :bugsnag + + # Or scoped to the task class only: + register :deprecator, :slack, ->(task) { Slack.notify("#{task.class} fired") } +end +``` + +Symbols not present in the registry fall through to a task instance method, so +existing `deprecation :handle_deprecation` declarations keep working. + ## Telemetry When deprecation fires (and conditions pass), Runtime emits the `:task_deprecated` telemetry event before the action runs, and the resulting `Result` reports `deprecated?` as `true`: diff --git a/lib/cmdx.rb b/lib/cmdx.rb index f68df98ca..2d3539002 100644 --- a/lib/cmdx.rb +++ b/lib/cmdx.rb @@ -107,6 +107,10 @@ module CMDx require_relative "cmdx/retriers/fibonacci" require_relative "cmdx/retriers/decorrelated_jitter" require_relative "cmdx/retriers" +require_relative "cmdx/deprecators/log" +require_relative "cmdx/deprecators/warn" +require_relative "cmdx/deprecators/error" +require_relative "cmdx/deprecators" require_relative "cmdx/input" require_relative "cmdx/inputs" require_relative "cmdx/output" diff --git a/lib/cmdx/configuration.rb b/lib/cmdx/configuration.rb index ab828175b..38de90280 100644 --- a/lib/cmdx/configuration.rb +++ b/lib/cmdx/configuration.rb @@ -10,8 +10,9 @@ module CMDx class Configuration attr_accessor :middlewares, :callbacks, :coercions, :validators, :executors, - :mergers, :retriers, :telemetry, :correlation_id, :default_locale, :strict_context, - :backtrace_cleaner, :log_exclusions, :log_formatter, :log_level, :logger + :mergers, :retriers, :deprecators, :telemetry, :correlation_id, :default_locale, + :strict_context, :backtrace_cleaner, :log_exclusions, :log_formatter, + :log_level, :logger def initialize @middlewares = Middlewares.new @@ -21,6 +22,7 @@ def initialize @executors = Executors.new @mergers = Mergers.new @retriers = Retriers.new + @deprecators = Deprecators.new @telemetry = Telemetry.new @correlation_id = nil @@ -79,6 +81,7 @@ def reset_configuration! Task.instance_variable_set(:@executors, nil) Task.instance_variable_set(:@mergers, nil) Task.instance_variable_set(:@retriers, nil) + Task.instance_variable_set(:@deprecators, nil) Task.instance_variable_set(:@telemetry, nil) end diff --git a/lib/cmdx/deprecation.rb b/lib/cmdx/deprecation.rb index 6da4ee5ae..0fbe4244d 100644 --- a/lib/cmdx/deprecation.rb +++ b/lib/cmdx/deprecation.rb @@ -31,14 +31,13 @@ def execute(task) yield case @value - when :log - task.logger.warn { "DEPRECATED: #{task.class} - migrate to a replacement or discontinue use" } - when :warn - Kernel.warn("[#{task.class}] DEPRECATED: migrate to a replacement or discontinue use") - when :error - raise DeprecationError, "#{task.class} usage prohibited" when Symbol - task.send(@value) + registry = deprecators_registry(task) + if registry.key?(@value) + registry.lookup(@value).call(task) + else + task.send(@value) + end when Proc task.instance_exec(task, &@value) else @@ -48,5 +47,21 @@ def execute(task) end end + private + + # Resolves the deprecators registry to consult for built-in actions. + # Prefers the task class's registry (so per-task `register(:deprecator, ...)` + # overrides take effect) and falls back to the global configuration. + # + # @param task [Task] + # @return [Deprecators] + def deprecators_registry(task) + if task.class.respond_to?(:deprecators) + task.class.deprecators + else + CMDx.configuration.deprecators + end + end + end end diff --git a/lib/cmdx/deprecators.rb b/lib/cmdx/deprecators.rb new file mode 100644 index 000000000..5ed9d9232 --- /dev/null +++ b/lib/cmdx/deprecators.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +module CMDx + # Registry of named deprecation actions consulted by `Deprecation#execute` + # to dispatch a task class's deprecation. Ships with built-ins for + # `:log`, `:warn`, and `:error`. A deprecator is any callable accepting + # `call(task)`; the return value is discarded. + class Deprecators + + attr_reader :registry + + def initialize + @registry = { + log: Deprecators::Log, + warn: Deprecators::Warn, + error: Deprecators::Error + } + end + + def initialize_copy(source) + @registry = source.registry.dup + end + + # Registers a named deprecator, overwriting any existing entry. + # + # @param name [Symbol] + # @param callable [#call, nil] pass either this or a block + # @yield deprecator body — `call(task)` + # @return [Deprecators] self for chaining + # @raise [ArgumentError] when both `callable` and a block are given, or + # when the resolved deprecator isn't callable + def register(name, callable = nil, &block) + deprecator = callable || block + + if callable && block + raise ArgumentError, "provide either a callable or a block, not both" + elsif !deprecator.respond_to?(:call) + raise ArgumentError, "deprecator must respond to #call" + end + + registry[name.to_sym] = deprecator + self + end + + # @param name [Symbol] + # @return [Deprecators] self for chaining + def deregister(name) + registry.delete(name.to_sym) + self + end + + # @param name [Symbol] + # @return [Boolean] whether a deprecator is registered under `name` + def key?(name) + registry.key?(name.to_sym) + end + + # @param name [Symbol] + # @return [#call] the registered deprecator + # @raise [ArgumentError] when `name` isn't registered + def lookup(name) + registry[name] || begin + raise ArgumentError, "unknown deprecator: #{name.inspect}" + end + end + + # Resolves a `deprecation` declaration's value to a concrete callable. + # Accepts a Symbol (registry lookup) or any object responding to `#call`. + # `nil` resolves to `nil` so callers can short-circuit. + # + # @param spec [Symbol, #call, nil] + # @return [#call, nil] + # @raise [ArgumentError] when `spec` is an unknown symbol or not callable + def resolve(spec) + case spec + when NilClass + nil + when Symbol + lookup(spec) + else + return spec if spec.respond_to?(:call) + + raise ArgumentError, "unknown deprecator: #{spec.inspect}" + end + end + + # @return [Boolean] + def empty? + registry.empty? + end + + # @return [Integer] + def size + registry.size + end + + end +end diff --git a/lib/cmdx/deprecators/error.rb b/lib/cmdx/deprecators/error.rb new file mode 100644 index 000000000..bb2928200 --- /dev/null +++ b/lib/cmdx/deprecators/error.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module CMDx + class Deprecators + # Raises {DeprecationError} to prevent the task from executing. Use for + # tasks that must no longer run. + # + # @api private + module Error + + extend self + + # @param task [Task] + # @return [void] + # @raise [DeprecationError] + def call(task) + raise DeprecationError, "#{task.class} usage prohibited" + end + + end + end +end diff --git a/lib/cmdx/deprecators/log.rb b/lib/cmdx/deprecators/log.rb new file mode 100644 index 000000000..ce740c821 --- /dev/null +++ b/lib/cmdx/deprecators/log.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module CMDx + class Deprecators + # Writes a `warn`-level entry to the task's logger noting the deprecation. + # Execution proceeds; useful for gradual migration where you want + # observability without breaking callers. + # + # @api private + module Log + + extend self + + # @param task [Task] + # @return [void] + def call(task) + task.logger.warn { "DEPRECATED: #{task.class} - migrate to a replacement or discontinue use" } + end + + end + end +end diff --git a/lib/cmdx/deprecators/warn.rb b/lib/cmdx/deprecators/warn.rb new file mode 100644 index 000000000..ab5d75711 --- /dev/null +++ b/lib/cmdx/deprecators/warn.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module CMDx + class Deprecators + # Emits a Ruby warning to stderr via `Kernel.warn`. Visible during + # development and testing without polluting structured production logs. + # + # @api private + module Warn + + extend self + + # @param task [Task] + # @return [void] + def call(task) + Kernel.warn("[#{task.class}] DEPRECATED: migrate to a replacement or discontinue use") + end + + end + end +end diff --git a/lib/cmdx/task.rb b/lib/cmdx/task.rb index f82176b62..6fda2afcf 100644 --- a/lib/cmdx/task.rb +++ b/lib/cmdx/task.rb @@ -140,9 +140,19 @@ def retriers end end + # @return [Deprecators] cloned from superclass/configuration on first call + def deprecators + @deprecators ||= + if superclass.respond_to?(:deprecators) + superclass.deprecators.dup + else + CMDx.configuration.deprecators.dup + end + end + # Dispatches to the appropriate registry's `register` method. # - # @param type [:middleware, :callback, :coercion, :validator, :executor, :merger, :retrier, :input, :output] + # @param type [:middleware, :callback, :coercion, :validator, :executor, :merger, :retrier, :deprecator, :input, :output] # @return [Object] the registry's self # @raise [ArgumentError] when `type` is unknown def register(type, ...) @@ -161,6 +171,8 @@ def register(type, ...) mergers.register(...) when :retrier retriers.register(...) + when :deprecator + deprecators.register(...) when :input inputs.register(self, ...) when :output @@ -171,7 +183,7 @@ def register(type, ...) # Dispatches to the appropriate registry's `deregister` method. # - # @param type [:middleware, :callback, :coercion, :validator, :executor, :merger, :retrier, :input, :output] + # @param type [:middleware, :callback, :coercion, :validator, :executor, :merger, :retrier, :deprecator, :input, :output] # @return [Object] the registry's self # @raise [ArgumentError] when `type` is unknown def deregister(type, ...) @@ -190,6 +202,8 @@ def deregister(type, ...) mergers.deregister(...) when :retrier retriers.deregister(...) + when :deprecator + deprecators.deregister(...) when :input inputs.deregister(self, ...) when :output diff --git a/spec/cmdx/deprecators_spec.rb b/spec/cmdx/deprecators_spec.rb new file mode 100644 index 000000000..7bd834e09 --- /dev/null +++ b/spec/cmdx/deprecators_spec.rb @@ -0,0 +1,128 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe CMDx::Deprecators do + subject(:deprecators) { described_class.new } + + describe "#initialize" do + it "registers the built-in deprecation actions" do + expect(deprecators.registry.keys).to contain_exactly(:log, :warn, :error) + expect(deprecators.lookup(:log)).to be(CMDx::Deprecators::Log) + expect(deprecators.lookup(:warn)).to be(CMDx::Deprecators::Warn) + expect(deprecators.lookup(:error)).to be(CMDx::Deprecators::Error) + end + end + + describe "#initialize_copy" do + it "dups the registry" do + copy = deprecators.dup + copy.deregister(:error) + expect(deprecators.registry).to have_key(:error) + expect(copy.registry).not_to have_key(:error) + end + end + + describe "#register" do + it "stores a callable" do + c = ->(_t) {} + deprecators.register(:custom, c) + expect(deprecators.lookup(:custom)).to be(c) + end + + it "stores a block" do + deprecators.register(:b) { |_t| nil } + expect(deprecators.lookup(:b)).to be_a(Proc) + end + + it "raises when both a callable and block are given" do + c = ->(_) {} + expect { deprecators.register(:x, c) { |_| nil } } + .to raise_error(ArgumentError, /either a callable or a block/) + end + + it "raises when the handler does not respond to call" do + expect { deprecators.register(:x, Object.new) } + .to raise_error(ArgumentError, /must respond to #call/) + end + end + + describe "#deregister" do + it "removes a key" do + deprecators.deregister(:log) + expect(deprecators.registry).not_to have_key(:log) + end + end + + describe "#key?" do + it "reports membership" do + expect(deprecators.key?(:log)).to be(true) + expect(deprecators.key?(:bogus)).to be(false) + end + end + + describe "#lookup" do + it "raises on unknown keys" do + expect { deprecators.lookup(:bogus) } + .to raise_error(ArgumentError, "unknown deprecator: :bogus") + end + end + + describe "#resolve" do + it "returns nil when spec is nil" do + expect(deprecators.resolve(nil)).to be_nil + end + + it "looks up registered symbols" do + expect(deprecators.resolve(:log)).to be(deprecators.lookup(:log)) + end + + it "passes through arbitrary callables" do + c = ->(_t) {} + expect(deprecators.resolve(c)).to be(c) + end + + it "raises on unknown symbols" do + expect { deprecators.resolve(:bogus) } + .to raise_error(ArgumentError, "unknown deprecator: :bogus") + end + + it "raises on non-callable specs" do + expect { deprecators.resolve(Object.new) } + .to raise_error(ArgumentError, /unknown deprecator/) + end + end + + describe "built-in behavior" do + let(:log_output) { StringIO.new } + let(:logger) { Logger.new(log_output) } + let(:task) do + log = logger + Class.new do + define_method(:logger) { log } + end.new + end + + it ":log writes a deprecation warning via the task logger" do + deprecators.lookup(:log).call(task) + expect(log_output.string).to include("DEPRECATED:", task.class.to_s) + end + + it ":warn writes to stderr via Kernel.warn" do + expect { deprecators.lookup(:warn).call(task) } + .to output(/DEPRECATED: migrate/).to_stderr + end + + it ":error raises DeprecationError" do + expect { deprecators.lookup(:error).call(task) } + .to raise_error(CMDx::DeprecationError, /usage prohibited/) + end + end + + describe "#empty? / #size" do + it "reports the registry size" do + expect(deprecators.size).to eq(3) + expect(deprecators).not_to be_empty + end + end +end From ee9c0abeeb80818d7e8f95ab018b20cf4e45f93e Mon Sep 17 00:00:00 2001 From: Juan Gomez <drexed@users.noreply.github.com> Date: Tue, 5 May 2026 09:21:56 -0400 Subject: [PATCH 41/54] Update v2-migration.md --- docs/v2-migration.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/docs/v2-migration.md b/docs/v2-migration.md index 2f4a4378d..d4b53c7a0 100644 --- a/docs/v2-migration.md +++ b/docs/v2-migration.md @@ -181,6 +181,19 @@ Rename `attribute` / `attributes` to `input` / `inputs`, and `type:` to `coerce: - `Attribute`, `AttributeRegistry`, `AttributeValue`, `Resolver`, `Identifier` classes +### Bridge + +Want to keep using `attribute` and `attributes`? + +``` +class ApplicationTask + class < self + alias attribute input + alias attributes inputs + end +end +``` + See [Inputs - Definitions](inputs/definitions.md). --- @@ -208,6 +221,18 @@ Outputs run **after** `work` returns successfully (skipped if the task halted). | `remove_returns :name` | `deregister :output, :name` | | `cmdx.returns.missing` locale key | `cmdx.outputs.missing` | +### Bridge + +Want to keep using `returns`? + +``` +class ApplicationTask + class < self + alias returns outputs + end +end +``` + See [Outputs](outputs.md) for the full surface. --- From 19cb8130a492bc3f5df94727d19d9c3de47d4b54 Mon Sep 17 00:00:00 2001 From: Juan Gomez <drexed@users.noreply.github.com> Date: Tue, 5 May 2026 09:34:51 -0400 Subject: [PATCH 42/54] Update v2-migration.md --- docs/v2-migration.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/docs/v2-migration.md b/docs/v2-migration.md index d4b53c7a0..51892f2ed 100644 --- a/docs/v2-migration.md +++ b/docs/v2-migration.md @@ -981,3 +981,26 @@ Stop when BOTH of these hold: If either fails and you can't resolve it from the rules above, stop and report the failing file:line with a one-line diagnosis. ```` + +## Future + +These new internals build a solid foundation for introducing even more functionality. Here's a list of some of the things we have planned: + +- Result-driven control flow + - retry_when on Result, not exceptions — retry_when status: :failed, reason: /rate.?limit/, limit: 3. retry_on only catches exceptions; many APIs return failures-as-data. + - Built-in idempotency — idempotent_by :payment_id, ttl: 5.minutes, store: CMDx::Stores::Memory (or Redis adapter) + - Built-in circuit breaker — circuit_break threshold: 5, cool_off: 30 + - Concurrency limit / bulkhead — concurrency_limit 10 +- Inputs / outputs / schema + - Schema export — MyTask.to_json_schema / to_openapi_operation derived from inputs + coercions + validators. + - Sensitive/redacted inputs — required :api_key, sensitive: true +- Observability / tooling + - Chain#to_mermaid / #to_dot — visualize the chain (and result statuses) for debugging deeply nested executions. + - W3C traceparent in Telemetry::Event so xid participates in distributed traces, not just logs. + - Optional-require integrations shipped in-tree: + - require "cmdx/telemetry/open_telemetry" — auto-spans per task with parent linkage from xid/cid. + - require "cmdx/telemetry/statsd" / dogstatsd — emit cmdx.task.duration, .success, .failed with task-class tags. + - require "cmdx/telemetry/active_support_notifications" — bridge for Rails apps. +- Developer experience + - YARD-driven schema docs — Rake task that walks every Task subclass and emits a Markdown reference (inputs, outputs, retries, callbacks, faults raised). Beats hand-maintaining docs/. + - Authorization gate — policy MyPolicy, action: :execute raising a typed AuthorizationFault From 7611a03dca3b3219da74ea3b71df527448de48f2 Mon Sep 17 00:00:00 2001 From: Juan Gomez <drexed@users.noreply.github.com> Date: Tue, 5 May 2026 10:10:21 -0400 Subject: [PATCH 43/54] Update v2-migration.md --- docs/v2-migration.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/docs/v2-migration.md b/docs/v2-migration.md index 51892f2ed..0a076bd2b 100644 --- a/docs/v2-migration.md +++ b/docs/v2-migration.md @@ -986,7 +986,15 @@ report the failing file:line with a one-line diagnosis. These new internals build a solid foundation for introducing even more functionality. Here's a list of some of the things we have planned: -- Result-driven control flow +- Workflows + - Sub-workflow inlining — calling another Workflow should appear as nested groups in the chain, not just one opaque result. Chain already supports nesting; pipeline doesn't expose it. + - Streaming results from parallel groups — yield each completed child result to a block as it finishes, not after the whole group joins. Useful for progress reporting. +- Tasks + - execute_async returning a Concurrent::Promise-shaped object (or at least an opaque future) without forcing the caller to wrap in Async {} + - First-class background-job adapter instead of the Sidekiq mixin recipe — Task.perform_async, perform_in, perform_at with adapter for Sidekiq / ActiveJob / GoodJob. Auto-handles JSON-safe context serialization (and refuses non-serializable values at enqueue). + - Checkpoint/resume for workflows — persist context after each group to a pluggable store; on restart, skip already-completed groups. Pairs with the idempotent_by primitive above. + - around_execution callbacks — current callbacks are all before/after-style; an around hook avoids forcing every wrapping concern into a middleware. + - REPL-friendly formatter — result.pretty_print (multi-line, color, child indentation) - retry_when on Result, not exceptions — retry_when status: :failed, reason: /rate.?limit/, limit: 3. retry_on only catches exceptions; many APIs return failures-as-data. - Built-in idempotency — idempotent_by :payment_id, ttl: 5.minutes, store: CMDx::Stores::Memory (or Redis adapter) - Built-in circuit breaker — circuit_break threshold: 5, cool_off: 30 From 33c0394c7bc75f9b1e57aab082f3b1a259a8d499 Mon Sep 17 00:00:00 2001 From: Juan Gomez <drexed@users.noreply.github.com> Date: Tue, 5 May 2026 10:16:35 -0400 Subject: [PATCH 44/54] Add reason helper on fault --- CHANGELOG.md | 2 +- docs/interruptions/faults.md | 12 ++++++++++++ lib/cmdx/fault.rb | 19 +++++++++++++++++++ spec/cmdx/fault_spec.rb | 30 ++++++++++++++++++++++++++++++ 4 files changed, 62 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 18324054b..8f05fd035 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,7 +37,7 @@ Full runtime rewrite: the v1 state-machine plus Zeitwerk architecture is replace - Add `Result#strict?`, `Result#deprecated?`, `Result#duration`, `Result#index`, `Result#root?`, `Result#backtrace`, `Result#errors`, `Result#tags`, `Result#origin`, and `Result#ctx` alias - Add `Signal#origin` / `Result#origin` — upstream `Result` a signal/result was echoed from (`nil` for locally originated failures); set by `Task#throw!`, `Pipeline` when propagating workflow failures, and `Runtime` when rescuing a `Fault` inside `work` - Add `Chain#unshift`, `Chain#root`, `Chain#state`, `Chain#status`, `Chain#last`, `Chain#freeze`; Runtime `unshift`s the root result (so `chain.root` and `chain[0]` point to the outermost task) and freezes the chain on root teardown -- Add `Fault.for?(*tasks)` and `Fault.matches?(&block)` anonymous matcher subclasses suitable for `rescue` +- Add `Fault.for?(*tasks)`, `Fault.reason?(reason)`, and `Fault.matches?(&block)` anonymous matcher subclasses suitable for `rescue` - Add `include Enumerable` to `Errors`, `Chain`, and `Context`, exposing `map`, `select`, `find`, `include?`, `to_a`, `any?`, `all?`, `group_by`, `partition`, etc. - Add `Set`-backed deduping per key on `Errors`, plus `keys`, `each_key`, `each_value`, `count`, `delete`, `clear`, `full_messages`, `to_hash(full)` - Add `Context#keys`, `values`, `empty?`, `size`, `delete`, `clear`, `eql?` / `==`, `hash`, `deep_dup`, `respond_to_missing?`, and `Context#merge` that accepts any context-like object diff --git a/docs/interruptions/faults.md b/docs/interruptions/faults.md index 0c1b0d6ef..17d1b0f31 100644 --- a/docs/interruptions/faults.md +++ b/docs/interruptions/faults.md @@ -59,6 +59,18 @@ rescue CMDx::Fault.for?(FormatValidator, ContentProcessor) => e end ``` +### Reason-Specific Matching + +`Fault.reason?(reason)` returns an anonymous matcher subclass that matches any fault whose `result.reason` equals the given string: + +```ruby +begin + ProcessPayment.execute!(payment_data: data) +rescue CMDx::Fault.reason?("Payment declined") => e + notify_customer(e.context.customer_id) +end +``` + ### Custom Logic Matching `Fault.matches?` takes a block returning `true`/`false` against the fault. Use it for arbitrary predicates — metadata, status, cause class, etc.: diff --git a/lib/cmdx/fault.rb b/lib/cmdx/fault.rb index 24de617b6..dedd40063 100644 --- a/lib/cmdx/fault.rb +++ b/lib/cmdx/fault.rb @@ -33,6 +33,25 @@ def for?(*tasks) end end + # Returns a matcher subclass that matches Faults whose `result.reason` + # is equal to the given string. Suitable for use in `rescue`. + # + # @param reason [String] the reason to match + # @return [Class<Fault>] anonymous matcher subclass + # @raise [ArgumentError] when no reason is given + # + # @example + # rescue Fault.reason?("Payment failed") => fault + # Alert.for_fault(fault) + # end + def reason?(reason) + raise ArgumentError, "reason required" unless reason + + matcher do |other| + other.result.reason == reason + end + end + # Returns a matcher subclass whose `===` runs `block` against the fault. # # @yieldparam fault [Fault] diff --git a/spec/cmdx/fault_spec.rb b/spec/cmdx/fault_spec.rb index dc0b5fd34..a763ece93 100644 --- a/spec/cmdx/fault_spec.rb +++ b/spec/cmdx/fault_spec.rb @@ -124,6 +124,36 @@ def build_result(signal, klass: task_class, **opts) end end + describe ".reason?" do + let(:signal) { CMDx::Signal.failed("boom") } + + it "raises when called without a reason" do + expect { described_class.reason?(nil) }.to raise_error(ArgumentError, "reason required") + end + + it "matches faults whose result reason equals the given reason" do + matcher = described_class.reason?("boom") + expect(matcher === described_class.new(build_result(signal))).to be(true) + end + + it "does not match faults with a different reason" do + matcher = described_class.reason?("other") + expect(matcher === described_class.new(build_result(signal))).to be(false) + end + + it "only matches instances of the class that defined it" do + subclass = Class.new(described_class) + matcher = subclass.reason?("boom") + + expect(matcher === described_class.new(build_result(signal))).to be(false) + end + + it "does not match non-Fault objects" do + matcher = described_class.reason?("boom") + expect(matcher === "not a fault").to be(false) + end + end + describe ".matches?" do let(:signal) { CMDx::Signal.failed("boom") } let(:result) { build_result(signal) } From 6d7ab921dd912257aeea1c4f927718d7f26b0c10 Mon Sep 17 00:00:00 2001 From: Juan Gomez <drexed@users.noreply.github.com> Date: Tue, 5 May 2026 10:29:37 -0400 Subject: [PATCH 45/54] Update v2-migration.md --- docs/v2-migration.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/docs/v2-migration.md b/docs/v2-migration.md index 0a076bd2b..7521e0ec1 100644 --- a/docs/v2-migration.md +++ b/docs/v2-migration.md @@ -986,6 +986,9 @@ report the failing file:line with a one-line diagnosis. These new internals build a solid foundation for introducing even more functionality. Here's a list of some of the things we have planned: +- Telemetry surface + - task_failed / task_skipped / task_succeeded events — currently only task_executed covers terminal outcomes; subscribers branch by inspecting event.payload[:result].status. Specific events make subscriptions cheaper and intent clearer. + - Sampling — subscribe(:task_executed, sample: 0.01) for high-volume tasks. Otherwise every team writes the same rand < 0.01 check. - Workflows - Sub-workflow inlining — calling another Workflow should appear as nested groups in the chain, not just one opaque result. Chain already supports nesting; pipeline doesn't expose it. - Streaming results from parallel groups — yield each completed child result to a block as it finishes, not after the whole group joins. Useful for progress reporting. @@ -994,7 +997,7 @@ These new internals build a solid foundation for introducing even more functiona - First-class background-job adapter instead of the Sidekiq mixin recipe — Task.perform_async, perform_in, perform_at with adapter for Sidekiq / ActiveJob / GoodJob. Auto-handles JSON-safe context serialization (and refuses non-serializable values at enqueue). - Checkpoint/resume for workflows — persist context after each group to a pluggable store; on restart, skip already-completed groups. Pairs with the idempotent_by primitive above. - around_execution callbacks — current callbacks are all before/after-style; an around hook avoids forcing every wrapping concern into a middleware. - - REPL-friendly formatter — result.pretty_print (multi-line, color, child indentation) + - REPL-friendly formatter — result.pretty_print (multi-line, color, child indentation); Pipeline visualization at runtime — result.chain.timeline returns Gantt-shaped data (start/end per task) usable in dashboards. The data exists; assembly is missing. - retry_when on Result, not exceptions — retry_when status: :failed, reason: /rate.?limit/, limit: 3. retry_on only catches exceptions; many APIs return failures-as-data. - Built-in idempotency — idempotent_by :payment_id, ttl: 5.minutes, store: CMDx::Stores::Memory (or Redis adapter) - Built-in circuit breaker — circuit_break threshold: 5, cool_off: 30 @@ -1012,3 +1015,7 @@ These new internals build a solid foundation for introducing even more functiona - Developer experience - YARD-driven schema docs — Rake task that walks every Task subclass and emits a Markdown reference (inputs, outputs, retries, callbacks, faults raised). Beats hand-maintaining docs/. - Authorization gate — policy MyPolicy, action: :execute raising a typed AuthorizationFault + - CMDx::Stores — pluggable KV interface (get/set/incr/del with TTL). Memory + Redis adapters become the substrate for idempotency, rate limiting, circuit breakers, checkpoints, and result caching — instead of every example shipping its own store class. + - CMDx::Cache — cache_result key: ->(t) { … }, ttl: 60 memoizes a successful result per-input on the same store. Common enough that handrolling it is wasteful. + - CMDx::Locks — lock_with key: …, ttl: …, wait: … for serializing executions. Pairs with idempotency but is genuinely separate (idempotency = "don't retry"; lock = "don't run concurrently"). + - Deterministic clock — CMDx.clock defaults to Time.now/Process.clock_gettime, but tests can swap to a frozen clock. Currently Result#duration is non-mockable. From 64b47d89e749e7ed9ade0c0dd0e33695432e2615 Mon Sep 17 00:00:00 2001 From: Juan Gomez <drexed@users.noreply.github.com> Date: Tue, 5 May 2026 10:31:57 -0400 Subject: [PATCH 46/54] Update v2-migration.md --- docs/v2-migration.md | 87 ++++++++++++++++++++++++++------------------ 1 file changed, 52 insertions(+), 35 deletions(-) diff --git a/docs/v2-migration.md b/docs/v2-migration.md index 7521e0ec1..d0b3ce2c1 100644 --- a/docs/v2-migration.md +++ b/docs/v2-migration.md @@ -984,38 +984,55 @@ report the failing file:line with a one-line diagnosis. ## Future -These new internals build a solid foundation for introducing even more functionality. Here's a list of some of the things we have planned: - -- Telemetry surface - - task_failed / task_skipped / task_succeeded events — currently only task_executed covers terminal outcomes; subscribers branch by inspecting event.payload[:result].status. Specific events make subscriptions cheaper and intent clearer. - - Sampling — subscribe(:task_executed, sample: 0.01) for high-volume tasks. Otherwise every team writes the same rand < 0.01 check. -- Workflows - - Sub-workflow inlining — calling another Workflow should appear as nested groups in the chain, not just one opaque result. Chain already supports nesting; pipeline doesn't expose it. - - Streaming results from parallel groups — yield each completed child result to a block as it finishes, not after the whole group joins. Useful for progress reporting. -- Tasks - - execute_async returning a Concurrent::Promise-shaped object (or at least an opaque future) without forcing the caller to wrap in Async {} - - First-class background-job adapter instead of the Sidekiq mixin recipe — Task.perform_async, perform_in, perform_at with adapter for Sidekiq / ActiveJob / GoodJob. Auto-handles JSON-safe context serialization (and refuses non-serializable values at enqueue). - - Checkpoint/resume for workflows — persist context after each group to a pluggable store; on restart, skip already-completed groups. Pairs with the idempotent_by primitive above. - - around_execution callbacks — current callbacks are all before/after-style; an around hook avoids forcing every wrapping concern into a middleware. - - REPL-friendly formatter — result.pretty_print (multi-line, color, child indentation); Pipeline visualization at runtime — result.chain.timeline returns Gantt-shaped data (start/end per task) usable in dashboards. The data exists; assembly is missing. - - retry_when on Result, not exceptions — retry_when status: :failed, reason: /rate.?limit/, limit: 3. retry_on only catches exceptions; many APIs return failures-as-data. - - Built-in idempotency — idempotent_by :payment_id, ttl: 5.minutes, store: CMDx::Stores::Memory (or Redis adapter) - - Built-in circuit breaker — circuit_break threshold: 5, cool_off: 30 - - Concurrency limit / bulkhead — concurrency_limit 10 -- Inputs / outputs / schema - - Schema export — MyTask.to_json_schema / to_openapi_operation derived from inputs + coercions + validators. - - Sensitive/redacted inputs — required :api_key, sensitive: true -- Observability / tooling - - Chain#to_mermaid / #to_dot — visualize the chain (and result statuses) for debugging deeply nested executions. - - W3C traceparent in Telemetry::Event so xid participates in distributed traces, not just logs. - - Optional-require integrations shipped in-tree: - - require "cmdx/telemetry/open_telemetry" — auto-spans per task with parent linkage from xid/cid. - - require "cmdx/telemetry/statsd" / dogstatsd — emit cmdx.task.duration, .success, .failed with task-class tags. - - require "cmdx/telemetry/active_support_notifications" — bridge for Rails apps. -- Developer experience - - YARD-driven schema docs — Rake task that walks every Task subclass and emits a Markdown reference (inputs, outputs, retries, callbacks, faults raised). Beats hand-maintaining docs/. - - Authorization gate — policy MyPolicy, action: :execute raising a typed AuthorizationFault - - CMDx::Stores — pluggable KV interface (get/set/incr/del with TTL). Memory + Redis adapters become the substrate for idempotency, rate limiting, circuit breakers, checkpoints, and result caching — instead of every example shipping its own store class. - - CMDx::Cache — cache_result key: ->(t) { … }, ttl: 60 memoizes a successful result per-input on the same store. Common enough that handrolling it is wasteful. - - CMDx::Locks — lock_with key: …, ttl: …, wait: … for serializing executions. Pairs with idempotency but is genuinely separate (idempotency = "don't retry"; lock = "don't run concurrently"). - - Deterministic clock — CMDx.clock defaults to Time.now/Process.clock_gettime, but tests can swap to a frozen clock. Currently Result#duration is non-mockable. +The v2 internals open the door to a number of additions that didn't fit the rewrite. The list below is **planned, not committed** — semantics may shift before they ship. + +### Tasks + +- **`around_execution` callbacks** — every existing callback is before/after-style. An around hook avoids forcing every wrapping concern through `middlewares.register`. +- **`retry_when` on `Result`** — retry on outcome, not exceptions: `retry_when status: :failed, reason: /rate.?limit/, limit: 3`. `retry_on` only catches raised errors; many APIs return failures-as-data. +- **`idempotent_by`** — declarative idempotency keyed off context: `idempotent_by :payment_id, ttl: 5.minutes`. Backed by `CMDx::Stores`. +- **`circuit_break`** — `circuit_break threshold: 5, cool_off: 30.seconds` without bolting on Stoplight per task. +- **`concurrency_limit`** — global bulkhead capping simultaneous executions of a task class. +- **`execute_async`** — returns a `Concurrent::Promise`-shaped future without forcing the caller to wrap in `Async { }`. +- **Background-job adapter** — `Task.perform_async` / `perform_in` / `perform_at` over Sidekiq, ActiveJob, or GoodJob, with JSON-safety enforced at enqueue time. Replaces the per-app Sidekiq mixin recipe. + +### Workflows + +- **Sub-workflow inlining** — invoking another `Workflow` from within one expands as nested groups in the chain instead of an opaque single result. `Chain` already supports nesting; `Pipeline` doesn't expose it. +- **Streaming parallel results** — yield each child result to a block as it completes rather than waiting for the join. Useful for progress reporting and fail-fast UIs. +- **Checkpoint/resume** — persist `context` after each group to a pluggable store so a restarted workflow skips completed groups. Pairs with `idempotent_by`. + +### Inputs / outputs / schema + +- **Schema export** — `MyTask.to_json_schema` / `to_openapi_operation` derived from inputs, coercions, and validators. +- **Sensitive inputs** — `required :api_key, sensitive: true` strips the value from logs, telemetry, `Result#to_h`, and `Fault#message`, including in nested results. + +### Telemetry + +- **Status-specific events** — `task_failed` / `task_skipped` / `task_succeeded` alongside `task_executed`. Specific events let subscribers express intent without inspecting `event.payload[:result].status`. +- **Sampling** — `subscribe(:task_executed, sample: 0.01)` for high-volume tasks instead of everyone reimplementing the same `rand < 0.01` gate. +- **W3C traceparent** in `Telemetry::Event` so `xid` participates in distributed traces, not just logs. +- **Optional-require integrations** shipped in-tree: + - `require "cmdx/telemetry/open_telemetry"` — per-task spans with parent linkage from `xid` / `cid`. + - `require "cmdx/telemetry/statsd"` / `dogstatsd` — `cmdx.task.duration` / `.success` / `.failed` tagged by task class. + - `require "cmdx/telemetry/active_support_notifications"` — bridge for Rails apps. + +### Observability / tooling + +- **`Chain#to_mermaid` / `#to_dot`** — render a chain (with result statuses) for debugging deeply nested executions. +- **`Chain#timeline`** — Gantt-shaped `(task, start, end, status)` rows usable directly in dashboards. The data exists; only the assembly is missing. +- **`Result#pretty_print`** — REPL-friendly multi-line formatter with color and child indentation; the current single-line `to_s` gets noisy at depth. + +### Infrastructure primitives + +The same example middlewares (rate limit, idempotency, circuit breaker) all reinvent the same KV interface. Promoting it to a core registry lets every primitive share one swappable backend. + +- **`CMDx::Stores`** — pluggable KV with `get` / `set` / `incr` / `del` + TTL. Memory and Redis adapters substrate `idempotent_by`, rate limiting, circuit breakers, checkpoints, and result caching. +- **`CMDx::Cache`** — `cache_result key: ->(t) { … }, ttl: 60` memoizes a successful result per-input on the configured store. +- **`CMDx::Locks`** — `lock_with key: …, ttl: …, wait: …` serializes executions. Distinct from idempotency: the latter says "don't retry", the former says "don't run concurrently". + +### Developer experience + +- **Authorization gate** — `policy MyPolicy, action: :execute` raising a typed `AuthorizationFault` before input resolution touches sensitive params. +- **YARD-driven schema docs** — Rake task that walks every `Task` subclass and emits a Markdown reference (inputs, outputs, retries, callbacks, faults raised). Beats hand-maintaining `docs/`. +- **Deterministic clock** — `CMDx.clock` defaults to `Time.now` / `Process.clock_gettime` but is swappable in tests so `Result#duration` is mockable. From 613f3c187b9f4ab6c7c44a2854b9c6ca9788a7d3 Mon Sep 17 00:00:00 2001 From: Juan Gomez <drexed@users.noreply.github.com> Date: Tue, 5 May 2026 11:03:17 -0400 Subject: [PATCH 47/54] Improve translation --- CHANGELOG.md | 1 + lib/cmdx/fault.rb | 2 +- lib/cmdx/i18n_proxy.rb | 24 +++++++++++++++++------- lib/cmdx/pipeline.rb | 2 +- spec/cmdx/fault_spec.rb | 5 +++++ spec/cmdx/i18n_proxy_spec.rb | 25 +++++++++++++++++++++++++ spec/cmdx/pipeline_spec.rb | 15 +++++++++++++++ 7 files changed, 65 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f05fd035..606e3a30a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), Full runtime rewrite: the v1 state-machine plus Zeitwerk architecture is replaced by an explicit signal-based runtime, immutable results, fiber-local chains, and a slimmer registry surface. See [docs/v2-migration.md](docs/v2-migration.md) for the full upgrade guide. ### Added +- Add `CMDx::I18nProxy.tr` helper that translates a `reason` through the i18n layer when a matching key is present and falls back to the literal reason (or the `cmdx.reasons.unspecified` default when nil). `Fault#initialize` and `Pipeline` failure aggregation now route `result.reason` through it, so failure messages can be localized via translation keys without breaking literal-string reasons. - Add `xid` correlation id on `Chain`, `Result`, and `Telemetry::Event`, sourced once per root execution from `CMDx.configuration.correlation_id` (a callable). Useful for threading external ids like Rails `request_id` through every task in a chain so they can be filtered together in logs/telemetry. - Add `Context#as_json` / `Context#to_json` — JSON serialization delegating to `#to_h` - Add `Errors#as_json` / `Errors#to_json` — JSON serialization delegating to `#to_h` diff --git a/lib/cmdx/fault.rb b/lib/cmdx/fault.rb index dedd40063..70378957e 100644 --- a/lib/cmdx/fault.rb +++ b/lib/cmdx/fault.rb @@ -83,7 +83,7 @@ def matcher(&) def initialize(result) @result = result - super(result.reason || I18nProxy.t("cmdx.reasons.unspecified")) + super(I18nProxy.tr(result.reason)) if (frames = result.backtrace || result.cause&.backtrace_locations) frames = frames.map(&:to_s) diff --git a/lib/cmdx/i18n_proxy.rb b/lib/cmdx/i18n_proxy.rb index efa991f70..cc214f2ac 100644 --- a/lib/cmdx/i18n_proxy.rb +++ b/lib/cmdx/i18n_proxy.rb @@ -9,6 +9,11 @@ class I18nProxy class << self + # @return [Array<String>] directories searched (in order) for bundled locale YAMLs + def locale_paths + @locale_paths ||= [File.expand_path("../locales", __dir__)] + end + # @param key [String, Symbol] dot-separated translation key # @param options [Hash{Symbol => Object}] interpolation values (e.g. `type:`) # @return [String, Object] the translated string (or the raw default value) @@ -18,11 +23,6 @@ def translate(key, **options) end alias t translate - # @return [Array<String>] directories searched (in order) for bundled locale YAMLs - def locale_paths - @locale_paths ||= [File.expand_path("../locales", __dir__)] - end - # Register an additional directory containing locale YAML files. Later # registrations take precedence over earlier ones (the most recently # registered path's values win during deep merge). Resets the memoized @@ -36,6 +36,16 @@ def register(path) locale_paths end + # Resolves a reason string through translation, falling back to either + # the literal reason (when present) or the `cmdx.reasons.unspecified` + # default (when nil). + # + # @param reason [String, Symbol, nil] reason text or translation key + # @return [String] translated message, literal reason, or default + def tr(reason) + translate(reason || "cmdx.reasons.unspecified", default: reason) + end + end # @param key [String, Symbol] dot-separated translation key @@ -44,9 +54,9 @@ def register(path) def translate(key, **options) return ::I18n.translate(key, **options) if defined?(::I18n) && ::I18n.respond_to?(:translate) - options[:default] ||= translation_default(key) + message = translation_default(key) || options[:default] - case message = options.delete(:default) + case message when String message % options when NilClass diff --git a/lib/cmdx/pipeline.rb b/lib/cmdx/pipeline.rb index 4161beb9a..a319c63e7 100644 --- a/lib/cmdx/pipeline.rb +++ b/lib/cmdx/pipeline.rb @@ -158,7 +158,7 @@ def aggregate(failures, continue:) prefix = result.task.name if result.errors.empty? - message = result.reason || I18nProxy.t("cmdx.reasons.unspecified") + message = I18nProxy.tr(result.reason) @workflow.errors.add(:"#{prefix}.#{result.status}", message) else result.errors.each do |key, messages| diff --git a/spec/cmdx/fault_spec.rb b/spec/cmdx/fault_spec.rb index a763ece93..64db5ecdd 100644 --- a/spec/cmdx/fault_spec.rb +++ b/spec/cmdx/fault_spec.rb @@ -38,6 +38,11 @@ def build_result(signal, klass: task_class, **opts) .to eq(CMDx::I18nProxy.t("cmdx.reasons.unspecified")) end + it "resolves the reason through I18nProxy when a translation key matches" do + allow(CMDx::I18nProxy).to receive(:tr).with("boom").and_return("Translated boom") + expect(described_class.new(result).message).to eq("Translated boom") + end + it "descends from CMDx::Error" do expect(described_class.new(result)).to be_a(CMDx::Error) end diff --git a/spec/cmdx/i18n_proxy_spec.rb b/spec/cmdx/i18n_proxy_spec.rb index 65b981f9f..4decef662 100644 --- a/spec/cmdx/i18n_proxy_spec.rb +++ b/spec/cmdx/i18n_proxy_spec.rb @@ -69,6 +69,31 @@ def self.translate(key, **opts) = [key, opts] end end + describe ".tr" do + before { hide_const("I18n") } + + it "returns the unspecified default when the reason is nil" do + expect(described_class.tr(nil)) + .to eq(described_class.t("cmdx.reasons.unspecified")) + end + + it "returns the literal reason when no translation key matches" do + expect(described_class.tr("Payment failed")).to eq("Payment failed") + end + + it "resolves the reason through translation when a matching key exists" do + Dir.mktmpdir do |dir| + File.write(File.join(dir, "en.yml"), { "en" => { "payment_failed" => "Payment failed" } }.to_yaml) + described_class.register(dir) + + expect(described_class.tr("payment_failed")).to eq("Payment failed") + end + ensure + described_class.instance_variable_set(:@locale_paths, nil) + described_class.instance_variable_set(:@proxy, nil) + end + end + describe ".locale_paths / .register" do let(:default_paths) { [File.expand_path("../../lib/cmdx/../locales", __dir__)] } diff --git a/spec/cmdx/pipeline_spec.rb b/spec/cmdx/pipeline_spec.rb index c829c2b37..aa88611bb 100644 --- a/spec/cmdx/pipeline_spec.rb +++ b/spec/cmdx/pipeline_spec.rb @@ -139,6 +139,21 @@ expect(result.errors[:"Reasonless.failed"]).to eq([CMDx::I18nProxy.t("cmdx.reasons.unspecified")]) end + it "resolves the failure reason through I18nProxy when a translation key matches" do + allow(CMDx::I18nProxy).to receive(:tr).with("translatable.reason").and_return("Translated reason") + a = create_failing_task(name: "Translatable", reason: "translatable.reason") + + workflow_class = create_workflow_class do + tasks a, continue_on_failure: true + end + + result = workflow_class.execute + + expect(result).to be_failed + translatable_key = result.errors.messages.keys.find { |k| k.to_s.start_with?("Translatable") } + expect(result.errors[translatable_key]).to eq(["Translated reason"]) + end + it "the first failure (declaration order) becomes the signal origin" do first_fail = create_failing_task(name: "First", reason: "first") second_fail = create_failing_task(name: "Second", reason: "second") From 4df60e355f17ceb88c75b7f8989d0105a7e4aa7e Mon Sep 17 00:00:00 2001 From: Juan Gomez <drexed@users.noreply.github.com> Date: Tue, 5 May 2026 12:22:56 -0400 Subject: [PATCH 48/54] Add after execution callback --- docs/callbacks.md | 7 ++++--- docs/configuration.md | 3 ++- lib/cmdx/callbacks.rb | 1 + lib/cmdx/runtime.rb | 1 + skills/references/configuration.md | 2 +- 5 files changed, 9 insertions(+), 5 deletions(-) diff --git a/docs/callbacks.md b/docs/callbacks.md index fe0ac8cb5..d8460b368 100644 --- a/docs/callbacks.md +++ b/docs/callbacks.md @@ -23,9 +23,10 @@ Callbacks execute in a predictable lifecycle order: # --- inputs resolved, Task#work runs (with retries), outputs verified --- # --- #rollback runs here when failed --- -3. on_[complete|interrupted] # State-based (execution lifecycle) -4. on_[success|skipped|failed] # Status-based (business outcome) -5. on_[ok|ko] # Outcome-based (success/skip vs fail) +3. after_execution # Execution teardown +4. on_[complete|interrupted] # State-based (execution lifecycle) +5. on_[success|skipped|failed] # Status-based (business outcome) +6. on_[ok|ko] # Outcome-based (success/skip vs fail) ``` !!! note "Callbacks are additive, not exclusive" diff --git a/docs/configuration.md b/docs/configuration.md index efc66a81c..3aaec3689 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -154,8 +154,9 @@ Callbacks fire at specific lifecycle points. Valid events: | Event | When | |-------|------| -| `:before_execution` | First lifecycle step inside `measure_duration` | +| `:before_execution` | Before any `work` is executed | | `:before_validation` | Right after `:before_execution`, before input resolution | +| `:after_execution` | After `work` and `rollback` is executed | | `:on_complete` | When `state == "complete"` (success path) | | `:on_interrupted` | When `state == "interrupted"` (skip or fail) | | `:on_success` | When `status == "success"` | diff --git a/lib/cmdx/callbacks.rb b/lib/cmdx/callbacks.rb index eb7427aac..be6c07536 100644 --- a/lib/cmdx/callbacks.rb +++ b/lib/cmdx/callbacks.rb @@ -14,6 +14,7 @@ class Callbacks EVENTS = Set[ :before_validation, :before_execution, + :after_execution, :on_complete, :on_interrupted, :on_success, diff --git a/lib/cmdx/runtime.rb b/lib/cmdx/runtime.rb index 454b9f170..d10e85bf0 100644 --- a/lib/cmdx/runtime.rb +++ b/lib/cmdx/runtime.rb @@ -93,6 +93,7 @@ def run_lifecycle run_callbacks(:before_validation) perform_work perform_rollback if @signal.failed? + run_callbacks(:after_execution) run_callbacks(:"on_#{@signal.state}") run_callbacks(:"on_#{@signal.status}") run_callbacks(:on_ok) if @signal.ok? diff --git a/skills/references/configuration.md b/skills/references/configuration.md index 1e08a883d..2c94b3491 100644 --- a/skills/references/configuration.md +++ b/skills/references/configuration.md @@ -165,7 +165,7 @@ No middleware is built into the gem. ### Callback events -`:before_execution`, `:before_validation`, `:on_complete`, `:on_interrupted`, `:on_success`, `:on_skipped`, `:on_failed`, `:on_ok`, `:on_ko`. +`:before_execution`, `:before_validation`, `:after_execution`, `:on_complete`, `:on_interrupted`, `:on_success`, `:on_skipped`, `:on_failed`, `:on_ok`, `:on_ko`. Each event has a class-level DSL method: `before_execution :method_name`, `on_failed { |task| ... }`, etc. Callbacks accept Symbol (`task.send`), Proc (`instance_exec(task, &)`), or any `#call(task)`-able. Supports `if:`/`unless:` gates. From 8d9664f78abcb00f1af945478119ae4aebeea85e Mon Sep 17 00:00:00 2001 From: Juan Gomez <drexed@users.noreply.github.com> Date: Tue, 5 May 2026 12:36:14 -0400 Subject: [PATCH 49/54] Add around execution callback --- CHANGELOG.md | 3 +- docs/callbacks.md | 71 ++++++++++++++++-- docs/configuration.md | 1 + docs/v2-migration.md | 1 - lib/cmdx.rb | 4 + lib/cmdx/callbacks.rb | 60 ++++++++++++--- lib/cmdx/runtime.rb | 17 ++++- skills/references/configuration.md | 2 +- spec/cmdx/callbacks_spec.rb | 93 ++++++++++++++++++++++++ spec/integration/tasks/callbacks_spec.rb | 68 +++++++++++++++++ 10 files changed, 299 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 606e3a30a..8b61d5b02 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,7 +26,8 @@ Full runtime rewrite: the v1 state-machine plus Zeitwerk architecture is replace - Add `CMDx::Util` single conditional-evaluation module (`evaluate`, `if?`, `unless?`, `satisfied?`) consolidating the v1 `Utils::*` modules - Add `CMDx::I18nProxy` translation façade that delegates to `I18n` when available, otherwise loads the bundled YAML and percent-interpolates with memoization - Add `CMDx::LoggerProxy` returning a per-task logger, `dup`-ing the base only when the task overrides `log_level` or `log_formatter` -- Add new exception classes: `DefinitionError`, `DeprecationError`, `ImplementationError`, `MiddlewareError` +- 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` +- Add new exception classes: `DefinitionError`, `DeprecationError`, `ImplementationError`, `MiddlewareError`, `CallbackError` - Add `Task#work` abstract method (raises `ImplementationError` when not defined) - 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 - Add saga-style pipeline rollback: when a `Workflow` halts, `Pipeline` walks every previously executed task instance whose result is `success?` in reverse execution order and invokes `#rollback` (when defined), then flips that result's `rolled_back?` to `true`. Skipped tasks are excluded; the failing task is rolled back by Runtime and not re-invoked. Exceptions raised inside a compensator propagate — handling them is the developer's responsibility. Applies across groups, within `continue_on_failure: true` groups, and to `:parallel` groups (compensators see the per-task `deep_dup`'d context) diff --git a/docs/callbacks.md b/docs/callbacks.md index d8460b368..52c488b80 100644 --- a/docs/callbacks.md +++ b/docs/callbacks.md @@ -18,15 +18,16 @@ Callbacks execute in a predictable lifecycle order: ```ruby 1. before_execution # Prepare for execution -2. before_validation # Pre-validation setup +2. around_execution # Wraps everything below; must invoke its continuation +3. before_validation # Pre-validation setup # --- inputs resolved, Task#work runs (with retries), outputs verified --- # --- #rollback runs here when failed --- -3. after_execution # Execution teardown -4. on_[complete|interrupted] # State-based (execution lifecycle) -5. on_[success|skipped|failed] # Status-based (business outcome) -6. on_[ok|ko] # Outcome-based (success/skip vs fail) +4. after_execution # Execution teardown +5. on_[complete|interrupted] # State-based (execution lifecycle) +6. on_[success|skipped|failed] # Status-based (business outcome) +7. on_[ok|ko] # Outcome-based (success/skip vs fail) ``` !!! note "Callbacks are additive, not exclusive" @@ -159,6 +160,66 @@ class ProcessBooking < CMDx::Task end ``` +## Around Callbacks + +`around_execution` wraps `before_validation`, `Task#work`, any `#rollback`, +and `after_execution` in a single hook. Each callback **must invoke its +continuation exactly once** — failure to do so raises `CMDx::CallbackError`. +Multiple `around_execution` hooks nest in declaration order (outer-first). + +The continuation surface differs by callback form: + +- **Symbol** — the instance method receives the continuation as its block; use `yield` (or capture `&blk` and call it): + + ```ruby + class ProcessBooking < CMDx::Task + around_execution :instrument + + private + + def instrument + started = Process.clock_gettime(Process::CLOCK_MONOTONIC) + yield + ensure + Metrics.record(self.class.name, Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) + end + end + ``` + +- **Proc / Lambda / block** — receives `(task, continuation)`; call `continuation.call`: + + ```ruby + class ProcessBooking < CMDx::Task + around_execution ->(task, cont) { + ActiveRecord::Base.transaction { cont.call } + } + end + ``` + +- **Class or instance callable** — `#call(task, continuation)`: + + ```ruby + class WithRequestStore + def self.call(task, continuation) + RequestStore.store[:tid] = task.tid + continuation.call + ensure + RequestStore.clear! + end + end + + class ProcessBooking < CMDx::Task + around_execution WithRequestStore + end + ``` + +`around_execution` runs **inside** registered middlewares but **outside** the +state/status callbacks (`on_complete`, `on_success`, etc.), so its +"after"-portion still observes the result-producing signal but cannot affect +which `on_*` callbacks fire. Use it for symmetric concerns like +transactions, instrumentation, and per-task logging context. Use a middleware +when the wrapping logic must also straddle telemetry/deprecation events. + ## Callback Removal `deregister :callback, event` drops **every** callback for the event. Pass an diff --git a/docs/configuration.md b/docs/configuration.md index 3aaec3689..d88bb475e 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -156,6 +156,7 @@ Callbacks fire at specific lifecycle points. Valid events: |-------|------| | `:before_execution` | Before any `work` is executed | | `:before_validation` | Right after `:before_execution`, before input resolution | +| `:around_execution` | Wraps `before_validation`, `work`, `rollback`, and `after_execution`; must invoke its continuation | | `:after_execution` | After `work` and `rollback` is executed | | `:on_complete` | When `state == "complete"` (success path) | | `:on_interrupted` | When `state == "interrupted"` (skip or fail) | diff --git a/docs/v2-migration.md b/docs/v2-migration.md index d0b3ce2c1..ca961b6a8 100644 --- a/docs/v2-migration.md +++ b/docs/v2-migration.md @@ -988,7 +988,6 @@ The v2 internals open the door to a number of additions that didn't fit the rewr ### Tasks -- **`around_execution` callbacks** — every existing callback is before/after-style. An around hook avoids forcing every wrapping concern through `middlewares.register`. - **`retry_when` on `Result`** — retry on outcome, not exceptions: `retry_when status: :failed, reason: /rate.?limit/, limit: 3`. `retry_on` only catches raised errors; many APIs return failures-as-data. - **`idempotent_by`** — declarative idempotency keyed off context: `idempotent_by :payment_id, ttl: 5.minutes`. Backed by `CMDx::Stores`. - **`circuit_break`** — `circuit_break threshold: 5, cool_off: 30.seconds` without bolting on Stoplight per task. diff --git a/lib/cmdx.rb b/lib/cmdx.rb index 2d3539002..86356082e 100644 --- a/lib/cmdx.rb +++ b/lib/cmdx.rb @@ -38,6 +38,10 @@ module CMDx # by `execute!`. Error = Exception = Class.new(StandardError) + # Raised when an `around_execution` callback fails to invoke its + # continuation, which would otherwise silently skip the task body. + CallbackError = Class.new(Error) + # Raised when a task or workflow attempts to define an input where an # accessor with the same name already exists. DefinitionError = Class.new(Error) diff --git a/lib/cmdx/callbacks.rb b/lib/cmdx/callbacks.rb index be6c07536..e3ae15dbf 100644 --- a/lib/cmdx/callbacks.rb +++ b/lib/cmdx/callbacks.rb @@ -14,6 +14,7 @@ class Callbacks EVENTS = Set[ :before_validation, :before_execution, + :around_execution, :after_execution, :on_complete, :on_interrupted, @@ -106,21 +107,62 @@ def count # @return [void] # @raise [ArgumentError] when a callback is neither a Symbol nor responds to `#call` def process(event, task) - return unless (callbacks = registry[event]) + callbacks = registry[event] + return if callbacks.nil? || callbacks.empty? callbacks.each do |callable, options| next unless Util.satisfied?(options[:if], options[:unless], task) - case callable - when Symbol - task.send(callable) - when Proc - task.instance_exec(task, &callable) - else - next callable.call(task) if callable.respond_to?(:call) + invoke(callable, task) + end + end + + # Wraps `block` with every callback registered for `event` as a nested + # chain (outer-first by declaration order). Each callback receives a + # continuation it must invoke exactly once: Symbol callbacks get it as + # their block (use `yield`); Procs/blocks are `instance_exec`'d on the + # task with `(task, continuation)`; arbitrary callables receive + # `(task, continuation)`. Gates skip individual links silently while + # still running the body. + # + # @param event [Symbol] + # @param task [Task] + # @yield the innermost link — the lifecycle body to wrap + # @return [void] + # @raise [CallbackError] when a callback fails to invoke its continuation + def around(event, task, &body) + callbacks = registry[event] + return yield if callbacks.nil? || callbacks.empty? - raise ArgumentError, "callback must be a Symbol, Proc, or respond to #call" + callbacks.reverse_each.reduce(body) do |succ, (callable, options)| + lambda do + next succ.call unless Util.satisfied?(options[:if], options[:unless], task) + + called = false + cont = lambda do + called = true + succ.call + end + + invoke(callable, task, cont, &cont) + + called || raise(CallbackError, "#{event} callback did not invoke its continuation") end + end.call + end + + private + + def invoke(callable, task, *extras, &) + case callable + when Symbol + task.send(callable, &) + when Proc + task.instance_exec(task, *extras, &callable) + else + return callable.call(task, *extras) if callable.respond_to?(:call) + + raise ArgumentError, "callback must be a Symbol, Proc, or respond to #call" end end diff --git a/lib/cmdx/runtime.rb b/lib/cmdx/runtime.rb index d10e85bf0..d45ef69d9 100644 --- a/lib/cmdx/runtime.rb +++ b/lib/cmdx/runtime.rb @@ -90,10 +90,12 @@ def run_deprecation def run_lifecycle measure_duration do run_callbacks(:before_execution) - run_callbacks(:before_validation) - perform_work - perform_rollback if @signal.failed? - run_callbacks(:after_execution) + run_around(:around_execution) do + run_callbacks(:before_validation) + perform_work + perform_rollback if @signal.failed? + run_callbacks(:after_execution) + end run_callbacks(:"on_#{@signal.state}") run_callbacks(:"on_#{@signal.status}") run_callbacks(:on_ok) if @signal.ok? @@ -156,6 +158,13 @@ def run_callbacks(event) callbacks.process(event, @task) end + def run_around(event, &) + callbacks = @task.class.callbacks + return yield if callbacks.empty? + + callbacks.around(event, @task, &) + end + def perform_work @signal = catch(Signal::TAG) do resolve_inputs! diff --git a/skills/references/configuration.md b/skills/references/configuration.md index 2c94b3491..9718f6b47 100644 --- a/skills/references/configuration.md +++ b/skills/references/configuration.md @@ -165,7 +165,7 @@ No middleware is built into the gem. ### Callback events -`:before_execution`, `:before_validation`, `:after_execution`, `:on_complete`, `:on_interrupted`, `:on_success`, `:on_skipped`, `:on_failed`, `:on_ok`, `:on_ko`. +`:before_execution`, `:before_validation`, `:around_execution`, `:after_execution`, `:on_complete`, `:on_interrupted`, `:on_success`, `:on_skipped`, `:on_failed`, `:on_ok`, `:on_ko`. `:around_execution` callbacks wrap the lifecycle and must invoke their continuation (Symbol → `yield`; Proc/callable → `(task, continuation)`). Each event has a class-level DSL method: `before_execution :method_name`, `on_failed { |task| ... }`, etc. Callbacks accept Symbol (`task.send`), Proc (`instance_exec(task, &)`), or any `#call(task)`-able. Supports `if:`/`unless:` gates. diff --git a/spec/cmdx/callbacks_spec.rb b/spec/cmdx/callbacks_spec.rb index 4d57a73c3..a17c301b2 100644 --- a/spec/cmdx/callbacks_spec.rb +++ b/spec/cmdx/callbacks_spec.rb @@ -416,9 +416,102 @@ def self.call(task) = task.ready end end + describe "#around" do + let(:task_class) do + Class.new do + attr_reader :calls + + def initialize + @calls = [] + end + + def wrap_with_log + @calls << :before + yield + @calls << :after + end + end + end + let(:task) { task_class.new } + + it "yields the inner block when no callbacks are registered" do + ran = false + callbacks.around(:around_execution, task) { ran = true } + + expect(ran).to be(true) + end + + it "invokes a Symbol callback whose method yields" do + callbacks.register(:around_execution, :wrap_with_log) + callbacks.around(:around_execution, task) { task.calls << :inner } + + expect(task.calls).to eq(%i[before inner after]) + end + + it "invokes a Proc callback with (task, continuation)" do + callbacks.register(:around_execution, proc { |t, cont| + t.calls << :before + cont.call + t.calls << :after + }) + callbacks.around(:around_execution, task) { task.calls << :inner } + + expect(task.calls).to eq(%i[before inner after]) + end + + it "invokes a class-level callable with (task, continuation)" do + handler = Class.new do + def self.call(task, continuation) + task.calls << :before + continuation.call + task.calls << :after + end + end + callbacks.register(:around_execution, handler) + callbacks.around(:around_execution, task) { task.calls << :inner } + + expect(task.calls).to eq(%i[before inner after]) + end + + it "nests multiple callbacks in declaration order (outer first)" do + callbacks.register(:around_execution, proc { |t, cont| + t.calls << :outer_before + cont.call + t.calls << :outer_after + }) + callbacks.register(:around_execution, proc { |t, cont| + t.calls << :inner_before + cont.call + t.calls << :inner_after + }) + callbacks.around(:around_execution, task) { task.calls << :body } + + expect(task.calls).to eq(%i[outer_before inner_before body inner_after outer_after]) + end + + it "skips a link whose :if gate evaluates falsy but still runs the body" do + callbacks.register(:around_execution, :wrap_with_log, if: proc { false }) + callbacks.around(:around_execution, task) { task.calls << :inner } + + expect(task.calls).to eq(%i[inner]) + end + + it "raises CallbackError when the callback never invokes its continuation" do + callbacks.register(:around_execution, proc { |_t, _cont| :noop }) + + expect do + callbacks.around(:around_execution, task) { :unreached } + end.to raise_error(CMDx::CallbackError, /around_execution callback did not invoke its continuation/) + end + end + describe "EVENTS" do it "is frozen" do expect(described_class::EVENTS).to be_frozen end + + it "includes :around_execution" do + expect(described_class::EVENTS).to include(:around_execution) + end end end diff --git a/spec/integration/tasks/callbacks_spec.rb b/spec/integration/tasks/callbacks_spec.rb index 6c6ee899d..1f4e6cc5d 100644 --- a/spec/integration/tasks/callbacks_spec.rb +++ b/spec/integration/tasks/callbacks_spec.rb @@ -274,6 +274,74 @@ def self.call(task) = task.context.gate_open == true end end + describe "around_execution" do + it "wraps before_validation, work, and after_execution" do + task = create_task_class(name: "AroundOk") do + before_execution { (context.log ||= []) << :before_execution } + around_execution(proc { |t, cont| + (t.context.log ||= []) << :around_before + cont.call + t.context.log << :around_after + }) + before_validation { (context.log ||= []) << :before_validation } + after_execution { (context.log ||= []) << :after_execution } + on_complete { (context.log ||= []) << :on_complete } + on_success { (context.log ||= []) << :on_success } + define_method(:work) { context.log << :work } + end + + expect(task.execute.context[:log]).to eq( + %i[before_execution around_before before_validation work after_execution around_after on_complete on_success] + ) + end + + it "still runs the after-portion when the work fails" do + task = create_task_class(name: "AroundFail") do + around_execution :wrap + define_method(:work) { fail!("nope") } + define_method(:wrap) do |&continuation| + (context.log ||= []) << :before + continuation.call + context.log << :after + end + end + + result = task.execute + + expect(result).to be_failed + expect(result.context[:log]).to eq(%i[before after]) + end + + it "nests multiple around_execution callbacks in declaration order" do + task = create_task_class(name: "AroundNested") do + around_execution(proc { |t, c| + (t.context.log ||= []) << :outer_before + c.call + t.context.log << :outer_after + }) + around_execution(proc { |t, c| + (t.context.log ||= []) << :inner_before + c.call + t.context.log << :inner_after + }) + define_method(:work) { context.log << :work } + end + + expect(task.execute.context[:log]).to eq( + %i[outer_before inner_before work inner_after outer_after] + ) + end + + it "raises CallbackError when the around hook forgets to yield" do + task = create_task_class(name: "AroundNoYield") do + around_execution(proc { |_t, _c| :swallowed }) + define_method(:work) { context.ran = true } + end + + expect { task.execute }.to raise_error(CMDx::CallbackError, /around_execution/) + end + end + describe "callback interactions" do it "runs before_validation before input resolution so it can populate context" do task = create_task_class(name: "PrefillTask") do From b44aa3586df98480cd31b17ffb452f53320aff08 Mon Sep 17 00:00:00 2001 From: Juan Gomez <drexed@users.noreply.github.com> Date: Tue, 5 May 2026 12:49:00 -0400 Subject: [PATCH 50/54] Remove stuff i wont do --- docs/v2-migration.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/docs/v2-migration.md b/docs/v2-migration.md index ca961b6a8..801d11620 100644 --- a/docs/v2-migration.md +++ b/docs/v2-migration.md @@ -1004,13 +1004,11 @@ The v2 internals open the door to a number of additions that didn't fit the rewr ### Inputs / outputs / schema - **Schema export** — `MyTask.to_json_schema` / `to_openapi_operation` derived from inputs, coercions, and validators. -- **Sensitive inputs** — `required :api_key, sensitive: true` strips the value from logs, telemetry, `Result#to_h`, and `Fault#message`, including in nested results. ### Telemetry - **Status-specific events** — `task_failed` / `task_skipped` / `task_succeeded` alongside `task_executed`. Specific events let subscribers express intent without inspecting `event.payload[:result].status`. - **Sampling** — `subscribe(:task_executed, sample: 0.01)` for high-volume tasks instead of everyone reimplementing the same `rand < 0.01` gate. -- **W3C traceparent** in `Telemetry::Event` so `xid` participates in distributed traces, not just logs. - **Optional-require integrations** shipped in-tree: - `require "cmdx/telemetry/open_telemetry"` — per-task spans with parent linkage from `xid` / `cid`. - `require "cmdx/telemetry/statsd"` / `dogstatsd` — `cmdx.task.duration` / `.success` / `.failed` tagged by task class. @@ -1034,4 +1032,3 @@ The same example middlewares (rate limit, idempotency, circuit breaker) all rein - **Authorization gate** — `policy MyPolicy, action: :execute` raising a typed `AuthorizationFault` before input resolution touches sensitive params. - **YARD-driven schema docs** — Rake task that walks every `Task` subclass and emits a Markdown reference (inputs, outputs, retries, callbacks, faults raised). Beats hand-maintaining `docs/`. -- **Deterministic clock** — `CMDx.clock` defaults to `Time.now` / `Process.clock_gettime` but is swappable in tests so `Result#duration` is mockable. From e98dfa89b2d7ce27ea69fb3bfb2bee70c8e0719f Mon Sep 17 00:00:00 2001 From: Juan Gomez <drexed@users.noreply.github.com> Date: Tue, 5 May 2026 13:49:53 -0400 Subject: [PATCH 51/54] Clean up docs --- Gemfile.lock | 4 ++-- docs/basics/chain.md | 2 +- docs/basics/execution.md | 6 +++--- docs/comparison.md | 2 +- docs/configuration.md | 12 +++++++----- docs/deprecation.md | 18 +++++++++++++++--- docs/getting_started.md | 6 +++--- docs/index.md | 2 +- docs/inputs/coercions.md | 14 +++++++------- docs/inputs/transformations.md | 14 ++++++++++---- docs/inputs/validations.md | 4 +++- docs/interruptions/exceptions.md | 22 +++++++++++++++++++--- docs/interruptions/faults.md | 6 +++--- docs/interruptions/signals.md | 9 ++++++--- docs/middlewares.md | 3 ++- docs/outcomes/states.md | 2 +- docs/outcomes/statuses.md | 2 +- docs/outputs.md | 3 ++- docs/tips_and_tricks.md | 4 ++-- docs/v2-migration.md | 6 +++--- docs/workflows.md | 4 ++-- 21 files changed, 94 insertions(+), 51 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index dca73c5e9..77095a7d2 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -16,11 +16,11 @@ GEM erb (6.0.4) i18n (1.14.8) concurrent-ruby (~> 1.0) - json (2.19.4) + json (2.19.5) language_server-protocol (3.17.0.5) lint_roller (1.1.0) logger (1.7.0) - parallel (2.0.1) + parallel (2.1.0) parser (3.3.11.1) ast (~> 2.4.1) racc diff --git a/docs/basics/chain.md b/docs/basics/chain.md index 239a60db6..1149b8fa8 100644 --- a/docs/basics/chain.md +++ b/docs/basics/chain.md @@ -49,7 +49,7 @@ class ImportDataset < CMDx::Task def work context.dataset = Dataset.find(context.dataset_id) - result1 = ValidateSchema.execute + result1 = ValidateSchema.execute(context) result1.chain.size #=> 1 (the parent hasn't finalized yet) result2 = TransformData.execute!(context) diff --git a/docs/basics/execution.md b/docs/basics/execution.md index f20847412..1376ccd21 100644 --- a/docs/basics/execution.md +++ b/docs/basics/execution.md @@ -8,8 +8,8 @@ Both methods return results, but handle failures differently: | Method | Returns | Exceptions | Use Case | |--------|---------|------------|----------| -| `execute` | Always returns `CMDx::Result` | Never raises | Branch on `result.success?` / `failed?` / `skipped?` | -| `execute!` | Returns `CMDx::Result` on success or skip | Raises `CMDx::Fault` (or the underlying exception) when failed | Exception-based control flow | +| `execute` | Returns `CMDx::Result` for any task outcome | Never raises for ordinary failures; framework errors (`CMDx::Error` subclasses like `ImplementationError`, `MiddlewareError`, `CallbackError`, `DefinitionError`, `DeprecationError`) still propagate | Branch on `result.success?` / `failed?` / `skipped?` | +| `execute!` | Returns `CMDx::Result` on success or skip | Raises `CMDx::Fault` on failed outcomes, or the underlying exception when `work` raised a non-`Fault` `StandardError` | Exception-based control flow | `call` / `call!` are aliases. `execute` / `execute!` also accept a block — when given, the block receives the `Result` and its return value is returned instead of the result. @@ -51,7 +51,7 @@ flowchart LR ## Non-bang Execution -Always returns a `CMDx::Result`, never raises. Default choice for most call sites. +Returns a `CMDx::Result` for every task outcome (success, skip, fail). Default choice for most call sites. Framework errors (`CMDx::Error` subclasses) still propagate — they signal misconfiguration that should never be silently swallowed. ```ruby result = CreateAccount.execute(email: "user@example.com") diff --git a/docs/comparison.md b/docs/comparison.md index b7175a5e3..43c0c12eb 100644 --- a/docs/comparison.md +++ b/docs/comparison.md @@ -29,7 +29,7 @@ CMDx bundles structured logging, telemetry hooks, type coercion, middleware, and - **Retries and faults** — declarative `retry_on` with configurable jitter, halt primitives (`success!` / `skip!` / `fail!`), and `throw!` for propagating peer failures. -- **Pluggable parallelism** — workflow groups can run tasks concurrently using registered executors (`:threads`, `:fibers`, or custom) and fold results with registered mergers (`:last_write_wins`, `:deep_merge`, `:no_merge`, or custom). See [Workflows - Parallel Groups](workflows.md#parallel-groups). +- **Pluggable parallelism** — workflow groups can run tasks concurrently using registered executors (`:threads`, `:fibers`, or custom) and fold results with registered mergers (`:last_write_wins`, `:deep_merge`, `:no_merge`, or custom). See [Workflows - Parallel Groups](workflows.md#parallel-execution). - **Full telemetry surface** — `:task_started`, `:task_deprecated`, `:task_retried`, `:task_rolled_back`, and `:task_executed` events are emitted only when subscribers exist; subscribe from a single `CMDx.configure` block. diff --git a/docs/configuration.md b/docs/configuration.md index d88bb475e..b5e61097c 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -11,7 +11,7 @@ CMDx uses a two-tier configuration system: !!! warning "Important" - Class-level registries (`middlewares`, `callbacks`, `coercions`, `validators`, `executors`, `mergers`, `telemetry`) are **lazily duplicated** from the parent class (or from the global configuration at the top of the hierarchy) on first access. Configure globals before any task first touches a registry, or call `CMDx.reset_configuration!` in test setup to invalidate the cached copies on `Task`. + Every class-level registry (`middlewares`, `callbacks`, `coercions`, `validators`, `executors`, `mergers`, `retriers`, `deprecators`, `telemetry`, `inputs`, `outputs`) is **lazily duplicated** from the parent class (or from the global configuration at the top of the hierarchy) on first access. Configure globals before any task first touches a registry, or call `CMDx.reset_configuration!` in test setup to invalidate the cached copies on `Task`. ## Global Configuration @@ -33,6 +33,8 @@ CMDx uses a two-tier configuration system: | `validators` | `Validators.new` (7 built-ins) | Validator registry | | `executors` | `Executors.new` (`:threads`, `:fibers`) | Parallel-group executor registry | | `mergers` | `Mergers.new` (`:last_write_wins`, `:deep_merge`, `:no_merge`) | Parallel-group merge-strategy registry | +| `retriers` | `Retriers.new` (7 built-ins) | Retry/jitter strategy registry | +| `deprecators` | `Deprecators.new` (`:log`, `:warn`, `:error`) | Deprecation handler registry | | `telemetry` | `Telemetry.new` (empty) | Telemetry pub/sub | ### Default Locale @@ -289,7 +291,7 @@ CMDx.configure do |config| end ``` -See [Workflows - Parallel Groups](workflows.md#parallel-groups) for usage. +See [Workflows - Parallel Groups](workflows.md#parallel-execution) for usage. ### Mergers @@ -306,7 +308,7 @@ CMDx.configure do |config| end ``` -See [Workflows - Parallel Groups](workflows.md#parallel-groups) for usage. +See [Workflows - Parallel Groups](workflows.md#parallel-execution) for usage. ## Class-Level Configuration @@ -359,7 +361,7 @@ class FetchInvoice < CMDx::Task limit: 3, delay: 0.5, max_delay: 5.0, - jitter: :exponential # :exponential, :half_random, :full_random, :bounded_random + jitter: :exponential # :exponential, :half_random, :full_random, :bounded_random, :linear, :fibonacci, :decorrelated_jitter retry_on External::ApiError, limit: 5 do |attempt, delay| delay * (attempt + 1) # custom jitter block @@ -453,4 +455,4 @@ end !!! warning "Important" - `reset_configuration!` clears `@middlewares`, `@callbacks`, `@coercions`, `@validators`, `@executors`, `@mergers`, and `@telemetry` on `Task` only — subclasses that already cached their own copy keep them. In tests, prefer letting each example use freshly defined task classes (e.g. via `stub_const` or anonymous classes). + `reset_configuration!` clears `@middlewares`, `@callbacks`, `@coercions`, `@validators`, `@executors`, `@mergers`, `@retriers`, `@deprecators`, and `@telemetry` on `Task` only — subclasses that already cached their own copy keep them. In tests, prefer letting each example use freshly defined task classes (e.g. via `stub_const` or anonymous classes). diff --git a/docs/deprecation.md b/docs/deprecation.md index 7a7da2449..9127e766d 100644 --- a/docs/deprecation.md +++ b/docs/deprecation.md @@ -109,11 +109,17 @@ class OutdatedConnector < CMDx::Task deprecation proc { |task| Rails.env.development? ? raise(CMDx::DeprecationError, "#{task.class} retired") : task.logger.warn("legacy") } +end +class TenantLegacy < CMDx::Task deprecation ->(task) { task.context.tenant.legacy_mode? ? warn("legacy") : nil } end ``` +!!! warning + + Only one `deprecation` declaration is honored per class — repeated calls overwrite the previous declaration. To express multi-modal behavior, branch inside a single callable. + ### Class or Module Anything that responds to `#call(task)`: @@ -128,8 +134,11 @@ class OutdatedTaskDeprecator end class OutdatedConnector < CMDx::Task - deprecation OutdatedTaskDeprecator # class - deprecation OutdatedTaskDeprecator.new # instance + deprecation OutdatedTaskDeprecator # class — must define `.call(task)` +end + +class AnotherOutdatedConnector < CMDx::Task + deprecation OutdatedTaskDeprecator.new # instance — must define `#call(task)` end ``` @@ -140,7 +149,10 @@ Pass `:if` / `:unless` to skip the deprecation action when the gate fails. Both ```ruby class OutdatedConnector < CMDx::Task deprecation :error, if: -> { Rails.env.production? } - deprecation :log, unless: :tenant_grandfathered? +end + +class GrandfatheredTenants < CMDx::Task + deprecation :log, unless: :tenant_grandfathered? private diff --git a/docs/getting_started.md b/docs/getting_started.md index ab45e0059..37dbe4df1 100644 --- a/docs/getting_started.md +++ b/docs/getting_started.md @@ -29,9 +29,9 @@ CMDx is a Ruby framework for building maintainable, observable business logic th ## Requirements - Ruby: MRI 3.3+ or a compatible JRuby/TruffleRuby release -- Dependencies: None +- Runtime dependencies: `bigdecimal` and `logger` (both stdlib gems on most distributions) -Rails support is built-in, but it's framework-agnostic at its core. +No ActiveSupport or Rails required — Rails integration is opt-in via `CMDx::Railtie`. ## Installation @@ -227,7 +227,7 @@ flowchart TD Work -.->|raises StandardError| Fail Retry -.->|retriable error| Retry OK --> Verify[Verify outputs] - Fail --> Rollback{task.rollback?} + Fail --> Rollback{defines #rollback?} Echo --> Rollback Rollback -->|yes| DoRollback[Run rollback] Rollback -->|no| Cb diff --git a/docs/index.md b/docs/index.md index efce9960e..f99749b9b 100644 --- a/docs/index.md +++ b/docs/index.md @@ -8,7 +8,7 @@ template: home.html ```ruby class ApproveLoan < CMDx::Task - register :middleware, DeeplI18nMiddleware + register :middleware, DeepLI18nMiddleware required :application_id, coerce: :integer diff --git a/docs/inputs/coercions.md b/docs/inputs/coercions.md index e865eb67d..be8064be3 100644 --- a/docs/inputs/coercions.md +++ b/docs/inputs/coercions.md @@ -48,7 +48,7 @@ ParseMetrics.execute( | `:date` | `:strptime` | `Date.parse` on strings, `#to_date` on anything else that responds | `"2024-01-23"` → `Date.new(2024, 1, 23)` | | `:date_time` | `:strptime` | `DateTime.parse` on strings, `#to_datetime` on anything else that responds | `"2024-01-23 10:30"` → `DateTime.new(2024, 1, 23, 10, 30)` | | `:float` | | Floating-point numbers | `"123.45"` → `123.45` | -| `:hash` | | Hash conversion with JSON support (`nil` → `{}`) | `'{"a":1}'` → `{"a" => 1}` | +| `:hash` | | `nil` → `{}`; strings are JSON-decoded (must decode to a Hash); falls back to `#to_hash`/`#to_h` | `'{"a":1}'` → `{"a" => 1}` | | `:integer` | | Integer via `Kernel#Integer` (hex/octal with explicit prefix) | `"0xFF"` → `255`, `"0o77"` → `63` | | `:rational` | `:denominator` (default `1`) | Rational numbers | `"1/2"` → `Rational(1, 2)` | | `:string` | | String conversion | `123` → `"123"` | @@ -77,14 +77,14 @@ class TransformCoordinates < CMDx::Task rescue StandardError CMDx::Coercions::Failure.new("could not convert into a geolocation") end +end +class FormatTimeRange < CMDx::Task # Lambda - register :coercion, :geolocation, ->(value, **options) { - begin - Geolocation(value) - rescue StandardError - CMDx::Coercions::Failure.new("could not convert into a geolocation") - end + register :coercion, :time_range, ->(value, **options) { + TimeRange(value) + rescue StandardError + CMDx::Coercions::Failure.new("could not convert into a time range") } end ``` diff --git a/docs/inputs/transformations.md b/docs/inputs/transformations.md index 780e51247..a6ca88c9c 100644 --- a/docs/inputs/transformations.md +++ b/docs/inputs/transformations.md @@ -53,17 +53,23 @@ Use any object that responds to `#call(value, task)` for reusable transformation ```ruby class EmailNormalizer - def call(value, task) + def self.call(value, _task) value.to_s.downcase.strip end end +class PhoneNormalizer + def call(value, _task) + value.to_s.gsub(/\D/, "") + end +end + class ProcessContacts < CMDx::Task - # Class or Module + # Class with a class-level `.call` — pass the class itself input :email, transform: EmailNormalizer - # Instance - input :email, transform: EmailNormalizer.new + # Instance with `#call` — instantiate when registering + input :phone, transform: PhoneNormalizer.new end ``` diff --git a/docs/inputs/validations.md b/docs/inputs/validations.md index 3477f6ae1..495f66761 100644 --- a/docs/inputs/validations.md +++ b/docs/inputs/validations.md @@ -125,9 +125,11 @@ end ```ruby class ProcessProduct < CMDx::Task + # Shorthand: a bare Regexp is normalized to `{ with: regex }` input :sku, format: /\A[A-Z]{3}-[0-9]{4}\z/ - input :sku, format: { with: /\A[A-Z]{3}-[0-9]{4}\z/ } + # Equivalent long form + input :code, format: { with: /\A[A-Z]{3}-[0-9]{4}\z/ } end ``` diff --git a/docs/interruptions/exceptions.md b/docs/interruptions/exceptions.md index c1eddd27a..523317a40 100644 --- a/docs/interruptions/exceptions.md +++ b/docs/interruptions/exceptions.md @@ -11,6 +11,7 @@ CMDx defines a small, flat exception hierarchy. Every exception the framework ra ``` StandardError └── CMDx::Error (alias: CMDx::Exception) + ├── CMDx::CallbackError ├── CMDx::DefinitionError ├── CMDx::DeprecationError ├── CMDx::ImplementationError @@ -94,6 +95,21 @@ IncompleteTask.execute #=> raises CMDx::ImplementationError IncompleteTask.execute! #=> raises CMDx::ImplementationError ``` +### CMDx::CallbackError + +Raised when an `around_execution` callback fails to invoke its continuation. Without this guard, a buggy around callback would silently bypass the task body. + +```ruby +class ForgetfulCallback < CMDx::Task + around_execution proc { |task, _cont| log("started") } # never calls cont + + def work; end +end + +ForgetfulCallback.execute! +#=> raises CMDx::CallbackError: "around_execution callback did not invoke its continuation" +``` + ### CMDx::MiddlewareError Raised by the middleware chain when a registered middleware forgets to yield to `next_link`. Without this guard, a buggy middleware would silently bypass the task body. @@ -136,8 +152,8 @@ rescue CMDx::Fault => e e.result.origin #=> the upstream result this signal was echoed from e.context #=> the failing task's frozen context e.chain #=> the full Chain of Results from the run - e.message #=> e.result.reason (or the localized "unspecified" fallback) - e.backtrace #=> cleaned via Settings#backtrace_cleaner when configured + e.message #=> I18nProxy.tr(e.result.reason) — translated when the reason is an i18n key, otherwise passes through verbatim; falls back to the localized "unspecified" string when reason is nil + e.backtrace #=> cleaned via the task's `backtrace_cleaner` setting when configured end ``` @@ -174,7 +190,7 @@ end | `throw!(failed_result)` | failed result | raises `Fault` | | Coercion / validation error on input | failed result | raises `Fault` | | Non-framework `StandardError` inside `work` | failed result with `cause` | re-raises the **original** exception | -| Any `CMDx::Error` subclass inside `work` (`ImplementationError`, `DeprecationError`, `MiddlewareError`) | propagates | propagates | +| Any `CMDx::Error` subclass inside `work` (`ImplementationError`, `DeprecationError`, `MiddlewareError`, `CallbackError`) | propagates | propagates | | `ImplementationError` from `Workflow.method_added` | propagates at class-load time | propagates at class-load time | | `DefinitionError` from a conflicting input declaration | propagates at class-load time | propagates at class-load time | | Non-`StandardError` (e.g. `Interrupt`, `SignalException`) | propagates | propagates | diff --git a/docs/interruptions/faults.md b/docs/interruptions/faults.md index 17d1b0f31..f01d9c1d1 100644 --- a/docs/interruptions/faults.md +++ b/docs/interruptions/faults.md @@ -10,8 +10,8 @@ | `fault.task` | `Class<CMDx::Task>` | The failing task **class** (`fault.result.task`) | | `fault.context` | `CMDx::Context` | The failing task's frozen context | | `fault.chain` | `CMDx::Chain` | The chain of every result produced during the run | -| `fault.message` | `String` | The result's `reason`, or the localized `cmdx.reasons.unspecified` when `nil` | -| `fault.backtrace` | `Array<String>` | Cleaned via `task.settings.backtrace_cleaner` when configured | +| `fault.message` | `String` | `I18nProxy.tr(result.reason)` — translates when the reason is an i18n key, otherwise passes through; falls back to the localized `cmdx.reasons.unspecified` when reason is `nil` | +| `fault.backtrace` | `Array<String>` | From `result.backtrace` or `result.cause&.backtrace_locations`, cleaned via `task.settings.backtrace_cleaner` when configured | Use `fault.result` to read the failed outcome's `reason`, `metadata`, `cause`, `origin`, `state`, and `status`. @@ -142,7 +142,7 @@ begin DocumentWorkflow.execute!(document_data: data) rescue CMDx::Fault => e puts "Originated by #{e.task}: #{e.message}" - puts "Workflow result: #{e.chain.first.task}" + puts "Root task: #{e.chain.first.task}" # chain.first is always the root execution end # Or via non-bang execute: diff --git a/docs/interruptions/signals.md b/docs/interruptions/signals.md index 1d9e0a762..4bf29aa27 100644 --- a/docs/interruptions/signals.md +++ b/docs/interruptions/signals.md @@ -84,7 +84,7 @@ result.reason #=> "Refund period has expired" !!! note - `result.reason` is exactly what you passed (or `nil`). The `"Unspecified"` fallback only appears on `Fault#message` when `execute!` raises with no reason. + `result.reason` is exactly what you passed (or `nil`). The localized `cmdx.reasons.unspecified` fallback only appears on `Fault#message` when `execute!` raises with no reason. ## Metadata Enrichment @@ -180,7 +180,10 @@ Use `throw!` to halt the current task by echoing another task's failed result. I class ReportMonthlyMetrics < CMDx::Task def work result = BuildReport.execute(context) - throw!(result) # bubbles up with the same reason, metadata, origin + throw!(result) # echoes the peer's state/status/reason; the upstream + # result is exposed via `origin`. Metadata on this task's + # result is this task's `metadata` (merged with any kwargs + # passed to throw!), not a copy of the peer's metadata. # ...happy path continues here when result isn't failed end @@ -204,7 +207,7 @@ fail!("File format not supported by processor", code: "FORMAT_UNSUPPORTED") # Good: Clear reason skip!("Document processing paused for compliance review") -# Avoid: nil reason (Fault#message falls back to the localized "Unspecified") +# Avoid: nil reason (Fault#message falls back to the localized cmdx.reasons.unspecified) skip! fail! ``` diff --git a/docs/middlewares.md b/docs/middlewares.md index 5eb29f93c..f45a02d6f 100644 --- a/docs/middlewares.md +++ b/docs/middlewares.md @@ -39,7 +39,8 @@ end # 1. AuditMiddleware (before) # 2. AuthorizationMiddleware (before) # 3. CacheMiddleware (before) -# 4. [task lifecycle: callbacks → work → callbacks] +# 4. [deprecation, callbacks, input resolution, retried `work`, +# output verification, rollback, completion callbacks, result finalization] # 5. CacheMiddleware (after) # 6. AuthorizationMiddleware (after) # 7. AuditMiddleware (after) diff --git a/docs/outcomes/states.md b/docs/outcomes/states.md index 9a8c667fd..4a1f2c008 100644 --- a/docs/outcomes/states.md +++ b/docs/outcomes/states.md @@ -7,7 +7,7 @@ States track the lifecycle dimension of a result: did `work` run end-to-end, or | State | Description | | ----- | ----------- | | `complete` | Task finished `work` (and output verification) without interruption. Includes both the implicit success path and an explicit `success!` halt. | -| `interrupted` | Task halted via `skip!`, `fail!`, `throw!`, an unrescued `StandardError`, or accumulated `task.errors`. | +| `interrupted` | Task halted via `skip!`, `fail!`, `throw!`, accumulated `task.errors`, or a `StandardError` raised from `work` (Runtime captures it on `result.cause` and converts the outcome to failed). | State-Status combinations: diff --git a/docs/outcomes/statuses.md b/docs/outcomes/statuses.md index 37624b348..9deb0464b 100644 --- a/docs/outcomes/statuses.md +++ b/docs/outcomes/statuses.md @@ -8,7 +8,7 @@ Statuses represent the business outcome — did the task succeed, skip, or fail? | ------ | ----------- | | `success` | Task `work` ran to completion (and any declared outputs verified), or halted via `success!`. Default outcome. | | `skipped` | Task halted via `skip!`. Treated as a non-failure outcome. | -| `failed` | Task halted via `fail!`, `throw!`, an unrescued `StandardError`, or accumulated `task.errors`. | +| `failed` | Task halted via `fail!`, `throw!`, accumulated `task.errors`, or a `StandardError` raised from `work` (Runtime captures it on `result.cause`). | !!! note diff --git a/docs/outputs.md b/docs/outputs.md index e24395d99..bc647577e 100644 --- a/docs/outputs.md +++ b/docs/outputs.md @@ -31,7 +31,8 @@ end ```ruby output :report_path -output :exported_at, if: ->(t) { t.context.persist? } +output :exported_at, if: -> { context.persist? } # Proc/Lambda is instance_exec'd (no args) +output :tracked, if: :persist? # Symbol calls task.persist? ``` ### Defaults diff --git a/docs/tips_and_tricks.md b/docs/tips_and_tricks.md index 4a5474391..7fc0787e7 100644 --- a/docs/tips_and_tricks.md +++ b/docs/tips_and_tricks.md @@ -158,7 +158,7 @@ end ## Sharing Behavior via a Base Class -Pull cross-cutting concerns onto a base task. Subclasses inherit `settings`, `callbacks`, `middlewares`, `coercions`, `validators`, `executors`, `mergers`, `telemetry`, and `retry_on` automatically. +Pull cross-cutting concerns onto a base task. Subclasses inherit `settings`, `callbacks`, `middlewares`, `coercions`, `validators`, `executors`, `mergers`, `retriers`, `deprecators`, `telemetry`, `inputs`, `outputs`, `retry_on`, and `deprecation` automatically. ```ruby class ApplicationTask < CMDx::Task @@ -184,7 +184,7 @@ class ProcessInvoice < ApplicationTask end ``` -Inherited registries (callbacks, middlewares, validators, coercions, executors, mergers) accumulate — declaring more in a subclass appends to (or overwrites by name in) the parent's list. To opt out of an inherited entry, use `deregister` (e.g. `deregister :callback, :before_execution, :ensure_current_tenant!`). `retry_on` and `settings` likewise accumulate via merge: a subclass `retry_on` adds exception classes and overrides individual options (`limit:`, `delay:`, …) without dropping the parent's, and `settings` merges new keys on top. +Inherited registries (callbacks, middlewares, validators, coercions, executors, mergers, retriers, deprecators, inputs, outputs) accumulate — declaring more in a subclass appends to (or overwrites by name in) the parent's list. To opt out of an inherited entry, use `deregister` (e.g. `deregister :callback, :before_execution, :ensure_current_tenant!`). `retry_on` and `settings` likewise accumulate via merge: a subclass `retry_on` adds exception classes and overrides individual options (`limit:`, `delay:`, …) without dropping the parent's, and `settings` merges new keys on top. ## Useful Examples diff --git a/docs/v2-migration.md b/docs/v2-migration.md index 801d11620..e5744f73a 100644 --- a/docs/v2-migration.md +++ b/docs/v2-migration.md @@ -187,7 +187,7 @@ Want to keep using `attribute` and `attributes`? ``` class ApplicationTask - class < self + class << self alias attribute input alias attributes inputs end @@ -227,7 +227,7 @@ Want to keep using `returns`? ``` class ApplicationTask - class < self + class << self alias returns outputs end end @@ -458,7 +458,7 @@ end - Each parallel worker `deep_dup`s the workflow context, runs its task, then merges its successful child context back into the workflow (on the parent thread, after all workers join). - All workers share the parent's fiber-local `Chain` — each worker sets `Fiber[Chain::STORAGE_KEY]` on thread entry, and each result is pushed under a `Mutex`. -- By default (`continue_on_failure: false`), pending workers are drained as soon as any sibling fails (in-flight tasks still finish, successful contexts still merge), and the first failure **by completion time** is propagated. With `continue_on_failure: true`, every worker runs to completion and all failures are aggregated into the workflow's `errors` (keyed `"TaskClass.input"` for input/validation errors and `"TaskClass.<status>"` for bare `fail!` reasons); the first failure **by declaration index** is propagated via `throw!`. +- By default (`continue_on_failure: false`), pending workers are drained as soon as any sibling fails (in-flight tasks still finish, successful contexts still merge), and the first failure **by declaration index** is propagated. With `continue_on_failure: true`, every worker runs to completion and all failures are aggregated into the workflow's `errors` (keyed `:"TaskClass.<input>"` for input/validation errors and `:"TaskClass.<status>"` for bare `fail!` reasons); the first failure **by declaration index** is still the one propagated via `throw!`. - Additional knobs: `:executor` (`:threads` default, `:fibers`, or a callable), `:merger` (`:last_write_wins` default, `:deep_merge`, `:no_merge`, or a callable), and `:continue_on_failure`. See [Workflows - Parallel Execution](workflows.md#parallel-execution). ### Behavioral Changes diff --git a/docs/workflows.md b/docs/workflows.md index e3fbf96c6..9d80251f2 100644 --- a/docs/workflows.md +++ b/docs/workflows.md @@ -66,7 +66,7 @@ Options apply to the entire group: | `pool_size:` | `tasks.size` | Worker/fiber count when `strategy: :parallel` | | `executor:` | `:threads` | Parallel dispatch backend: `:threads`, `:fibers`, or a callable. `:fibers` requires a `Fiber.scheduler` to be installed (e.g. inside `Async { ... }`) | | `merger:` | `:last_write_wins` | How successful parallel contexts fold back into the workflow context: `:last_write_wins`, `:deep_merge`, `:no_merge`, or a callable `->(workflow_context, result) { ... }` | -| `continue_on_failure:` | `false` | When `true`, run every task in the group to completion even after a failure, and aggregate all failures into the workflow's `errors` (keyed `"TaskClass.input"` for input/validation errors and `"TaskClass.<status>"` for bare `fail!` reasons). Applies to both strategies. When `false` (default), `:sequential` halts on the first failure and `:parallel` cancels pending tasks (in-flight tasks still finish) | +| `continue_on_failure:` | `false` | When `true`, run every task in the group to completion even after a failure, and aggregate all failures into the workflow's `errors` (keyed as the Symbol `:"TaskClass.<input>"` for input/validation errors and `:"TaskClass.<status>"` for bare `fail!` reasons). Applies to both strategies. When `false` (default), `:sequential` halts on the first failure and `:parallel` cancels pending tasks (in-flight tasks still finish) | | `if:` / `unless:` | — | Skip the entire group when the predicate isn't satisfied | ### Conditionals @@ -242,7 +242,7 @@ end !!! warning - Each parallel task receives its own deep-duplicated `context` copy, which is merged back into the workflow's context after execution. If multiple tasks write to the same key, the last merge wins non-deterministically. Use distinct keys per task to avoid conflicts. By default, when any parallel task fails, pending tasks are cancelled (in-flight tasks still finish and successful contexts still merge) and the failed result is propagated through `throw!`. With `continue_on_failure: true`, every task runs to completion and all failures are aggregated into the workflow's `errors` keyed `"TaskClass.input"` (validation errors) or `"TaskClass.<status>"` (bare `fail!` reasons); the first failure (declaration order) is still propagated through `throw!`. + Each parallel task receives its own deep-duplicated `context` copy, which is merged back into the workflow's context after execution (declaration order, not completion order). If multiple tasks write to the same key, the last task in declaration order wins. Use distinct keys per task to avoid conflicts. By default, when any parallel task fails, pending tasks are cancelled (in-flight tasks still finish and successful contexts still merge) and the failed result is propagated through `throw!`. With `continue_on_failure: true`, every task runs to completion and all failures are aggregated into the workflow's `errors` (keyed as Symbols: `:"TaskClass.<input>"` for validation errors or `:"TaskClass.<status>"` for bare `fail!` reasons); the first failure in declaration order is still the one propagated through `throw!`. ### Batch processing with `continue_on_failure` From 2c6ada14aadc11f0b41895a21ced085dc0c30e9f Mon Sep 17 00:00:00 2001 From: Juan Gomez <drexed@users.noreply.github.com> Date: Tue, 5 May 2026 14:04:53 -0400 Subject: [PATCH 52/54] Clean up docs --- lib/cmdx/callbacks.rb | 11 ++++- lib/cmdx/coercions.rb | 9 +++- lib/cmdx/coercions/array.rb | 3 +- lib/cmdx/coercions/boolean.rb | 3 +- lib/cmdx/coercions/float.rb | 3 +- lib/cmdx/coercions/hash.rb | 3 +- lib/cmdx/coercions/integer.rb | 3 +- lib/cmdx/coercions/string.rb | 3 +- lib/cmdx/coercions/symbol.rb | 3 +- lib/cmdx/context.rb | 12 +++++ lib/cmdx/deprecators.rb | 3 ++ lib/cmdx/executors.rb | 3 ++ lib/cmdx/fault.rb | 5 ++ lib/cmdx/i18n_proxy.rb | 16 +++++- lib/cmdx/input.rb | 15 ++++++ lib/cmdx/inputs.rb | 81 +++++++++++++++++++++++++++++-- lib/cmdx/mergers.rb | 3 ++ lib/cmdx/middlewares.rb | 6 ++- lib/cmdx/output.rb | 2 + lib/cmdx/outputs.rb | 6 +++ lib/cmdx/pipeline.rb | 11 ++++- lib/cmdx/result.rb | 2 + lib/cmdx/retriers.rb | 3 ++ lib/cmdx/retry.rb | 5 +- lib/cmdx/runtime.rb | 8 +++ lib/cmdx/signal.rb | 13 +++++ lib/cmdx/task.rb | 83 +++++++++++++++++++++++++++++--- lib/cmdx/telemetry.rb | 5 +- lib/cmdx/util.rb | 2 +- lib/cmdx/validators.rb | 14 ++++++ lib/cmdx/validators/exclusion.rb | 12 +++++ lib/cmdx/validators/inclusion.rb | 12 +++++ lib/cmdx/validators/length.rb | 48 ++++++++++++++++++ lib/cmdx/validators/numeric.rb | 48 ++++++++++++++++++ lib/cmdx/workflow.rb | 6 +++ spec/cmdx/i18n_proxy_spec.rb | 9 ++-- 36 files changed, 443 insertions(+), 31 deletions(-) diff --git a/lib/cmdx/callbacks.rb b/lib/cmdx/callbacks.rb index e3ae15dbf..396e8a560 100644 --- a/lib/cmdx/callbacks.rb +++ b/lib/cmdx/callbacks.rb @@ -31,6 +31,8 @@ def initialize @registry = {} end + # @param source [Callbacks] registry to duplicate + # @return [void] def initialize_copy(source) @registry = source.registry.transform_values(&:dup) end @@ -39,13 +41,14 @@ def initialize_copy(source) # # @param event [Symbol] one of {EVENTS} # @param callable [Symbol, #call, nil] method name or callable; pass either this or a block + # @param block [#call, nil] callback body when `callable` is omitted # @param options [Hash{Symbol => Object}] # @option options [Symbol, Proc, #call] :if gate that must evaluate truthy # @option options [Symbol, Proc, #call] :unless gate that must evaluate falsy - # @yield the callback body # @return [Callbacks] self for chaining # @raise [ArgumentError] when both `callable` and a block are given, when the # callback type is invalid, or when `event` is unknown + # @yield the callback body def register(event, callable = nil, **options, &block) callback = callable || block @@ -127,6 +130,7 @@ def process(event, task) # # @param event [Symbol] # @param task [Task] + # @param body [#call] inner continuation (Runtime lifecycle body) # @yield the innermost link — the lifecycle body to wrap # @return [void] # @raise [CallbackError] when a callback fails to invoke its continuation @@ -153,6 +157,11 @@ def around(event, task, &body) private + # @param callable [Symbol, Proc, #call] + # @param task [Task] + # @param extras [Array<Object>] extra args after `task` for continuation-style callbacks + # @return [Object] the callback's return value + # @raise [ArgumentError] when `callable` is invalid def invoke(callable, task, *extras, &) case callable when Symbol diff --git a/lib/cmdx/coercions.rb b/lib/cmdx/coercions.rb index 189750e1e..811a9e0da 100644 --- a/lib/cmdx/coercions.rb +++ b/lib/cmdx/coercions.rb @@ -32,6 +32,8 @@ def initialize } end + # @param source [Coercions] registry to duplicate + # @return [void] def initialize_copy(source) @registry = source.registry.dup end @@ -41,6 +43,7 @@ def initialize_copy(source) # # @param name [Symbol] # @param callable [#call, nil] pass either this or a block + # @param block [#call, nil] coercion implementation when `callable` is omitted # @yield (see built-in coercion signatures — `call(value, options = {})`) # @return [Coercions] self for chaining # @raise [ArgumentError] when both `callable` and a block are given, or @@ -79,6 +82,7 @@ def lookup(name) # callable. # # @param options [Hash{Symbol => Object}] declaration options + # @option options [Object] :coerce coercion rule(s): Symbol, Array, Hash, or `#call`-able # @return [Array<Array(Object, Hash)>] pairs of handler + per-handler options # @raise [ArgumentError] when `:coerce` is an unsupported format def extract(options) @@ -118,7 +122,7 @@ def size # # @param task [Task] used for inline `Symbol`/`Proc` handlers and error recording # @param name [Symbol] attribute name for error reporting - # @param value [Object] value to coerce + # @param value [Object] raw input before coercion rules run # @param rules [Array<Array(Object, Hash)>] from {#extract} # @return [Object, Failure] coerced value, or `Failure` when every rule failed def coerce(task, name, value, rules) @@ -152,6 +156,9 @@ def coerce(task, name, value, rules) private + # @param entry [Object] Array entry from a `:coerce` list + # @return [Array(Object, Hash)] handler + options pair + # @raise [ArgumentError] when `entry` is unsupported def normalize_entry(entry) case entry when ::Symbol, ::Proc diff --git a/lib/cmdx/coercions/array.rb b/lib/cmdx/coercions/array.rb index d192f7989..12a854b83 100644 --- a/lib/cmdx/coercions/array.rb +++ b/lib/cmdx/coercions/array.rb @@ -9,7 +9,8 @@ module Array extend self # @param value [Object] - # @param options [Hash{Symbol => Object}] unused + # @param options [Hash{Symbol => Object}] + # @option options [Object] reserved for future per-coercion configuration (currently ignored) # @return [Array, Coercions::Failure] def call(value, options = EMPTY_HASH) if value.is_a?(::Array) diff --git a/lib/cmdx/coercions/boolean.rb b/lib/cmdx/coercions/boolean.rb index 4ce87f0b3..6fec0c91d 100644 --- a/lib/cmdx/coercions/boolean.rb +++ b/lib/cmdx/coercions/boolean.rb @@ -13,7 +13,8 @@ module Boolean FALSEY = Set["false", "no", "off", "n", "0", "f"].freeze # @param value [Object] - # @param options [Hash{Symbol => Object}] unused + # @param options [Hash{Symbol => Object}] + # @option options [Object] reserved for future per-coercion configuration (currently ignored) # @return [Boolean, Coercions::Failure] def call(value, options = EMPTY_HASH) return coercion_failure if value.nil? diff --git a/lib/cmdx/coercions/float.rb b/lib/cmdx/coercions/float.rb index 5ad4d4427..0de45fd82 100644 --- a/lib/cmdx/coercions/float.rb +++ b/lib/cmdx/coercions/float.rb @@ -8,7 +8,8 @@ module Float extend self # @param value [Object] - # @param options [Hash{Symbol => Object}] unused + # @param options [Hash{Symbol => Object}] + # @option options [Object] reserved for future per-coercion configuration (currently ignored) # @return [Float, Coercions::Failure] def call(value, options = EMPTY_HASH) Float(value) diff --git a/lib/cmdx/coercions/hash.rb b/lib/cmdx/coercions/hash.rb index a65b1dec3..cef0adf6c 100644 --- a/lib/cmdx/coercions/hash.rb +++ b/lib/cmdx/coercions/hash.rb @@ -9,7 +9,8 @@ module Hash extend self # @param value [Object] - # @param options [Hash{Symbol => Object}] unused + # @param options [Hash{Symbol => Object}] + # @option options [Object] reserved for future per-coercion configuration (currently ignored) # @return [Hash, Coercions::Failure] def call(value, options = EMPTY_HASH) if value.nil? diff --git a/lib/cmdx/coercions/integer.rb b/lib/cmdx/coercions/integer.rb index b25266849..2aa8255fa 100644 --- a/lib/cmdx/coercions/integer.rb +++ b/lib/cmdx/coercions/integer.rb @@ -8,7 +8,8 @@ module Integer extend self # @param value [Object] - # @param options [Hash{Symbol => Object}] unused + # @param options [Hash{Symbol => Object}] + # @option options [Object] reserved for future per-coercion configuration (currently ignored) # @return [Integer, Coercions::Failure] def call(value, options = EMPTY_HASH) Integer(value) diff --git a/lib/cmdx/coercions/string.rb b/lib/cmdx/coercions/string.rb index 24edf30a5..ec476a386 100644 --- a/lib/cmdx/coercions/string.rb +++ b/lib/cmdx/coercions/string.rb @@ -8,7 +8,8 @@ module String extend self # @param value [Object] - # @param options [Hash{Symbol => Object}] unused + # @param options [Hash{Symbol => Object}] + # @option options [Object] reserved for future per-coercion configuration (currently ignored) # @return [String] def call(value, options = EMPTY_HASH) String(value) diff --git a/lib/cmdx/coercions/symbol.rb b/lib/cmdx/coercions/symbol.rb index 43bfd63dd..787d6d40d 100644 --- a/lib/cmdx/coercions/symbol.rb +++ b/lib/cmdx/coercions/symbol.rb @@ -9,7 +9,8 @@ module Symbol extend self # @param value [Object] - # @param options [Hash{Symbol => Object}] unused + # @param options [Hash{Symbol => Object}] + # @option options [Object] reserved for future per-coercion configuration (currently ignored) # @return [Symbol, Coercions::Failure] def call(value, options = EMPTY_HASH) return value if value.is_a?(::Symbol) diff --git a/lib/cmdx/context.rb b/lib/cmdx/context.rb index 267578561..aa9ab3b7c 100644 --- a/lib/cmdx/context.rb +++ b/lib/cmdx/context.rb @@ -274,6 +274,10 @@ def freeze # - `ctx.name = val` — stores `val` under `:name`. # - `ctx.name?` — truthy check for `@table[:name]`. # + # @param method_name [Symbol] dynamic reader/writer/predicate name + # @param args [Array<Object>] stores RHS for writers (`name=` → `[value]`) + # @param _kwargs [Hash{Symbol => Object}] ignored (accepted for Ruby keyword forwarding) + # @option _kwargs [Object] ignored # @raise [NoMethodError] when {#strict?} is true and the key is missing # @api private def method_missing(method_name, *args, **_kwargs, &) @@ -288,10 +292,15 @@ def method_missing(method_name, *args, **_kwargs, &) end end + # @param method_name [Symbol] + # @param include_private [Boolean] forwarded to Ruby's `respond_to?` lookup + # @return [Boolean] def respond_to_missing?(method_name, include_private = false) @table.key?(method_name) || method_name.end_with?("=", "?") || super end + # @param value [Object] nested value from the context table + # @return [Object] recursively duplicated scalar/collection snapshot def compute_deep_dup(value) case value when Numeric, Symbol, TrueClass, FalseClass, NilClass @@ -309,6 +318,9 @@ def compute_deep_dup(value) end end + # @param lhs [Hash] + # @param rhs [Hash] + # @return [Hash] merged hash (recursive for nested `{Hash => Hash}` pairs) def compute_deep_merge(lhs, rhs) lhs.merge(rhs) do |_key, l, r| l.is_a?(Hash) && r.is_a?(Hash) ? compute_deep_merge(l, r) : r diff --git a/lib/cmdx/deprecators.rb b/lib/cmdx/deprecators.rb index 5ed9d9232..c1fb36dfd 100644 --- a/lib/cmdx/deprecators.rb +++ b/lib/cmdx/deprecators.rb @@ -17,6 +17,8 @@ def initialize } end + # @param source [Deprecators] registry to duplicate + # @return [void] def initialize_copy(source) @registry = source.registry.dup end @@ -25,6 +27,7 @@ def initialize_copy(source) # # @param name [Symbol] # @param callable [#call, nil] pass either this or a block + # @param block [#call, nil] deprecator callable when `callable` is omitted # @yield deprecator body — `call(task)` # @return [Deprecators] self for chaining # @raise [ArgumentError] when both `callable` and a block are given, or diff --git a/lib/cmdx/executors.rb b/lib/cmdx/executors.rb index f3e0f682b..075cec726 100644 --- a/lib/cmdx/executors.rb +++ b/lib/cmdx/executors.rb @@ -17,6 +17,8 @@ def initialize } end + # @param source [Executors] registry to duplicate + # @return [void] def initialize_copy(source) @registry = source.registry.dup end @@ -25,6 +27,7 @@ def initialize_copy(source) # # @param name [Symbol] # @param callable [#call, nil] pass either this or a block + # @param block [#call, nil] executor callable when `callable` is omitted # @yield executor body — `call(jobs:, concurrency:, on_job:)` # @return [Executors] self for chaining # @raise [ArgumentError] when both `callable` and a block are given, or diff --git a/lib/cmdx/fault.rb b/lib/cmdx/fault.rb index 70378957e..9376f669c 100644 --- a/lib/cmdx/fault.rb +++ b/lib/cmdx/fault.rb @@ -21,6 +21,8 @@ class << self # @raise [ArgumentError] when no tasks are given # # @example + # begin + # MyTask.execute!(ctx) # rescue Fault.for?(ProcessOrder, ChargeCard) => fault # Alert.for_fault(fault) # end @@ -41,6 +43,8 @@ def for?(*tasks) # @raise [ArgumentError] when no reason is given # # @example + # begin + # MyTask.execute!(ctx) # rescue Fault.reason?("Payment failed") => fault # Alert.for_fault(fault) # end @@ -54,6 +58,7 @@ def reason?(reason) # Returns a matcher subclass whose `===` runs `block` against the fault. # + # @param block [#call] `(fault) -> Boolean` matcher body # @yieldparam fault [Fault] # @yieldreturn [Boolean] # @return [Class<Fault>] anonymous matcher subclass diff --git a/lib/cmdx/i18n_proxy.rb b/lib/cmdx/i18n_proxy.rb index cc214f2ac..30ce61a94 100644 --- a/lib/cmdx/i18n_proxy.rb +++ b/lib/cmdx/i18n_proxy.rb @@ -15,13 +15,21 @@ def locale_paths end # @param key [String, Symbol] dot-separated translation key - # @param options [Hash{Symbol => Object}] interpolation values (e.g. `type:`) + # @param options [Hash{Symbol => Object}] forwarded to `I18n.translate` or bundled interpolation + # @option options [Object] :default fallback when the bundled YAML lookup misses + # @option options [Hash{Symbol => Object}] extra keys interpolated via `String#%` for bundled translations # @return [String, Object] the translated string (or the raw default value) def translate(key, **options) @proxy ||= new @proxy.translate(key, **options) end - alias t translate + + # @param (see .translate) + # @option (see .translate) + # @return (see .translate) + def t(key, **options) + translate(key, **options) + end # Register an additional directory containing locale YAML files. Later # registrations take precedence over earlier ones (the most recently @@ -50,6 +58,8 @@ def tr(reason) # @param key [String, Symbol] dot-separated translation key # @param options [Hash{Symbol => Object}] interpolation values + # @option options [Object] :default fallback when the bundled YAML lookup misses + # @option options [Hash{Symbol => Object}] extra keys interpolated via `String#%` for bundled translations # @return [String, Object] the translated/interpolated message def translate(key, **options) return ::I18n.translate(key, **options) if defined?(::I18n) && ::I18n.respond_to?(:translate) @@ -69,6 +79,8 @@ def translate(key, **options) private + # @param key [String, Symbol] lookup key (without locale prefix) + # @return [Object, nil] message template from bundled YAML, when present def translation_default(key) default_locale = CMDx.configuration.default_locale || "en" translation_key = "#{default_locale}.#{key}" diff --git a/lib/cmdx/input.rb b/lib/cmdx/input.rb index c955413c2..0498123e3 100644 --- a/lib/cmdx/input.rb +++ b/lib/cmdx/input.rb @@ -177,6 +177,10 @@ def to_json(*args) private + # @param value [Object] candidate after source resolution + # @param key_provided [Boolean] whether the source reported an explicit key/value pair + # @param task [Task] + # @return [Object, nil] def run_pipeline(value, key_provided, task) if required?(task) && !key_provided task.errors.add(accessor_name, I18nProxy.t("cmdx.attributes.required")) @@ -198,6 +202,8 @@ def run_pipeline(value, key_provided, task) value end + # @param task [Task] + # @return [Array(Object, Boolean)] `[value, key_provided]` def resolve_with_key(task) case source when :context @@ -216,12 +222,16 @@ def resolve_with_key(task) end end + # @param parent_value [#[], #key?, Object] + # @return [Array(Object, Boolean)] `[value, key_provided]` def resolve_from_parent_with_key(parent_value) return [nil, false] unless parent_value.respond_to?(:[]) fetch_by_name(parent_value) end + # @param obj [Object] source object (`Hash`, duck-typed reader, etc.) + # @return [Array(Object, Boolean)] `[value, key_provided]` def fetch_by_name(obj) if obj.respond_to?(name, true) [obj.send(name), true] @@ -243,6 +253,8 @@ def fetch_by_name(obj) end end + # @param task [Task] + # @return [Object, nil] def apply_default(task) return if default.nil? @@ -258,6 +270,9 @@ def apply_default(task) end end + # @param value [Object] + # @param task [Task] + # @return [Object] def apply_transform(value, task) case transform when Symbol diff --git a/lib/cmdx/inputs.rb b/lib/cmdx/inputs.rb index 420269aa8..59d893cdd 100644 --- a/lib/cmdx/inputs.rb +++ b/lib/cmdx/inputs.rb @@ -12,6 +12,8 @@ def initialize @registry = {} end + # @param source [Inputs] registry to duplicate + # @return [void] def initialize_copy(source) @registry = source.registry.dup end @@ -21,9 +23,22 @@ def initialize_copy(source) # # @param klass [Class] the task class to define readers on # @param names [Array<Symbol>] input names + # @param block [#call, nil] nested-input DSL (see {ChildBuilder}) # @param options [Hash{Symbol => Object}] passed to {Input#initialize} - # @yield block evaluated in a {ChildBuilder} for nested inputs + # @option options [String] :description (also accepts `:desc`) + # @option options [Symbol] :as overrides the accessor name + # @option options [Boolean, String] :prefix prefix for the accessor name + # @option options [Boolean, String] :suffix suffix for the accessor name + # @option options [Symbol, Proc, #call] :source (`:context`) where to fetch from + # @option options [Object, Symbol, Proc, #call] :default + # @option options [Symbol, Proc, #call] :transform mutator applied after coercion + # @option options [Symbol, Proc, #call] :if + # @option options [Symbol, Proc, #call] :unless + # @option options [Boolean] :required + # @option options [Object] :coerce forwarded with declaration (see {Coercions#extract}) + # @option options [Object] :validate forwarded with declaration (see {Validators#extract}) # @return [Inputs] self for chaining + # @yield block evaluated in a {ChildBuilder} for nested inputs def register(klass, *names, **options, &block) children = block ? ChildBuilder.build(&block) : EMPTY_ARRAY @@ -75,6 +90,10 @@ def resolve(task) private + # @param input [Input] parent input whose children should be resolved + # @param parent_value [Object] resolved parent value child inputs read from + # @param task [Task] + # @return [void] def resolve_children(input, parent_value, task) return if input.children.empty? || parent_value.nil? @@ -109,7 +128,20 @@ def initialize end # @param names [Array<Symbol>] - # @param options [Hash{Symbol => Object}] + # @param options [Hash{Symbol => Object}] forwarded to {Input#initialize} + # @option options [String] :description (also accepts `:desc`) + # @option options [Symbol] :as overrides the accessor name + # @option options [Boolean, String] :prefix prefix for the accessor name + # @option options [Boolean, String] :suffix suffix for the accessor name + # @option options [Symbol, Proc, #call] :source (`:context`) where to fetch from + # @option options [Object, Symbol, Proc, #call] :default + # @option options [Symbol, Proc, #call] :transform mutator applied after coercion + # @option options [Symbol, Proc, #call] :if + # @option options [Symbol, Proc, #call] :unless + # @option options [Boolean] :required + # @option options [Object] :coerce forwarded with declaration (see {Coercions#extract}) + # @option options [Object] :validate forwarded with declaration (see {Validators#extract}) + # @yield nested child input DSL # @return [Array<Input>] def inputs(*names, **options, &) build(*names, **options, &) @@ -118,7 +150,19 @@ def inputs(*names, **options, &) # Declares optional child inputs (equivalent to `inputs ..., required: false`). # @param names [Array<Symbol>] - # @param options [Hash{Symbol => Object}] + # @param options [Hash{Symbol => Object}] forwarded to {Input#initialize} + # @option options [String] :description (also accepts `:desc`) + # @option options [Symbol] :as overrides the accessor name + # @option options [Boolean, String] :prefix prefix for the accessor name + # @option options [Boolean, String] :suffix suffix for the accessor name + # @option options [Symbol, Proc, #call] :source (`:context`) where to fetch from + # @option options [Object, Symbol, Proc, #call] :default + # @option options [Symbol, Proc, #call] :transform mutator applied after coercion + # @option options [Symbol, Proc, #call] :if + # @option options [Symbol, Proc, #call] :unless + # @option options [Object] :coerce forwarded with declaration (see {Coercions#extract}) + # @option options [Object] :validate forwarded with declaration (see {Validators#extract}) + # @yield nested child input DSL # @return [Array<Input>] def optional(*names, **options, &) build(*names, required: false, **options, &) @@ -126,7 +170,19 @@ def optional(*names, **options, &) # Declares required child inputs (equivalent to `inputs ..., required: true`). # @param names [Array<Symbol>] - # @param options [Hash{Symbol => Object}] + # @param options [Hash{Symbol => Object}] forwarded to {Input#initialize} + # @option options [String] :description (also accepts `:desc`) + # @option options [Symbol] :as overrides the accessor name + # @option options [Boolean, String] :prefix prefix for the accessor name + # @option options [Boolean, String] :suffix suffix for the accessor name + # @option options [Symbol, Proc, #call] :source (`:context`) where to fetch from + # @option options [Object, Symbol, Proc, #call] :default + # @option options [Symbol, Proc, #call] :transform mutator applied after coercion + # @option options [Symbol, Proc, #call] :if + # @option options [Symbol, Proc, #call] :unless + # @option options [Object] :coerce forwarded with declaration (see {Coercions#extract}) + # @option options [Object] :validate forwarded with declaration (see {Validators#extract}) + # @yield nested child input DSL # @return [Array<Input>] def required(*names, **options, &) build(*names, required: true, **options, &) @@ -134,6 +190,23 @@ def required(*names, **options, &) private + # @param names [Array<Symbol>] + # @param block [#call, nil] + # @param options [Hash{Symbol => Object}] forwarded to {Input#initialize} + # @option options [String] :description (also accepts `:desc`) + # @option options [Symbol] :as overrides the accessor name + # @option options [Boolean, String] :prefix prefix for the accessor name + # @option options [Boolean, String] :suffix suffix for the accessor name + # @option options [Symbol, Proc, #call] :source (`:context`) where to fetch from + # @option options [Object, Symbol, Proc, #call] :default + # @option options [Symbol, Proc, #call] :transform mutator applied after coercion + # @option options [Symbol, Proc, #call] :if + # @option options [Symbol, Proc, #call] :unless + # @option options [Boolean] :required + # @option options [Object] :coerce forwarded with declaration (see {Coercions#extract}) + # @option options [Object] :validate forwarded with declaration (see {Validators#extract}) + # @return [Array<Input>] + # @yield nested child input DSL def build(*names, **options, &block) nested = block ? self.class.build(&block) : EMPTY_ARRAY names.map { |name| children << Input.new(name, children: nested, **options) } diff --git a/lib/cmdx/mergers.rb b/lib/cmdx/mergers.rb index f2a115cb1..daac5625a 100644 --- a/lib/cmdx/mergers.rb +++ b/lib/cmdx/mergers.rb @@ -17,6 +17,8 @@ def initialize } end + # @param source [Mergers] registry to duplicate + # @return [void] def initialize_copy(source) @registry = source.registry.dup end @@ -25,6 +27,7 @@ def initialize_copy(source) # # @param name [Symbol] # @param callable [#call, nil] pass either this or a block + # @param block [#call, nil] merger callable when `callable` is omitted # @yield merger body — `call(workflow_context, result)` # @return [Mergers] self for chaining # @raise [ArgumentError] when both `callable` and a block are given, or diff --git a/lib/cmdx/middlewares.rb b/lib/cmdx/middlewares.rb index beeb46cea..7e4a22825 100644 --- a/lib/cmdx/middlewares.rb +++ b/lib/cmdx/middlewares.rb @@ -12,6 +12,8 @@ def initialize @registry = [] end + # @param source [Middlewares] registry to duplicate + # @return [void] def initialize_copy(source) @registry = source.registry.dup end @@ -21,13 +23,15 @@ def initialize_copy(source) # gates evaluated against the task at process time. # # @param callable [#call, nil] provide either this or a block + # @param block [#call, nil] middleware callable when `callable` is omitted # @param options [Hash{Symbol => Object}] # @option options [Symbol, Proc, #call] :if gate that must evaluate truthy # @option options [Symbol, Proc, #call] :unless gate that must evaluate falsy - # @yield the middleware body, receiving `(task)` and `next_link` via block + # @option options [Integer] :at insertion index (see implementation) # @return [Middlewares] self for chaining # @raise [ArgumentError] when both or neither of `callable`/block are given, # when the callable doesn't respond to `#call`, or when `:at` isn't an Integer + # @yield the middleware body, receiving `(task)` and `next_link` via block def register(callable = nil, **options, &block) middleware = callable || block at = options.delete(:at) diff --git a/lib/cmdx/output.rb b/lib/cmdx/output.rb index cc4f20f16..8e059c14c 100644 --- a/lib/cmdx/output.rb +++ b/lib/cmdx/output.rb @@ -94,6 +94,8 @@ def verify(task) private + # @param task [Task] + # @return [Object, nil] def apply_default(task) return if default.nil? diff --git a/lib/cmdx/outputs.rb b/lib/cmdx/outputs.rb index 3adda000a..ffe406deb 100644 --- a/lib/cmdx/outputs.rb +++ b/lib/cmdx/outputs.rb @@ -12,6 +12,8 @@ def initialize @registry = {} end + # @param source [Outputs] registry to duplicate + # @return [void] def initialize_copy(source) @registry = source.registry.dup end @@ -20,6 +22,10 @@ def initialize_copy(source) # # @param keys [Array<Symbol>] # @param options [Hash{Symbol => Object}] passed through to {Output#initialize} + # @option options [String] :description (also accepts `:desc`) + # @option options [Symbol, Proc, #call] :if + # @option options [Symbol, Proc, #call] :unless + # @option options [Object, Symbol, Proc, #call] :default # @return [Outputs] self for chaining def register(*keys, **options) keys.each do |key| diff --git a/lib/cmdx/pipeline.rb b/lib/cmdx/pipeline.rb index a319c63e7..ccd328da1 100644 --- a/lib/cmdx/pipeline.rb +++ b/lib/cmdx/pipeline.rb @@ -18,7 +18,7 @@ class Pipeline class << self - # @param workflow [Task & Workflow] + # @param workflow [Task] workflow instance whose class includes {Workflow} # @return [void] def execute(workflow) new(workflow).execute @@ -26,7 +26,7 @@ def execute(workflow) end - # @param workflow [Task & Workflow] + # @param workflow [Task] workflow instance whose class includes {Workflow} def initialize(workflow) @workflow = workflow @executed = [] @@ -70,6 +70,8 @@ def execute private + # @param group [Workflow::ExecutionGroup] + # @return [Result, nil] failed result to halt on, or nil when the group succeeds def run_sequential(group) continue = group.options[:continue_on_failure] failures = group.tasks.each_with_object([]) do |task_class, bucket| @@ -85,6 +87,8 @@ def run_sequential(group) aggregate(failures, continue:) end + # @param group [Workflow::ExecutionGroup] + # @return [Result, nil] failed result to halt on, or nil when the group succeeds def run_parallel(group) tasks = group.tasks chain = Chain.current @@ -150,6 +154,9 @@ def rollback_executed! end end + # @param failures [Array<Result>] + # @param continue [Boolean] when true, merges failures into the workflow's errors + # @return [Result, nil] first failure (echoed upstream), or nil when `failures` is empty def aggregate(failures, continue:) return if failures.empty? return failures.first unless continue diff --git a/lib/cmdx/result.rb b/lib/cmdx/result.rb index 44c36e431..978696a25 100644 --- a/lib/cmdx/result.rb +++ b/lib/cmdx/result.rb @@ -346,6 +346,8 @@ def deconstruct private + # @param key [Symbol] reader name such as `:caused_failure` or `:threw_failure` + # @return [Hash{Symbol => Object}, nil] compact `{task:, tid:}` map for graph hints def hash_for_failure(key) r = public_send(key) return if r.nil? diff --git a/lib/cmdx/retriers.rb b/lib/cmdx/retriers.rb index 4ca59a1fb..518a41e7e 100644 --- a/lib/cmdx/retriers.rb +++ b/lib/cmdx/retriers.rb @@ -22,6 +22,8 @@ def initialize } end + # @param source [Retriers] registry to duplicate + # @return [void] def initialize_copy(source) @registry = source.registry.dup end @@ -30,6 +32,7 @@ def initialize_copy(source) # # @param name [Symbol] # @param callable [#call, nil] pass either this or a block + # @param block [#call, nil] retrier callable when `callable` is omitted # @yield retrier body — `call(attempt, delay, prev_delay)` # @return [Retriers] self for chaining # @raise [ArgumentError] when both `callable` and a block are given, or diff --git a/lib/cmdx/retry.rb b/lib/cmdx/retry.rb index b3bff4e5e..653922927 100644 --- a/lib/cmdx/retry.rb +++ b/lib/cmdx/retry.rb @@ -11,13 +11,15 @@ class Retry # @param exceptions [Array<Class>] exceptions to retry on # @param options [Hash{Symbol => Object}] + # @param block [#call, nil] optional jitter callable used when `:jitter` isn't set # @option options [Integer] :limit (3) maximum retry attempts # @option options [Float] :delay (0.5) base delay in seconds between attempts # @option options [Float] :max_delay clamp for computed delays # @option options [Symbol, Proc, #call] :jitter built-in strategy (`:exponential`, # `:half_random`, `:full_random`, `:bounded_random`, `:linear`, `:fibonacci`, # `:decorrelated_jitter`) or custom - # @yield [attempt, delay] optional custom jitter block, used when `:jitter` isn't set + # @yieldparam attempt [Integer] + # @yieldparam delay [Float] def initialize(exceptions, options = EMPTY_HASH, &block) @exceptions = exceptions.flatten @options = options.freeze @@ -30,6 +32,7 @@ def initialize(exceptions, options = EMPTY_HASH, &block) # # @param new_exceptions [Array<Class>] # @param new_options [Hash{Symbol => Object}] + # @param block [#call, nil] replacement jitter callable (falls back to the prior block) # @yield [attempt, delay] optional replacement jitter block # @return [Retry] def build(new_exceptions, new_options, &block) diff --git a/lib/cmdx/runtime.rb b/lib/cmdx/runtime.rb index d45ef69d9..9cfdbb2e4 100644 --- a/lib/cmdx/runtime.rb +++ b/lib/cmdx/runtime.rb @@ -151,6 +151,8 @@ def measure_duration @duration = Process.clock_gettime(Process::CLOCK_MONOTONIC, :float_millisecond) - start end + # @param event [Symbol] callback event from {Callbacks::EVENTS} + # @return [void] def run_callbacks(event) callbacks = @task.class.callbacks return if callbacks.empty? @@ -158,6 +160,9 @@ def run_callbacks(event) callbacks.process(event, @task) end + # @param event [Symbol] callback event from {Callbacks::EVENTS} + # @yield nested lifecycle segment wrapped by `around_execution` callbacks + # @return [Object] the wrapped block's return value def run_around(event, &) callbacks = @task.class.callbacks return yield if callbacks.empty? @@ -220,6 +225,9 @@ def signal_errors! throw(Signal::TAG, Signal.failed(@task.errors.to_s, metadata: @task.metadata)) end + # @param name [Symbol] telemetry channel from {Telemetry::EVENTS} + # @param payload [Hash{Symbol => Object}] forwarded onto {Telemetry::Event#payload} + # @return [void] def emit_telemetry(name, payload = EMPTY_HASH) telemetry = @task.class.telemetry return unless telemetry.subscribed?(name) diff --git a/lib/cmdx/signal.rb b/lib/cmdx/signal.rb index abe456d00..ed0c087c9 100644 --- a/lib/cmdx/signal.rb +++ b/lib/cmdx/signal.rb @@ -31,6 +31,9 @@ class << self # # @param reason [String, nil] optional human-readable reason # @param options [Hash{Symbol => Object}] optional `:metadata`, `:cause`, `:backtrace` + # @option options [Hash{Symbol => Object}] :metadata merged onto the task metadata payload + # @option options [Exception] :cause upstream exception when mirroring failures + # @option options [Array<Thread::Backtrace::Location>] :backtrace captured frames # @return [Signal] new instance with frozen options def success(reason = nil, **options) new(COMPLETE, SUCCESS, **options, reason:) @@ -40,6 +43,9 @@ def success(reason = nil, **options) # # @param reason [String, nil] optional human-readable reason # @param options [Hash{Symbol => Object}] optional `:metadata`, `:cause`, `:backtrace` + # @option options [Hash{Symbol => Object}] :metadata merged onto the task metadata payload + # @option options [Exception] :cause upstream exception when mirroring failures + # @option options [Array<Thread::Backtrace::Location>] :backtrace captured frames # @return [Signal] new instance with frozen options def skipped(reason = nil, **options) new(INTERRUPTED, SKIPPED, **options, reason:) @@ -49,6 +55,9 @@ def skipped(reason = nil, **options) # # @param reason [String, nil] optional human-readable reason # @param options [Hash{Symbol => Object}] optional `:metadata`, `:cause`, `:backtrace` + # @option options [Hash{Symbol => Object}] :metadata merged onto the task metadata payload + # @option options [Exception] :cause upstream exception when mirroring failures + # @option options [Array<Thread::Backtrace::Location>] :backtrace captured frames # @return [Signal] new instance with frozen options def failed(reason = nil, **options) new(INTERRUPTED, FAILED, **options, reason:) @@ -60,6 +69,10 @@ def failed(reason = nil, **options) # @param other [Signal, Result] source to mirror state/status/reason from # @param options [Hash{Symbol => Object}] overrides: `:metadata`, `:cause`, # `:backtrace`, `:origin` + # @option options [Hash{Symbol => Object}] :metadata merged onto the task metadata payload + # @option options [Exception] :cause upstream exception when mirroring failures + # @option options [Array<Thread::Backtrace::Location>] :backtrace captured frames + # @option options [Result] :origin peer result this signal echoes (set automatically for Results) # @return [Signal] new instance mirroring `other` # @raise [ArgumentError] when `other` is neither a Signal nor a Result def echoed(other, **options) diff --git a/lib/cmdx/task.rb b/lib/cmdx/task.rb index 6fda2afcf..897609e68 100644 --- a/lib/cmdx/task.rb +++ b/lib/cmdx/task.rb @@ -22,6 +22,12 @@ class << self # # @param exceptions [Array<Class>] # @param options [Hash{Symbol => Object}] see {Retry#initialize} + # @option options [Integer] :limit (see {Retry#initialize}) + # @option options [Float] :delay (see {Retry#initialize}) + # @option options [Float] :max_delay (see {Retry#initialize}) + # @option options [Symbol, Proc, #call] :jitter (see {Retry#initialize}) + # @option options [Symbol, Proc, #call] :if gate `(task, error, attempt)` for retries + # @option options [Symbol, Proc, #call] :unless gate `(task, error, attempt)` for retries # @yield [attempt, delay] optional custom jitter block # @return [Retry] def retry_on(*exceptions, **options, &) @@ -40,6 +46,13 @@ def retry_on(*exceptions, **options, &) # Reads or extends this class's {Settings}. Inherits from the superclass. # # @param options [Hash{Symbol => Object}] merged onto the current settings + # @option options [Logger] :logger (see {Settings#initialize}) + # @option options [#call] :log_formatter (see {Settings#initialize}) + # @option options [Integer] :log_level (see {Settings#initialize}) + # @option options [#call] :backtrace_cleaner (see {Settings#initialize}) + # @option options [Array<Symbol>] :log_exclusions (see {Settings#initialize}) + # @option options [Array<Symbol, String>] :tags (see {Settings#initialize}) + # @option options [Boolean] :strict_context (see {Settings#initialize}) # @return [Settings] def settings(options = EMPTY_HASH) @settings ||= @@ -217,9 +230,12 @@ def deregister(type, ...) # the locally defined one, or the superclass's. # # @param value [:log, :warn, :error, Symbol, Proc, #call, nil] - # @param options [Hash{Symbol => Object}] `:if`/`:unless` conditions - # @yield optional block used as the deprecation callable + # @param block [#call, nil] optional block used as the deprecation callable + # @param options [Hash{Symbol => Object}] `:if`/`:unless` conditions (see {Deprecation#initialize}) + # @option options [Symbol, Proc, #call] :if (see {Deprecation#initialize}) + # @option options [Symbol, Proc, #call] :unless (see {Deprecation#initialize}) # @return [Deprecation, nil] + # @yield optional block used as the deprecation callable def deprecation(value = nil, **options, &block) if value || block @deprecation = Deprecation.new(value || block, options) @@ -235,6 +251,18 @@ def deprecation(value = nil, **options, &block) # # @param names [Array<Symbol>] # @param options [Hash{Symbol => Object}] see {Input#initialize} + # @option options [String] :description (also accepts `:desc`) + # @option options [Symbol] :as overrides the accessor name + # @option options [Boolean, String] :prefix prefix for the accessor name + # @option options [Boolean, String] :suffix suffix for the accessor name + # @option options [Symbol, Proc, #call] :source (`:context`) where to fetch from + # @option options [Object, Symbol, Proc, #call] :default + # @option options [Symbol, Proc, #call] :transform mutator applied after coercion + # @option options [Symbol, Proc, #call] :if + # @option options [Symbol, Proc, #call] :unless + # @option options [Boolean] :required + # @option options [Object] :coerce (see {Coercions#extract}) + # @option options [Object] :validate (see {Validators#extract}) # @yield nested-input DSL block (see {Inputs::ChildBuilder}) # @return [Inputs] def inputs(*names, **options, &) @@ -252,11 +280,41 @@ def inputs(*names, **options, &) alias input inputs # Declares optional inputs (shorthand for `inputs ..., required: false`). + # + # @param names [Array<Symbol>] + # @param options [Hash{Symbol => Object}] see {Input#initialize} + # @option options [String] :description (also accepts `:desc`) + # @option options [Symbol] :as overrides the accessor name + # @option options [Boolean, String] :prefix prefix for the accessor name + # @option options [Boolean, String] :suffix suffix for the accessor name + # @option options [Symbol, Proc, #call] :source (`:context`) where to fetch from + # @option options [Object, Symbol, Proc, #call] :default + # @option options [Symbol, Proc, #call] :transform mutator applied after coercion + # @option options [Symbol, Proc, #call] :if + # @option options [Symbol, Proc, #call] :unless + # @option options [Object] :coerce (see {Coercions#extract}) + # @option options [Object] :validate (see {Validators#extract}) + # @yield nested-input DSL block (see {Inputs::ChildBuilder}) def optional(*names, **options, &) register(:input, *names, required: false, **options, &) end # Declares required inputs (shorthand for `inputs ..., required: true`). + # + # @param names [Array<Symbol>] + # @param options [Hash{Symbol => Object}] see {Input#initialize} + # @option options [String] :description (also accepts `:desc`) + # @option options [Symbol] :as overrides the accessor name + # @option options [Boolean, String] :prefix prefix for the accessor name + # @option options [Boolean, String] :suffix suffix for the accessor name + # @option options [Symbol, Proc, #call] :source (`:context`) where to fetch from + # @option options [Object, Symbol, Proc, #call] :default + # @option options [Symbol, Proc, #call] :transform mutator applied after coercion + # @option options [Symbol, Proc, #call] :if + # @option options [Symbol, Proc, #call] :unless + # @option options [Object] :coerce (see {Coercions#extract}) + # @option options [Object] :validate (see {Validators#extract}) + # @yield nested-input DSL block (see {Inputs::ChildBuilder}) def required(*names, **options, &) register(:input, *names, required: true, **options, &) end @@ -270,6 +328,10 @@ def inputs_schema # # @param keys [Array<Symbol>] # @param options [Hash{Symbol => Object}] see {Output#initialize} + # @option options [String] :description (also accepts `:desc`) + # @option options [Symbol, Proc, #call] :if + # @option options [Symbol, Proc, #call] :unless + # @option options [Object, Symbol, Proc, #call] :default # @return [Outputs] def outputs(*keys, **options) @outputs ||= @@ -320,6 +382,9 @@ def execute!(context = EMPTY_HASH, &) private + # @param input [Input] defines `##{input.accessor_name}` when not already taken + # @return [void] + # @raise [DefinitionError] when the accessor name collides def define_input_reader(input) accessor = input.accessor_name @@ -332,6 +397,8 @@ def define_input_reader(input) input.children.each { |child| define_input_reader(child) } end + # @param input [Input] removes `##{input.accessor_name}` if defined on this class + # @return [void] def undefine_input_reader(input) accessor = input.accessor_name undef_method(accessor) if method_defined?(accessor) @@ -390,7 +457,8 @@ def work # Signals a successful halt. # # @param reason [String, nil] - # @param sigdata [Hash{Symbol => Object}] + # @param sigdata [Hash{Symbol => Object}] arbitrary metadata merged into {#metadata} before throwing + # @option sigdata [Object] arbitrary entries merged via `metadata.merge!` # @return [void] throws `Signal::TAG`; never returns # @raise [FrozenError] when the task has already been frozen (post-execution) # @note Must be called from inside `work` (inside Runtime's `catch(:cmdx_signal)`). @@ -404,7 +472,8 @@ def success!(reason = nil, **sigdata) # Signals a skip (interrupted + skipped). # # @param reason [String, nil] - # @param sigdata [Hash{Symbol => Object}] + # @param sigdata [Hash{Symbol => Object}] arbitrary metadata merged into {#metadata} before throwing + # @option sigdata [Object] arbitrary entries merged via `metadata.merge!` # @return [void] throws `Signal::TAG`; never returns # @raise [FrozenError] def skip!(reason = nil, **sigdata) @@ -418,7 +487,8 @@ def skip!(reason = nil, **sigdata) # backtrace for Fault propagation. # # @param reason [String, nil] - # @param sigdata [Hash{Symbol => Object}] + # @param sigdata [Hash{Symbol => Object}] arbitrary metadata merged into {#metadata} before throwing + # @option sigdata [Object] arbitrary entries merged via `metadata.merge!` # @return [void] throws `Signal::TAG`; never returns # @raise [FrozenError] def fail!(reason = nil, **sigdata) @@ -432,7 +502,8 @@ def fail!(reason = nil, **sigdata) # `other` didn't fail. # # @param other [Result] - # @param sigdata [Hash{Symbol => Object}] + # @param sigdata [Hash{Symbol => Object}] arbitrary metadata merged into {#metadata} before echoing + # @option sigdata [Object] arbitrary entries merged via `metadata.merge!` # @return [void] # @raise [FrozenError] def throw!(other, **sigdata) diff --git a/lib/cmdx/telemetry.rb b/lib/cmdx/telemetry.rb index 541e2555b..a3a2d5212 100644 --- a/lib/cmdx/telemetry.rb +++ b/lib/cmdx/telemetry.rb @@ -24,6 +24,8 @@ def initialize @registry = {} end + # @param source [Telemetry] registry to duplicate + # @return [void] def initialize_copy(source) @registry = source.registry.transform_values(&:dup) end @@ -32,7 +34,8 @@ def initialize_copy(source) # # @param event [Symbol] one of {EVENTS} # @param callable [#call, nil] subscriber callable; pass either this or a block - # @yieldparam event [Event] + # @param block [#call, nil] subscriber when `callable` is omitted + # @yieldparam evt [Event] # @return [Telemetry] self for chaining # @raise [ArgumentError] when both `callable` and a block are provided, when # the subscriber isn't callable, or when `event` is unknown diff --git a/lib/cmdx/util.rb b/lib/cmdx/util.rb index 1238aba7c..5e048a2d8 100644 --- a/lib/cmdx/util.rb +++ b/lib/cmdx/util.rb @@ -10,7 +10,7 @@ module Util # Evaluates a condition against `receiver`, dispatching by type. # - # @param condition [Boolean, nil, Symbol, Proc, #call] condition to evaluate + # @param condition [Boolean, nil, Symbol, Proc, #call] `:if`/`:unless`-style gate, method name, or callable evaluated against `receiver` # @param receiver [Object] object the condition runs against (usually a Task) # @param args [Array<Object>] extra arguments forwarded to the condition # @return [Boolean, Object] truthiness result (Procs `instance_exec` on receiver) diff --git a/lib/cmdx/validators.rb b/lib/cmdx/validators.rb index 20fd8efaa..0daa092db 100644 --- a/lib/cmdx/validators.rb +++ b/lib/cmdx/validators.rb @@ -26,6 +26,8 @@ def initialize } end + # @param source [Validators] registry to duplicate + # @return [void] def initialize_copy(source) @registry = source.registry.dup end @@ -34,6 +36,7 @@ def initialize_copy(source) # # @param name [Symbol] # @param callable [#call, nil] pass either this or a block + # @param block [#call, nil] validator callable when `callable` is omitted # @yield validator body — `call(value, options = {})` # @return [Validators] self for chaining # @raise [ArgumentError] when both `callable` and a block are given, or @@ -71,6 +74,14 @@ def lookup(name) # appends `:validate` (inline callable(s)) when present. # # @param options [Hash{Symbol => Object}] declaration options + # @option options [Object] :presence payload for the presence validator (`call`) + # @option options [Object] :absence payload for the absence validator (`call`) + # @option options [Object] :format payload for the format validator (`call`) + # @option options [Object] :inclusion payload for the inclusion validator (`call`) + # @option options [Object] :exclusion payload for the exclusion validator (`call`) + # @option options [Object] :length payload for the length validator (`call`) + # @option options [Object] :numeric payload for the numeric validator (`call`) + # @option options [Object, Array<Object>] :validate inline callable(s) (`Validators::Validate`) # @return [Hash{Symbol => Object}] validator rules to run def extract(options) return EMPTY_HASH if options.empty? @@ -126,6 +137,9 @@ def validate(task, name, value, rules) private + # @param raw_options [Object] truthy flag, Hash, Array, Regexp, etc. from a declaration + # @return [Hash{Symbol => Object}, nil] normalized rule options, or nil when disabled + # @raise [ArgumentError] when `raw_options` has an unsupported shape def normalize_options(raw_options) case raw_options when FalseClass, NilClass diff --git a/lib/cmdx/validators/exclusion.rb b/lib/cmdx/validators/exclusion.rb index 085c9e14a..e64d88b6f 100644 --- a/lib/cmdx/validators/exclusion.rb +++ b/lib/cmdx/validators/exclusion.rb @@ -30,6 +30,11 @@ def call(value, options = EMPTY_HASH) private + # @param values [Enumerable] collection rendered into the failure message + # @param options [Hash{Symbol => Object}] + # @option options [String] :of_message + # @option options [String] :message + # @return [Validators::Failure] def of_failure(values, options) values = values.map(&:inspect).join(", ") message = options[:of_message] || options[:message] @@ -38,6 +43,13 @@ def of_failure(values, options) Failure.new(message || I18nProxy.t("cmdx.validators.exclusion.of", values:)) end + # @param min [Object] range/exclusion lower bound + # @param max [Object] range/exclusion upper bound + # @param options [Hash{Symbol => Object}] + # @option options [String] :in_message + # @option options [String] :within_message + # @option options [String] :message + # @return [Validators::Failure] def within_failure(min, max, options) message = options[:in_message] || options[:within_message] || options[:message] message %= { min:, max: } unless message.nil? diff --git a/lib/cmdx/validators/inclusion.rb b/lib/cmdx/validators/inclusion.rb index b70376e0a..dcbd29468 100644 --- a/lib/cmdx/validators/inclusion.rb +++ b/lib/cmdx/validators/inclusion.rb @@ -30,6 +30,11 @@ def call(value, options = EMPTY_HASH) private + # @param values [Enumerable] collection rendered into the failure message + # @param options [Hash{Symbol => Object}] + # @option options [String] :of_message + # @option options [String] :message + # @return [Validators::Failure] def of_failure(values, options) values = values.map(&:inspect).join(", ") message = options[:of_message] || options[:message] @@ -38,6 +43,13 @@ def of_failure(values, options) Failure.new(message || I18nProxy.t("cmdx.validators.inclusion.of", values:)) end + # @param min [Object] range/inclusion lower bound + # @param max [Object] range/inclusion upper bound + # @param options [Hash{Symbol => Object}] + # @option options [String] :in_message + # @option options [String] :within_message + # @option options [String] :message + # @return [Validators::Failure] def within_failure(min, max, options) message = options[:in_message] || options[:within_message] || options[:message] message %= { min:, max: } unless message.nil? diff --git a/lib/cmdx/validators/length.rb b/lib/cmdx/validators/length.rb index 4c1413c27..aa39ee2a7 100644 --- a/lib/cmdx/validators/length.rb +++ b/lib/cmdx/validators/length.rb @@ -69,11 +69,22 @@ def call(value, options = EMPTY_HASH) private + # @param options [Hash{Symbol => Object}] + # @option options [String] :nil_message + # @option options [String] :message + # @return [Validators::Failure] def nil_failure(options) message = options[:nil_message] || options[:message] Failure.new(message || I18nProxy.t("cmdx.validators.length.nil_value")) end + # @param min [Object] + # @param max [Object] + # @param options [Hash{Symbol => Object}] + # @option options [String] :within_message + # @option options [String] :in_message + # @option options [String] :message + # @return [Validators::Failure] def within_failure(min, max, options) message = options[:within_message] || options[:in_message] || options[:message] message %= { min:, max: } unless message.nil? @@ -81,6 +92,13 @@ def within_failure(min, max, options) Failure.new(message || I18nProxy.t("cmdx.validators.length.within", min:, max:)) end + # @param min [Object] + # @param max [Object] + # @param options [Hash{Symbol => Object}] + # @option options [String] :not_within_message + # @option options [String] :not_in_message + # @option options [String] :message + # @return [Validators::Failure] def not_within_failure(min, max, options) message = options[:not_within_message] || options[:not_in_message] || options[:message] message %= { min:, max: } unless message.nil? @@ -88,6 +106,11 @@ def not_within_failure(min, max, options) Failure.new(message || I18nProxy.t("cmdx.validators.length.not_within", min:, max:)) end + # @param min [Object] + # @param options [Hash{Symbol => Object}] + # @option options [String] :min_message + # @option options [String] :message + # @return [Validators::Failure] def min_failure(min, options) message = options[:min_message] || options[:message] message %= { min: } unless message.nil? @@ -95,6 +118,11 @@ def min_failure(min, options) Failure.new(message || I18nProxy.t("cmdx.validators.length.min", min:)) end + # @param max [Object] + # @param options [Hash{Symbol => Object}] + # @option options [String] :max_message + # @option options [String] :message + # @return [Validators::Failure] def max_failure(max, options) message = options[:max_message] || options[:message] message %= { max: } unless message.nil? @@ -102,6 +130,11 @@ def max_failure(max, options) Failure.new(message || I18nProxy.t("cmdx.validators.length.max", max:)) end + # @param gt [Object] + # @param options [Hash{Symbol => Object}] + # @option options [String] :gt_message + # @option options [String] :message + # @return [Validators::Failure] def gt_failure(gt, options) message = options[:gt_message] || options[:message] message %= { gt: } unless message.nil? @@ -109,6 +142,11 @@ def gt_failure(gt, options) Failure.new(message || I18nProxy.t("cmdx.validators.length.gt", gt:)) end + # @param lt [Object] + # @param options [Hash{Symbol => Object}] + # @option options [String] :lt_message + # @option options [String] :message + # @return [Validators::Failure] def lt_failure(lt, options) message = options[:lt_message] || options[:message] message %= { lt: } unless message.nil? @@ -116,6 +154,11 @@ def lt_failure(lt, options) Failure.new(message || I18nProxy.t("cmdx.validators.length.lt", lt:)) end + # @param is [Object] + # @param options [Hash{Symbol => Object}] + # @option options [String] :is_message + # @option options [String] :message + # @return [Validators::Failure] def is_failure(is, options) # rubocop:disable Naming/PredicatePrefix message = options[:is_message] || options[:message] message %= { is: } unless message.nil? @@ -123,6 +166,11 @@ def is_failure(is, options) # rubocop:disable Naming/PredicatePrefix Failure.new(message || I18nProxy.t("cmdx.validators.length.is", is:)) end + # @param is_not [Object] + # @param options [Hash{Symbol => Object}] + # @option options [String] :is_not_message + # @option options [String] :message + # @return [Validators::Failure] def is_not_failure(is_not, options) # rubocop:disable Naming/PredicatePrefix message = options[:is_not_message] || options[:message] message %= { is_not: } unless message.nil? diff --git a/lib/cmdx/validators/numeric.rb b/lib/cmdx/validators/numeric.rb index ec1757d41..bd1a398b0 100644 --- a/lib/cmdx/validators/numeric.rb +++ b/lib/cmdx/validators/numeric.rb @@ -66,11 +66,22 @@ def call(value, options = EMPTY_HASH) private + # @param options [Hash{Symbol => Object}] + # @option options [String] :nil_message + # @option options [String] :message + # @return [Validators::Failure] def nil_failure(options) message = options[:nil_message] || options[:message] Failure.new(message || I18nProxy.t("cmdx.validators.numeric.nil_value")) end + # @param min [Object] + # @param max [Object] + # @param options [Hash{Symbol => Object}] + # @option options [String] :within_message + # @option options [String] :in_message + # @option options [String] :message + # @return [Validators::Failure] def within_failure(min, max, options) message = options[:within_message] || options[:in_message] || options[:message] message %= { min:, max: } unless message.nil? @@ -78,6 +89,13 @@ def within_failure(min, max, options) Failure.new(message || I18nProxy.t("cmdx.validators.numeric.within", min:, max:)) end + # @param min [Object] + # @param max [Object] + # @param options [Hash{Symbol => Object}] + # @option options [String] :not_within_message + # @option options [String] :not_in_message + # @option options [String] :message + # @return [Validators::Failure] def not_within_failure(min, max, options) message = options[:not_within_message] || options[:not_in_message] || options[:message] message %= { min:, max: } unless message.nil? @@ -85,6 +103,11 @@ def not_within_failure(min, max, options) Failure.new(message || I18nProxy.t("cmdx.validators.numeric.not_within", min:, max:)) end + # @param min [Object] + # @param options [Hash{Symbol => Object}] + # @option options [String] :min_message + # @option options [String] :message + # @return [Validators::Failure] def min_failure(min, options) message = options[:min_message] || options[:message] message %= { min: } unless message.nil? @@ -92,6 +115,11 @@ def min_failure(min, options) Failure.new(message || I18nProxy.t("cmdx.validators.numeric.min", min:)) end + # @param max [Object] + # @param options [Hash{Symbol => Object}] + # @option options [String] :max_message + # @option options [String] :message + # @return [Validators::Failure] def max_failure(max, options) message = options[:max_message] || options[:message] message %= { max: } unless message.nil? @@ -99,6 +127,11 @@ def max_failure(max, options) Failure.new(message || I18nProxy.t("cmdx.validators.numeric.max", max:)) end + # @param gt [Object] + # @param options [Hash{Symbol => Object}] + # @option options [String] :gt_message + # @option options [String] :message + # @return [Validators::Failure] def gt_failure(gt, options) message = options[:gt_message] || options[:message] message %= { gt: } unless message.nil? @@ -106,6 +139,11 @@ def gt_failure(gt, options) Failure.new(message || I18nProxy.t("cmdx.validators.numeric.gt", gt:)) end + # @param lt [Object] + # @param options [Hash{Symbol => Object}] + # @option options [String] :lt_message + # @option options [String] :message + # @return [Validators::Failure] def lt_failure(lt, options) message = options[:lt_message] || options[:message] message %= { lt: } unless message.nil? @@ -113,6 +151,11 @@ def lt_failure(lt, options) Failure.new(message || I18nProxy.t("cmdx.validators.numeric.lt", lt:)) end + # @param is [Object] + # @param options [Hash{Symbol => Object}] + # @option options [String] :is_message + # @option options [String] :message + # @return [Validators::Failure] def is_failure(is, options) # rubocop:disable Naming/PredicatePrefix message = options[:is_message] || options[:message] message %= { is: } unless message.nil? @@ -120,6 +163,11 @@ def is_failure(is, options) # rubocop:disable Naming/PredicatePrefix Failure.new(message || I18nProxy.t("cmdx.validators.numeric.is", is:)) end + # @param is_not [Object] + # @param options [Hash{Symbol => Object}] + # @option options [String] :is_not_message + # @option options [String] :message + # @return [Validators::Failure] def is_not_failure(is_not, options) # rubocop:disable Naming/PredicatePrefix message = options[:is_not_message] || options[:message] message %= { is_not: } unless message.nil? diff --git a/lib/cmdx/workflow.rb b/lib/cmdx/workflow.rb index f4df36074..86f2ff38f 100644 --- a/lib/cmdx/workflow.rb +++ b/lib/cmdx/workflow.rb @@ -12,6 +12,8 @@ module Workflow module ClassMethods # @api private + # @param subclass [Class] newly defined workflow subclass + # @return [void] def inherited(subclass) super subclass.instance_variable_set(:@pipeline, pipeline.dup) @@ -76,6 +78,8 @@ def tasks(*tasks, **options) # Forbids user-defined `work` on workflows; `Workflow#work` delegates # to {Pipeline}. # + # @param method_name [Symbol] hook name reported by Ruby + # @return [void] # @raise [ImplementationError] when a workflow defines `work` def method_added(method_name) return super unless method_name == :work @@ -89,6 +93,8 @@ def method_added(method_name) ExecutionGroup = Data.define(:tasks, :options) # @api private + # @param base [Class] task class including this mixin + # @return [void] def self.included(base) base.extend(ClassMethods) end diff --git a/spec/cmdx/i18n_proxy_spec.rb b/spec/cmdx/i18n_proxy_spec.rb index 4decef662..2015a27eb 100644 --- a/spec/cmdx/i18n_proxy_spec.rb +++ b/spec/cmdx/i18n_proxy_spec.rb @@ -36,8 +36,9 @@ def self.translate(key, **opts) = [key, opts] end end - it "is aliased as #t" do - expect(proxy.method(:t)).to eq(proxy.method(:translate)) + it "delegates to #translate with the same arguments" do + allow(proxy).to receive(:translation_default).and_return(nil) + expect(proxy.t("nope.nothing")).to eq(proxy.translate("nope.nothing")) end end @@ -48,8 +49,8 @@ def self.translate(key, **opts) = [key, opts] expect(described_class.translate("cmdx.faults.unspecified")).to be_a(String) end - it "is aliased as .t" do - expect(described_class.method(:t)).to eq(described_class.method(:translate)) + it "delegates to .translate with the same arguments" do + expect(described_class.t("cmdx.faults.unspecified")).to eq(described_class.translate("cmdx.faults.unspecified")) end end From fba371757ff34d37d0ec3f55fbfcd667bb8b0ab6 Mon Sep 17 00:00:00 2001 From: Juan Gomez <drexed@users.noreply.github.com> Date: Tue, 5 May 2026 14:22:02 -0400 Subject: [PATCH 53/54] Update ci.yml --- .github/workflows/ci.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f24c40a7d..d741dfd3b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,6 +16,9 @@ jobs: steps: - name: Checkout code uses: actions/checkout@v6 + with: + # Required so `origin/${{ github.base_ref }}` exists for `yard-lint --diff` + fetch-depth: 0 - name: Set up Ruby ${{ matrix.ruby-version }} uses: ruby/setup-ruby@v1 with: From 2988fb0997ce582428d0631536e799a11dac6daa Mon Sep 17 00:00:00 2001 From: Juan Gomez <drexed@users.noreply.github.com> Date: Tue, 5 May 2026 14:37:43 -0400 Subject: [PATCH 54/54] Update key_value_spec.rb --- spec/cmdx/log_formatters/key_value_spec.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/spec/cmdx/log_formatters/key_value_spec.rb b/spec/cmdx/log_formatters/key_value_spec.rb index dc1c086f2..8593cf808 100644 --- a/spec/cmdx/log_formatters/key_value_spec.rb +++ b/spec/cmdx/log_formatters/key_value_spec.rb @@ -17,7 +17,8 @@ it "uses to_h for message objects" do message = Struct.new(:name).new("alice") + line = formatter.call("INFO", time, nil, message) - expect(formatter.call("INFO", time, nil, message)).to include('message={name: "alice"}') + expect(line).to include("message=#{message.to_h.inspect}") end end