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,574 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Actionable
4
+ # Where a non-bang recording verb (+fail+ / +fail_with+ / +skip+ /
5
+ # +succeed+) may sit in a step or hook method (owner rulings R5, R10). Those verbs record an outcome and
6
+ # keep running, so +fail :card_expired if card.expired?+ followed by more
7
+ # code is almost always a +fail!+ that forgot its bang. The rule: the verb
8
+ # must be the method's last expression, or explicitly end it.
9
+ #
10
+ # One implementation over a Prism AST, shared by the first-run check
11
+ # ({Action.run} raises a {DefinitionError}) and the opt-in RuboCop cop
12
+ # (+require: actionable/rubocop+). Self-contained on purpose: the cop loads
13
+ # it without the rest of the gem. Prism is optional (Ruby 3.2 ships without
14
+ # it), so nothing here requires it at load time; see {.prism?}.
15
+ #
16
+ # A verb call (receiver-less, or on +self+) is fine when it is:
17
+ # - in tail position: the method body's last statement, or the last
18
+ # statement of a branch (+if+/+elsif+/+else+, ternary, +case+/+when+/+in+,
19
+ # +rescue+, +ensure+, the right side of +&&+/+and+/+||+/+or+, a block or
20
+ # loop body) whose enclosing construct is itself in tail position;
21
+ # - returned: +return fail(:x)+, or followed directly by +return+;
22
+ # - inside a block or loop body, followed directly by +next+/+break+, when
23
+ # that block's call is in tail position.
24
+ # Anything else (more code follows on the method's path) is an offense. A
25
+ # begin body that has an +else+ is followed by it; +ensure+ is cleanup that
26
+ # runs even after a bang verb, so it never counts as following code.
27
+ #
28
+ # @api private
29
+ module VerbPosition
30
+ # The non-bang verbs that record an outcome without halting: every
31
+ # +Action+ verb that writes the result and returns. +adopt_failure!+ (the
32
+ # runner's nested-failure hand-off) halts, so it is not one.
33
+ # @return [Array<Symbol>]
34
+ VERBS = %i[fail fail_with skip succeed].freeze
35
+
36
+ # The class-body declarations whose named methods run as steps or hooks.
37
+ # @return [Array<Symbol>]
38
+ DECLARATIONS = %i[step on_success on_failure on_skip always after_commit].freeze
39
+
40
+ # The two-argument +on+ / one-argument +default+ branch targets inside a
41
+ # +case_step+ block.
42
+ # @return [Array<Symbol>]
43
+ CASE_BRANCHES = %i[on default].freeze
44
+
45
+ # The classes the RuboCop cop treats as action bases by default (its
46
+ # +ActionBaseClasses+ option): a class body is checked only when its
47
+ # superclass is one of these, or a class defined in the same file whose
48
+ # own superclass chain reaches one.
49
+ # @return [Array<String>]
50
+ ACTION_BASE_CLASSES = %w[Actionable::Action ApplicationAction].freeze
51
+
52
+ # The node types that open a new method scope: never looked inside.
53
+ # @return [Array<Symbol>]
54
+ SCOPES = %i[def_node class_node module_node singleton_class_node].freeze
55
+
56
+ # One misplaced verb call.
57
+ class Offense
58
+ # @return [Symbol] the verb (one of {VERBS})
59
+ attr_reader :verb
60
+ # @return [Integer] the 1-based line of the call
61
+ attr_reader :line
62
+ # @return [Integer] the 0-based column of the call
63
+ attr_reader :column
64
+ # @return [Integer] the call's start, as a character offset into the source
65
+ attr_reader :start_character
66
+ # @return [Integer] the call's end, as a character offset into the source
67
+ attr_reader :end_character
68
+
69
+ # @param verb [Symbol]
70
+ # @param line [Integer]
71
+ # @param column [Integer]
72
+ # @param start_character [Integer]
73
+ # @param end_character [Integer]
74
+ def initialize(verb, line, column, start_character, end_character)
75
+ @verb = verb
76
+ @line = line
77
+ @column = column
78
+ @start_character = start_character
79
+ @end_character = end_character
80
+ end
81
+ end
82
+
83
+ @files = {}
84
+ @files_lock = Mutex.new
85
+
86
+ class << self
87
+ # Whether Prism can be loaded (it is a default gem from Ruby 3.3, and a
88
+ # dependency of RuboCop). Checked once. Prism before 1.0 counts as
89
+ # missing: its node API differs.
90
+ #
91
+ # @return [Boolean]
92
+ def prism?
93
+ return @prism if defined?(@prism)
94
+
95
+ @prism = begin
96
+ require 'prism'
97
+ Gem::Version.new(::Prism::VERSION) >= Gem::Version.new('1.0')
98
+ rescue LoadError
99
+ false
100
+ end
101
+ end
102
+
103
+ # The misplaced verb calls in one method.
104
+ #
105
+ # @param def_node [Prism::DefNode]
106
+ # @return [Array<Offense>]
107
+ def offenses(def_node)
108
+ found = [] # : Array[Offense]
109
+ visit(def_node.body, true, nil, found)
110
+ found
111
+ end
112
+
113
+ # The misplaced verb calls in an instance method, read from its source
114
+ # file (parsed once per path and mtime). Empty when Prism is missing,
115
+ # the method has no readable source (+eval+, a C method), the file does
116
+ # not parse cleanly, or no +def+ by that name starts on that line (a
117
+ # +define_method+). The def is looked up by +original_name+, so a step
118
+ # naming an +alias_method+ of it is checked too.
119
+ #
120
+ # Known limit: for a module prepended over the class's def, the method
121
+ # resolved is the wrapper, so only the wrapper's body is checked, not
122
+ # the class's own def it calls through +super+.
123
+ #
124
+ # @param method [UnboundMethod]
125
+ # @return [Array<Offense>]
126
+ def method_offenses(method)
127
+ path, line = method.source_location
128
+ return [] unless path && prism? && File.file?(path) && File.readable?(path)
129
+
130
+ file_offenses(path).fetch([method.original_name, line], [])
131
+ end
132
+
133
+ # The step and hook methods an action class body declares, with their
134
+ # offenses, for the RuboCop cop. A class body counts when its superclass
135
+ # is an action base (owner ruling R10): one of +base_classes+, or a
136
+ # class defined in this file (+class X < Y+ or +X = Class.new(Y)+, any
137
+ # depth) whose superclass is. So a dry-transaction class, which also
138
+ # declares +step+, is left alone. A superclass name resolves the way
139
+ # Ruby looks it up: from the enclosing +module+ / +class+ keywords
140
+ # outwards (a compact +class A::B+ opens only +A::B+), to the first
141
+ # match among the file's classes and modules and +base_classes+, else
142
+ # the top level; +::X+ is the top level. So +B::Base+ is not +A::Base+,
143
+ # and a file class shadows a base class of the same name. It
144
+ # must also declare a +step+ or +case_step+; the methods checked are the
145
+ # ones it names in a declaration ({DECLARATIONS}, +case_step+ branch
146
+ # targets), never a +case_step+'s value source. Covers +class X < Y+ and
147
+ # +Class.new(Y) do ... end+.
148
+ #
149
+ # @param root [Prism::Node] a parsed file's root
150
+ # @param base_classes [Array<String>] the action base class names
151
+ # @return [Array<Offense>]
152
+ def declared_step_offenses(root, base_classes: ACTION_BASE_CLASSES)
153
+ bases = base_classes.map { |name| name.to_s.delete_prefix('::') }
154
+ parents = file_superclasses(root)
155
+ lineage = {bases: bases, parents: parents, known: known_names(parents.keys + bases)}
156
+ found = [] # : Array[Offense]
157
+ each_class_body(root) do |statements, superclass, nesting|
158
+ next unless action_superclass?(superclass, nesting, lineage, [])
159
+
160
+ names = declared_methods(statements)
161
+ next if names.empty?
162
+
163
+ each_instance_def(statements) { |def_node| found.concat(offenses(def_node)) if names.include?(def_node.name) }
164
+ end
165
+ found
166
+ end
167
+
168
+ private
169
+
170
+ # @param path [String]
171
+ # @return [Hash{Array(Symbol, Integer)=>Array<Offense>}] [name, def line] => offenses
172
+ def file_offenses(path)
173
+ key = [path, File.mtime(path)]
174
+ @files_lock.synchronize { @files[key] ||= analyze_file(path) }
175
+ end
176
+
177
+ # Every def in the file, keyed by name and start line. Holding the
178
+ # results, not the tree, keeps the cache small.
179
+ #
180
+ # @param path [String]
181
+ # @return [Hash{Array(Symbol, Integer)=>Array<Offense>}]
182
+ def analyze_file(path)
183
+ result = ::Prism.parse_file(path)
184
+ return {} unless result.errors.empty?
185
+
186
+ index = {} # : Hash[[Symbol, Integer], Array[Offense]]
187
+ each_node(result.value) do |node|
188
+ index[[node.name, node.location.start_line]] = offenses(node) if node.type == :def_node
189
+ end
190
+ index
191
+ end
192
+
193
+ # Depth-first walk over every node.
194
+ #
195
+ # @param node [Prism::Node]
196
+ # @yieldparam node [Prism::Node]
197
+ # @return [void]
198
+ def each_node(node, &block)
199
+ yield node
200
+ node.compact_child_nodes.each { |child| each_node(child, &block) }
201
+ end
202
+
203
+ # @param node [Prism::Node, nil]
204
+ # @param tail [Boolean] whether nothing follows +node+ on the method's path
205
+ # @param loop_tail [Boolean, nil] the tail of the innermost block or loop
206
+ # body (+next+/+break+ end it), +nil+ outside one
207
+ # @param found [Array<Offense>]
208
+ # @return [void]
209
+ def visit(node, tail, loop_tail, found)
210
+ return if node.nil? || SCOPES.include?(node.type)
211
+
212
+ case node.type
213
+ when :statements_node then visit_statements(node.body, tail, loop_tail, found)
214
+ when :call_node then visit_call(node, tail, loop_tail, found)
215
+ when :if_node, :unless_node, :else_node, :case_node, :case_match_node, :when_node, :in_node,
216
+ :rescue_node, :ensure_node, :parentheses_node
217
+ visit_branches(node, tail, loop_tail, found)
218
+ when :begin_node then visit_begin(node, tail, loop_tail, found)
219
+ when :and_node, :or_node
220
+ visit(node.left, false, loop_tail, found)
221
+ visit(node.right, tail, loop_tail, found)
222
+ when :rescue_modifier_node
223
+ visit(node.expression, tail, loop_tail, found)
224
+ visit(node.rescue_expression, tail, loop_tail, found)
225
+ when :return_node then visit_returned(node, true, loop_tail, found)
226
+ when :next_node, :break_node then visit_returned(node, loop_tail || false, loop_tail, found)
227
+ when :while_node, :until_node, :for_node then visit_loop(node, tail, loop_tail, found)
228
+ when :lambda_node then visit(node.body, tail, tail, found)
229
+ else visit_children(node.compact_child_nodes, loop_tail, found)
230
+ end
231
+ end
232
+
233
+ # A statement is in tail position when it is the last one, and is
234
+ # treated as such when the next one ends the path: +return+ always,
235
+ # +next+/+break+ as the end of the block or loop body.
236
+ #
237
+ # @param statements [Array<Prism::Node>]
238
+ # @param tail [Boolean]
239
+ # @param loop_tail [Boolean, nil]
240
+ # @param found [Array<Offense>]
241
+ # @return [void]
242
+ def visit_statements(statements, tail, loop_tail, found)
243
+ statements.each_with_index do |statement, index|
244
+ following = statements[index + 1]
245
+ statement_tail =
246
+ case following&.type
247
+ when nil then tail
248
+ when :return_node then true
249
+ when :next_node, :break_node then loop_tail || false
250
+ else false
251
+ end
252
+ visit(statement, statement_tail, loop_tail, found)
253
+ end
254
+ end
255
+
256
+ # @param node [Prism::CallNode]
257
+ # @param tail [Boolean]
258
+ # @param loop_tail [Boolean, nil]
259
+ # @param found [Array<Offense>]
260
+ # @return [void]
261
+ def visit_call(node, tail, loop_tail, found)
262
+ found << offense(node) if verb_call?(node) && !tail
263
+ visit(node.receiver, false, loop_tail, found)
264
+ visit(node.arguments, false, loop_tail, found)
265
+ block = node.block
266
+ if block&.type == :block_node
267
+ visit(block.body, tail, tail, found)
268
+ else
269
+ visit(block, false, loop_tail, found)
270
+ end
271
+ end
272
+
273
+ # Conditions and patterns run first, so they are never in tail
274
+ # position; every branch body inherits the construct's.
275
+ #
276
+ # @param node [Prism::Node]
277
+ # @param tail [Boolean]
278
+ # @param loop_tail [Boolean, nil]
279
+ # @param found [Array<Offense>]
280
+ # @return [void]
281
+ def visit_branches(node, tail, loop_tail, found)
282
+ branches = branch_nodes(node)
283
+ conditions = node.compact_child_nodes - branches
284
+ visit_children(conditions, loop_tail, found)
285
+ branches.each { |branch| visit(branch, tail, loop_tail, found) }
286
+ end
287
+
288
+ # @param node [Prism::Node]
289
+ # @return [Array<Prism::Node>] the child nodes that are branch bodies
290
+ def branch_nodes(node)
291
+ case node.type
292
+ when :if_node then [node.statements, node.subsequent]
293
+ when :unless_node then [node.statements, node.else_clause]
294
+ when :case_node, :case_match_node then [*node.conditions, node.else_clause]
295
+ when :rescue_node then [node.statements, node.subsequent]
296
+ when :parentheses_node then [node.body]
297
+ else [node.statements] # else / when / in / ensure
298
+ end.compact
299
+ end
300
+
301
+ # The value of +return+ / +next+ / +break+ is what ends the path.
302
+ #
303
+ # @param node [Prism::ReturnNode, Prism::NextNode, Prism::BreakNode]
304
+ # @param tail [Boolean]
305
+ # @param loop_tail [Boolean, nil]
306
+ # @param found [Array<Offense>]
307
+ # @return [void]
308
+ def visit_returned(node, tail, loop_tail, found)
309
+ (node.arguments&.arguments || []).each { |value| visit(value, tail, loop_tail, found) }
310
+ end
311
+
312
+ # A begin body is followed by its +else+ (which runs when nothing was
313
+ # rescued), never by its +ensure+.
314
+ #
315
+ # @param node [Prism::BeginNode]
316
+ # @param tail [Boolean]
317
+ # @param loop_tail [Boolean, nil]
318
+ # @param found [Array<Offense>]
319
+ # @return [void]
320
+ def visit_begin(node, tail, loop_tail, found)
321
+ visit(node.statements, node.else_clause ? false : tail, loop_tail, found)
322
+ [node.rescue_clause, node.else_clause, node.ensure_clause].each { |part| visit(part, tail, loop_tail, found) }
323
+ end
324
+
325
+ # A loop body is like a block body: its end (or +next+/+break+) is in
326
+ # tail position only when the loop is.
327
+ #
328
+ # @param node [Prism::WhileNode, Prism::UntilNode, Prism::ForNode]
329
+ # @param tail [Boolean]
330
+ # @param loop_tail [Boolean, nil]
331
+ # @param found [Array<Offense>]
332
+ # @return [void]
333
+ def visit_loop(node, tail, loop_tail, found)
334
+ visit_children(node.compact_child_nodes - [node.statements], loop_tail, found)
335
+ visit(node.statements, tail, tail, found)
336
+ end
337
+
338
+ # @param nodes [Array<Prism::Node>]
339
+ # @param loop_tail [Boolean, nil]
340
+ # @param found [Array<Offense>]
341
+ # @return [void]
342
+ def visit_children(nodes, loop_tail, found)
343
+ nodes.each { |child| visit(child, false, loop_tail, found) }
344
+ end
345
+
346
+ # @param node [Prism::CallNode]
347
+ # @return [Boolean]
348
+ def verb_call?(node)
349
+ VERBS.include?(node.name) && (node.receiver.nil? || node.receiver.type == :self_node) && !node.safe_navigation?
350
+ end
351
+
352
+ # @param node [Prism::CallNode]
353
+ # @return [Offense]
354
+ def offense(node)
355
+ location = node.location
356
+ Offense.new(node.name, location.start_line, location.start_column,
357
+ location.start_character_offset, location.end_character_offset)
358
+ end
359
+
360
+ # Every class body in the tree, with its superclass and the lexical
361
+ # nesting the superclass is looked up in: +class X < Y+ bodies and
362
+ # +Class.new(Y) do ... end+ blocks.
363
+ #
364
+ # @param root [Prism::Node]
365
+ # @yieldparam statements [Prism::StatementsNode]
366
+ # @yieldparam superclass [Prism::Node, nil]
367
+ # @yieldparam nesting [Array<String>]
368
+ # @return [void]
369
+ def each_class_body(root)
370
+ each_scoped_node(root, []) do |node, nesting|
371
+ body, superclass =
372
+ if node.type == :class_node
373
+ [node.body, node.superclass]
374
+ elsif class_new?(node) && node.block&.type == :block_node
375
+ [node.block.body, class_new_superclass(node)]
376
+ end
377
+ yield body, superclass, nesting if body&.type == :statements_node
378
+ end
379
+ end
380
+
381
+ # Depth-first walk over every node, with the full names of the
382
+ # +module+ / +class+ keywords around it, innermost first (Ruby's
383
+ # +Module.nesting+). A class's own node (and so its superclass) sees the
384
+ # nesting outside it.
385
+ #
386
+ # @param node [Prism::Node]
387
+ # @param nesting [Array<String>]
388
+ # @yieldparam node [Prism::Node]
389
+ # @yieldparam nesting [Array<String>]
390
+ # @return [void]
391
+ def each_scoped_node(node, nesting, &block)
392
+ yield node, nesting
393
+ inner = nesting
394
+ if %i[class_node module_node].include?(node.type)
395
+ name = defined_name(node.constant_path, nesting)
396
+ inner = [name, *nesting] if name
397
+ end
398
+ node.compact_child_nodes.each { |child| each_scoped_node(child, inner, &block) }
399
+ end
400
+
401
+ # The classes and modules defined in the file, keyed by full name, with
402
+ # the superclass each definition names and the nesting it is looked up
403
+ # in. A module, or a class with no superclass, maps to none: it still
404
+ # shadows a base class of the same name.
405
+ #
406
+ # @param root [Prism::Node]
407
+ # @return [Hash{String=>Array<Array(Prism::Node, Array<String>)>}]
408
+ def file_superclasses(root)
409
+ parents = Hash.new { |hash, name| hash[name] = [] } # : Hash[String, Array[untyped]]
410
+ each_scoped_node(root, []) do |node, nesting|
411
+ name, superclass =
412
+ case node.type
413
+ when :class_node, :module_node
414
+ [defined_name(node.constant_path, nesting), node.type == :class_node && node.superclass]
415
+ when :constant_write_node
416
+ [defined_name(node, nesting), class_new_superclass(node.value)] if class_new?(node.value)
417
+ end
418
+ next unless name
419
+
420
+ parents[name]
421
+ parents[name] << [superclass, nesting] if superclass
422
+ end
423
+ parents
424
+ end
425
+
426
+ # Every name and each of its namespaces (+A::B::C+ gives +A+, +A::B+,
427
+ # +A::B::C+).
428
+ #
429
+ # @param names [Array<String>]
430
+ # @return [Array<String>]
431
+ def known_names(names)
432
+ names.flat_map do |name|
433
+ segments = name.split('::')
434
+ (1..segments.size).map { |size| segments.first(size).join('::') }
435
+ end.uniq
436
+ end
437
+
438
+ # Whether +node+ names an action base, directly or through classes
439
+ # defined in the file. +seen+ guards against a superclass cycle.
440
+ #
441
+ # @param node [Prism::Node, nil]
442
+ # @param nesting [Array<String>] where +node+ is looked up
443
+ # @param lineage [Hash{Symbol=>Array<String>, Hash}] +:bases+, +:parents+
444
+ # ({file_superclasses}) and +:known+ ({known_names})
445
+ # @param seen [Array<String>]
446
+ # @return [Boolean]
447
+ def action_superclass?(node, nesting, lineage, seen)
448
+ name = resolve(node, nesting, lineage[:known])
449
+ return false unless name
450
+ return true if lineage[:bases].include?(name)
451
+ return false if seen.include?(name)
452
+
453
+ seen << name
454
+ lineage[:parents].fetch(name, []).any? { |parent, scope| action_superclass?(parent, scope, lineage, seen) }
455
+ end
456
+
457
+ # The full name a constant reference resolves to: its first segment
458
+ # joined to the innermost +nesting+ entry that defines it, else the top
459
+ # level.
460
+ #
461
+ # @param node [Prism::Node, nil]
462
+ # @param nesting [Array<String>]
463
+ # @param known [Array<String>]
464
+ # @return [String, nil] +nil+ when +node+ is not a plain constant reference
465
+ def resolve(node, nesting, known)
466
+ path, absolute = constant_reference(node)
467
+ return path if path.nil? || absolute
468
+
469
+ head = path.split('::').first
470
+ scope = nesting.find { |cref| known.include?("#{cref}::#{head}") }
471
+ scope ? "#{scope}::#{path}" : path
472
+ end
473
+
474
+ # The full name a +class+ / +module+ keyword or constant assignment
475
+ # defines: its path inside the innermost nesting entry (a compact path
476
+ # is taken as written there), or as written after a leading +::+.
477
+ #
478
+ # @param node [Prism::Node] the constant path, or a +ConstantWriteNode+
479
+ # @param nesting [Array<String>]
480
+ # @return [String, nil]
481
+ def defined_name(node, nesting)
482
+ path, absolute = node.type == :constant_write_node ? [node.name.to_s, false] : constant_reference(node)
483
+ return path if path.nil? || absolute || nesting.empty?
484
+
485
+ "#{nesting.first}::#{path}"
486
+ end
487
+
488
+ # @param node [Prism::Node, nil]
489
+ # @return [Array(String, Boolean), nil] the constant path +node+ spells,
490
+ # without a leading +::+, and whether it had one; +nil+ for anything
491
+ # else (+Class.new(base)+, +foo::Bar+)
492
+ def constant_reference(node)
493
+ case node&.type
494
+ when :constant_read_node then [node.name.to_s, false]
495
+ when :constant_path_node
496
+ name = node.full_name
497
+ [name.delete_prefix('::'), name.start_with?('::')]
498
+ end
499
+ rescue ::Prism::ConstantPathNode::DynamicPartsInConstantPathError
500
+ nil
501
+ end
502
+
503
+ # @param node [Prism::Node, nil]
504
+ # @return [Boolean] whether +node+ is +Class.new(...)+, with or without a block
505
+ def class_new?(node)
506
+ node&.type == :call_node && node.name == :new && node.receiver&.type == :constant_read_node &&
507
+ node.receiver.name == :Class
508
+ end
509
+
510
+ # @param node [Prism::CallNode] a +Class.new(...)+ call
511
+ # @return [Prism::Node, nil] its superclass argument
512
+ def class_new_superclass(node)
513
+ node.arguments&.arguments&.first
514
+ end
515
+
516
+ # The method names a class body's declarations name, or none when it
517
+ # declares no +step+ / +case_step+ (an ActiveRecord model's
518
+ # +after_commit+ alone is not an action).
519
+ #
520
+ # @param statements [Prism::StatementsNode]
521
+ # @return [Array<Symbol>]
522
+ def declared_methods(statements)
523
+ calls = statements.body.select { |node| node.type == :call_node && node.receiver.nil? }
524
+ return [] unless calls.any? { |call| %i[step case_step].include?(call.name) }
525
+
526
+ calls.flat_map do |call|
527
+ if call.name == :case_step
528
+ case_branch_targets(call)
529
+ elsif DECLARATIONS.include?(call.name)
530
+ [target_name(call.arguments&.arguments&.first)]
531
+ else
532
+ []
533
+ end
534
+ end.compact
535
+ end
536
+
537
+ # @param call [Prism::CallNode] a +case_step+ declaration
538
+ # @return [Array<Symbol, nil>]
539
+ def case_branch_targets(call)
540
+ body = call.block&.body
541
+ return [] unless call.block&.type == :block_node && body&.type == :statements_node
542
+
543
+ body.body.filter_map do |branch|
544
+ next unless branch.type == :call_node && CASE_BRANCHES.include?(branch.name)
545
+
546
+ arguments = branch.arguments&.arguments || []
547
+ target_name(branch.name == :on ? arguments[1] : arguments[0])
548
+ end
549
+ end
550
+
551
+ # @param node [Prism::Node, nil] a declaration's target argument
552
+ # @return [Symbol, nil] the method it names, if it names one
553
+ def target_name(node)
554
+ case node&.type
555
+ when :symbol_node then node.unescaped.to_sym
556
+ when :string_node then node.unescaped.to_sym
557
+ end
558
+ end
559
+
560
+ # The instance-method defs directly in a class body, including those
561
+ # wrapped in +private def ...+.
562
+ #
563
+ # @param statements [Prism::StatementsNode]
564
+ # @yieldparam def_node [Prism::DefNode]
565
+ # @return [void]
566
+ def each_instance_def(statements)
567
+ statements.body.each do |node|
568
+ candidates = node.type == :call_node && node.receiver.nil? ? node.arguments&.arguments || [] : [node]
569
+ candidates.each { |candidate| yield candidate if candidate.type == :def_node && candidate.receiver.nil? }
570
+ end
571
+ end
572
+ end
573
+ end
574
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Actionable
4
+ # @return [String]
5
+ VERSION = '1.9.0'
6
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The gem is published as +actionable-rb+ (the +actionable+ name on RubyGems
4
+ # belongs to an unrelated project). Bundler.require loads a gem by its name,
5
+ # so this file lets +gem 'actionable-rb'+ work without +require: 'actionable'+.
6
+ require_relative 'actionable'
data/lib/actionable.rb ADDED
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'field_struct'
4
+
5
+ require_relative 'actionable/version'
6
+ require_relative 'actionable/errors'
7
+ require_relative 'actionable/value_match'
8
+ require_relative 'actionable/measurement'
9
+ require_relative 'actionable/history'
10
+ require_relative 'actionable/registry'
11
+ require_relative 'actionable/result'
12
+ require_relative 'actionable/results/success'
13
+ require_relative 'actionable/results/failure'
14
+ require_relative 'actionable/results/skipped'
15
+ require_relative 'actionable/batch_result'
16
+ require_relative 'actionable/input_dispatch'
17
+ require_relative 'actionable/steps/base'
18
+ require_relative 'actionable/steps/method'
19
+ require_relative 'actionable/steps/action'
20
+ require_relative 'actionable/steps/case'
21
+ require_relative 'actionable/steps'
22
+ require_relative 'actionable/verb_position'
23
+ require_relative 'actionable/runner'
24
+ require_relative 'actionable/action'
25
+ require_relative 'actionable/rbs'
26
+
27
+ # Actionable: typed, composable Ruby service objects.
28
+ #
29
+ # This is the core entry point. Per decision D14, every core file gets an
30
+ # explicit +require_relative+ here — no autoload, no Zeitwerk. The optional
31
+ # adapters (+actionable/rails+, +actionable/rspec+) are separate requires and
32
+ # are deliberately NOT loaded here, keeping the core Rails-free.
33
+ module Actionable
34
+ @verb_position_check = true
35
+
36
+ # Turn the first-run verb-position check (owner rulings R5, R10) on or
37
+ # off for the whole process. Off, {Action.run} never parses a method's
38
+ # source and never loads Prism; the per-call code check in +fail+ / +skip+
39
+ # stays on. Meant as an escape hatch while upgrading (set it in an
40
+ # initializer), with the +Actionable/VerbNotLast+ cop as the only guard
41
+ # left. A class first run while off is checked on its first run after the
42
+ # check is turned back on.
43
+ #
44
+ # Actionable.verb_position_check = false
45
+ #
46
+ # @param enabled [Boolean] +false+ to skip the check
47
+ # @return [Boolean] whether the check is now on
48
+ def self.verb_position_check=(enabled)
49
+ @verb_position_check = enabled ? true : false
50
+ end
51
+
52
+ # Whether {Action.run} checks where each step and hook method places its
53
+ # plain recording verbs on an action's first run. On by default.
54
+ #
55
+ # @return [Boolean]
56
+ def self.verb_position_check?
57
+ @verb_position_check
58
+ end
59
+ end
@@ -0,0 +1,42 @@
1
+ # Hand-written overloads for the DSL methods whose blocks run with a different
2
+ # +self+. Sord cannot express a block self-type, so the generated
3
+ # sig/actionable.rbs declares these methods without their block; each `| ...`
4
+ # here adds the block form alongside the generated signature rather than
5
+ # replacing it. Keep the non-block shapes in YARD.
6
+ #
7
+ # A schema block (+output+, +input+, +fails+, +skips+) is the body of
8
+ # +Class.new(FieldStruct::Base, &block)+, so +self+ is the new schema class.
9
+ # A branch block (+case_step+, +input_for+) is +instance_eval+'d on a builder.
10
+ module Actionable
11
+ class Action
12
+ def self.output: () { (singleton(FieldStruct::Base) schema) [self: singleton(FieldStruct::Base)] -> void } -> singleton(FieldStruct::Base)
13
+ | ...
14
+
15
+ def self.input: () { (singleton(FieldStruct::Base) schema) [self: singleton(FieldStruct::Base)] -> void } -> singleton(FieldStruct::Base)
16
+ | ...
17
+
18
+ def self.fails: ((Symbol | String) code, ?String? message, **Object metadata) { (singleton(FieldStruct::Base) schema) [self: singleton(FieldStruct::Base)] -> void } -> ::Hash[Symbol, ::Hash[Symbol, Object]]
19
+ | ...
20
+
21
+ def self.skips: ((Symbol | String) code, ?String? message, **Object metadata) { (singleton(FieldStruct::Base) schema) [self: singleton(FieldStruct::Base)] -> void } -> ::Hash[Symbol, ::Hash[Symbol, Object]]
22
+ | ...
23
+
24
+ def self.case_step: ((Symbol | String) value_source, **Object options) { (Steps::Case::Builder builder) [self: Steps::Case::Builder] -> void } -> ::Set[Steps::Base]
25
+ | ...
26
+
27
+ def self.input_for: ((Symbol | String) discriminator) { (InputDispatch::Builder builder) [self: InputDispatch::Builder] -> void } -> InputDispatch
28
+ | ...
29
+ end
30
+
31
+ class InputDispatch
32
+ def self.define: ((Symbol | String) discriminator) { (Builder builder) [self: Builder] -> void } -> InputDispatch
33
+ | ...
34
+ end
35
+
36
+ module Steps
37
+ class Case
38
+ def self.define: ((Symbol | String) value_source, **Object options) { (Builder builder) [self: Builder] -> void } -> Case
39
+ | ...
40
+ end
41
+ end
42
+ end