actionable-rb 1.9.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 (56) hide show
  1. checksums.yaml +7 -0
  2. data/.claude-plugin/marketplace.json +15 -0
  3. data/.claude-plugin/plugin.json +9 -0
  4. data/.rspec +3 -0
  5. data/AGENTS.md +150 -0
  6. data/CHANGELOG.md +766 -0
  7. data/CODE_OF_CONDUCT.md +132 -0
  8. data/LICENSE.txt +21 -0
  9. data/README.md +555 -0
  10. data/UPGRADING.md +957 -0
  11. data/USAGE.md +1291 -0
  12. data/docs/getting_started.md +611 -0
  13. data/lib/actionable/action.rb +1220 -0
  14. data/lib/actionable/batch_result.rb +92 -0
  15. data/lib/actionable/errors.rb +36 -0
  16. data/lib/actionable/history.rb +91 -0
  17. data/lib/actionable/input_dispatch.rb +94 -0
  18. data/lib/actionable/job.rb +157 -0
  19. data/lib/actionable/measurement.rb +64 -0
  20. data/lib/actionable/rails/after_commit.rb +142 -0
  21. data/lib/actionable/rails/proxy_validator.rb +59 -0
  22. data/lib/actionable/rails/transactions.rb +95 -0
  23. data/lib/actionable/rails.rb +24 -0
  24. data/lib/actionable/railtie.rb +10 -0
  25. data/lib/actionable/rbs.rb +502 -0
  26. data/lib/actionable/registry.rb +64 -0
  27. data/lib/actionable/result.rb +166 -0
  28. data/lib/actionable/results/failure.rb +43 -0
  29. data/lib/actionable/results/skipped.rb +30 -0
  30. data/lib/actionable/results/success.rb +14 -0
  31. data/lib/actionable/rspec/coverage/report.rb +264 -0
  32. data/lib/actionable/rspec/coverage/tracker.rb +330 -0
  33. data/lib/actionable/rspec/coverage.rb +45 -0
  34. data/lib/actionable/rspec/matchers.rb +266 -0
  35. data/lib/actionable/rspec/stubs.rb +250 -0
  36. data/lib/actionable/rspec.rb +40 -0
  37. data/lib/actionable/rubocop/config.yml +8 -0
  38. data/lib/actionable/rubocop/cop/actionable/verb_not_last.rb +62 -0
  39. data/lib/actionable/rubocop/plugin.rb +31 -0
  40. data/lib/actionable/rubocop.rb +25 -0
  41. data/lib/actionable/runner.rb +300 -0
  42. data/lib/actionable/steps/action.rb +76 -0
  43. data/lib/actionable/steps/base.rb +107 -0
  44. data/lib/actionable/steps/case.rb +124 -0
  45. data/lib/actionable/steps/method.rb +39 -0
  46. data/lib/actionable/steps.rb +27 -0
  47. data/lib/actionable/value_match.rb +22 -0
  48. data/lib/actionable/verb_position.rb +574 -0
  49. data/lib/actionable/version.rb +6 -0
  50. data/lib/actionable-rb.rb +6 -0
  51. data/lib/actionable.rb +59 -0
  52. data/sig/actionable/dsl.rbs +42 -0
  53. data/sig/actionable/rails.rbs +16 -0
  54. data/sig/actionable.rbs +2593 -0
  55. data/skills/actionable/SKILL.md +214 -0
  56. metadata +132 -0
