actionable-rb 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. checksums.yaml +7 -0
  2. data/.claude-plugin/marketplace.json +15 -0
  3. data/.claude-plugin/plugin.json +9 -0
  4. data/.rspec +3 -0
  5. data/AGENTS.md +150 -0
  6. data/CHANGELOG.md +766 -0
  7. data/CODE_OF_CONDUCT.md +132 -0
  8. data/LICENSE.txt +21 -0
  9. data/README.md +555 -0
  10. data/UPGRADING.md +957 -0
  11. data/USAGE.md +1291 -0
  12. data/docs/getting_started.md +611 -0
  13. data/lib/actionable/action.rb +1220 -0
  14. data/lib/actionable/batch_result.rb +92 -0
  15. data/lib/actionable/errors.rb +36 -0
  16. data/lib/actionable/history.rb +91 -0
  17. data/lib/actionable/input_dispatch.rb +94 -0
  18. data/lib/actionable/job.rb +157 -0
  19. data/lib/actionable/measurement.rb +64 -0
  20. data/lib/actionable/rails/after_commit.rb +142 -0
  21. data/lib/actionable/rails/proxy_validator.rb +59 -0
  22. data/lib/actionable/rails/transactions.rb +95 -0
  23. data/lib/actionable/rails.rb +24 -0
  24. data/lib/actionable/railtie.rb +10 -0
  25. data/lib/actionable/rbs.rb +502 -0
  26. data/lib/actionable/registry.rb +64 -0
  27. data/lib/actionable/result.rb +166 -0
  28. data/lib/actionable/results/failure.rb +43 -0
  29. data/lib/actionable/results/skipped.rb +30 -0
  30. data/lib/actionable/results/success.rb +14 -0
  31. data/lib/actionable/rspec/coverage/report.rb +264 -0
  32. data/lib/actionable/rspec/coverage/tracker.rb +330 -0
  33. data/lib/actionable/rspec/coverage.rb +45 -0
  34. data/lib/actionable/rspec/matchers.rb +266 -0
  35. data/lib/actionable/rspec/stubs.rb +250 -0
  36. data/lib/actionable/rspec.rb +40 -0
  37. data/lib/actionable/rubocop/config.yml +8 -0
  38. data/lib/actionable/rubocop/cop/actionable/verb_not_last.rb +62 -0
  39. data/lib/actionable/rubocop/plugin.rb +31 -0
  40. data/lib/actionable/rubocop.rb +25 -0
  41. data/lib/actionable/runner.rb +300 -0
  42. data/lib/actionable/steps/action.rb +76 -0
  43. data/lib/actionable/steps/base.rb +107 -0
  44. data/lib/actionable/steps/case.rb +124 -0
  45. data/lib/actionable/steps/method.rb +39 -0
  46. data/lib/actionable/steps.rb +27 -0
  47. data/lib/actionable/value_match.rb +22 -0
  48. data/lib/actionable/verb_position.rb +574 -0
  49. data/lib/actionable/version.rb +6 -0
  50. data/lib/actionable-rb.rb +6 -0
  51. data/lib/actionable.rb +59 -0
  52. data/sig/actionable/dsl.rbs +42 -0
  53. data/sig/actionable/rails.rbs +16 -0
  54. data/sig/actionable.rbs +2593 -0
  55. data/skills/actionable/SKILL.md +214 -0
  56. metadata +132 -0
@@ -0,0 +1,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