cmdx 2.0.1 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +53 -8
  3. data/lib/cmdx/callbacks.rb +31 -11
  4. data/lib/cmdx/chain.rb +29 -10
  5. data/lib/cmdx/coercions/big_decimal.rb +1 -1
  6. data/lib/cmdx/coercions/boolean.rb +3 -9
  7. data/lib/cmdx/coercions/coerce.rb +4 -1
  8. data/lib/cmdx/coercions/date_time.rb +1 -1
  9. data/lib/cmdx/coercions/integer.rb +11 -2
  10. data/lib/cmdx/coercions/symbol.rb +23 -4
  11. data/lib/cmdx/coercions.rb +25 -10
  12. data/lib/cmdx/configuration.rb +31 -16
  13. data/lib/cmdx/context.rb +36 -52
  14. data/lib/cmdx/deprecation.rb +4 -7
  15. data/lib/cmdx/deprecators/error.rb +4 -1
  16. data/lib/cmdx/deprecators.rb +17 -8
  17. data/lib/cmdx/errors.rb +11 -10
  18. data/lib/cmdx/executors/fiber.rb +16 -4
  19. data/lib/cmdx/executors/thread.rb +18 -4
  20. data/lib/cmdx/executors.rb +22 -7
  21. data/lib/cmdx/fault.rb +15 -3
  22. data/lib/cmdx/i18n_proxy.rb +9 -5
  23. data/lib/cmdx/input.rb +23 -21
  24. data/lib/cmdx/inputs.rb +14 -26
  25. data/lib/cmdx/log_formatters/json.rb +8 -1
  26. data/lib/cmdx/log_formatters/logstash.rb +7 -1
  27. data/lib/cmdx/mergers.rb +22 -7
  28. data/lib/cmdx/middlewares.rb +40 -24
  29. data/lib/cmdx/output.rb +5 -2
  30. data/lib/cmdx/pipeline.rb +18 -3
  31. data/lib/cmdx/railtie.rb +1 -0
  32. data/lib/cmdx/result.rb +22 -6
  33. data/lib/cmdx/retriers/decorrelated_jitter.rb +10 -5
  34. data/lib/cmdx/retriers/exponential.rb +10 -2
  35. data/lib/cmdx/retriers/fibonacci.rb +29 -12
  36. data/lib/cmdx/retriers.rb +17 -8
  37. data/lib/cmdx/retry.rb +20 -13
  38. data/lib/cmdx/runtime.rb +18 -17
  39. data/lib/cmdx/settings.rb +9 -9
  40. data/lib/cmdx/signal.rb +1 -1
  41. data/lib/cmdx/task.rb +90 -45
  42. data/lib/cmdx/telemetry.rb +37 -10
  43. data/lib/cmdx/util.rb +50 -4
  44. data/lib/cmdx/validators/absence.rb +1 -1
  45. data/lib/cmdx/validators/exclusion.rb +15 -15
  46. data/lib/cmdx/validators/format.rb +12 -4
  47. data/lib/cmdx/validators/inclusion.rb +15 -15
  48. data/lib/cmdx/validators/length.rb +5 -49
  49. data/lib/cmdx/validators/numeric.rb +5 -49
  50. data/lib/cmdx/validators/presence.rb +1 -1
  51. data/lib/cmdx/validators/validate.rb +7 -1
  52. data/lib/cmdx/validators.rb +21 -9
  53. data/lib/cmdx/version.rb +1 -1
  54. data/lib/cmdx/workflow.rb +28 -14
  55. data/lib/cmdx.rb +24 -0
  56. data/lib/generators/cmdx/templates/install.rb +80 -39
  57. data/mkdocs.yml +1 -0
  58. metadata +1 -1
@@ -15,17 +15,25 @@ module CMDx
15
15
  # @option options [String] :message override for the failure message
16
16
  # @return [Validators::Failure, nil]
17
17
  # @raise [ArgumentError] when neither `:with` nor `:without` is given
18
+ # @note Non-String values that do not respond to `#match?` fail with the
19
+ # regular format failure rather than raise `NoMethodError`. Coerce inputs
20
+ # to String beforehand when format checks are required.
18
21
  def call(value, options = EMPTY_HASH)
