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,502 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Actionable
|
|
4
|
+
# Generates RBS for a *user* action class — track 2 of the RBS strategy
|
|
5
|
+
# (decision D13). Sord covers the library's static classes, but it can't see
|
|
6
|
+
# +CreateInvoice.run(amount:)+ or +result.invoice+: those depend on the
|
|
7
|
+
# action's declared +input+/+output+ schemas. This walks those schemas (via
|
|
8
|
+
# FieldStruct's metadata and +FieldStruct::RBS.type_for+) and emits:
|
|
9
|
+
#
|
|
10
|
+
# * a typed +.run+ signature from the input schema (or +(*untyped)+ when
|
|
11
|
+
# free-form), with an overload taking a built input struct;
|
|
12
|
+
# * for a fixed +input+ schema, a nested +InputSchema+ struct and a typed
|
|
13
|
+
# +#input+ reader returning it. Like +Output+ and +Result+ it exists only
|
|
14
|
+
# in RBS, so generation raises +ArgumentError+ when Ruby already resolves
|
|
15
|
+
# +Action::InputSchema+;
|
|
16
|
+
# * a nullable instance variable per output field (+@total: ::Integer?+),
|
|
17
|
+
# so steps that assign it type-check. The generated RBS owns these: a
|
|
18
|
+
# host sig must not declare them again, since RBS rejects an instance
|
|
19
|
+
# variable declared twice;
|
|
20
|
+
# * a nested +Output+ struct with a typed accessor per output field;
|
|
21
|
+
# * a nested +Result+ with +output+ and the convenience-delegation readers,
|
|
22
|
+
# which +.run+ returns;
|
|
23
|
+
# * when the action declares failures with +fails+, a +failure_code+ type
|
|
24
|
+
# alias listing every code it can fail with: the declared ones, then the
|
|
25
|
+
# {Action::GEM_FAILURE_CODES} (decision D20);
|
|
26
|
+
# * when the action declares skips with +skips+, a +skip_code+ type alias
|
|
27
|
+
# listing every code it can skip with (decision D21). The gem emits no skip
|
|
28
|
+
# codes of its own, so the alias holds the declared ones only.
|
|
29
|
+
#
|
|
30
|
+
# Under typed outcomes (decision D23) +.run+ returns the action's own
|
|
31
|
+
# +Success | Skipped | Failure+ instead, each typed with its +code+ and an
|
|
32
|
+
# +output+ that names what the outcome can carry: a nested class per code
|
|
33
|
+
# that declares an output schema (+AlreadyActiveSkipOutput+,
|
|
34
|
+
# +InvalidFailureOutput+), and a Hash for a code without one. A failure's
|
|
35
|
+
# output also admits every skip schema and +Output+, since a broken one ends
|
|
36
|
+
# the run as +invalid_output+ carrying it.
|
|
37
|
+
#
|
|
38
|
+
# class CreateInvoice < Actionable::Action
|
|
39
|
+
# input { required :amount, :integer }
|
|
40
|
+
# output { required :invoice, :string }
|
|
41
|
+
# end
|
|
42
|
+
#
|
|
43
|
+
# puts Actionable::RBS.generate(CreateInvoice)
|
|
44
|
+
# # class CreateInvoice < ::Actionable::Action
|
|
45
|
+
# # def self.run: (amount: ::Integer) -> Result
|
|
46
|
+
# # | (InputSchema input) -> Result
|
|
47
|
+
# # def input: () -> InputSchema
|
|
48
|
+
# # @invoice: ::String?
|
|
49
|
+
# # class InputSchema < ::FieldStruct::Base ... end
|
|
50
|
+
# # class Output < ::FieldStruct::Base ... end
|
|
51
|
+
# # class Result < ::Actionable::Result ... end
|
|
52
|
+
# # end
|
|
53
|
+
#
|
|
54
|
+
# The action names its real superclass, and every named ancestor up to
|
|
55
|
+
# {Actionable::Action} is declared before it, member-less, so the output
|
|
56
|
+
# resolves without the host generating its abstract bases.
|
|
57
|
+
#
|
|
58
|
+
# The emitted +Result+/+Output+/+InputSchema+ are generated type aids: at
|
|
59
|
+
# runtime +.run+ returns a {Success}/{Failure}/{Skipped} that responds to
|
|
60
|
+
# these via output delegation, and the schemas are anonymous classes. Field
|
|
61
|
+
# types come from +FieldStruct::RBS.type_for+, so they match FieldStruct's own
|
|
62
|
+
# generator, and describe a valid instance. Referenced field types appear as
|
|
63
|
+
# qualified names (+::Invoice+) — generate RBS for those (e.g. via
|
|
64
|
+
# +FieldStruct::RBS+) so they resolve.
|
|
65
|
+
module RBS
|
|
66
|
+
# Generate RBS source for a user action class.
|
|
67
|
+
#
|
|
68
|
+
# @param klass [Class<Actionable::Action>]
|
|
69
|
+
# @return [String] RBS for the action, wrapped in its module nesting
|
|
70
|
+
# @raise [ArgumentError] when +klass+ is not a named {Actionable::Action}
|
|
71
|
+
# subclass, or declares a fixed +input+ schema while Ruby already
|
|
72
|
+
# resolves +klass::InputSchema+
|
|
73
|
+
def self.generate(klass)
|
|
74
|
+
unless klass.is_a?(::Class) && klass < Actionable::Action
|
|
75
|
+
raise ArgumentError, "Actionable::RBS.generate expects an Actionable::Action subclass, got #{klass.inspect}"
|
|
76
|
+
end
|
|
77
|
+
raise ArgumentError, 'cannot generate RBS for an anonymous action' unless klass.name
|
|
78
|
+
|
|
79
|
+
check_input_schema_name!(klass)
|
|
80
|
+
ancestor_blocks(klass) + wrap_namespace(klass.name, class_block(klass))
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# The generated input type is named +InputSchema+, nested in the action.
|
|
84
|
+
# It exists only in RBS, so a Ruby constant that +klass::InputSchema+
|
|
85
|
+
# already resolves to (on the action, an ancestor class or an included
|
|
86
|
+
# module; +Object+'s top-level constants don't resolve through +klass::+)
|
|
87
|
+
# would make the RBS name lie.
|
|
88
|
+
#
|
|
89
|
+
# @param klass [Class<Actionable::Action>]
|
|
90
|
+
# @return [void]
|
|
91
|
+
# @raise [ArgumentError] when +klass+ has a fixed input schema and Ruby
|
|
92
|
+
# already resolves +klass::InputSchema+
|
|
93
|
+
def self.check_input_schema_name!(klass)
|
|
94
|
+
return unless input_schema_class?(klass)
|
|
95
|
+
|
|
96
|
+
owner = klass.ancestors.take_while { |mod| mod != ::Object }.find { |mod| mod.const_defined?(:InputSchema, false) }
|
|
97
|
+
return unless owner
|
|
98
|
+
|
|
99
|
+
raise ArgumentError,
|
|
100
|
+
"#{klass.name}::InputSchema is already defined (in #{owner.name || owner.inspect}); " \
|
|
101
|
+
'Actionable::RBS.generate names the generated input type InputSchema, so rename that constant'
|
|
102
|
+
end
|
|
103
|
+
private_class_method :check_input_schema_name!
|
|
104
|
+
|
|
105
|
+
# @param klass [Class<Actionable::Action>]
|
|
106
|
+
# @return [Boolean] whether the generated RBS declares +InputSchema+: for a
|
|
107
|
+
# fixed +input+ schema, not a discriminated +input_for+ one
|
|
108
|
+
def self.input_schema_class?(klass)
|
|
109
|
+
!klass.input_schema.nil? && klass.input_dispatch.nil?
|
|
110
|
+
end
|
|
111
|
+
private_class_method :input_schema_class?
|
|
112
|
+
|
|
113
|
+
# Declares every named ancestor between +klass+ and {Actionable::Action},
|
|
114
|
+
# so the action's superclass resolves on its own: a host often never
|
|
115
|
+
# generates its abstract base actions. Each is member-less, except that a
|
|
116
|
+
# typed ancestor also declares its result classes empty, for a typed
|
|
117
|
+
# subclass's result classes to subclass. Being empty, each declaration
|
|
118
|
+
# merges with the ancestor's full signature when that is generated too, and
|
|
119
|
+
# repeats byte-identically across the subclasses of one base, which RBS
|
|
120
|
+
# accepts across files.
|
|
121
|
+
#
|
|
122
|
+
# @param klass [Class<Actionable::Action>]
|
|
123
|
+
# @return [String] one namespaced block per ancestor, outermost first
|
|
124
|
+
def self.ancestor_blocks(klass)
|
|
125
|
+
named_ancestors(klass).reverse.map { |ancestor| "#{wrap_namespace(ancestor.name, ancestor_block(ancestor))}\n" }.join
|
|
126
|
+
end
|
|
127
|
+
private_class_method :ancestor_blocks
|
|
128
|
+
|
|
129
|
+
# @param ancestor [Class<Actionable::Action>]
|
|
130
|
+
# @return [String] the ancestor's member-less class block
|
|
131
|
+
def self.ancestor_block(ancestor)
|
|
132
|
+
head = class_head(ancestor)
|
|
133
|
+
return "#{head}\nend" unless typed_parent?(ancestor)
|
|
134
|
+
|
|
135
|
+
lines = %w[Success Skipped Failure].map { |name| "class #{name} < #{outcome_base(ancestor.superclass, name)}\nend" }
|
|
136
|
+
"#{head}\n#{indent(lines.join("\n"))}\nend"
|
|
137
|
+
end
|
|
138
|
+
private_class_method :ancestor_block
|
|
139
|
+
|
|
140
|
+
# @param klass [Class<Actionable::Action>]
|
|
141
|
+
# @return [Array<Class<Actionable::Action>>] the named classes strictly
|
|
142
|
+
# between +klass+ and {Actionable::Action}, nearest first
|
|
143
|
+
def self.named_ancestors(klass)
|
|
144
|
+
chain = []
|
|
145
|
+
parent = klass.superclass
|
|
146
|
+
while parent && parent != Actionable::Action
|
|
147
|
+
chain << parent if parent.name
|
|
148
|
+
parent = parent.superclass
|
|
149
|
+
end
|
|
150
|
+
chain
|
|
151
|
+
end
|
|
152
|
+
private_class_method :named_ancestors
|
|
153
|
+
|
|
154
|
+
# @param klass [Class<Actionable::Action>]
|
|
155
|
+
# @return [String] +class Name < ::Superclass+, naming the nearest named
|
|
156
|
+
# superclass (anonymous ones have no RBS name)
|
|
157
|
+
def self.class_head(klass)
|
|
158
|
+
parent = named_ancestors(klass).first || Actionable::Action
|
|
159
|
+
"class #{klass.name.split("::").last} < ::#{parent.name}"
|
|
160
|
+
end
|
|
161
|
+
private_class_method :class_head
|
|
162
|
+
|
|
163
|
+
# @param parent [Class]
|
|
164
|
+
# @return [Boolean] whether a subclass's result classes subclass +parent+'s
|
|
165
|
+
def self.typed_parent?(parent)
|
|
166
|
+
parent.respond_to?(:typed_outcomes?) && parent.typed_outcomes? && !parent.name.nil?
|
|
167
|
+
end
|
|
168
|
+
private_class_method :typed_parent?
|
|
169
|
+
|
|
170
|
+
# @param klass [Class<Actionable::Action>]
|
|
171
|
+
# @return [String]
|
|
172
|
+
def self.class_block(klass)
|
|
173
|
+
return typed_class_block(klass) if klass.typed_outcomes?
|
|
174
|
+
|
|
175
|
+
sections = leading_sections(klass, klass.output_schema ? 'Result' : '::Actionable::Result')
|
|
176
|
+
if (schema = klass.output_schema)
|
|
177
|
+
sections << output_block(schema)
|
|
178
|
+
sections << result_block(schema)
|
|
179
|
+
end
|
|
180
|
+
"#{class_head(klass)}\n#{indent(sections.join("\n\n"))}\nend"
|
|
181
|
+
end
|
|
182
|
+
private_class_method :class_block
|
|
183
|
+
|
|
184
|
+
# The class block of an action with typed outcomes (decision D23).
|
|
185
|
+
#
|
|
186
|
+
# @param klass [Class<Actionable::Action>]
|
|
187
|
+
# @return [String]
|
|
188
|
+
def self.typed_class_block(klass)
|
|
189
|
+
sections = leading_sections(klass, '(Success | Skipped | Failure)')
|
|
190
|
+
sections << output_block(klass.output_schema) if klass.output_schema
|
|
191
|
+
outcome_schemas(klass).each { |name, schema| sections << output_block(schema, name) }
|
|
192
|
+
sections.concat(outcome_blocks(klass))
|
|
193
|
+
"#{class_head(klass)}\n#{indent(sections.join("\n\n"))}\nend"
|
|
194
|
+
end
|
|
195
|
+
private_class_method :typed_class_block
|
|
196
|
+
|
|
197
|
+
# The sections every action's class block opens with: the code aliases,
|
|
198
|
+
# +.run+, the typed +#input+ reader, the output instance variables, and the
|
|
199
|
+
# +InputSchema+ class.
|
|
200
|
+
#
|
|
201
|
+
# @param klass [Class<Actionable::Action>]
|
|
202
|
+
# @param return_type [String] what +.run+ returns
|
|
203
|
+
# @return [Array<String>]
|
|
204
|
+
def self.leading_sections(klass, return_type)
|
|
205
|
+
aliases = []
|
|
206
|
+
aliases << failure_code_alias(klass) unless klass.declared_failures.empty?
|
|
207
|
+
aliases << skip_code_alias(klass) unless klass.declared_skips.empty?
|
|
208
|
+
sections = []
|
|
209
|
+
sections << aliases.join("\n") unless aliases.empty?
|
|
210
|
+
sections << "def self.run: #{run_signature(klass, return_type: return_type)}"
|
|
211
|
+
sections << 'def input: () -> InputSchema' if input_schema_class?(klass)
|
|
212
|
+
sections << output_ivar_lines(klass.output_schema).join("\n") if klass.output_schema&.metadata&.any?
|
|
213
|
+
sections << output_block(klass.input_schema, 'InputSchema') if input_schema_class?(klass)
|
|
214
|
+
sections
|
|
215
|
+
end
|
|
216
|
+
private_class_method :leading_sections
|
|
217
|
+
|
|
218
|
+
# One instance variable per output field, which steps assign and the
|
|
219
|
+
# {Runner} captures into the output. Each is nullable: it is unset until a
|
|
220
|
+
# step assigns it. The generator owns these declarations, since RBS rejects
|
|
221
|
+
# the same instance variable declared in two files.
|
|
222
|
+
#
|
|
223
|
+
# @param schema [Class<FieldStruct::Base>] the success output schema
|
|
224
|
+
# @return [Array<String>]
|
|
225
|
+
def self.output_ivar_lines(schema)
|
|
226
|
+
schema.metadata.map { |field| "@#{field.name}: #{FieldStruct::RBS.type_for(field, nullable: true)}" }
|
|
227
|
+
end
|
|
228
|
+
private_class_method :output_ivar_lines
|
|
229
|
+
|
|
230
|
+
# The nested output class of every skip, then every failure, code that
|
|
231
|
+
# declares an output schema, named after the code.
|
|
232
|
+
#
|
|
233
|
+
# @param klass [Class<Actionable::Action>]
|
|
234
|
+
# @return [Array<Array(String, Class<FieldStruct::Base>)>] name, schema
|
|
235
|
+
def self.outcome_schemas(klass)
|
|
236
|
+
skips = klass.skip_declarations.keys.filter_map do |code|
|
|
237
|
+
schema = klass.skip_output_schema(code)
|
|
238
|
+
[code, "#{camelize(code)}SkipOutput", schema] if schema
|
|
239
|
+
end
|
|
240
|
+
failures = klass.failure_declarations.keys.filter_map do |code|
|
|
241
|
+
schema = klass.failure_output_schema(code)
|
|
242
|
+
[code, "#{camelize(code)}FailureOutput", schema] if schema
|
|
243
|
+
end
|
|
244
|
+
check_outcome_names!(klass, 'skip', skips)
|
|
245
|
+
check_outcome_names!(klass, 'failure', failures)
|
|
246
|
+
(skips + failures).map { |_, name, schema| [name, schema] }
|
|
247
|
+
end
|
|
248
|
+
private_class_method :outcome_schemas
|
|
249
|
+
|
|
250
|
+
# Every output class name must be an RBS class name, and unique: two codes
|
|
251
|
+
# that camelize alike (+:ready+ / +:ready_+, +:expired?+ /
|
|
252
|
+
# +:expired_predicate+) would otherwise emit one class for two schemas.
|
|
253
|
+
#
|
|
254
|
+
# @param klass [Class<Actionable::Action>]
|
|
255
|
+
# @param kind [String] +skip+ or +failure+
|
|
256
|
+
# @param entries [Array<Array(Symbol, String, Class<FieldStruct::Base>)>]
|
|
257
|
+
# code, class name, schema
|
|
258
|
+
# @return [void]
|
|
259
|
+
# @raise [ArgumentError] on an invalid or a shared class name
|
|
260
|
+
def self.check_outcome_names!(klass, kind, entries)
|
|
261
|
+
entries.each do |code, name, _|
|
|
262
|
+
next if CONSTANT_NAME.match?(name)
|
|
263
|
+
|
|
264
|
+
raise ArgumentError, "#{klass.name}: #{kind} code #{code.inspect} gives #{name}, which is not a valid " \
|
|
265
|
+
'RBS class name (it must start with an ASCII capital); rename the code'
|
|
266
|
+
end
|
|
267
|
+
entries.group_by { |_, name, _| name }.each_value do |same|
|
|
268
|
+
next if same.size == 1
|
|
269
|
+
|
|
270
|
+
codes = same.map { |code, _, _| code.inspect }.join(' and ')
|
|
271
|
+
raise ArgumentError, "#{klass.name}: #{kind} codes #{codes} both name #{same.first[1]} in the " \
|
|
272
|
+
'generated RBS; rename one of the codes'
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
private_class_method :check_outcome_names!
|
|
276
|
+
|
|
277
|
+
# The +Success+, +Skipped+ and +Failure+ blocks of a typed action.
|
|
278
|
+
#
|
|
279
|
+
# @param klass [Class<Actionable::Action>]
|
|
280
|
+
# @return [Array<String>]
|
|
281
|
+
def self.outcome_blocks(klass)
|
|
282
|
+
skips = outcome_schemas(klass).select { |name, _| name.end_with?('SkipOutput') }
|
|
283
|
+
failures = outcome_schemas(klass).select { |name, _| name.end_with?('FailureOutput') }
|
|
284
|
+
failures += skips
|
|
285
|
+
failures << ['Output', klass.output_schema] if klass.output_schema
|
|
286
|
+
[
|
|
287
|
+
outcome_block(klass, 'Success', success_lines(klass)),
|
|
288
|
+
outcome_block(klass, 'Skipped', ["def code: () -> #{code_type(klass.declared_skips, "skip_code")}",
|
|
289
|
+
"def output: () -> #{union(skips.map(&:first))}",
|
|
290
|
+
*delegator_lines(skips.map(&:last))]),
|
|
291
|
+
outcome_block(klass, 'Failure', ["def code: () -> #{code_type(klass.declared_failures, "failure_code")}",
|
|
292
|
+
"def output: () -> #{union(failures.map(&:first))}",
|
|
293
|
+
*delegator_lines(failures.map(&:last), nullable: true)])
|
|
294
|
+
]
|
|
295
|
+
end
|
|
296
|
+
private_class_method :outcome_blocks
|
|
297
|
+
|
|
298
|
+
# The readers a result delegates to its output struct, one per field any
|
|
299
|
+
# of +schemas+ declares. A field several declare types as the union of
|
|
300
|
+
# their types, nullable when any declares it optional, or when +nullable+.
|
|
301
|
+
# A failure's readers are all nullable: an +invalid_output+ failure carries
|
|
302
|
+
# the output that broke its schema, so a required field can be missing.
|
|
303
|
+
#
|
|
304
|
+
# @param schemas [Array<Class<FieldStruct::Base>>]
|
|
305
|
+
# @param nullable [Boolean] make every reader nullable
|
|
306
|
+
# @return [Array<String>]
|
|
307
|
+
def self.delegator_lines(schemas, nullable: false)
|
|
308
|
+
fields = Hash.new { |hash, name| hash[name] = [] }
|
|
309
|
+
schemas.each { |schema| schema.metadata.each { |field| fields[field.name] << field } }
|
|
310
|
+
fields.map do |name, declared|
|
|
311
|
+
types = declared.map { |field| rbs_type(field, nullable: false) }.uniq
|
|
312
|
+
type = if types.include?('untyped') then 'untyped'
|
|
313
|
+
elsif types.one? then types.first
|
|
314
|
+
else "(#{types.join(" | ")})"
|
|
315
|
+
end
|
|
316
|
+
type = nullable(type) if nullable || declared.any? { |field| !field.required? }
|
|
317
|
+
"def #{name}: () -> #{type}"
|
|
318
|
+
end
|
|
319
|
+
end
|
|
320
|
+
private_class_method :delegator_lines
|
|
321
|
+
|
|
322
|
+
# @param klass [Class<Actionable::Action>]
|
|
323
|
+
# @return [Array<String>] the +Success+ block's signatures
|
|
324
|
+
def self.success_lines(klass)
|
|
325
|
+
schema = klass.output_schema
|
|
326
|
+
lines = ['def code: () -> :success', "def output: () -> #{schema ? "Output" : HASH_OUTPUT}"]
|
|
327
|
+
schema&.metadata&.each { |field| lines << "def #{field.name}: () -> #{rbs_type(field)}" }
|
|
328
|
+
lines
|
|
329
|
+
end
|
|
330
|
+
private_class_method :success_lines
|
|
331
|
+
|
|
332
|
+
# @param declared [Hash] the declared codes
|
|
333
|
+
# @param name [String] the alias naming them
|
|
334
|
+
# @return [String] the alias, or +::Symbol+ when none are declared
|
|
335
|
+
def self.code_type(declared, name)
|
|
336
|
+
declared.empty? ? '::Symbol' : name
|
|
337
|
+
end
|
|
338
|
+
private_class_method :code_type
|
|
339
|
+
|
|
340
|
+
# @return [String]
|
|
341
|
+
HASH_OUTPUT = '::Hash[::Symbol, untyped]'
|
|
342
|
+
private_constant :HASH_OUTPUT
|
|
343
|
+
|
|
344
|
+
# @return [Regexp] what RBS accepts as a class name
|
|
345
|
+
CONSTANT_NAME = /\A[A-Z][A-Za-z0-9_]*\z/
|
|
346
|
+
private_constant :CONSTANT_NAME
|
|
347
|
+
|
|
348
|
+
# @param types [Array<String>] the output classes an outcome can carry
|
|
349
|
+
# @return [String] them and a Hash, as one RBS type
|
|
350
|
+
def self.union(types)
|
|
351
|
+
types.empty? ? HASH_OUTPUT : "(#{(types + [HASH_OUTPUT]).join(" | ")})"
|
|
352
|
+
end
|
|
353
|
+
private_class_method :union
|
|
354
|
+
|
|
355
|
+
# @param klass [Class<Actionable::Action>]
|
|
356
|
+
# @param name [String] +Success+, +Skipped+ or +Failure+
|
|
357
|
+
# @param lines [Array<String>] the block's method signatures
|
|
358
|
+
# @return [String] the class block, subclassing the parent action's own
|
|
359
|
+
# result class when the parent has typed outcomes, else the gem's
|
|
360
|
+
def self.outcome_block(klass, name, lines)
|
|
361
|
+
"class #{name} < #{outcome_base(klass.superclass, name)}\n#{indent(lines.join("\n"))}\nend"
|
|
362
|
+
end
|
|
363
|
+
private_class_method :outcome_block
|
|
364
|
+
|
|
365
|
+
# @param parent [Class] the superclass of the action being generated
|
|
366
|
+
# @param name [String] +Success+, +Skipped+ or +Failure+
|
|
367
|
+
# @return [String] the parent's result class when it has typed outcomes,
|
|
368
|
+
# else the gem's
|
|
369
|
+
def self.outcome_base(parent, name)
|
|
370
|
+
typed_parent?(parent) ? "::#{parent.name}::#{name}" : "::Actionable::#{name}"
|
|
371
|
+
end
|
|
372
|
+
private_class_method :outcome_base
|
|
373
|
+
|
|
374
|
+
# A code's trailing +?+ / +!+ becomes +Predicate+ / +Bang+, since neither
|
|
375
|
+
# may appear in a constant; +:x+ and +:x?+ get different names.
|
|
376
|
+
#
|
|
377
|
+
# @param code [Symbol]
|
|
378
|
+
# @return [String] +:already_active+ as +AlreadyActive+, +:expired?+ as
|
|
379
|
+
# +ExpiredPredicate+, +:done!+ as +DoneBang+
|
|
380
|
+
def self.camelize(code)
|
|
381
|
+
base = code.to_s.split('_').map(&:capitalize).join
|
|
382
|
+
base.sub(/\?\z/, 'Predicate').sub(/!\z/, 'Bang')
|
|
383
|
+
end
|
|
384
|
+
private_class_method :camelize
|
|
385
|
+
|
|
386
|
+
# @param klass [Class<Actionable::Action>]
|
|
387
|
+
# @param return_type [String] what +.run+ returns
|
|
388
|
+
# @return [String] the +.run+ signature: typed kwargs from the input schema
|
|
389
|
+
# (or +(*untyped)+ free-form), plus an overload taking a built
|
|
390
|
+
# +InputSchema+ instance, which +.run+ passes through
|
|
391
|
+
def self.run_signature(klass, return_type:)
|
|
392
|
+
# An input_for dispatch decides the input at run time, even under a
|
|
393
|
+
# fixed input schema, so its keywords can't be listed.
|
|
394
|
+
schema = klass.input_schema if klass.input_dispatch.nil?
|
|
395
|
+
params = schema ? schema.metadata.map { |field| run_param(field) }.join(', ') : '*untyped'
|
|
396
|
+
signature = "(#{params}) -> #{return_type}"
|
|
397
|
+
return signature unless input_schema_class?(klass)
|
|
398
|
+
|
|
399
|
+
"#{signature}\n | (InputSchema input) -> #{return_type}"
|
|
400
|
+
end
|
|
401
|
+
private_class_method :run_signature
|
|
402
|
+
|
|
403
|
+
# @param klass [Class<Actionable::Action>] an action that declares failures
|
|
404
|
+
# @return [String] the +failure_code+ alias: a union of the declared codes
|
|
405
|
+
# and the codes the gem emits itself, as Symbol literal types
|
|
406
|
+
def self.failure_code_alias(klass)
|
|
407
|
+
codes = (klass.declared_failures.keys + Actionable::Action::GEM_FAILURE_CODES).uniq
|
|
408
|
+
"type failure_code = #{codes.map(&:inspect).join(" | ")}"
|
|
409
|
+
end
|
|
410
|
+
private_class_method :failure_code_alias
|
|
411
|
+
|
|
412
|
+
# @param klass [Class<Actionable::Action>] an action that declares skips
|
|
413
|
+
# @return [String] the +skip_code+ alias: a union of the declared skip codes
|
|
414
|
+
# as Symbol literal types
|
|
415
|
+
def self.skip_code_alias(klass)
|
|
416
|
+
"type skip_code = #{klass.declared_skips.keys.map(&:inspect).join(" | ")}"
|
|
417
|
+
end
|
|
418
|
+
private_class_method :skip_code_alias
|
|
419
|
+
|
|
420
|
+
# @param field [FieldStruct::Field]
|
|
421
|
+
# @return [String] +name: Type+ (required), +?name: Type+ (required with a
|
|
422
|
+
# default, so it may be omitted) or +?name: Type?+ (optional: it may be
|
|
423
|
+
# omitted or passed +nil+; +run(note: nil)+ succeeds).
|
|
424
|
+
def self.run_param(field)
|
|
425
|
+
omittable = !field.required? || !field.default.nil?
|
|
426
|
+
"#{"?" if omittable}#{field.name}: #{rbs_type(field)}"
|
|
427
|
+
end
|
|
428
|
+
private_class_method :run_param
|
|
429
|
+
|
|
430
|
+
# A nested struct class with FieldStruct's reader and writer shape: the
|
|
431
|
+
# reader nullable when optional, the writer +(T? value) -> T?+.
|
|
432
|
+
#
|
|
433
|
+
# @param schema [Class<FieldStruct::Base>] an output or input schema
|
|
434
|
+
# @param name [String] the nested class name
|
|
435
|
+
# @return [String] the nested class block
|
|
436
|
+
def self.output_block(schema, name = 'Output')
|
|
437
|
+
lines = ["class #{name} < ::FieldStruct::Base"]
|
|
438
|
+
schema.metadata.each do |field|
|
|
439
|
+
writer = rbs_type(field, nullable: true)
|
|
440
|
+
lines << " attr_reader #{field.name}: #{rbs_type(field)}"
|
|
441
|
+
lines << " def #{field.name}=: (#{writer} value) -> #{writer}"
|
|
442
|
+
end
|
|
443
|
+
lines << 'end'
|
|
444
|
+
lines.join("\n")
|
|
445
|
+
end
|
|
446
|
+
private_class_method :output_block
|
|
447
|
+
|
|
448
|
+
# @param schema [Class<FieldStruct::Base>] the output schema
|
|
449
|
+
# @return [String] the nested +Result+ class block with output delegation
|
|
450
|
+
def self.result_block(schema)
|
|
451
|
+
lines = ['class Result < ::Actionable::Result', ' def output: () -> Output']
|
|
452
|
+
schema.metadata.each { |field| lines << " def #{field.name}: () -> #{rbs_type(field)}" }
|
|
453
|
+
lines << 'end'
|
|
454
|
+
lines.join("\n")
|
|
455
|
+
end
|
|
456
|
+
private_class_method :result_block
|
|
457
|
+
|
|
458
|
+
# The field's RBS type, as FieldStruct's own generator emits it: made
|
|
459
|
+
# nullable when optional, or as +nullable+ says (D13).
|
|
460
|
+
#
|
|
461
|
+
# @param field [FieldStruct::Field]
|
|
462
|
+
# @param nullable [Boolean, nil] +nil+ follows the field's optionality
|
|
463
|
+
# @return [String]
|
|
464
|
+
def self.rbs_type(field, nullable: nil)
|
|
465
|
+
FieldStruct::RBS.type_for(field, nullable: nullable)
|
|
466
|
+
end
|
|
467
|
+
private_class_method :rbs_type
|
|
468
|
+
|
|
469
|
+
# @param type [String] an RBS type expression
|
|
470
|
+
# @return [String] +type+ made nullable, unless it already admits +nil+
|
|
471
|
+
def self.nullable(type)
|
|
472
|
+
type == 'untyped' || type.end_with?('?') ? type : "#{type}?"
|
|
473
|
+
end
|
|
474
|
+
private_class_method :nullable
|
|
475
|
+
|
|
476
|
+
# Wrap a class block in the nesting implied by its qualified name,
|
|
477
|
+
# indenting two spaces per level. A namespace that is a class opens as
|
|
478
|
+
# +class+ (no superclass, so it merges with the class's own declaration),
|
|
479
|
+
# since RBS rejects a class and a module of the same name.
|
|
480
|
+
#
|
|
481
|
+
# @param full_name [String]
|
|
482
|
+
# @param inner [String]
|
|
483
|
+
# @return [String] RBS source ending in a trailing newline
|
|
484
|
+
def self.wrap_namespace(full_name, inner)
|
|
485
|
+
parts = full_name.split('::')[0...-1]
|
|
486
|
+
block = inner
|
|
487
|
+
parts.each_index.reverse_each do |i|
|
|
488
|
+
keyword = Object.const_get(parts[0..i].join('::')).is_a?(::Class) ? 'class' : 'module'
|
|
489
|
+
block = "#{keyword} #{parts[i]}\n#{indent(block)}\nend"
|
|
490
|
+
end
|
|
491
|
+
"#{block}\n"
|
|
492
|
+
end
|
|
493
|
+
private_class_method :wrap_namespace
|
|
494
|
+
|
|
495
|
+
# @param text [String]
|
|
496
|
+
# @return [String] +text+ with every non-blank line indented two spaces
|
|
497
|
+
def self.indent(text)
|
|
498
|
+
text.split("\n", -1).map { |line| line.empty? ? line : " #{line}" }.join("\n")
|
|
499
|
+
end
|
|
500
|
+
private_class_method :indent
|
|
501
|
+
end
|
|
502
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Actionable
|
|
4
|
+
# A read-only map of every named action class, keyed by class name (decision
|
|
5
|
+
# D11). {Action} registers each subclass through its +inherited+ hook;
|
|
6
|
+
# anonymous subclasses (which have no name) are skipped. Used for discovery
|
|
7
|
+
# and tooling — e.g. sweeping all actions to generate RBS.
|
|
8
|
+
class Registry
|
|
9
|
+
def initialize
|
|
10
|
+
@map = {}
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# Register an action class under its name. No-op for anonymous classes.
|
|
14
|
+
#
|
|
15
|
+
# @param klass [Class] the action subclass
|
|
16
|
+
# @return [self]
|
|
17
|
+
def register(klass)
|
|
18
|
+
name = klass.name
|
|
19
|
+
@map[name] = klass unless name.nil?
|
|
20
|
+
self
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# @param name [String, Symbol] an action class name
|
|
24
|
+
# @return [Class, nil] the registered class, or +nil+
|
|
25
|
+
def [](name)
|
|
26
|
+
@map[name.to_s]
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# @yieldparam name [String]
|
|
30
|
+
# @yieldparam klass [Class]
|
|
31
|
+
# @return [Enumerator, self]
|
|
32
|
+
def each(&)
|
|
33
|
+
return @map.each unless block_given?
|
|
34
|
+
|
|
35
|
+
@map.each(&)
|
|
36
|
+
self
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# @return [Array<String>] the registered class names
|
|
40
|
+
def keys
|
|
41
|
+
@map.keys
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# @return [Array<Class>] the registered classes
|
|
45
|
+
def values
|
|
46
|
+
@map.values
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# @return [Integer] the number of registered classes
|
|
50
|
+
def size
|
|
51
|
+
@map.size
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# @return [Boolean] whether nothing is registered
|
|
55
|
+
def empty?
|
|
56
|
+
@map.empty?
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# @return [Registry] the process-wide action registry
|
|
61
|
+
def self.registry
|
|
62
|
+
@registry ||= Registry.new
|
|
63
|
+
end
|
|
64
|
+
end
|