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,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
|
+
# 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
|