@@ -0,0 +1,300 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Actionable
4
+ # Executes an action instance's steps and produces the {Result}.
5
+ #
6
+ # The main loop runs inside {Actionable.catch_halt}, so a halting step
7
+ # (+throw Actionable::HALT+) unwinds out of the loop without running the
8
+ # remaining steps. Genuine exceptions are not caught — they propagate to the
9
+ # caller (decision D4). When no step recorded a result, the run auto-succeeds.
10
+ #
11
+ # After the main steps the runner picks the lifecycle hook category from the
12
+ # outcome (+on_success+ vs +on_failure+) and runs it, then the +always+ hooks
13
+ # (decision D2). Hooks are ordinary methods: they may record via the
14
+ # control-flow verbs, and a halting verb (+fail!+ / +skip!+ / +succeed!+ /
15
+ # +halt!+) has its result adopted, then stops the remaining hooks of its own
16
+ # phase: a halt in an outcome hook skips the rest of that category but the
17
+ # +always+ hooks still run (seeing the adopted result), and a halt in an
18
+ # +always+ hook skips the remaining +always+ hooks. The output is refreshed
19
+ # (re-captured and re-validated) after the main steps and after each hook
20
+ # (decision D6).
21
+ #
22
+ # When the action (or an enclosing measuring run) enables measurement, the
23
+ # runner records a {History} of every step — section, name, timing, result
24
+ # code, and nested child history (decision D10).
25
+ class Runner
26
+ # @param instance [Action] the action instance to run
27
+ def initialize(instance)
28
+ @instance = instance
29
+ end
30
+
31
+ # Run the action's steps and lifecycle hooks, returning the finalized result.
32
+ #
33
+ # @return [Result] the recorded result (or a fresh {Success} if none was
34
+ # set), with its typed output and (when measuring) history assigned
35
+ def run
36
+ @measuring = @instance.class.measure_all? || @instance.class.measure_sample_hit? || Measurement.active?
37
+ @history = History.new if @measuring
38
+ measured { perform }
39
+ @result.history = @history if @measuring
40
+ @result
41
+ end
42
+
43
+ private
44
+
45
+ # Run the body, marking measurement active so nested runs cascade.
46
+ #
47
+ # @yield the body to run
48
+ # @yieldreturn [Object]
49
+ # @return [Object] the block's value
50
+ def measured(&)
51
+ return yield unless @measuring
52
+
53
+ Measurement.measuring(&)
54
+ end
55
+
56
+ # The main steps, then the outcome and always lifecycle hooks.
57
+ #
58
+ # @return [void]
59
+ def perform
60
+ Actionable.catch_halt { run_steps }
61
+ @result = instance_result || Success.new
62
+ refresh_output
63
+ run_lifecycle
64
+ end
65
+
66
+ # Run each declared step in order, skipping any whose guard opts out.
67
+ #
68
+ # @return [void]
69
+ def run_steps
70
+ @instance.class.steps.each { |step| call_step(:main, step) }
71
+ end
72
+
73
+ # Run the outcome-appropriate hooks then the +always+ hooks, each phase in
74
+ # its own halt catch: a hook's halt stops the remaining hooks of its phase
75
+ # only, so +always+ hooks run even after an outcome hook halts. The
76
+ # category is fixed by the post-main-steps outcome — it does not re-dispatch
77
+ # if a hook later flips the result.
78
+ #
79
+ # @return [void]
80
+ def run_lifecycle
81
+ section, hooks =
82
+ if @result.success?
83
+ [:success, @instance.class.success_hooks]
84
+ elsif @result.skipped?
85
+ [:skip, @instance.class.skip_hooks]
86
+ else
87
+ [:failure, @instance.class.failure_hooks]
88
+ end
89
+ Actionable.catch_halt { hooks.each { |hook| run_hook(section, hook) } }
90
+ Actionable.catch_halt { @instance.class.always_hooks.each { |hook| run_hook(:always, hook) } }
91
+ end
92
+
93
+ # Run one hook, then adopt any result it recorded and refresh the output.
94
+ # A hook that halts is caught here first so its result is adopted too, then
95
+ # the halt is re-thrown to stop the remaining hooks of the phase.
96
+ #
97
+ # @param section [Symbol]
98
+ # @param hook [Steps::Base]
99
+ # @return [void]
100
+ def run_hook(section, hook)
101
+ return if hook.skip?(@instance)
102
+
103
+ before = @instance.result
104
+ completed = false
105
+ Actionable.catch_halt do
106
+ record(section, hook) { hook.call(@instance) }
107
+ completed = true
108
+ end
109
+ @result = instance_result unless instance_result.equal?(before)
110
+ refresh_output
111
+ throw HALT unless completed
112
+ end
113
+
114
+ # Run one main step, skipping it when its guard opts out.
115
+ #
116
+ # @param section [Symbol]
117
+ # @param step [Steps::Base]
118
+ # @return [void]
119
+ def call_step(section, step)
120
+ return if step.skip?(@instance)
121
+
122
+ record(section, step) { step.call(@instance) }
123
+ end
124
+
125
+ # Time and record a step into the history when measuring; otherwise just run
126
+ # it. The result code is recorded only when the step changed the recorded
127
+ # result, so steps that merely set ivars carry a nil code.
128
+ #
129
+ # @param section [Symbol]
130
+ # @param step [Steps::Base]
131
+ # @return [void]
132
+ def record(section, step, &)
133
+ return yield unless @measuring
134
+
135
+ entry = History::Step.new(section: section, name: step.name, started_at: Time.now)
136
+ before = @instance.result
137
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
138
+ begin
139
+ Measurement.recording(entry, &)
140
+ ensure
141
+ entry.duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started
142
+ entry.code = instance_result&.code unless @instance.result.equal?(before)
143
+ @history << entry
144
+ end
145
+ end
146
+
147
+ # Capture, coerce, and attach the typed output (decision D6). Free-form
148
+ # actions (no schema) keep whatever output was recorded. With a schema,
149
+ # capture the declared-field ivars overlaid with any +succeed+ kwargs
150
+ # (kwargs win) and coerce them. Only a strict {Success} is validated — its
151
+ # output failing validation means it can't really have succeeded, so
152
+ # +@result+ becomes a +Failure(:invalid_output)+.
153
+ #
154
+ # A {Failure} or {Skipped} whose code declares its own output schema
155
+ # (decision D23) is built from its kwargs alone and validated the same way.
156
+ # Without one it carries an empty output under typed outcomes, and
157
+ # otherwise a best-effort, unvalidated capture into the success schema
158
+ # (decisions D6/D17). The failures the gem builds keep their output.
159
+ #
160
+ # @return [void]
161
+ def refresh_output
162
+ return refresh_success_output if @result.success?
163
+
164
+ if (schema = outcome_schema)
165
+ attach_validated(build_output(schema, outcome_attributes(schema)))
166
+ elsif @instance.class.typed_outcomes?
167
+ @result.output = {} unless gem_failure?
168
+ elsif (schema = @instance.class.output_schema) && !gem_failure?
169
+ attach_unvalidated(build_output(schema, captured_attributes(schema)))
170
+ end
171
+ end
172
+
173
+ # @return [void]
174
+ def refresh_success_output
175
+ schema = @instance.class.output_schema
176
+ return unless schema
177
+
178
+ attach_validated(build_output(schema, captured_attributes(schema)))
179
+ end
180
+
181
+ # Build the output struct and note on the result which fields the run
182
+ # actually set, so a parent absorbing this run copies only those (D3).
183
+ #
184
+ # @param schema [Class<FieldStruct::Base>]
185
+ # @param attrs [Hash{Symbol=>Object}]
186
+ # @return [FieldStruct::Base]
187
+ def build_output(schema, attrs)
188
+ @result.output_fields_set = attrs.keys
189
+ schema.new(**attrs)
190
+ end
191
+
192
+ # The result the action instance recorded, refusing anything else stored
193
+ # in the reserved +@result+ ivar with a clear error instead of a deep
194
+ # +NoMethodError+.
195
+ #
196
+ # @raise [DefinitionError] when +@result+ holds something other than a {Result}
197
+ # @return [Result, nil]
198
+ def instance_result
199
+ result = @instance.result
200
+ return result if result.nil? || result.is_a?(Result)
201
+
202
+ raise DefinitionError,
203
+ "#{@instance.class.name || "action"} set @result to a #{result.class}; " \
204
+ '@result is reserved for the run\'s result, so rename the instance variable'
205
+ end
206
+
207
+ # Attach +output+ when it is valid; otherwise the run ends as a
208
+ # +Failure(:invalid_output)+ carrying it.
209
+ #
210
+ # @param output [FieldStruct::Base]
211
+ # @return [void]
212
+ def attach_validated(output)
213
+ output.valid? ? (@result.output = output) : (@result = invalid_output_failure(output))
214
+ end
215
+
216
+ # Attach a best-effort output to a failure or skip without validating it
217
+ # (decisions D6/D17). Assigning a struct makes the result validate it, which
218
+ # would add an +output+ error, so only the errors the result already had
219
+ # are kept.
220
+ #
221
+ # @param output [FieldStruct::Base]
222
+ # @return [void]
223
+ def attach_unvalidated(output)
224
+ kept = Array(@result.errors.to_h[:output]).dup
225
+ @result.output = output
226
+ @result.errors.clear(:output)
227
+ kept.each { |message| @result.errors.add(:output, message) }
228
+ end
229
+
230
+ # @return [Class<FieldStruct::Base>, nil] the output schema the skip or
231
+ # failure's code declares (decision D23), if any
232
+ def outcome_schema
233
+ klass = @instance.class
234
+ @result.skipped? ? klass.skip_output_schema(@result.code) : klass.failure_output_schema(@result.code)
235
+ end
236
+
237
+ # A skip or failure's output fields, from the kwargs it was recorded with,
238
+ # never from instance variables (decision D23). After a hook refresh the
239
+ # output is already the struct, so its own attributes are reused, limited
240
+ # to the fields the kwargs set so the set-fields marker stays true.
241
+ #
242
+ # @param schema [Class<FieldStruct::Base>]
243
+ # @return [Hash{Symbol=>Object}]
244
+ def outcome_attributes(schema)
245
+ recorded = @result.output
246
+ if recorded.is_a?(FieldStruct::Base)
247
+ set = @result.output_fields_set
248
+ recorded = set ? recorded.attributes.slice(*set) : recorded.attributes
249
+ end
250
+ recorded.is_a?(Hash) ? recorded.slice(*schema.attribute_names) : {}
251
+ end
252
+
253
+ # @return [Boolean] whether the result is a failure the gem itself built
254
+ # ({Action::GEM_FAILURE_CODES}), whose output is left as it is
255
+ def gem_failure?
256
+ @result.failure? && Action::GEM_FAILURE_CODES.include?(@result.code)
257
+ end
258
+
259
+ # Declared-field ivars from the action instance, overlaid with the recorded
260
+ # +succeed+ / +skip+ kwargs, kwargs winning on conflict.
261
+ #
262
+ # @param schema [Class<FieldStruct::Base>]
263
+ # @return [Hash{Symbol=>Object}]
264
+ def captured_attributes(schema)
265
+ attrs = {}
266
+ schema.attribute_names.each do |name|
267
+ ivar = :"@#{name}"
268
+ attrs[name] = @instance.instance_variable_get(ivar) if @instance.instance_variable_defined?(ivar)
269
+ end
270
+ attrs.merge(recorded_overrides.slice(*schema.attribute_names))
271
+ end
272
+
273
+ # The +succeed+ / +skip+ kwargs recorded with the current result. They sit
274
+ # on +@result.output+ as a Hash only until the first refresh replaces it
275
+ # with the struct, so they are kept here to overlay every later refresh
276
+ # (after each hook) as well.
277
+ #
278
+ # @return [Hash{Symbol=>Object}]
279
+ def recorded_overrides
280
+ if @result.output.is_a?(Hash)
281
+ @overrides_owner = @result
282
+ @overrides = @result.output
283
+ end
284
+ @overrides_owner.equal?(@result) ? @overrides : {}
285
+ end
286
+
287
+ # @param output [FieldStruct::Base] the invalid output struct
288
+ # @return [Failure] code +:invalid_output+, with the output's validation
289
+ # errors copied onto the failure and the (invalid) output attached
290
+ def invalid_output_failure(output)
291
+ failure = Failure.new(
292
+ code: :invalid_output,
293
+ message: 'output failed validation',
294
+ metadata: @instance.class.declared_failures.fetch(:invalid_output, {})
295
+ ).absorb_errors_from(output)
296
+ failure.output = output
297
+ failure
298
+ end
299
+ end
300
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Actionable
4
+ module Steps
5
+ # A step that runs another action as part of this one (decision D3,
6
+ # triggered by an action +Class+ step argument).
7
+ #
8
+ # +:input+ threads named parent instance variables into the child's +.run+;
9
+ # +:expose+ limits which of the child's outputs are absorbed back as parent
10
+ # instance variables (default: all). A child failure fails the parent with
11
+ # the child's code, message, errors and metadata, and halts the remaining
12
+ # steps.
13
+ class Action < Base
14
+ # Run the nested action, threading input in and absorbing output back.
15
+ #
16
+ # @param instance [Actionable::Action] the parent action instance
17
+ # @return [void]
18
+ def call(instance)
19
+ child = name.run(**input_kwargs(instance))
20
+
21
+ # Attach the child's history to the open step record (no-op unless the
22
+ # parent run is measuring), before any failure propagation unwinds.
23
+ Measurement.open_step&.nested = child.history if child.history.is_a?(History)
24
+
25
+ if child.failure?
26
+ # Propagate the child's failure onto the parent and halt. Not through
27
+ # +fail!+: splatting the errors into a keyword list lost the metadata
28
+ # and let an error keyed like a keyword the parent's +fail+ takes bind
29
+ # to it (decision D20).
30
+ instance.__send__(:adopt_failure!, child)
31
+ else
32
+ absorb(instance, child)
33
+ end
34
+ end
35
+
36
+ # @return [Symbol]
37
+ def kind
38
+ :action
39
+ end
40
+
41
+ private
42
+
43
+ # @param instance [Actionable::Action]
44
+ # @return [Hash{Symbol=>Object}] +{ name => @name }+ for each +:input+ name
45
+ def input_kwargs(instance)
46
+ Array(options[:input]).to_h { |name| [name, instance.instance_variable_get(:"@#{name}")] }
47
+ end
48
+
49
+ # Copy the child's exposed output fields onto the parent as instance
50
+ # variables, so the parent's later steps and output capture see them.
51
+ # Only fields the child actually set are copied: a field it left nil or
52
+ # at its default must not overwrite the parent's value.
53
+ #
54
+ # @param instance [Actionable::Action]
55
+ # @param child [Result]
56
+ # @return [void]
57
+ def absorb(instance, child)
58
+ data = output_hash(child.output)
59
+ set = child.output_fields_set
60
+ data = data.slice(*set) if set && child.output.is_a?(FieldStruct::Base)
61
+ exposed = options.key?(:expose) ? data.slice(*Array(options[:expose])) : data
62
+ exposed.each { |field, value| instance.instance_variable_set(:"@#{field}", value) }
63
+ end
64
+
65
+ # @param output [FieldStruct::Base, Hash, Object]
66
+ # @return [Hash{Symbol=>Object}] the child output as a plain hash
67
+ def output_hash(output)
68
+ case output
69
+ when FieldStruct::Base then output.attributes
70
+ when Hash then output
71
+ else {}
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Actionable
4
+ # Namespace for the concrete step types an action's pipeline is built from
5
+ # (decision D3): {Steps::Method} now, {Steps::Action} and {Steps::Case} later.
6
+ module Steps
7
+ # Shared behavior for every step: a +name+, an +options+ hash, value
8
+ # identity for Set deduplication (keyed by +[type, name]+, decision D2),
9
+ # and the +:if+ / +:unless+ skip logic. Subclasses implement {#call}.
10
+ #
11
+ # @abstract Subclass and implement {#call}.
12
+ class Base
13
+ # @return [Object] the step's name — a Symbol for a {Steps::Method},
14
+ # an action Class for a {Steps::Action}
15
+ attr_reader :name
16
+
17
+ # @return [Hash{Symbol=>Object}] the declared step options (e.g. +:if+,
18
+ # +:unless+, and step-type-specific keys)
19
+ attr_reader :options
20
+
21
+ # @param name [Object] the step name (see {#name})
22
+ # @param options [Object] step options
23
+ def initialize(name, **options)
24
+ @name = name
25
+ @options = options
26
+ end
27
+
28
+ # Run the step against an action instance.
29
+ #
30
+ # @param _instance [Object] the action instance the step runs against
31
+ # @raise [NotImplementedError] always — concrete step types override this
32
+ # @return [void]
33
+ def call(_instance)
34
+ raise NotImplementedError, "#{self.class} must implement #call"
35
+ end
36
+
37
+ # Instance method names this step needs the action to implement, used by
38
+ # the run-start definition guard (decision D18). None for the base; concrete
39
+ # types that call instance methods override this.
40
+ #
41
+ # @return [Array<Symbol>]
42
+ def required_methods
43
+ []
44
+ end
45
+
46
+ # Instance methods whose bodies run as this step, for the recording-verb
47
+ # position check (owner ruling R5): not guards, not a case step's value
48
+ # source. None for the base; a nested action is checked on its own run.
49
+ #
50
+ # @return [Array<Symbol>]
51
+ def body_methods
52
+ []
53
+ end
54
+
55
+ # @return [Symbol] a short tag identifying the step type, for introspection
56
+ # (+Actionable::Action.describe+). Concrete types override it.
57
+ def kind
58
+ :step
59
+ end
60
+
61
+ # Whether this step should be skipped for the given instance, per its
62
+ # +:if+ / +:unless+ guards. A guard is a Symbol naming a (possibly
63
+ # private) instance method, or a callable invoked with the instance.
64
+ # When both guards are present, either one asking to skip wins.
65
+ #
66
+ # @param instance [Object] the action instance to evaluate guards against
67
+ # @return [Boolean]
68
+ def skip?(instance)
69
+ return true if options.key?(:if) && !evaluate_guard(options[:if], instance)
70
+ return true if options.key?(:unless) && evaluate_guard(options[:unless], instance)
71
+
72
+ false
73
+ end
74
+
75
+ # Value identity: same concrete class AND same name. This keys steps in
76
+ # the per-action Set so a redeclared step dedups (decision D2).
77
+ #
78
+ # @param other [Object]
79
+ # @return [Boolean]
80
+ def ==(other)
81
+ other.instance_of?(self.class) && name == other.name
82
+ end
83
+ alias eql? ==
84
+
85
+ # @return [Integer] consistent with {#==}, so steps work as Set members
86
+ def hash
87
+ [self.class, name].hash
88
+ end
89
+
90
+ private
91
+
92
+ # Resolve a guard against the instance. A Symbol/String names an instance
93
+ # method (called via +__send__+, so private predicates work); anything
94
+ # else is treated as a callable receiving the instance.
95
+ #
96
+ # @param guard [Symbol, String, #call]
97
+ # @param instance [Object]
98
+ # @return [Boolean]
99
+ def evaluate_guard(guard, instance)
100
+ case guard
101
+ when Symbol, String then instance.__send__(guard)
102
+ else guard.call(instance)
103
+ end
104
+ end
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Actionable
4
+ module Steps
5
+ # A step that branches to one of several targets based on a value
6
+ # (decision D3, declared with +case_step+). The switch value is read from
7
+ # the action via the named method; each branch's target is an ordinary step
8
+ # (a method or a nested action), so branches compose like any other step.
9
+ #
10
+ # case_step :status do
11
+ # on 'active', :handle_active
12
+ # on %w[trial grace], :handle_trial # Array membership
13
+ # on(/\A4\d\d\z/, :handle_client_err # Regexp
14
+ # default :handle_unknown
15
+ # end
16
+ class Case < Base
17
+ # Collects +on+/+default+ branch declarations inside a +case_step+ block.
18
+ class Builder
19
+ # @return [Array<Array(Object, Steps::Base)>] ordered [match, step] pairs
20
+ attr_reader :branches
21
+ # @return [Steps::Base, nil] the fallback step, if +default+ was declared
22
+ attr_reader :default_step
23
+
24
+ def initialize
25
+ @branches = []
26
+ @default_step = nil
27
+ end
28
+
29
+ # Register a branch. +value+ matches the switch value by +==+, by
30
+ # +Regexp#match?+ when it is a Regexp, or by membership when it is an
31
+ # Array. +target+ is any step target (method name or nested action).
32
+ #
33
+ # @param value [Object, Regexp, Array]
34
+ # @param target [Symbol, String, Class<Actionable::Action>]
35
+ # @param options [Object] step options for the target (+:if+ / +:unless+
36
+ # guard the branch: a matched branch whose guard opts out does nothing)
37
+ # @return [void]
38
+ def on(value, target, **options)
39
+ @branches << [value, Steps.build(target, **options)]
40
+ end
41
+
42
+ # Register the fallback branch, run when no +on+ branch matches.
43
+ #
44
+ # @param target [Symbol, String, Class<Actionable::Action>]
45
+ # @param options [Object] step options for the target
46
+ # @return [void]
47
+ def default(target, **options)
48
+ @default_step = Steps.build(target, **options)
49
+ end
50
+ end
51
+
52
+ # Build a case step from a +case_step+ declaration.
53
+ #
54
+ # @param value_source [Symbol, String] the method whose value is switched on
55
+ # @param options [Object] step options (e.g. +:if+, +:unless+)
56
+ # @yield the branch declarations (+on+ / +default+)
57
+ # @return [Case]
58
+ def self.define(value_source, **options, &block)
59
+ builder = Builder.new
60
+ builder.instance_eval(&block)
61
+ new(value_source, builder.branches, builder.default_step, **options)
62
+ end
63
+
64
+ # @param value_source [Symbol, String] the method whose value is switched on
65
+ # @param branches [Array<Array(Object, Steps::Base)>] ordered [match, step] pairs
66
+ # @param default_step [Steps::Base, nil] the fallback step
67
+ # @param options [Object] step options
68
+ def initialize(value_source, branches, default_step, **options)
69
+ super(value_source, **options)
70
+ @branches = branches
71
+ @default_step = default_step
72
+ end
73
+
74
+ # Evaluate the switch value and run the first matching branch (or the
75
+ # default). No-op when nothing matches and no default was declared.
76
+ #
77
+ # The branch is chosen by value alone; its +:if+ / +:unless+ guards then
78
+ # decide whether its target runs, exactly as for a plain step. A matched
79
+ # branch whose guard opts out is a no-op: it does not fall through to a
80
+ # later branch or to the default.
81
+ #
82
+ # @param instance [Actionable::Action]
83
+ # @return [void]
84
+ def call(instance)
85
+ value = instance.__send__(name) # private, like step methods
86
+ step = match(value) || @default_step
87
+ step.call(instance) unless step.nil? || step.skip?(instance)
88
+ end
89
+
90
+ # @return [Array<Symbol>] the switch-value method plus every branch
91
+ # target's required methods
92
+ def required_methods
93
+ ([name] + branch_steps.flat_map(&:required_methods)).uniq
94
+ end
95
+
96
+ # @return [Array<Symbol>] every branch target's body methods (never the
97
+ # switch-value method, which only returns the value)
98
+ def body_methods
99
+ branch_steps.flat_map(&:body_methods).uniq
100
+ end
101
+
102
+ # @return [Symbol]
103
+ def kind
104
+ :case
105
+ end
106
+
107
+ private
108
+
109
+ # @return [Array<Steps::Base>] every branch's step, the default included
110
+ def branch_steps
111
+ steps = @branches.map(&:last)
112
+ steps << @default_step if @default_step
113
+ steps
114
+ end
115
+
116
+ # @param value [Object] the switch value
117
+ # @return [Steps::Base, nil] the first matching branch's step
118
+ def match(value)
119
+ @branches.each { |branch_value, step| return step if ValueMatch.matches?(branch_value, value) }
120
+ nil
121
+ end
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Actionable
4
+ module Steps
5
+ # A step that calls an instance method on the action (decision D3,
6
+ # triggered by a Symbol/String step argument).
7
+ class Method < Base
8
+ # @param name [Symbol, String] the instance method to call; normalized
9
+ # to a Symbol
10
+ # @param options [Object] step options (e.g. +:if+, +:unless+)
11
+ def initialize(name, **options)
12
+ super(name.to_sym, **options)
13
+ end
14
+
15
+ # Invoke the named method on the instance.
16
+ #
17
+ # @param instance [Object] the action instance
18
+ # @return [Object] the method's return value
19
+ def call(instance)
20
+ instance.__send__(name) # private step methods work, like guards
21
+ end
22
+
23
+ # @return [Array<Symbol>] the instance method this step calls
24
+ def required_methods
25
+ [name]
26
+ end
27
+
28
+ # @return [Array<Symbol>] the instance method whose body is this step
29
+ def body_methods
30
+ [name]
31
+ end
32
+
33
+ # @return [Symbol]
34
+ def kind
35
+ :method
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Actionable
4
+ module Steps
5
+ # Build the right step type from a +step+ declaration's target, inferring
6
+ # the type from the argument (decision D2): a Symbol/String becomes a
7
+ # {Steps::Method}, an action +Class+ becomes a {Steps::Action}. The case
8
+ # block type ({Steps::Case}) joins in a later slice.
9
+ #
10
+ # @param target [Symbol, String, Class<Actionable::Action>] the step target
11
+ # @param options [Object] step options (e.g. +:if+, +:unless+,
12
+ # and for action steps +:input+ / +:expose+)
13
+ # @return [Steps::Base] the constructed step
14
+ # @raise [ArgumentError] when the target's type is not supported
15
+ def self.build(target, **options)
16
+ case target
17
+ when Symbol, String then Method.new(target, **options)
18
+ when Class
19
+ raise ArgumentError, "unsupported step target: #{target.inspect}" unless target < Actionable::Action
20
+
21
+ Action.new(target, **options)
22
+ else
23
+ raise ArgumentError, "unsupported step target: #{target.inspect}"
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Actionable
4
+ # Shared branch-value matching for the two places Actionable dispatches on a
5
+ # value: case steps (decision D3) and discriminated input (decision D19). A
6
+ # branch value matches by Array membership, by +Regexp#match?+ against the
7
+ # value's string form, or by +==+.
8
+ module ValueMatch
9
+ module_function
10
+
11
+ # @param branch_value [Object, Array, Regexp] the declared branch matcher
12
+ # @param value [Object] the runtime value being dispatched on
13
+ # @return [Boolean] whether +value+ matches +branch_value+
14
+ def matches?(branch_value, value)
15
+ case branch_value
16
+ when Array then branch_value.include?(value)
17
+ when Regexp then branch_value.match?(value.to_s)
18
+ else branch_value == value
19
+ end
20
+ end
21
+ end
22
+ end