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