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.
- checksums.yaml +7 -0
- data/.claude-plugin/marketplace.json +15 -0
- data/.claude-plugin/plugin.json +9 -0
- data/.rspec +3 -0
- data/AGENTS.md +150 -0
- data/CHANGELOG.md +766 -0
- data/CODE_OF_CONDUCT.md +132 -0
- data/LICENSE.txt +21 -0
- data/README.md +555 -0
- data/UPGRADING.md +957 -0
- data/USAGE.md +1291 -0
- data/docs/getting_started.md +611 -0
- data/lib/actionable/action.rb +1220 -0
- data/lib/actionable/batch_result.rb +92 -0
- data/lib/actionable/errors.rb +36 -0
- data/lib/actionable/history.rb +91 -0
- data/lib/actionable/input_dispatch.rb +94 -0
- data/lib/actionable/job.rb +157 -0
- data/lib/actionable/measurement.rb +64 -0
- data/lib/actionable/rails/after_commit.rb +142 -0
- data/lib/actionable/rails/proxy_validator.rb +59 -0
- data/lib/actionable/rails/transactions.rb +95 -0
- data/lib/actionable/rails.rb +24 -0
- data/lib/actionable/railtie.rb +10 -0
- data/lib/actionable/rbs.rb +502 -0
- data/lib/actionable/registry.rb +64 -0
- data/lib/actionable/result.rb +166 -0
- data/lib/actionable/results/failure.rb +43 -0
- data/lib/actionable/results/skipped.rb +30 -0
- data/lib/actionable/results/success.rb +14 -0
- data/lib/actionable/rspec/coverage/report.rb +264 -0
- data/lib/actionable/rspec/coverage/tracker.rb +330 -0
- data/lib/actionable/rspec/coverage.rb +45 -0
- data/lib/actionable/rspec/matchers.rb +266 -0
- data/lib/actionable/rspec/stubs.rb +250 -0
- data/lib/actionable/rspec.rb +40 -0
- data/lib/actionable/rubocop/config.yml +8 -0
- data/lib/actionable/rubocop/cop/actionable/verb_not_last.rb +62 -0
- data/lib/actionable/rubocop/plugin.rb +31 -0
- data/lib/actionable/rubocop.rb +25 -0
- data/lib/actionable/runner.rb +300 -0
- data/lib/actionable/steps/action.rb +76 -0
- data/lib/actionable/steps/base.rb +107 -0
- data/lib/actionable/steps/case.rb +124 -0
- data/lib/actionable/steps/method.rb +39 -0
- data/lib/actionable/steps.rb +27 -0
- data/lib/actionable/value_match.rb +22 -0
- data/lib/actionable/verb_position.rb +574 -0
- data/lib/actionable/version.rb +6 -0
- data/lib/actionable-rb.rb +6 -0
- data/lib/actionable.rb +59 -0
- data/sig/actionable/dsl.rbs +42 -0
- data/sig/actionable/rails.rbs +16 -0
- data/sig/actionable.rbs +2593 -0
- data/skills/actionable/SKILL.md +214 -0
- 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
|