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,1220 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Actionable
|
|
4
|
+
# Base class for service objects. Subclass it, declare an ordered list of
|
|
5
|
+
# steps with {.step}, implement each step as an instance method, and run it
|
|
6
|
+
# with {.run} (decisions D1/D2).
|
|
7
|
+
#
|
|
8
|
+
# class Greet < Actionable::Action
|
|
9
|
+
# step :build_message
|
|
10
|
+
# def build_message = @message = 'hello'
|
|
11
|
+
# end
|
|
12
|
+
#
|
|
13
|
+
# Greet.run # => #<Actionable::Success ...>
|
|
14
|
+
class Action
|
|
15
|
+
# The failure codes the gem itself emits: +:invalid_input+ (input failed
|
|
16
|
+
# validation, or no shape matched a discriminator) and +:invalid_output+
|
|
17
|
+
# (a success's output failed validation). An action that declares failures
|
|
18
|
+
# with {.fails} may still end with these without declaring them (decision
|
|
19
|
+
# D20); declaring one only attaches metadata to it.
|
|
20
|
+
# @return [Array<Symbol>]
|
|
21
|
+
GEM_FAILURE_CODES = %i[invalid_input invalid_output].freeze
|
|
22
|
+
|
|
23
|
+
# Ruby's core classes, whose methods (+puts+, +exit+, +hash+…) never count
|
|
24
|
+
# as an action implementing a step or hook.
|
|
25
|
+
# @return [Array<Module>]
|
|
26
|
+
CORE_METHOD_OWNERS = [Kernel, Object, BasicObject].freeze
|
|
27
|
+
|
|
28
|
+
# What a +fail+ / +skip+ code must look like: a Ruby identifier (letters,
|
|
29
|
+
# digits and underscores, not starting with a digit), optionally ending
|
|
30
|
+
# in +?+ or +!+. Refuses a sentence, which is +Kernel#fail+ habit
|
|
31
|
+
# (owner ruling R5).
|
|
32
|
+
# @return [Regexp]
|
|
33
|
+
CODE_FORMAT = /\A[[:alpha:]_][[:alnum:]_]*[?!]?\z/
|
|
34
|
+
|
|
35
|
+
class << self
|
|
36
|
+
# The ordered, deduplicated set of steps declared on this action.
|
|
37
|
+
# A +Set+ keyed by step identity ([type, name]) so a redeclared step
|
|
38
|
+
# collapses to one entry; insertion order is preserved (decision D2).
|
|
39
|
+
# The last declaration of a step wins and keeps the first one's position
|
|
40
|
+
# (see {.step}).
|
|
41
|
+
#
|
|
42
|
+
# @return [Set<Steps::Base>]
|
|
43
|
+
def steps
|
|
44
|
+
@steps ||= Set.new
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Output field names that would collide with the result's own attributes
|
|
48
|
+
# (so delegation silently breaks) or with the action's reserved instance
|
|
49
|
+
# variables (+@result+ / +@input+, which the runner reads). Declaring one
|
|
50
|
+
# raises a {DefinitionError} (decisions D18/D20).
|
|
51
|
+
# @return [Array<Symbol>]
|
|
52
|
+
RESERVED_OUTPUT_FIELDS = %i[code message output history errors metadata result input].freeze
|
|
53
|
+
|
|
54
|
+
# Declare the action's typed output schema, or read it back.
|
|
55
|
+
#
|
|
56
|
+
# With a block, builds an anonymous +FieldStruct::Base+ subclass from the
|
|
57
|
+
# FieldStruct DSL (+required+/+optional+/…) and stores it as this action's
|
|
58
|
+
# output schema (decision D6). At the end of a run the {Runner} captures
|
|
59
|
+
# the instance variables whose names match declared fields, coerces them
|
|
60
|
+
# through the schema, and assigns the struct to +result.output+. Field names
|
|
61
|
+
# in {RESERVED_OUTPUT_FIELDS} raise a {DefinitionError}.
|
|
62
|
+
#
|
|
63
|
+
# output do
|
|
64
|
+
# required :invoice, Invoice
|
|
65
|
+
# optional :receipt, Receipt
|
|
66
|
+
# end
|
|
67
|
+
#
|
|
68
|
+
# @yield the FieldStruct field declarations
|
|
69
|
+
# @raise [DefinitionError] when a field name is reserved
|
|
70
|
+
# @return [Class<FieldStruct::Base>, nil] the output schema, or +nil+ when
|
|
71
|
+
# none has been declared (the free-form path)
|
|
72
|
+
def output(&block)
|
|
73
|
+
return @output_schema unless block
|
|
74
|
+
|
|
75
|
+
@output_schema = build_output_schema(block)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# @return [Class<FieldStruct::Base>, nil] the declared output schema, or
|
|
79
|
+
# +nil+ for a free-form action
|
|
80
|
+
attr_reader :output_schema
|
|
81
|
+
|
|
82
|
+
# Declare the action's typed input schema (decision D7). Builds an
|
|
83
|
+
# anonymous +FieldStruct::Base+ subclass from the FieldStruct DSL; +.run+
|
|
84
|
+
# then coerces its arguments into this schema, validates them, and exposes
|
|
85
|
+
# the struct to steps via the instance's +input+ reader. Declaring input
|
|
86
|
+
# takes over the constructor role — steps read +input.field+ rather than
|
|
87
|
+
# capturing positional/keyword args in a custom +initialize+.
|
|
88
|
+
#
|
|
89
|
+
# input do
|
|
90
|
+
# required :amount, :big_decimal
|
|
91
|
+
# optional :name, :string
|
|
92
|
+
# end
|
|
93
|
+
#
|
|
94
|
+
# Called without a block it reads the declared schema, as {.output} does.
|
|
95
|
+
#
|
|
96
|
+
# @yield the FieldStruct field declarations
|
|
97
|
+
# @return [Class<FieldStruct::Base>, nil] the input schema, or +nil+ when
|
|
98
|
+
# none has been declared
|
|
99
|
+
def input(&block)
|
|
100
|
+
return @input_schema unless block
|
|
101
|
+
|
|
102
|
+
@input_schema = Class.new(FieldStruct::Base, &block)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# @return [Class<FieldStruct::Base>, nil] the declared input schema, or
|
|
106
|
+
# +nil+ for a free-form action
|
|
107
|
+
attr_reader :input_schema
|
|
108
|
+
|
|
109
|
+
# Declare a *discriminated* input schema (decision D19): pick the input
|
|
110
|
+
# FieldStruct at run time from a discriminator value, instead of a single
|
|
111
|
+
# fixed schema. The block declares branches with +on(value, Schema)+ and an
|
|
112
|
+
# optional +default(Schema)+; +.run+ reads the discriminator from its
|
|
113
|
+
# keyword arguments, chooses the schema, then coerces and validates exactly
|
|
114
|
+
# like a fixed +input+. An unmatched value with no +default+ short-circuits
|
|
115
|
+
# to +Failure(:invalid_input)+.
|
|
116
|
+
#
|
|
117
|
+
# input_for :event_type do
|
|
118
|
+
# on 'sale', SaleShape
|
|
119
|
+
# on 'refund', RefundShape
|
|
120
|
+
# default UnknownShape
|
|
121
|
+
# end
|
|
122
|
+
#
|
|
123
|
+
# @param discriminator [Symbol, String] the kwarg whose value selects the schema
|
|
124
|
+
# @yield the branch declarations (+on+ / +default+)
|
|
125
|
+
# @return [InputDispatch] the stored dispatch
|
|
126
|
+
def input_for(discriminator, &block)
|
|
127
|
+
@input_dispatch = InputDispatch.define(discriminator, &block)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# @return [InputDispatch, nil] the discriminated-input dispatch, or +nil+
|
|
131
|
+
# when input isn't discriminated
|
|
132
|
+
attr_reader :input_dispatch
|
|
133
|
+
|
|
134
|
+
# Declare one failure code this action can end with, and the metadata a
|
|
135
|
+
# failure with that code carries (decision D20).
|
|
136
|
+
#
|
|
137
|
+
# The metadata is neutral: arbitrary keywords the gem stores, attaches to
|
|
138
|
+
# the {Failure} as +result.metadata+, and shows in {.describe}, but never
|
|
139
|
+
# interprets. A host app gives the keys their meaning (say, +kind:+ for
|
|
140
|
+
# how an adapter answers the failure). +message+ is the default message
|
|
141
|
+
# for a +fail+ that gives none.
|
|
142
|
+
#
|
|
143
|
+
# fails :refused, 'the card was refused', kind: :rejected
|
|
144
|
+
# fails :not_found, kind: :not_found
|
|
145
|
+
#
|
|
146
|
+
# Declarations are inherited and a subclass can add to them; redeclaring a
|
|
147
|
+
# code replaces its message and metadata. Once an action declares any
|
|
148
|
+
# code, failing with an undeclared one raises a {DefinitionError} at the
|
|
149
|
+
# +fail+ call; the {GEM_FAILURE_CODES} are exempt.
|
|
150
|
+
#
|
|
151
|
+
# A block declares the output a failure with this code carries, in the
|
|
152
|
+
# FieldStruct DSL of {.output} (decision D23); see {.failure_output_schema}.
|
|
153
|
+
#
|
|
154
|
+
# @param code [Symbol, String] the failure code
|
|
155
|
+
# @param message [String, nil] the default message for this code
|
|
156
|
+
# @param metadata [Object] per-code metadata, passed through
|
|
157
|
+
# @yield the FieldStruct field declarations of this code's output
|
|
158
|
+
# @raise [DefinitionError] when an output field name is reserved
|
|
159
|
+
# @return [Hash{Symbol=>Hash{Symbol=>Object}}] {.declared_failures}
|
|
160
|
+
def fails(code, message = nil, **metadata, &block)
|
|
161
|
+
failure_declarations[code.to_sym] = declaration(message, metadata, block)
|
|
162
|
+
declared_failures
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# The output schema declared for failure +code+ with a {.fails} block, or
|
|
166
|
+
# +nil+ when the code has none (decision D23).
|
|
167
|
+
#
|
|
168
|
+
# @param code [Symbol, String]
|
|
169
|
+
# @return [Class<FieldStruct::Base>, nil]
|
|
170
|
+
def failure_output_schema(code)
|
|
171
|
+
failure_declarations.dig(code.to_sym, :output_schema)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# The failure codes this action declares with {.fails}, each mapped to its
|
|
175
|
+
# metadata (decision D20). Empty when the action declares none.
|
|
176
|
+
#
|
|
177
|
+
# @return [Hash{Symbol=>Hash{Symbol=>Object}}]
|
|
178
|
+
def declared_failures
|
|
179
|
+
failure_declarations.transform_values { |declaration| declaration[:metadata] }
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# Whether this action may end with a failure of +code+ (decision D20):
|
|
183
|
+
# always when it declares no failures, otherwise when it declares +code+
|
|
184
|
+
# or +code+ is one of the {GEM_FAILURE_CODES}. The check behind the
|
|
185
|
+
# strictness guard, the RSpec stubs and +and_fail+.
|
|
186
|
+
#
|
|
187
|
+
# @param code [Symbol, String]
|
|
188
|
+
# @return [Boolean]
|
|
189
|
+
def allows_failure_code?(code)
|
|
190
|
+
code = code.to_sym
|
|
191
|
+
failure_declarations.empty? || failure_declarations.key?(code) || GEM_FAILURE_CODES.include?(code)
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# Every {.fails} declaration, keyed by code, as +{message:, metadata:}+.
|
|
195
|
+
# @return [Hash{Symbol=>Hash{Symbol=>Object}}]
|
|
196
|
+
def failure_declarations
|
|
197
|
+
@failure_declarations ||= {}
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Declare one skip code this action can end with, and the metadata a skip
|
|
201
|
+
# with that code carries (decision D21). The skip counterpart of {.fails}.
|
|
202
|
+
#
|
|
203
|
+
# The metadata is neutral: arbitrary keywords the gem stores, attaches to
|
|
204
|
+
# the {Skipped} as +result.metadata+, and shows in {.describe}, but never
|
|
205
|
+
# interprets. +message+ is the default message for a +skip+ that gives
|
|
206
|
+
# none.
|
|
207
|
+
#
|
|
208
|
+
# skips :not_ready, 'the field is not created yet', retry: true
|
|
209
|
+
# skips :already_synced
|
|
210
|
+
#
|
|
211
|
+
# Declarations are inherited and a subclass can add to them; redeclaring a
|
|
212
|
+
# code replaces its message and metadata. Once an action declares any
|
|
213
|
+
# skip, skipping with an undeclared code raises a {DefinitionError} at the
|
|
214
|
+
# +skip+ call, and that includes +skip+'s implicit default +:skipped+.
|
|
215
|
+
#
|
|
216
|
+
# A block declares the output a skip with this code carries, in the
|
|
217
|
+
# FieldStruct DSL of {.output} (decision D23); see {.skip_output_schema}.
|
|
218
|
+
#
|
|
219
|
+
# skips :already_active do
|
|
220
|
+
# required :id, :integer
|
|
221
|
+
# end
|
|
222
|
+
#
|
|
223
|
+
# @param code [Symbol, String] the skip code
|
|
224
|
+
# @param message [String, nil] the default message for this code
|
|
225
|
+
# @param metadata [Object] per-code metadata, passed through
|
|
226
|
+
# @yield the FieldStruct field declarations of this code's output
|
|
227
|
+
# @raise [DefinitionError] when an output field name is reserved
|
|
228
|
+
# @return [Hash{Symbol=>Hash{Symbol=>Object}}] {.declared_skips}
|
|
229
|
+
def skips(code, message = nil, **metadata, &block)
|
|
230
|
+
skip_declarations[code.to_sym] = declaration(message, metadata, block)
|
|
231
|
+
declared_skips
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# The output schema declared for skip +code+ with a {.skips} block, or
|
|
235
|
+
# +nil+ when the code has none (decision D23).
|
|
236
|
+
#
|
|
237
|
+
# @param code [Symbol, String]
|
|
238
|
+
# @return [Class<FieldStruct::Base>, nil]
|
|
239
|
+
def skip_output_schema(code)
|
|
240
|
+
skip_declarations.dig(code.to_sym, :output_schema)
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
# The gem result class each outcome {Result#status} stands for, which an
|
|
244
|
+
# action with typed outcomes subclasses once per outcome (decision D23).
|
|
245
|
+
# @return [Hash{Symbol=>Class<Result>}]
|
|
246
|
+
OUTCOME_BASES = {success: Success, failure: Failure, skipped: Skipped}.freeze
|
|
247
|
+
|
|
248
|
+
# Turn typed outcomes on (or off) for this action and its descendants
|
|
249
|
+
# (decision D23). With it on, a skip or failure whose code declares no
|
|
250
|
+
# output schema carries an empty output instead of a best-effort capture,
|
|
251
|
+
# and {.run} returns the action's own result classes: +Success+,
|
|
252
|
+
# +Failure+ and +Skipped+ defined inside it, each a subclass of the gem
|
|
253
|
+
# class (or of the parent action's own, for a subclass).
|
|
254
|
+
#
|
|
255
|
+
# @param enabled [Boolean]
|
|
256
|
+
# @raise [DefinitionError] when the action already has a constant of its
|
|
257
|
+
# own named +Success+, +Failure+ or +Skipped+
|
|
258
|
+
# @return [Boolean] the new setting
|
|
259
|
+
def typed_outcomes(enabled: true)
|
|
260
|
+
@typed_outcomes = enabled ? true : false
|
|
261
|
+
define_outcome_classes if @typed_outcomes
|
|
262
|
+
@typed_outcomes
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
# @return [Boolean] whether typed outcomes are on (decision D23)
|
|
266
|
+
def typed_outcomes?
|
|
267
|
+
@typed_outcomes ? true : false
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# The skip codes this action declares with {.skips}, each mapped to its
|
|
271
|
+
# metadata (decision D21). Empty when the action declares none.
|
|
272
|
+
#
|
|
273
|
+
# @return [Hash{Symbol=>Hash{Symbol=>Object}}]
|
|
274
|
+
def declared_skips
|
|
275
|
+
skip_declarations.transform_values { |declaration| declaration[:metadata] }
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
# Whether this action may end with a skip of +code+ (decision D21): always
|
|
279
|
+
# when it declares no skips, otherwise only when it declares +code+. The
|
|
280
|
+
# gem emits no skip codes of its own, so nothing is exempt, +skip+'s
|
|
281
|
+
# default +:skipped+ included. The check behind the strictness guard, the
|
|
282
|
+
# RSpec skip stubs and +and_skip+.
|
|
283
|
+
#
|
|
284
|
+
# @param code [Symbol, String]
|
|
285
|
+
# @return [Boolean]
|
|
286
|
+
def allows_skip_code?(code)
|
|
287
|
+
skip_declarations.empty? || skip_declarations.key?(code.to_sym)
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
# Every {.skips} declaration, keyed by code, as +{message:, metadata:}+
|
|
291
|
+
# (decision D21).
|
|
292
|
+
#
|
|
293
|
+
# @return [Hash{Symbol=>Hash{Symbol=>Object}}]
|
|
294
|
+
def skip_declarations
|
|
295
|
+
@skip_declarations ||= {}
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
# Declare a step. The step type is inferred from +target+ (a Symbol or
|
|
299
|
+
# String names an instance method — a {Steps::Method}).
|
|
300
|
+
#
|
|
301
|
+
# Redeclaring a step of the same type and name (here or in a subclass)
|
|
302
|
+
# replaces the earlier declaration's options in place: the last
|
|
303
|
+
# declaration wins and keeps the original position. A subclass's
|
|
304
|
+
# override never changes the parent's steps.
|
|
305
|
+
#
|
|
306
|
+
# @param target [Symbol, String] the step target
|
|
307
|
+
# @param options [Object] step options (e.g. +:if+, +:unless+)
|
|
308
|
+
# @return [Set<Steps::Base>] the updated step set
|
|
309
|
+
def step(target, **options)
|
|
310
|
+
declare(steps, Steps.build(target, **options))
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
# Lifecycle hooks that run after the main steps when the run succeeds.
|
|
314
|
+
# @return [Set<Steps::Base>]
|
|
315
|
+
def success_hooks
|
|
316
|
+
@success_hooks ||= Set.new
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
# Lifecycle hooks that run after the main steps when the run fails.
|
|
320
|
+
# @return [Set<Steps::Base>]
|
|
321
|
+
def failure_hooks
|
|
322
|
+
@failure_hooks ||= Set.new
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
# Lifecycle hooks that run after the main steps when the run was skipped.
|
|
326
|
+
# @return [Set<Steps::Base>]
|
|
327
|
+
def skip_hooks
|
|
328
|
+
@skip_hooks ||= Set.new
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
# Lifecycle hooks that run after the main steps regardless of outcome.
|
|
332
|
+
# @return [Set<Steps::Base>]
|
|
333
|
+
def always_hooks
|
|
334
|
+
@always_hooks ||= Set.new
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
# Declare a hook that runs at the end of a run iff it succeeded (D2).
|
|
338
|
+
# @param target [Symbol, String] the instance method to run
|
|
339
|
+
# @param options [Object] step options (e.g. +:if+)
|
|
340
|
+
# @return [Set<Steps::Base>]
|
|
341
|
+
def on_success(target, **options)
|
|
342
|
+
declare(success_hooks, Steps.build(target, **options))
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
# Declare a hook that runs at the end of a run iff it failed (D2).
|
|
346
|
+
# @param target [Symbol, String] the instance method to run
|
|
347
|
+
# @param options [Object] step options (e.g. +:if+)
|
|
348
|
+
# @return [Set<Steps::Base>]
|
|
349
|
+
def on_failure(target, **options)
|
|
350
|
+
declare(failure_hooks, Steps.build(target, **options))
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
# Declare a hook that runs at the end of a run iff it was skipped (D17).
|
|
354
|
+
# @param target [Symbol, String] the instance method to run
|
|
355
|
+
# @param options [Object] step options (e.g. +:if+)
|
|
356
|
+
# @return [Set<Steps::Base>]
|
|
357
|
+
def on_skip(target, **options)
|
|
358
|
+
declare(skip_hooks, Steps.build(target, **options))
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
# Declare a hook that runs at the end of a run regardless of outcome (D2).
|
|
362
|
+
# @param target [Symbol, String] the instance method to run
|
|
363
|
+
# @param options [Object] step options (e.g. +:if+)
|
|
364
|
+
# @return [Set<Steps::Base>]
|
|
365
|
+
def always(target, **options)
|
|
366
|
+
declare(always_hooks, Steps.build(target, **options))
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
# Declare a branching step (decision D3). The switch value is read from
|
|
370
|
+
# +value_source+ (an instance method); the block declares branches with
|
|
371
|
+
# +on(value, target)+ and an optional +default(target)+.
|
|
372
|
+
#
|
|
373
|
+
# case_step :status do
|
|
374
|
+
# on 'active', :handle_active
|
|
375
|
+
# default :handle_unknown
|
|
376
|
+
# end
|
|
377
|
+
#
|
|
378
|
+
# @param value_source [Symbol, String] the method whose value is switched on
|
|
379
|
+
# @param options [Object] step options (e.g. +:if+, +:unless+)
|
|
380
|
+
# @yield the branch declarations
|
|
381
|
+
# @return [Set<Steps::Base>] the updated step set
|
|
382
|
+
def case_step(value_source, **options, &block)
|
|
383
|
+
declare(steps, Steps::Case.define(value_source, **options, &block))
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
# Enable or read the action's measurement mode (decisions D10/D19). +:all+
|
|
387
|
+
# records an execution {History}; +:none+ (the default) records nothing for
|
|
388
|
+
# zero overhead; +:sampled+ records on a fraction of runs (+rate:+ between 0
|
|
389
|
+
# and 1) for low-overhead production observability. A measuring run cascades
|
|
390
|
+
# into the nested actions it invokes regardless of their own setting.
|
|
391
|
+
#
|
|
392
|
+
# measure :sampled, rate: 0.1 # record ~10% of runs
|
|
393
|
+
#
|
|
394
|
+
# @param mode [:all, :none, :sampled, nil] the mode to set; omit to read
|
|
395
|
+
# @param rate [Numeric, nil] required for +:sampled+ — the probability
|
|
396
|
+
# (0..1) that a given run is measured
|
|
397
|
+
# @raise [ArgumentError] on an unknown mode, or +:sampled+ without a valid rate
|
|
398
|
+
# @return [:all, :none, :sampled] the resolved mode
|
|
399
|
+
def measure(mode = nil, rate: nil)
|
|
400
|
+
unless mode.nil?
|
|
401
|
+
raise ArgumentError, "unknown measure mode #{mode.inspect}" unless %i[all none sampled].include?(mode)
|
|
402
|
+
|
|
403
|
+
if mode == :sampled && !(rate.is_a?(Numeric) && rate.between?(0, 1))
|
|
404
|
+
raise ArgumentError, "measure :sampled requires rate: between 0 and 1, got #{rate.inspect}"
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
@measure = mode
|
|
408
|
+
@measure_rate = rate if mode == :sampled
|
|
409
|
+
end
|
|
410
|
+
@measure ||= :none
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
# @return [Float, nil] the sampling rate for +:sampled+ mode, else +nil+
|
|
414
|
+
attr_reader :measure_rate
|
|
415
|
+
|
|
416
|
+
# @return [Boolean] whether this action always records history
|
|
417
|
+
def measure_all?
|
|
418
|
+
measure == :all
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
# @return [Boolean] whether this action samples history
|
|
422
|
+
def measure_sampled?
|
|
423
|
+
measure == :sampled
|
|
424
|
+
end
|
|
425
|
+
|
|
426
|
+
# The per-run sampling decision: true with probability {.measure_rate} when
|
|
427
|
+
# in +:sampled+ mode, false otherwise. Rolled once per run by the {Runner}.
|
|
428
|
+
#
|
|
429
|
+
# @return [Boolean]
|
|
430
|
+
def measure_sample_hit?
|
|
431
|
+
measure_sampled? && rand < measure_rate
|
|
432
|
+
end
|
|
433
|
+
|
|
434
|
+
# A structured, at-a-glance summary of the action — its name, input/output
|
|
435
|
+
# field metadata, ordered steps (with type), declared failures and skips
|
|
436
|
+
# (each code's message and metadata, decisions D20/D21), lifecycle hooks, measurement mode,
|
|
437
|
+
# and transaction config (decision D18). Lets humans and agents
|
|
438
|
+
# understand an action without reading its source.
|
|
439
|
+
#
|
|
440
|
+
# @return [Hash{Symbol=>Object}]
|
|
441
|
+
def describe
|
|
442
|
+
{
|
|
443
|
+
name: name,
|
|
444
|
+
input: input_descriptor,
|
|
445
|
+
output: output_schema&.metadata&.to_h,
|
|
446
|
+
steps: steps.map { |step| {type: step.kind, name: step.name, options: step.options} },
|
|
447
|
+
failures: describe_declarations(failure_declarations),
|
|
448
|
+
skips: describe_declarations(skip_declarations),
|
|
449
|
+
hooks: {
|
|
450
|
+
on_success: success_hooks.map(&:name),
|
|
451
|
+
on_failure: failure_hooks.map(&:name),
|
|
452
|
+
on_skip: skip_hooks.map(&:name),
|
|
453
|
+
always: always_hooks.map(&:name)
|
|
454
|
+
},
|
|
455
|
+
measure: measure,
|
|
456
|
+
transactional: respond_to?(:transaction_config) ? transaction_config : nil
|
|
457
|
+
}
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
# A human/agent-readable rendering of {.describe} — the same information as
|
|
461
|
+
# a multi-line summary instead of a Hash (decision D18). The input/output
|
|
462
|
+
# field lines reuse FieldStruct's own +Metadata#describe+ (type,
|
|
463
|
+
# required-ness, and the options each field's type accepts); declared
|
|
464
|
+
# failures, declared skips and hook sections are listed only when there are any.
|
|
465
|
+
#
|
|
466
|
+
# Greet.describe_text
|
|
467
|
+
# # => "Greet (measure: none)\n Input: (free-form)\n ..."
|
|
468
|
+
#
|
|
469
|
+
# @return [String]
|
|
470
|
+
def describe_text
|
|
471
|
+
summary = describe
|
|
472
|
+
transactional = summary[:transactional] ? ', transactional' : ''
|
|
473
|
+
lines = ["#{summary[:name] || "action"} (measure: #{summary[:measure]}#{transactional})"]
|
|
474
|
+
|
|
475
|
+
lines << " Input:#{input_summary}"
|
|
476
|
+
lines << " Output:#{schema_summary(output_schema)}"
|
|
477
|
+
|
|
478
|
+
lines << ' Steps:'
|
|
479
|
+
steps_summary = summary[:steps]
|
|
480
|
+
lines << ' (none)' if steps_summary.empty?
|
|
481
|
+
steps_summary.each { |step| lines << " - #{step[:name]} (#{step[:type]})" }
|
|
482
|
+
|
|
483
|
+
lines.concat(declarations_summary('Failures', failure_declarations))
|
|
484
|
+
lines.concat(declarations_summary('Skips', skip_declarations))
|
|
485
|
+
|
|
486
|
+
present_hooks = summary[:hooks].reject { |_, names| names.empty? }
|
|
487
|
+
unless present_hooks.empty?
|
|
488
|
+
lines << ' Hooks:'
|
|
489
|
+
present_hooks.each { |section, names| lines << " #{section}: #{names.join(", ")}" }
|
|
490
|
+
end
|
|
491
|
+
|
|
492
|
+
lines.join("\n")
|
|
493
|
+
end
|
|
494
|
+
|
|
495
|
+
# Run the action. With a declared input schema, coerce the arguments
|
|
496
|
+
# (keyword args, or a single input-schema instance) into the input struct
|
|
497
|
+
# and validate it — a required input that is missing or won't coerce
|
|
498
|
+
# short-circuits to a +Failure(:invalid_input)+ without running any steps
|
|
499
|
+
# (decision D7). Without an input schema, the arguments are forwarded
|
|
500
|
+
# verbatim to the action's constructor (the free-form path).
|
|
501
|
+
#
|
|
502
|
+
# Under typed outcomes the result is an instance of the action's own
|
|
503
|
+
# result class for its outcome (decision D23).
|
|
504
|
+
#
|
|
505
|
+
# @param args [Object] positional arguments: a single input-schema
|
|
506
|
+
# instance, or (free-form) whatever the constructor takes
|
|
507
|
+
# @param kwargs [Object] keyword arguments: the input fields, or
|
|
508
|
+
# (free-form) whatever the constructor takes
|
|
509
|
+
# @return [Result] the run's {Success} or {Failure}
|
|
510
|
+
def run(*args, **kwargs)
|
|
511
|
+
typed_result(run_untyped(args, kwargs))
|
|
512
|
+
end
|
|
513
|
+
|
|
514
|
+
# Run the action once per item in +enumerable+, collecting the per-item
|
|
515
|
+
# results into a {BatchResult} (decision D19). The single most common
|
|
516
|
+
# batch/ingest shape: each item runs independently (its own {.run}, its own
|
|
517
|
+
# outcome), so one item's failure never affects another, and the aggregate
|
|
518
|
+
# is inspectable via {BatchResult#all_ok?} / +#any_failure?+ / +#partial?+.
|
|
519
|
+
#
|
|
520
|
+
# The optional block maps each item to +.run+ arguments: return a Hash for
|
|
521
|
+
# keyword args, an Array for positional args, or any other value to pass as
|
|
522
|
+
# a single argument. Without a block, each item is passed to +.run+
|
|
523
|
+
# directly.
|
|
524
|
+
#
|
|
525
|
+
# ImportRow.run_each(rows) { |row| {row: row} }
|
|
526
|
+
# Charge.run_each(amounts) # => each amount passed to .run
|
|
527
|
+
#
|
|
528
|
+
# @param enumerable [Enumerable] the items to run
|
|
529
|
+
# @yieldparam item [Object] one element of +enumerable+
|
|
530
|
+
# @yieldreturn [Hash, Array, Object] the +.run+ arguments for that item
|
|
531
|
+
# @return [BatchResult] the per-item results
|
|
532
|
+
def run_each(enumerable, &block)
|
|
533
|
+
results = enumerable.map do |item|
|
|
534
|
+
args = block ? block.call(item) : item
|
|
535
|
+
case args
|
|
536
|
+
when Hash then run(**args)
|
|
537
|
+
when Array then run(*args)
|
|
538
|
+
else run(args)
|
|
539
|
+
end
|
|
540
|
+
end
|
|
541
|
+
BatchResult.new(results)
|
|
542
|
+
end
|
|
543
|
+
|
|
544
|
+
private
|
|
545
|
+
|
|
546
|
+
# The body of {.run}: build the input, then run the steps.
|
|
547
|
+
#
|
|
548
|
+
# @param args [Array<Object>]
|
|
549
|
+
# @param kwargs [Hash{Symbol=>Object}]
|
|
550
|
+
# @return [Result]
|
|
551
|
+
def run_untyped(args, kwargs)
|
|
552
|
+
if input_schema || input_dispatch
|
|
553
|
+
input = build_input(args, kwargs)
|
|
554
|
+
return input if input.is_a?(Failure) # unmatched discriminator
|
|
555
|
+
return invalid_input_failure(input) unless input.valid?
|
|
556
|
+
|
|
557
|
+
instance = new
|
|
558
|
+
instance.instance_variable_set(:@input, input)
|
|
559
|
+
else
|
|
560
|
+
instance = new(*args, **kwargs)
|
|
561
|
+
end
|
|
562
|
+
|
|
563
|
+
ensure_steps_implemented!(instance)
|
|
564
|
+
ensure_verbs_last! unless @verbs_checked || !Actionable.verb_position_check?
|
|
565
|
+
Runner.new(instance).run
|
|
566
|
+
end
|
|
567
|
+
|
|
568
|
+
# Define this action's own +Success+, +Failure+ and +Skipped+ (decision
|
|
569
|
+
# D23), each subclassing the parent action's own when it has them, else
|
|
570
|
+
# the gem class. Defined once; a later call is a no-op.
|
|
571
|
+
#
|
|
572
|
+
# @raise [DefinitionError] when the action has its own constant by one
|
|
573
|
+
# of those names
|
|
574
|
+
# @return [Hash{Symbol=>Class<Result>}] status => result class
|
|
575
|
+
def define_outcome_classes
|
|
576
|
+
@define_outcome_classes ||= OUTCOME_BASES.to_h do |status, base|
|
|
577
|
+
[status, define_outcome_class(base.name.split('::').last.to_sym, status, base)]
|
|
578
|
+
end
|
|
579
|
+
end
|
|
580
|
+
|
|
581
|
+
# @param const [Symbol] the constant to define
|
|
582
|
+
# @param status [Symbol] the outcome {Result#status}
|
|
583
|
+
# @param base [Class<Result>] the gem result class
|
|
584
|
+
# @return [Class<Result>]
|
|
585
|
+
def define_outcome_class(const, status, base)
|
|
586
|
+
if const_defined?(const, false)
|
|
587
|
+
raise DefinitionError,
|
|
588
|
+
"#{name || "action"} has its own #{const}, which typed outcomes would replace; rename it"
|
|
589
|
+
end
|
|
590
|
+
|
|
591
|
+
inherited_class = superclass.instance_variable_get(:@define_outcome_classes)&.fetch(status)
|
|
592
|
+
action = self
|
|
593
|
+
outcome_class = Class.new(inherited_class || base)
|
|
594
|
+
outcome_class.define_singleton_method(:outcome_of) { action }
|
|
595
|
+
const_set(const, outcome_class)
|
|
596
|
+
end
|
|
597
|
+
|
|
598
|
+
# The result as this action's own result class for its outcome, under
|
|
599
|
+
# typed outcomes (decision D23). A result already of that class, or of a
|
|
600
|
+
# host's own result subclass, is returned as it is; a gem result, or
|
|
601
|
+
# another action's own (a nested failure that crossed), is rebuilt with
|
|
602
|
+
# its attributes and errors.
|
|
603
|
+
#
|
|
604
|
+
# @param result [Result]
|
|
605
|
+
# @return [Result]
|
|
606
|
+
def typed_result(result)
|
|
607
|
+
return result unless typed_outcomes?
|
|
608
|
+
|
|
609
|
+
target = define_outcome_classes.fetch(result.status)
|
|
610
|
+
return result if result.instance_of?(target)
|
|
611
|
+
return result unless OUTCOME_BASES.value?(result.class) || result.class.respond_to?(:outcome_of)
|
|
612
|
+
|
|
613
|
+
converted = target.new(**result.attributes)
|
|
614
|
+
converted.output_fields_set = result.output_fields_set
|
|
615
|
+
# Building it re-validates a nested output struct, which adds errors the
|
|
616
|
+
# result already has; the result's own errors are the whole truth.
|
|
617
|
+
converted.errors.to_h.each_key { |field| converted.errors.clear(field) }
|
|
618
|
+
result.errors.to_h.each { |field, messages| Array(messages).each { |m| converted.errors.add(field, m) } }
|
|
619
|
+
converted
|
|
620
|
+
end
|
|
621
|
+
|
|
622
|
+
# Build an output schema from a FieldStruct DSL block, refusing a field
|
|
623
|
+
# named like a result attribute (decisions D6/D18/D23).
|
|
624
|
+
#
|
|
625
|
+
# @param block [Proc] the FieldStruct field declarations
|
|
626
|
+
# @raise [DefinitionError] when a field name is in {RESERVED_OUTPUT_FIELDS}
|
|
627
|
+
# @return [Class<FieldStruct::Base>]
|
|
628
|
+
def build_output_schema(block)
|
|
629
|
+
schema = Class.new(FieldStruct::Base, &block)
|
|
630
|
+
conflicting = schema.attribute_names & RESERVED_OUTPUT_FIELDS
|
|
631
|
+
unless conflicting.empty?
|
|
632
|
+
raise DefinitionError,
|
|
633
|
+
"#{name || "action"}: output field(s) #{conflicting.map(&:inspect).join(", ")} " \
|
|
634
|
+
'conflict with reserved result attributes / instance variables; rename them'
|
|
635
|
+
end
|
|
636
|
+
|
|
637
|
+
schema
|
|
638
|
+
end
|
|
639
|
+
|
|
640
|
+
# One {.fails} / {.skips} declaration: the default message, the frozen
|
|
641
|
+
# metadata, and the output schema when a block declares one (D20/D21/D23).
|
|
642
|
+
#
|
|
643
|
+
# @param message [String, nil]
|
|
644
|
+
# @param metadata [Hash{Symbol=>Object}]
|
|
645
|
+
# @param block [Proc, nil]
|
|
646
|
+
# @return [Hash{Symbol=>Object}]
|
|
647
|
+
def declaration(message, metadata, block)
|
|
648
|
+
entry = {message: message, metadata: metadata.freeze}
|
|
649
|
+
entry[:output_schema] = build_output_schema(block) if block
|
|
650
|
+
entry.freeze
|
|
651
|
+
end
|
|
652
|
+
|
|
653
|
+
# {.describe}'s view of the declarations: each code's message and
|
|
654
|
+
# metadata, plus its output fields when it declares an output schema.
|
|
655
|
+
#
|
|
656
|
+
# @param declarations [Hash{Symbol=>Hash{Symbol=>Object}}]
|
|
657
|
+
# @return [Hash{Symbol=>Hash{Symbol=>Object}}]
|
|
658
|
+
def describe_declarations(declarations)
|
|
659
|
+
declarations.transform_values do |entry|
|
|
660
|
+
described = entry.slice(:message, :metadata)
|
|
661
|
+
described[:output] = entry[:output_schema].metadata.to_h if entry[:output_schema]
|
|
662
|
+
described
|
|
663
|
+
end
|
|
664
|
+
end
|
|
665
|
+
|
|
666
|
+
# One {.describe_text} block for an input/output schema: +" (free-form)"+
|
|
667
|
+
# when none is declared, otherwise the FieldStruct +Metadata#describe+
|
|
668
|
+
# field lines indented one level under their +Input:+/+Output:+ heading.
|
|
669
|
+
#
|
|
670
|
+
# @param schema [Class<FieldStruct::Base>, nil]
|
|
671
|
+
# @return [String]
|
|
672
|
+
def schema_summary(schema)
|
|
673
|
+
return ' (free-form)' unless schema
|
|
674
|
+
|
|
675
|
+
"\n#{schema.metadata.describe.lines(chomp: true).map { |line| " #{line}" }.join("\n")}"
|
|
676
|
+
end
|
|
677
|
+
|
|
678
|
+
# The +Failures:+ or +Skips:+ lines for {.describe_text}, one per declared
|
|
679
|
+
# code with its metadata and default message, or none when nothing is
|
|
680
|
+
# declared (decisions D20/D21).
|
|
681
|
+
#
|
|
682
|
+
# @param heading [String] the section heading
|
|
683
|
+
# @param declarations [Hash{Symbol=>Hash{Symbol=>Object}}] code => {message:, metadata:}
|
|
684
|
+
# @return [Array<String>]
|
|
685
|
+
def declarations_summary(heading, declarations)
|
|
686
|
+
return [] if declarations.empty?
|
|
687
|
+
|
|
688
|
+
lines = [" #{heading}:"]
|
|
689
|
+
declarations.each do |code, declaration|
|
|
690
|
+
metadata = declaration[:metadata].map { |key, value| "#{key}: #{value.inspect}" }.join(', ')
|
|
691
|
+
line = " - #{code}"
|
|
692
|
+
line += " (#{metadata})" unless metadata.empty?
|
|
693
|
+
line += ": #{declaration[:message]}" if declaration[:message]
|
|
694
|
+
lines << line
|
|
695
|
+
end
|
|
696
|
+
lines
|
|
697
|
+
end
|
|
698
|
+
|
|
699
|
+
# The +input+ value for {.describe}: a fixed schema's field metadata, a
|
|
700
|
+
# discriminated-input descriptor (+discriminated_on+ + +shapes+), or +nil+
|
|
701
|
+
# for a free-form action.
|
|
702
|
+
#
|
|
703
|
+
# @return [Hash, nil]
|
|
704
|
+
def input_descriptor
|
|
705
|
+
return input_schema.metadata.to_h if input_schema
|
|
706
|
+
return nil unless input_dispatch
|
|
707
|
+
|
|
708
|
+
{discriminated_on: input_dispatch.discriminator, shapes: input_dispatch.variants}
|
|
709
|
+
end
|
|
710
|
+
|
|
711
|
+
# The +Input:+ block for {.describe_text}: the fixed-schema field lines, a
|
|
712
|
+
# one-line discriminated-input summary, or +" (free-form)"+.
|
|
713
|
+
#
|
|
714
|
+
# @return [String]
|
|
715
|
+
def input_summary
|
|
716
|
+
return schema_summary(input_schema) if input_schema || input_dispatch.nil?
|
|
717
|
+
|
|
718
|
+
" (discriminated on #{input_dispatch.discriminator}: #{input_dispatch.variants.map(&:inspect).join(", ")})"
|
|
719
|
+
end
|
|
720
|
+
|
|
721
|
+
# Run-start guard (decision D18): every method a step needs (method steps,
|
|
722
|
+
# case value sources and method branch targets, hook steps) must exist on
|
|
723
|
+
# the instance, or raise a clear {DefinitionError} rather than a deep
|
|
724
|
+
# +NoMethodError+. This is the earliest reliable point — steps are usually
|
|
725
|
+
# declared above the methods that implement them.
|
|
726
|
+
#
|
|
727
|
+
# @param instance [Action]
|
|
728
|
+
# @return [void]
|
|
729
|
+
def ensure_steps_implemented!(instance)
|
|
730
|
+
all = steps.to_a + success_hooks.to_a + failure_hooks.to_a + skip_hooks.to_a + always_hooks.to_a
|
|
731
|
+
missing = all.flat_map(&:required_methods).uniq.reject { |method| implements?(instance, method) }
|
|
732
|
+
return if missing.empty?
|
|
733
|
+
|
|
734
|
+
raise DefinitionError,
|
|
735
|
+
"#{name || "action"} declares step(s) calling #{missing.map(&:inspect).join(", ")} " \
|
|
736
|
+
'but does not implement them'
|
|
737
|
+
end
|
|
738
|
+
|
|
739
|
+
# First-run guard (owner ruling R5): in every step and hook method, a
|
|
740
|
+
# non-bang recording verb (+fail+ / +fail_with+ / +skip+ / +succeed+) must be the
|
|
741
|
+
# method's last expression, or explicitly end it, since anything after
|
|
742
|
+
# it still runs (+VerbPosition+ has the rules). Checked once per class,
|
|
743
|
+
# by parsing each method's source with Prism; skipped where that can't
|
|
744
|
+
# be done (no Prism, no readable source), and not run at all while
|
|
745
|
+
# {Actionable.verb_position_check?} is off. A failing (or skipped) check
|
|
746
|
+
# is not cached, so it raises on every run until fixed.
|
|
747
|
+
#
|
|
748
|
+
# @raise [DefinitionError] naming each offending method, file:line and verb
|
|
749
|
+
# @return [void]
|
|
750
|
+
def ensure_verbs_last!
|
|
751
|
+
problems = verb_checked_steps.flat_map(&:body_methods).uniq.flat_map do |method_name|
|
|
752
|
+
next [] unless method_defined?(method_name) || private_method_defined?(method_name)
|
|
753
|
+
|
|
754
|
+
method = instance_method(method_name)
|
|
755
|
+
VerbPosition.method_offenses(method).map do |offense|
|
|
756
|
+
"#{name || "action"}##{method_name} (#{method.source_location&.first}:#{offense.line}) calls " \
|
|
757
|
+
"`#{offense.verb}` with more code after it, which still runs: use #{offense.verb}! to stop here, " \
|
|
758
|
+
"or make it the method's last expression"
|
|
759
|
+
end
|
|
760
|
+
end
|
|
761
|
+
raise DefinitionError, "#{problems.join("\n")}\n(see USAGE, Control flow)" unless problems.empty?
|
|
762
|
+
|
|
763
|
+
@verbs_checked = true
|
|
764
|
+
end
|
|
765
|
+
|
|
766
|
+
# The steps and hooks whose methods {.ensure_verbs_last!} checks. The
|
|
767
|
+
# Rails adapter adds its +after_commit+ hooks.
|
|
768
|
+
#
|
|
769
|
+
# @return [Array<Steps::Base>]
|
|
770
|
+
def verb_checked_steps
|
|
771
|
+
steps.to_a + success_hooks.to_a + failure_hooks.to_a + skip_hooks.to_a + always_hooks.to_a
|
|
772
|
+
end
|
|
773
|
+
|
|
774
|
+
# Whether +instance+ implements +method+ itself (public or private), not
|
|
775
|
+
# merely inherits it from Ruby's core: steps are called with +__send__+,
|
|
776
|
+
# so +step :puts+ or +always :exit+ must not reach +Kernel+.
|
|
777
|
+
#
|
|
778
|
+
# @param instance [Action]
|
|
779
|
+
# @param method [Symbol]
|
|
780
|
+
# @return [Boolean]
|
|
781
|
+
def implements?(instance, method)
|
|
782
|
+
instance.respond_to?(method, true) && !CORE_METHOD_OWNERS.include?(instance.method(method).owner)
|
|
783
|
+
end
|
|
784
|
+
|
|
785
|
+
# Coerce +.run+ arguments into the input struct: pass an existing input
|
|
786
|
+
# instance through untouched, otherwise build one from the keyword args.
|
|
787
|
+
# With discriminated input ({.input_for}), the schema is chosen from the
|
|
788
|
+
# discriminator value first; an unmatched value returns a +Failure+.
|
|
789
|
+
#
|
|
790
|
+
# @param args [Array<Object>] the positional arguments given to {.run}
|
|
791
|
+
# @param kwargs [Hash{Symbol=>Object}] the keyword arguments given to {.run}
|
|
792
|
+
# @return [FieldStruct::Base, Failure]
|
|
793
|
+
def build_input(args, kwargs)
|
|
794
|
+
return build_dispatched_input(args, kwargs) if input_dispatch
|
|
795
|
+
|
|
796
|
+
candidate = args.first
|
|
797
|
+
return candidate if args.size == 1 && candidate.is_a?(input_schema)
|
|
798
|
+
|
|
799
|
+
input_schema.new(**kwargs)
|
|
800
|
+
end
|
|
801
|
+
|
|
802
|
+
# Resolve the discriminated schema from the discriminator value, then build
|
|
803
|
+
# the input the same way {.build_input} does — or return an
|
|
804
|
+
# +:invalid_input+ {Failure} when nothing matches and there's no default.
|
|
805
|
+
#
|
|
806
|
+
# @param args [Array<Object>] the positional arguments given to {.run}
|
|
807
|
+
# @param kwargs [Hash{Symbol=>Object}] the keyword arguments given to {.run}
|
|
808
|
+
# @return [FieldStruct::Base, Failure]
|
|
809
|
+
def build_dispatched_input(args, kwargs)
|
|
810
|
+
candidate = args.first
|
|
811
|
+
return candidate if args.size == 1 && input_dispatch.schemas.any? { |schema| candidate.is_a?(schema) }
|
|
812
|
+
|
|
813
|
+
value = kwargs[input_dispatch.discriminator]
|
|
814
|
+
schema = input_dispatch.schema_for(value)
|
|
815
|
+
return unmatched_discriminator_failure(value) unless schema
|
|
816
|
+
|
|
817
|
+
schema.new(**kwargs)
|
|
818
|
+
end
|
|
819
|
+
|
|
820
|
+
# @param value [Object] the unmatched discriminator value
|
|
821
|
+
# @return [Failure] code +:invalid_input+, naming the discriminator
|
|
822
|
+
def unmatched_discriminator_failure(value)
|
|
823
|
+
Failure.new(
|
|
824
|
+
code: :invalid_input,
|
|
825
|
+
message: "no input shape for #{input_dispatch.discriminator} #{value.inspect}",
|
|
826
|
+
metadata: declared_failures.fetch(:invalid_input, {})
|
|
827
|
+
)
|
|
828
|
+
end
|
|
829
|
+
|
|
830
|
+
# @param input [FieldStruct::Base] the invalid input struct
|
|
831
|
+
# @return [Failure] code +:invalid_input+, carrying the input's errors
|
|
832
|
+
def invalid_input_failure(input)
|
|
833
|
+
Failure.new(
|
|
834
|
+
code: :invalid_input,
|
|
835
|
+
message: 'input failed validation',
|
|
836
|
+
metadata: declared_failures.fetch(:invalid_input, {})
|
|
837
|
+
).absorb_errors_from(input)
|
|
838
|
+
end
|
|
839
|
+
|
|
840
|
+
# Add +step+ to +list+, or — when an equal step (same type and name) is
|
|
841
|
+
# already declared — swap it in at that step's position, so the last
|
|
842
|
+
# declaration wins (owner ruling P8; invariant 5). +list+ is this class's
|
|
843
|
+
# own copy (see {.inherited}), so a parent's list is never touched.
|
|
844
|
+
#
|
|
845
|
+
# @param list [Set<Steps::Base>]
|
|
846
|
+
# @param step [Steps::Base]
|
|
847
|
+
# @return [Set<Steps::Base>] +list+
|
|
848
|
+
def declare(list, step)
|
|
849
|
+
return list << step unless list.include?(step)
|
|
850
|
+
|
|
851
|
+
list.replace(list.map { |existing| existing.eql?(step) ? step : existing })
|
|
852
|
+
end
|
|
853
|
+
|
|
854
|
+
public
|
|
855
|
+
|
|
856
|
+
# Give each subclass its own copy of the inherited step set, so adding
|
|
857
|
+
# steps to a subclass never mutates the parent's (decision D2).
|
|
858
|
+
#
|
|
859
|
+
# @param subclass [Class]
|
|
860
|
+
# @return [void]
|
|
861
|
+
def inherited(subclass)
|
|
862
|
+
super
|
|
863
|
+
Actionable.registry.register(subclass)
|
|
864
|
+
subclass.instance_variable_set(:@steps, steps.dup)
|
|
865
|
+
subclass.instance_variable_set(:@output_schema, output_schema)
|
|
866
|
+
subclass.instance_variable_set(:@input_schema, input_schema)
|
|
867
|
+
subclass.instance_variable_set(:@input_dispatch, input_dispatch)
|
|
868
|
+
subclass.instance_variable_set(:@success_hooks, success_hooks.dup)
|
|
869
|
+
subclass.instance_variable_set(:@failure_hooks, failure_hooks.dup)
|
|
870
|
+
subclass.instance_variable_set(:@skip_hooks, skip_hooks.dup)
|
|
871
|
+
subclass.instance_variable_set(:@always_hooks, always_hooks.dup)
|
|
872
|
+
subclass.instance_variable_set(:@failure_declarations, failure_declarations.dup)
|
|
873
|
+
subclass.instance_variable_set(:@skip_declarations, skip_declarations.dup)
|
|
874
|
+
subclass.instance_variable_set(:@measure, measure)
|
|
875
|
+
subclass.instance_variable_set(:@measure_rate, measure_rate)
|
|
876
|
+
subclass.instance_variable_set(:@typed_outcomes, typed_outcomes?)
|
|
877
|
+
subclass.__send__(:define_outcome_classes) if typed_outcomes?
|
|
878
|
+
end
|
|
879
|
+
end
|
|
880
|
+
|
|
881
|
+
# Default free-form constructor: accept and ignore any arguments, so an
|
|
882
|
+
# action with no declared input still runs via {.run}. Subclasses override
|
|
883
|
+
# this to capture their inputs (decision D7; typed input lands in a later
|
|
884
|
+
# slice).
|
|
885
|
+
#
|
|
886
|
+
# @param _args [Object] ignored
|
|
887
|
+
# @param _kwargs [Object] ignored
|
|
888
|
+
def initialize(*_args, **_kwargs)
|
|
889
|
+
end
|
|
890
|
+
|
|
891
|
+
# The result recorded during the run, if any. The control-flow methods
|
|
892
|
+
# (+fail+/+succeed+/+fail!+/+succeed!+) write here; +nil+ means "no explicit
|
|
893
|
+
# result", which the {Runner} turns into an auto-{Success}.
|
|
894
|
+
#
|
|
895
|
+
# @return [Result, nil]
|
|
896
|
+
attr_reader :result
|
|
897
|
+
|
|
898
|
+
# The typed input struct for this run, when an +input+ schema is declared
|
|
899
|
+
# (decision D7); +nil+ for a free-form action. Steps read +input.field+.
|
|
900
|
+
#
|
|
901
|
+
# @return [FieldStruct::Base, nil]
|
|
902
|
+
attr_reader :input
|
|
903
|
+
|
|
904
|
+
private
|
|
905
|
+
|
|
906
|
+
# Record a {Failure} as the run's result without halting — later steps
|
|
907
|
+
# still run, and the final result reflects the most recent record (decision
|
|
908
|
+
# D4, "last write wins").
|
|
909
|
+
#
|
|
910
|
+
# A code declared with {Action.fails} attaches its metadata to the failure,
|
|
911
|
+
# and its declared message when +message+ is +nil+ (decision D20). Nothing
|
|
912
|
+
# but +errors+ follows the message, so an error on any field name, +kind+
|
|
913
|
+
# included, lands in +errors+.
|
|
914
|
+
#
|
|
915
|
+
# Under typed outcomes alone, +output:+ is reserved: it is the failure's
|
|
916
|
+
# output, held to the schema its code declares (decision D23).
|
|
917
|
+
#
|
|
918
|
+
# fail!(:invalid, 'check the form', output: {form_record: record}, name: 'is blank')
|
|
919
|
+
#
|
|
920
|
+
# @param code [Symbol] the error code
|
|
921
|
+
# @param message [String, nil] human-readable description
|
|
922
|
+
# @param errors [String, Array<String>, Hash{Symbol=>Object}, FieldStruct::Base] each
|
|
923
|
+
# keyword is a field => message(s) added to the result's errors collection;
|
|
924
|
+
# under typed outcomes the +output:+ keyword is the failure's output instead
|
|
925
|
+
# @return [false] so a step can branch on the call
|
|
926
|
+
def fail(code, message = nil, **errors)
|
|
927
|
+
output = self.class.typed_outcomes? ? errors.delete(:output) : nil
|
|
928
|
+
failure = declared_failure(code, message)
|
|
929
|
+
ensure_output_declared!(:fails, failure.code, output)
|
|
930
|
+
errors.each { |field, messages| Array(messages).each { |m| failure.errors.add(field, m) } }
|
|
931
|
+
failure.output = output unless output.nil?
|
|
932
|
+
@result = failure
|
|
933
|
+
false
|
|
934
|
+
end
|
|
935
|
+
|
|
936
|
+
# Record a {Failure} that absorbs an arbitrary FieldStruct's validation
|
|
937
|
+
# errors, without halting (decision D19). The same machinery
|
|
938
|
+
# +Failure(:invalid_input)+ / +Failure(:invalid_output)+ use internally,
|
|
939
|
+
# exposed as a verb so a step can validate a side struct and fail with its
|
|
940
|
+
# errors directly.
|
|
941
|
+
#
|
|
942
|
+
# def validate = fail_with(EventShape.new(payload), code: :invalid_event)
|
|
943
|
+
#
|
|
944
|
+
# @param source [#errors] any object exposing a FieldStruct-style +errors+
|
|
945
|
+
# collection (typically a +FieldStruct::Base+ instance)
|
|
946
|
+
# @param code [Symbol] the error code; defaults to +:invalid+
|
|
947
|
+
# @param message [String, nil] human-readable description
|
|
948
|
+
# @param output [Hash{Symbol=>Object}, nil] the failure's output, held to
|
|
949
|
+
# the schema its code declares; typed outcomes only (decision D23)
|
|
950
|
+
# @raise [ArgumentError] when +output+ is given without typed outcomes
|
|
951
|
+
# @return [false] so a step can branch on the call
|
|
952
|
+
def fail_with(source, code: :invalid, message: nil, output: nil)
|
|
953
|
+
unless output.nil? || self.class.typed_outcomes?
|
|
954
|
+
raise ArgumentError, 'fail_with takes output: only under typed_outcomes (decision D23)'
|
|
955
|
+
end
|
|
956
|
+
|
|
957
|
+
failure = declared_failure(code, message).absorb_errors_from(source)
|
|
958
|
+
ensure_output_declared!(:fails, failure.code, output)
|
|
959
|
+
failure.output = output unless output.nil?
|
|
960
|
+
@result = failure
|
|
961
|
+
false
|
|
962
|
+
end
|
|
963
|
+
|
|
964
|
+
# Record a {Failure} absorbing +source+'s errors and halt the run
|
|
965
|
+
# (decision D19). The halting companion to {#fail_with}.
|
|
966
|
+
#
|
|
967
|
+
# @param source [#errors] see {#fail_with}
|
|
968
|
+
# @param code [Symbol] the error code; defaults to +:invalid+
|
|
969
|
+
# @param message [String, nil] human-readable description
|
|
970
|
+
# @param output [Hash{Symbol=>Object}, nil] see {#fail_with}
|
|
971
|
+
# @return [void]
|
|
972
|
+
def fail_with!(source, code: :invalid, message: nil, output: nil)
|
|
973
|
+
fail_with(source, code: code, message: message, output: output)
|
|
974
|
+
halt!
|
|
975
|
+
end
|
|
976
|
+
|
|
977
|
+
# Record a {Success} as the run's result without halting (decision D4).
|
|
978
|
+
#
|
|
979
|
+
# @param message [String, nil] human-readable description
|
|
980
|
+
# @param output [Object] the success output payload
|
|
981
|
+
# @return [true] so a step can branch on the call
|
|
982
|
+
def succeed(message = nil, **output)
|
|
983
|
+
@result = Success.new(message: message, output: output)
|
|
984
|
+
true
|
|
985
|
+
end
|
|
986
|
+
|
|
987
|
+
# Record a {Failure} and halt the run, skipping the remaining steps
|
|
988
|
+
# (decision D4).
|
|
989
|
+
#
|
|
990
|
+
# @param code [Symbol] the error code
|
|
991
|
+
# @param message [String, nil] human-readable description
|
|
992
|
+
# @param errors [String, Array<String>, Hash{Symbol=>Object}, FieldStruct::Base] see {#fail}
|
|
993
|
+
# @raise [UncaughtThrowError] never escapes the {Runner}'s halt catch
|
|
994
|
+
# @return [void]
|
|
995
|
+
def fail!(code, message = nil, **errors)
|
|
996
|
+
fail(code, message, **errors)
|
|
997
|
+
halt!
|
|
998
|
+
end
|
|
999
|
+
|
|
1000
|
+
# Record a {Success} and halt the run, skipping the remaining steps
|
|
1001
|
+
# (decision D4).
|
|
1002
|
+
#
|
|
1003
|
+
# @param message [String, nil] human-readable description
|
|
1004
|
+
# @param output [Object] the success output payload
|
|
1005
|
+
# @return [void]
|
|
1006
|
+
def succeed!(message = nil, **output)
|
|
1007
|
+
succeed(message, **output)
|
|
1008
|
+
halt!
|
|
1009
|
+
end
|
|
1010
|
+
|
|
1011
|
+
# Record a {Skipped} as the run's result without halting — the action had
|
|
1012
|
+
# nothing to do (decision D17). Not a failure; later steps still run and the
|
|
1013
|
+
# final result reflects the most recent record (last write wins).
|
|
1014
|
+
#
|
|
1015
|
+
# A code declared with {Action.skips} attaches its metadata to the skip,
|
|
1016
|
+
# and its declared message when +message+ is +nil+ (decision D21). Once the
|
|
1017
|
+
# action declares any skip, an undeclared +code+ raises.
|
|
1018
|
+
#
|
|
1019
|
+
# @param code [Symbol] the skip reason; defaults to +:skipped+
|
|
1020
|
+
# @param message [String, nil] human-readable description
|
|
1021
|
+
# @param output [Object] the skip output payload — captured
|
|
1022
|
+
# best-effort like a {Success}'s output, but never validated (decision
|
|
1023
|
+
# D17). Lets an idempotent hit return the existing record's data.
|
|
1024
|
+
# @raise [DefinitionError] when the action declares skips but not +code+
|
|
1025
|
+
# @return [false] so a step can branch on the call (the run did not succeed here)
|
|
1026
|
+
def skip(code = :skipped, message = nil, **output)
|
|
1027
|
+
ensure_code_identifier!(:skip, code)
|
|
1028
|
+
code = code.to_sym
|
|
1029
|
+
ensure_skip_declared!(code)
|
|
1030
|
+
ensure_output_declared!(:skips, code, output) if self.class.typed_outcomes?
|
|
1031
|
+
declaration = self.class.skip_declarations.fetch(code, {message: nil, metadata: {}})
|
|
1032
|
+
@result = Skipped.new(
|
|
1033
|
+
code: code, message: message || declaration[:message], output: output, metadata: declaration[:metadata]
|
|
1034
|
+
)
|
|
1035
|
+
false
|
|
1036
|
+
end
|
|
1037
|
+
|
|
1038
|
+
# Record a {Skipped} and halt the run, skipping the remaining steps — the
|
|
1039
|
+
# common "nothing to do, stop here" case (decision D17).
|
|
1040
|
+
#
|
|
1041
|
+
# @param code [Symbol] the skip reason; defaults to +:skipped+
|
|
1042
|
+
# @param message [String, nil] human-readable description
|
|
1043
|
+
# @param output [Object] the skip output payload (see {#skip})
|
|
1044
|
+
# @return [void]
|
|
1045
|
+
def skip!(code = :skipped, message = nil, **output)
|
|
1046
|
+
skip(code, message, **output)
|
|
1047
|
+
halt!
|
|
1048
|
+
end
|
|
1049
|
+
|
|
1050
|
+
# Build a {Failure} for +code+ carrying what {Action.fails} declared for it:
|
|
1051
|
+
# its metadata, and its message when +message+ is +nil+ (decision D20).
|
|
1052
|
+
#
|
|
1053
|
+
# @param code [Symbol, String] the error code
|
|
1054
|
+
# @param message [String, nil] the message given at the call
|
|
1055
|
+
# @return [Failure]
|
|
1056
|
+
# @raise [DefinitionError] when the action declares failures but not +code+
|
|
1057
|
+
# (the {GEM_FAILURE_CODES} are exempt)
|
|
1058
|
+
def declared_failure(code, message)
|
|
1059
|
+
ensure_code_identifier!(:failure, code)
|
|
1060
|
+
code = code.to_sym
|
|
1061
|
+
ensure_failure_declared!(code)
|
|
1062
|
+
declaration = self.class.failure_declarations.fetch(code, {message: nil, metadata: {}})
|
|
1063
|
+
Failure.new(code: code, message: message || declaration[:message], metadata: declaration[:metadata])
|
|
1064
|
+
end
|
|
1065
|
+
|
|
1066
|
+
# Fail this run with a nested action's failure and halt (decision D20).
|
|
1067
|
+
# {Steps::Action} calls it instead of +fail!+, so the child's failure
|
|
1068
|
+
# crosses intact: its code, message, every error (on any field name, since
|
|
1069
|
+
# no keyword list is involved), its metadata, and its result class with any
|
|
1070
|
+
# extra attributes a host subclass adds. Under the child's metadata sits
|
|
1071
|
+
# what this action declares for the code, so the parent's declaration fills
|
|
1072
|
+
# in keys the child didn't set. The child's output and history stay on the
|
|
1073
|
+
# child. A declaring parent must declare the child's code too.
|
|
1074
|
+
#
|
|
1075
|
+
# Under typed outcomes the child's output crosses as this action's failure
|
|
1076
|
+
# output, so it is held to the schema this action declares for the code,
|
|
1077
|
+
# not the child's (decision D23), and the failure becomes this action's own
|
|
1078
|
+
# +Failure+ when {.run} returns.
|
|
1079
|
+
#
|
|
1080
|
+
# @param child [Failure] the nested action's failure
|
|
1081
|
+
# @raise [DefinitionError] when this action declares failures but not the
|
|
1082
|
+
# child's code
|
|
1083
|
+
# @return [void]
|
|
1084
|
+
def adopt_failure!(child)
|
|
1085
|
+
ensure_failure_declared!(child.code)
|
|
1086
|
+
declaration = self.class.failure_declarations.fetch(child.code, {message: nil, metadata: {}})
|
|
1087
|
+
failure_class = adopted_failure_class(child)
|
|
1088
|
+
attributes = child.attributes.except(:output, :history)
|
|
1089
|
+
@result = failure_class.new(
|
|
1090
|
+
**attributes,
|
|
1091
|
+
message: child.message || declaration[:message],
|
|
1092
|
+
metadata: declaration[:metadata].merge(attributes.fetch(:metadata, {}))
|
|
1093
|
+
).absorb_errors_from(child)
|
|
1094
|
+
@result.output = crossing_output(child.output) if crosses_output?(child)
|
|
1095
|
+
halt!
|
|
1096
|
+
end
|
|
1097
|
+
|
|
1098
|
+
# The class a nested failure crosses as: a host's own result subclass
|
|
1099
|
+
# stays (D20), but a typed child's own +Failure+ becomes the gem class,
|
|
1100
|
+
# since it names the child; a typed parent then makes it its own (D23).
|
|
1101
|
+
#
|
|
1102
|
+
# @param child [Result]
|
|
1103
|
+
# @return [Class<Failure>]
|
|
1104
|
+
def adopted_failure_class(child)
|
|
1105
|
+
return Failure unless child.is_a?(Failure)
|
|
1106
|
+
|
|
1107
|
+
child.class.respond_to?(:outcome_of) ? Failure : child.class
|
|
1108
|
+
end
|
|
1109
|
+
|
|
1110
|
+
# Whether a nested failure's output crosses into this action: under typed
|
|
1111
|
+
# outcomes, for every code but those the gem builds, which keep the output
|
|
1112
|
+
# they had before typed outcomes (none) (decision D23).
|
|
1113
|
+
#
|
|
1114
|
+
# @param child [Failure]
|
|
1115
|
+
# @return [Boolean]
|
|
1116
|
+
def crosses_output?(child)
|
|
1117
|
+
self.class.typed_outcomes? && !GEM_FAILURE_CODES.include?(child.code)
|
|
1118
|
+
end
|
|
1119
|
+
|
|
1120
|
+
# @param output [FieldStruct::Base, Hash, Object] a nested failure's output
|
|
1121
|
+
# @return [Hash{Symbol=>Object}] its fields, for this action's schema
|
|
1122
|
+
def crossing_output(output)
|
|
1123
|
+
case output
|
|
1124
|
+
when FieldStruct::Base then output.attributes
|
|
1125
|
+
when Hash then output
|
|
1126
|
+
else {}
|
|
1127
|
+
end
|
|
1128
|
+
end
|
|
1129
|
+
|
|
1130
|
+
# A +fail+ / +skip+ code must be an identifier-like Symbol or String
|
|
1131
|
+
# ({CODE_FORMAT}): +fail 'card expired'+ is +Kernel#fail+ habit, and in an
|
|
1132
|
+
# action it would record the sentence as a code and keep running (owner
|
|
1133
|
+
# ruling R5).
|
|
1134
|
+
#
|
|
1135
|
+
# @param kind [:failure, :skip]
|
|
1136
|
+
# @param code [Object]
|
|
1137
|
+
# @raise [ArgumentError] when +code+ is not an identifier
|
|
1138
|
+
# @return [void]
|
|
1139
|
+
def ensure_code_identifier!(kind, code)
|
|
1140
|
+
return if (code.is_a?(Symbol) || code.is_a?(String)) && CODE_FORMAT.match?(code)
|
|
1141
|
+
|
|
1142
|
+
raise ArgumentError,
|
|
1143
|
+
"#{self.class.name || "action"}: #{kind} code #{code.inspect} is not an identifier; use a Symbol like " \
|
|
1144
|
+
':card_expired. Inside an action, fail records a failure, it is not Kernel#fail: to raise an ' \
|
|
1145
|
+
'exception, use raise'
|
|
1146
|
+
end
|
|
1147
|
+
|
|
1148
|
+
# Strictness (decision D20): once an action declares any failure, every
|
|
1149
|
+
# code it fails with must be declared, so its declarations are its whole
|
|
1150
|
+
# failure contract. An action that declares none is unchecked, and the
|
|
1151
|
+
# {GEM_FAILURE_CODES} are always allowed.
|
|
1152
|
+
#
|
|
1153
|
+
# @param code [Symbol]
|
|
1154
|
+
# @raise [DefinitionError] when +code+ is undeclared
|
|
1155
|
+
# @return [void]
|
|
1156
|
+
def ensure_failure_declared!(code)
|
|
1157
|
+
return if self.class.allows_failure_code?(code)
|
|
1158
|
+
|
|
1159
|
+
declared = self.class.declared_failures.keys.map(&:inspect).join(', ')
|
|
1160
|
+
raise DefinitionError,
|
|
1161
|
+
"#{self.class.name || "action"} fails with #{code.inspect}, which it does not declare; " \
|
|
1162
|
+
"declare it with `fails #{code.inspect}` (declared: #{declared})"
|
|
1163
|
+
end
|
|
1164
|
+
|
|
1165
|
+
# Under typed outcomes, output given for a skip or failure must have a
|
|
1166
|
+
# schema to go into (decision D23): output for a code with none would be
|
|
1167
|
+
# dropped, so it is refused instead. The failures the gem builds keep
|
|
1168
|
+
# their output, so they need none. +output:+ itself must be a Hash or a
|
|
1169
|
+
# struct, which catches an old +output:+ error message.
|
|
1170
|
+
#
|
|
1171
|
+
# @param verb [:fails, :skips] the declaration that would add the schema
|
|
1172
|
+
# @param code [Symbol]
|
|
1173
|
+
# @param output [Hash, FieldStruct::Base, nil] the output given, if any
|
|
1174
|
+
# @raise [ArgumentError] when +output+ is neither a Hash nor a struct
|
|
1175
|
+
# @raise [DefinitionError] when +output+ is given for a code with no schema
|
|
1176
|
+
# @return [void]
|
|
1177
|
+
def ensure_output_declared!(verb, code, output)
|
|
1178
|
+
return if output.nil?
|
|
1179
|
+
unless output.is_a?(Hash) || output.is_a?(FieldStruct::Base)
|
|
1180
|
+
raise ArgumentError,
|
|
1181
|
+
"output: must be a Hash or a FieldStruct, got #{output.class}: under typed_outcomes it is the " \
|
|
1182
|
+
'outcome\'s output, not an error'
|
|
1183
|
+
end
|
|
1184
|
+
return if output.is_a?(Hash) && output.empty?
|
|
1185
|
+
|
|
1186
|
+
klass = self.class
|
|
1187
|
+
schema = verb == :skips ? klass.skip_output_schema(code) : klass.failure_output_schema(code)
|
|
1188
|
+
return if schema || (verb == :fails && GEM_FAILURE_CODES.include?(code))
|
|
1189
|
+
|
|
1190
|
+
raise DefinitionError,
|
|
1191
|
+
"#{klass.name || "action"} #{verb} with #{code.inspect} and output, but " \
|
|
1192
|
+
"#{code.inspect} declares no output schema; declare one with `#{verb} #{code.inspect} do … end`"
|
|
1193
|
+
end
|
|
1194
|
+
|
|
1195
|
+
# Strictness (decision D21): once an action declares any skip, every code
|
|
1196
|
+
# it skips with must be declared, the implicit default +:skipped+ included.
|
|
1197
|
+
# An action that declares none is unchecked.
|
|
1198
|
+
#
|
|
1199
|
+
# @param code [Symbol]
|
|
1200
|
+
# @raise [DefinitionError] when +code+ is undeclared
|
|
1201
|
+
# @return [void]
|
|
1202
|
+
def ensure_skip_declared!(code)
|
|
1203
|
+
return if self.class.allows_skip_code?(code)
|
|
1204
|
+
|
|
1205
|
+
declared = self.class.declared_skips.keys.map(&:inspect).join(', ')
|
|
1206
|
+
raise DefinitionError,
|
|
1207
|
+
"#{self.class.name || "action"} skips with #{code.inspect}, which it does not declare; " \
|
|
1208
|
+
"declare it with `skips #{code.inspect}` (declared: #{declared})"
|
|
1209
|
+
end
|
|
1210
|
+
|
|
1211
|
+
# Halt the run immediately, keeping whatever result was already recorded
|
|
1212
|
+
# (a prior +fail+/+succeed+/+skip+, or auto-{Success} if none). Control flow
|
|
1213
|
+
# only — not an exception (decision D4).
|
|
1214
|
+
#
|
|
1215
|
+
# @return [void]
|
|
1216
|
+
def halt!
|
|
1217
|
+
throw HALT
|
|
1218
|
+
end
|
|
1219
|
+
end
|
|
1220
|
+
end
|