22
+ str = value.nil? || value.respond_to?(:match?) ? value : value.to_s
23
+
19
24
  match =
20
25
  case options
21
26
  in with:, without:
22
- value&.match?(with) && !value&.match?(without)
27
+ str&.match?(with) && !str&.match?(without)
23
28
  in with:
24
- value&.match?(with)
29
+ str&.match?(with)
25
30
  in without:
26
- !value&.match?(without)
31
+ !str&.match?(without)
27
32
  else
28
- raise ArgumentError, "format validator requires :with and/or :without option"
33
+ raise ArgumentError, <<~MSG.chomp
34
+ format validator requires :with and/or :without (got #{options.keys.inspect}).
35
+ See https://drexed.github.io/cmdx/inputs/validations/#format
36
+ MSG
29
37
  end
30
38
 
31
39
  return if match
@@ -19,22 +19,29 @@ module CMDx
19
19
  # @raise [ArgumentError] when neither `:in` nor `:within` is given
20
20
  def call(value, options = EMPTY_HASH)
21
21
  values = options[:in] || options[:within]
22
- raise ArgumentError, "inclusion validator requires :in or :within option" if values.nil?
22
+ if values.nil?
23
+ raise ArgumentError, <<~MSG.chomp
24
+ inclusion validator requires :in or :within (got #{options.keys.inspect}).
25
+ See https://drexed.github.io/cmdx/inputs/validations/#inclusion
26
+ MSG
27
+ elsif values.is_a?(Hash)
28
+ raise ArgumentError, <<~MSG.chomp
29
+ inclusion validator :in/:within does not accept a Hash; pass an Array,
30
+ Set, Range, or other Enumerable (e.g. `#{values.inspect}.keys`).
31
+ See https://drexed.github.io/cmdx/inputs/validations/#inclusion
32
+ MSG
33
+ end
23
34
 
24
35
  if values.is_a?(Range)
25
36
  within_failure(values.begin, values.end, options) unless values.cover?(value)
26
- elsif Array(values).none? { |v| v === value }
27
- of_failure(values, options)
37
+ else
38
+ enum = values.is_a?(Enumerable) ? values : [values]
39
+ of_failure(enum, options) if enum.none? { |v| v === value }
28
40
  end
29
41
  end
30
42
 
31
43
  private
32
44
 
33
- # @param values [Enumerable] collection rendered into the failure message
34
- # @param options [Hash{Symbol => Object}]
35
- # @option options [String] :of_message
36
- # @option options [String] :message
37
- # @return [Validators::Failure]
38
45
  def of_failure(values, options)
39
46
  values = values.map(&:inspect).join(", ")
40
47
  message = options[:of_message] || options[:message]
@@ -43,13 +50,6 @@ module CMDx
43
50
  Failure.new(message || I18nProxy.t("cmdx.validators.inclusion.of", values:))
44
51
  end
45
52
 
46
- # @param min [Object] range/inclusion lower bound
47
- # @param max [Object] range/inclusion upper bound
48
- # @param options [Hash{Symbol => Object}]
49
- # @option options [String] :in_message
50
- # @option options [String] :within_message
51
- # @option options [String] :message
52
- # @return [Validators::Failure]
53
53
  def within_failure(min, max, options)
54
54
  message = options[:in_message] || options[:within_message] || options[:message]
55
55
  message %= { min:, max: } unless message.nil?
@@ -63,28 +63,21 @@ module CMDx
63
63
  in is_not:
64
64
  is_not_failure(is_not, options) if length == is_not
65
65
  else
66
- raise ArgumentError, "unknown length validator options given"
66
+ raise ArgumentError, <<~MSG.chomp
67
+ unknown length validator options #{options.keys.inspect};
68
+ expected one of [:within, :not_within, :in, :not_in, :min, :max, :gt, :lt, :is, :is_not] (aliases: :gte, :lte, :eq, :not_eq).
69
+ See https://drexed.github.io/cmdx/inputs/validations/#length
70
+ MSG
67
71
  end
68
72
  end
69
73
 
70
74
  private
71
75
 
72
- # @param options [Hash{Symbol => Object}]
73
- # @option options [String] :nil_message
74
- # @option options [String] :message
75
- # @return [Validators::Failure]
76
76
  def nil_failure(options)
77
77
  message = options[:nil_message] || options[:message]
78
78
  Failure.new(message || I18nProxy.t("cmdx.validators.length.nil_value"))
79
79
  end
80
80
 
81
- # @param min [Object]
82
- # @param max [Object]
83
- # @param options [Hash{Symbol => Object}]
84
- # @option options [String] :within_message
85
- # @option options [String] :in_message
86
- # @option options [String] :message
87
- # @return [Validators::Failure]
88
81
  def within_failure(min, max, options)
89
82
  message = options[:within_message] || options[:in_message] || options[:message]
90
83
  message %= { min:, max: } unless message.nil?
@@ -92,13 +85,6 @@ module CMDx
92
85
  Failure.new(message || I18nProxy.t("cmdx.validators.length.within", min:, max:))
93
86
  end
94
87
 
95
- # @param min [Object]
96
- # @param max [Object]
97
- # @param options [Hash{Symbol => Object}]
98
- # @option options [String] :not_within_message
99
- # @option options [String] :not_in_message
100
- # @option options [String] :message
101
- # @return [Validators::Failure]
102
88
  def not_within_failure(min, max, options)
103
89
  message = options[:not_within_message] || options[:not_in_message] || options[:message]
104
90
  message %= { min:, max: } unless message.nil?
@@ -106,11 +92,6 @@ module CMDx
106
92
  Failure.new(message || I18nProxy.t("cmdx.validators.length.not_within", min:, max:))
107
93
  end
108
94
 
109
- # @param min [Object]
110
- # @param options [Hash{Symbol => Object}]
111
- # @option options [String] :min_message
112
- # @option options [String] :message
113
- # @return [Validators::Failure]
114
95
  def min_failure(min, options)
115
96
  message = options[:min_message] || options[:message]
116
97
  message %= { min: } unless message.nil?
@@ -118,11 +99,6 @@ module CMDx
118
99
  Failure.new(message || I18nProxy.t("cmdx.validators.length.min", min:))
119
100
  end
120
101
 
121
- # @param max [Object]
122
- # @param options [Hash{Symbol => Object}]
123
- # @option options [String] :max_message
124
- # @option options [String] :message
125
- # @return [Validators::Failure]
126
102
  def max_failure(max, options)
127
103
  message = options[:max_message] || options[:message]
128
104
  message %= { max: } unless message.nil?
@@ -130,11 +106,6 @@ module CMDx
130
106
  Failure.new(message || I18nProxy.t("cmdx.validators.length.max", max:))
131
107
  end
132
108
 
133
- # @param gt [Object]
134
- # @param options [Hash{Symbol => Object}]
135
- # @option options [String] :gt_message
136
- # @option options [String] :message
137
- # @return [Validators::Failure]
138
109
  def gt_failure(gt, options)
139
110
  message = options[:gt_message] || options[:message]
140
111
  message %= { gt: } unless message.nil?
@@ -142,11 +113,6 @@ module CMDx
142
113
  Failure.new(message || I18nProxy.t("cmdx.validators.length.gt", gt:))
143
114
  end
144
115
 
145
- # @param lt [Object]
146
- # @param options [Hash{Symbol => Object}]
147
- # @option options [String] :lt_message
148
- # @option options [String] :message
149
- # @return [Validators::Failure]
150
116
  def lt_failure(lt, options)
151
117
  message = options[:lt_message] || options[:message]
152
118
  message %= { lt: } unless message.nil?
@@ -154,11 +120,6 @@ module CMDx
154
120
  Failure.new(message || I18nProxy.t("cmdx.validators.length.lt", lt:))
155
121
  end
156
122
 
157
- # @param is [Object]
158
- # @param options [Hash{Symbol => Object}]
159
- # @option options [String] :is_message
160
- # @option options [String] :message
161
- # @return [Validators::Failure]
162
123
  def is_failure(is, options) # rubocop:disable Naming/PredicatePrefix
163
124
  message = options[:is_message] || options[:message]
164
125
  message %= { is: } unless message.nil?
@@ -166,11 +127,6 @@ module CMDx
166
127
  Failure.new(message || I18nProxy.t("cmdx.validators.length.is", is:))
167
128
  end
168
129
 
169
- # @param is_not [Object]
170
- # @param options [Hash{Symbol => Object}]
171
- # @option options [String] :is_not_message
172
- # @option options [String] :message
173
- # @return [Validators::Failure]
174
130
  def is_not_failure(is_not, options) # rubocop:disable Naming/PredicatePrefix
175
131
  message = options[:is_not_message] || options[:message]
176
132
  message %= { is_not: } unless message.nil?
@@ -60,28 +60,21 @@ module CMDx
60
60
  in is_not:
61
61
  is_not_failure(is_not, options) if value == is_not
62
62
  else
63
- raise ArgumentError, "unknown numeric validator options given"
63
+ raise ArgumentError, <<~MSG.chomp
64
+ unknown numeric validator options #{options.keys.inspect};
65
+ expected one of [:within, :not_within, :in, :not_in, :min, :max, :gt, :lt, :is, :is_not] (aliases: :gte, :lte, :eq, :not_eq).
66
+ See https://drexed.github.io/cmdx/inputs/validations/#numeric
67
+ MSG
64
68
  end
65
69
  end
66
70
 
67
71
  private
68
72
 
69
- # @param options [Hash{Symbol => Object}]
70
- # @option options [String] :nil_message
71
- # @option options [String] :message
72
- # @return [Validators::Failure]
73
73
  def nil_failure(options)
74
74
  message = options[:nil_message] || options[:message]
75
75
  Failure.new(message || I18nProxy.t("cmdx.validators.numeric.nil_value"))
76
76
  end
77
77
 
78
- # @param min [Object]
79
- # @param max [Object]
80
- # @param options [Hash{Symbol => Object}]
81
- # @option options [String] :within_message
82
- # @option options [String] :in_message
83
- # @option options [String] :message
84
- # @return [Validators::Failure]
85
78
  def within_failure(min, max, options)
86
79
  message = options[:within_message] || options[:in_message] || options[:message]
87
80
  message %= { min:, max: } unless message.nil?
@@ -89,13 +82,6 @@ module CMDx
89
82
  Failure.new(message || I18nProxy.t("cmdx.validators.numeric.within", min:, max:))
90
83
  end
91
84
 
92
- # @param min [Object]
93
- # @param max [Object]
94
- # @param options [Hash{Symbol => Object}]
95
- # @option options [String] :not_within_message
96
- # @option options [String] :not_in_message
97
- # @option options [String] :message
98
- # @return [Validators::Failure]
99
85
  def not_within_failure(min, max, options)
100
86
  message = options[:not_within_message] || options[:not_in_message] || options[:message]
101
87
  message %= { min:, max: } unless message.nil?
@@ -103,11 +89,6 @@ module CMDx
103
89
  Failure.new(message || I18nProxy.t("cmdx.validators.numeric.not_within", min:, max:))
104
90
  end
105
91
 
106
- # @param min [Object]
107
- # @param options [Hash{Symbol => Object}]
108
- # @option options [String] :min_message
109
- # @option options [String] :message
110
- # @return [Validators::Failure]
111
92
  def min_failure(min, options)
112
93
  message = options[:min_message] || options[:message]
113
94
  message %= { min: } unless message.nil?
@@ -115,11 +96,6 @@ module CMDx
115
96
  Failure.new(message || I18nProxy.t("cmdx.validators.numeric.min", min:))
116
97
  end
117
98
 
118
- # @param max [Object]
119
- # @param options [Hash{Symbol => Object}]
120
- # @option options [String] :max_message
121
- # @option options [String] :message
122
- # @return [Validators::Failure]
123
99
  def max_failure(max, options)
124
100
  message = options[:max_message] || options[:message]
125
101
  message %= { max: } unless message.nil?
@@ -127,11 +103,6 @@ module CMDx
127
103
  Failure.new(message || I18nProxy.t("cmdx.validators.numeric.max", max:))
128
104
  end
129
105
 
130
- # @param gt [Object]
131
- # @param options [Hash{Symbol => Object}]
132
- # @option options [String] :gt_message
133
- # @option options [String] :message
134
- # @return [Validators::Failure]
135
106
  def gt_failure(gt, options)
136
107
  message = options[:gt_message] || options[:message]
137
108
  message %= { gt: } unless message.nil?
@@ -139,11 +110,6 @@ module CMDx
139
110
  Failure.new(message || I18nProxy.t("cmdx.validators.numeric.gt", gt:))
140
111
  end
141
112
 
142
- # @param lt [Object]
143
- # @param options [Hash{Symbol => Object}]
144
- # @option options [String] :lt_message
145
- # @option options [String] :message
146
- # @return [Validators::Failure]
147
113
  def lt_failure(lt, options)
148
114
  message = options[:lt_message] || options[:message]
149
115
  message %= { lt: } unless message.nil?
@@ -151,11 +117,6 @@ module CMDx
151
117
  Failure.new(message || I18nProxy.t("cmdx.validators.numeric.lt", lt:))
152
118
  end
153
119
 
154
- # @param is [Object]
155
- # @param options [Hash{Symbol => Object}]
156
- # @option options [String] :is_message
157
- # @option options [String] :message
158
- # @return [Validators::Failure]
159
120
  def is_failure(is, options) # rubocop:disable Naming/PredicatePrefix
160
121
  message = options[:is_message] || options[:message]
161
122
  message %= { is: } unless message.nil?
@@ -163,11 +124,6 @@ module CMDx
163
124
  Failure.new(message || I18nProxy.t("cmdx.validators.numeric.is", is:))
164
125
  end
165
126
 
166
- # @param is_not [Object]
167
- # @param options [Hash{Symbol => Object}]
168
- # @option options [String] :is_not_message
169
- # @option options [String] :message
170
- # @return [Validators::Failure]
171
127
  def is_not_failure(is_not, options) # rubocop:disable Naming/PredicatePrefix
172
128
  message = options[:is_not_message] || options[:message]
173
129
  message %= { is_not: } unless message.nil?
@@ -19,7 +19,7 @@ module CMDx
19
19
  elsif value.respond_to?(:empty?)
20
20
  !value.empty?
21
21
  else
22
- !value.nil?
22
+ !!value
23
23
  end
24
24
 
25
25
  return if present
@@ -13,6 +13,9 @@ module CMDx
13
13
  # @param handler [Symbol, Proc, #call]
14
14
  # @return [Validators::Failure, nil, Object] handler's return value
15
15
  # @raise [ArgumentError] when `handler` isn't a supported type
16
+ # @note Symbol handlers are dispatched via `send` so private helpers on
17
+ # the task are reachable. Handlers are baked into class definitions;
18
+ # never derive them from untrusted input.
16
19
  def call(task, value, handler)
17
20
  case handler
18
21
  when Symbol
@@ -22,7 +25,10 @@ module CMDx
22
25
  else
23
26
  return handler.call(value, task) if handler.respond_to?(:call)
24
27
 
25
- raise ArgumentError, "validate handler must be a Symbol, Proc, or respond to #call"
28
+ raise ArgumentError, <<~MSG.chomp
29
+ validate handler must be a Symbol, Proc, or respond to #call (got #{handler.class}).
30
+ See https://drexed.github.io/cmdx/inputs/validations/#inline-validate-callable
31
+ MSG
26
32
  end
27
33
  end
28
34
 
@@ -45,9 +45,12 @@ module CMDx
45
45
  validator = callable || block
46
46
 
47
47
  if callable && block
48
- raise ArgumentError, "provide either a callable or a block, not both"
48
+ raise ArgumentError, "validator: provide either a callable or a block, not both"
49
49
  elsif !validator.respond_to?(:call)
50
- raise ArgumentError, "validator must respond to #call"
50
+ raise ArgumentError, <<~MSG.chomp
51
+ validator must respond to #call (got #{validator.class}).
52
+ See https://drexed.github.io/cmdx/inputs/validations/#declarations
53
+ MSG
51
54
  end
52
55
 
53
56
  registry[name.to_sym] = validator
@@ -57,16 +60,25 @@ module CMDx
57
60
  # @param name [Symbol]
58
61
  # @return [Validators] self for chaining
59
62
  def deregister(name)
60
- registry.delete(name.to_sym)
63
+ registry.delete(name)
61
64
  self
62
65
  end
63
66
 
67
+ # @param name [Symbol]
68
+ # @return [Boolean] whether a validator is registered under `name`
69
+ def key?(name)
70
+ registry.key?(name)
71
+ end
72
+
64
73
  # @param name [Symbol]
65
74
  # @return [#call]
66
- # @raise [ArgumentError] when `name` isn't registered
75
+ # @raise [UnknownEntryError] when `name` isn't registered
67
76
  def lookup(name)
68
77
  registry[name] || begin
69
- raise ArgumentError, "unknown validator: #{name}"
78
+ raise UnknownEntryError, <<~MSG.chomp
79
+ unknown validator #{name.inspect}; registered: #{registry.keys.inspect}.
80
+ See https://drexed.github.io/cmdx/inputs/validations/#built-in-validators
81
+ MSG
70
82
  end
71
83
  end
72
84
 
@@ -137,9 +149,6 @@ module CMDx
137
149
 
138
150
  private
139
151
 
140
- # @param raw_options [Object] truthy flag, Hash, Array, Regexp, etc. from a declaration
141
- # @return [Hash{Symbol => Object}, nil] normalized rule options, or nil when disabled
142
- # @raise [ArgumentError] when `raw_options` has an unsupported shape
143
152
  def normalize_options(raw_options)
144
153
  case raw_options
145
154
  when FalseClass, NilClass
@@ -153,7 +162,10 @@ module CMDx
153
162
  when Regexp
154
163
  { with: raw_options }
155
164
  else
156
- raise ArgumentError, "unsupported validator option format: #{raw_options.inspect}"
165
+ raise ArgumentError, <<~MSG.chomp
166
+ unsupported validator option format #{raw_options.inspect}; expected Boolean, Hash, Array, or Regexp.
167
+ See https://drexed.github.io/cmdx/inputs/validations/#common-options
168
+ MSG
157
169
  end
158
170
  end
159
171
 
data/lib/cmdx/version.rb CHANGED
@@ -3,6 +3,6 @@
3
3
  module CMDx
4
4
 
5
5
  # Gem version. Bumped on release; mirrored in the gemspec.
6
- VERSION = "2.0.1"
6
+ VERSION = "2.1.0"
7
7
 
8
8
  end
data/lib/cmdx/workflow.rb CHANGED
@@ -24,10 +24,11 @@ module CMDx
24
24
  @pipeline ||= []
25
25
  end
26
26
 
27
- # Declares a task group. With no arguments, returns the pipeline.
28
- # Tasks must be `Task` subclasses.
27
+ # Declares a task group. At least one `Task` subclass is required —
28
+ # invoking with no arguments raises `DefinitionError`. Use {.pipeline}
29
+ # to read the existing group list.
29
30
  #
30
- # @param tasks [Array<Class<Task>>]
31
+ # @param tasks [Array<Class<Task>>] one or more `Task` subclasses
31
32
  # @param options [Hash{Symbol => Object}]
32
33
  # @option options [:sequential, :parallel] :strategy (:sequential)
33
34
  # @option options [Integer] :pool_size parallel worker/fiber count
@@ -55,18 +56,26 @@ module CMDx
55
56
  # `:parallel` cancels pending tasks (in-flight tasks still finish).
56
57
  # @option options [Symbol, Proc, #call] :if
57
58
  # @option options [Symbol, Proc, #call] :unless
58
- # @return [Array<ExecutionGroup>] the full pipeline
59
- # @raise [DefinitionError] when called with options but no tasks
59
+ # @return [Array<ExecutionGroup>] the full pipeline (with the new group appended)
60
+ # @raise [DefinitionError] when called with no tasks
60
61
  # @raise [TypeError] when any element isn't a `Task` subclass
61
62
  def tasks(*tasks, **options)
62
- raise DefinitionError, "#{name}: cannot declare an empty task group" if tasks.empty?
63
+ if tasks.empty?
64
+ raise DefinitionError, <<~MSG.chomp
65
+ #{name}: cannot declare an empty task group; pass at least one Task subclass.
66
+ See https://drexed.github.io/cmdx/workflows/#declarations
67
+ MSG
68
+ end
63
69
 
64
70
  pipeline << ExecutionGroup.new(
65
71
  tasks:
66
72
  tasks.map do |task|
67
73
  next task if task.is_a?(Class) && (task <= Task)
68
74
 
69
- raise TypeError, "#{task.inspect} is not a Task"
75
+ raise TypeError, <<~MSG.chomp
76
+ #{task.inspect} is not a Task subclass.
77
+ See https://drexed.github.io/cmdx/workflows/#declarations
78
+ MSG
70
79
  end,
71
80
  options:
72
81
  )
@@ -75,16 +84,13 @@ module CMDx
75
84
 
76
85
  private
77
86
 
78
- # Forbids user-defined `work` on workflows; `Workflow#work` delegates
79
- # to {Pipeline}.
80
- #
81
- # @param method_name [Symbol] hook name reported by Ruby
82
- # @return [void]
83
- # @raise [ImplementationError] when a workflow defines `work`
84
87
  def method_added(method_name)
85
88
  return super unless method_name == :work
86
89
 
87
- raise ImplementationError, "cannot define #{name}##{method_name} in a workflow"
90
+ raise ImplementationError, <<~MSG.chomp
91
+ cannot define #{name}##{method_name} in a workflow; #work is auto-generated to delegate to Pipeline.
92
+ See https://drexed.github.io/cmdx/workflows/#declarations
93
+ MSG
88
94
  end
89
95
 
90
96
  end
@@ -95,7 +101,15 @@ module CMDx
95
101
  # @api private
96
102
  # @param base [Class] task class including this mixin
97
103
  # @return [void]
104
+ # @raise [ImplementationError] when `base` is not a {Task} subclass
98
105
  def self.included(base)
106
+ unless base.is_a?(Class) && base <= Task
107
+ raise ImplementationError, <<~MSG.chomp
108
+ CMDx::Workflow can only be included in a CMDx::Task subclass (got #{base.inspect}).
109
+ See https://drexed.github.io/cmdx/workflows/#declarations
110
+ MSG
111
+ end
112
+
99
113
  base.extend(ClassMethods)
100
114
  end
101
115
 
data/lib/cmdx.rb CHANGED
@@ -24,6 +24,13 @@ module CMDx
24
24
  EMPTY_HASH = {}.freeze
25
25
  private_constant :EMPTY_HASH
26
26
 
27
+ # Sentinel object used as a placeholder return value to avoid per-call
28
+ # allocations on hot paths.
29
+ #
30
+ # @api private
31
+ EMPTY_SENTINEL = Object.new.freeze
32
+ private_constant :EMPTY_SENTINEL
33
+
27
34
  # Shared empty string constant used as a sentinel default. Intentionally
28
35
  # not frozen so callers may treat it as a mutable seed when needed.
29
36
  #
@@ -51,6 +58,11 @@ module CMDx
51
58
  # before continuing.
52
59
  DeprecationError = Class.new(Error)
53
60
 
61
+ # Raised when a control-flow signal (e.g. `skip!`, `fail!`) is thrown against
62
+ # a task that has already completed and been frozen, making further state
63
+ # transitions impossible.
64
+ FrozenTaskError = Class.new(Error)
65
+
54
66
  # Raised when a subclass fails to fulfill an abstract contract — most
55
67
  # commonly when {Task} is invoked without overriding `#work`, or when a
56
68
  # {Workflow} attempts to define `#work` itself.
@@ -60,6 +72,18 @@ module CMDx
60
72
  # yield to `next_link`, which would otherwise silently skip the task body.
61
73
  MiddlewareError = Class.new(Error)
62
74
 
75
+ # Raised by {Context} in strict mode when accessing a key that was never
76
+ # assigned, preventing silent `nil` propagation across task boundaries.
77
+ UnknownAccessorError = Class.new(Error)
78
+
79
+ # Raised when a registry lookup (coercion, validator, middleware, etc.) is
80
+ # performed against a name that has not been registered.
81
+ UnknownEntryError = Class.new(Error)
82
+
83
+ # Raised when the configured locale cannot be resolved to a translation
84
+ # file on the i18n load path.
85
+ UnknownLocaleError = Class.new(Error)
86
+
63
87
  end
64
88
 
65
89
  require_relative "cmdx/version"