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,250 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Actionable
4
+ module RSpec
5
+ # Helpers mixed into example groups by +actionable/rspec+ that stub an
6
+ # action's +.run+ to return a real {Success} / {Failure} built from a hash,
7
+ # so callers can assert on +result.code+/+output+/+errors+ without executing
8
+ # the action (decision D12).
9
+ #
10
+ # +allow_*+ permits the call (a stub); +stub_*+ also sets a call expectation
11
+ # (a mock, verified on the example) — mirroring RSpec's allow/expect split.
12
+ #
13
+ # A stubbed failure of an action that declares failures with
14
+ # {Actionable::Action.fails} carries the declared metadata (and message,
15
+ # when none is given) for its code, and a code the action doesn't declare
16
+ # raises +ArgumentError+, so a stub can't return what the action never
17
+ # could (decision D20). A stubbed skip follows {Actionable::Action.skips}
18
+ # the same way (decision D21), the default +:skipped+ included.
19
+ module Stubs
20
+ # @param action_class [Class<Actionable::Action>] the action whose +.run+ is stubbed
21
+ # @param opts [Object] {#actionable_success_result}'s keywords (+message:+, +output:+, +errors:+)
22
+ # @return [Success] the stubbed result
23
+ def allow_actionable_success(action_class, **opts)
24
+ allow_run(action_class, actionable_success_result(**opts))
25
+ end
26
+
27
+ # @param action_class [Class<Actionable::Action>] the action whose +.run+ is stubbed
28
+ # @param opts [Object] {#actionable_success_result}'s keywords (+message:+, +output:+, +errors:+)
29
+ # @return [Success] the stubbed result
30
+ def stub_actionable_success(action_class, **opts)
31
+ expect_run(action_class, actionable_success_result(**opts))
32
+ end
33
+
34
+ # @param action_class [Class<Actionable::Action>] the action whose +.run+ is stubbed
35
+ # @param opts [Object] {#actionable_failure_result}'s keywords; +code:+ defaults to +:error+
36
+ # @return [Failure] the stubbed result
37
+ # @raise [ArgumentError] when the action declares failures but not the code
38
+ def allow_actionable_failure(action_class, **opts)
39
+ allow_run(action_class, declared_failure_result(action_class, **opts))
40
+ end
41
+
42
+ # @param action_class [Class<Actionable::Action>] the action whose +.run+ is stubbed
43
+ # @param opts [Object] {#actionable_failure_result}'s keywords; +code:+ defaults to +:error+
44
+ # @return [Failure] the stubbed result
45
+ # @raise [ArgumentError] when the action declares failures but not the code
46
+ def stub_actionable_failure(action_class, **opts)
47
+ expect_run(action_class, declared_failure_result(action_class, **opts))
48
+ end
49
+
50
+ # @param action_class [Class<Actionable::Action>] the action whose +.run+ is stubbed
51
+ # @param opts [Object] {#actionable_skip_result}'s keywords; +code:+ defaults to +:skipped+
52
+ # @return [Skipped] the stubbed result
53
+ # @raise [ArgumentError] when the action declares skips but not the code
54
+ def allow_actionable_skip(action_class, **opts)
55
+ allow_run(action_class, declared_skip_result(action_class, **opts))
56
+ end
57
+
58
+ # @param action_class [Class<Actionable::Action>] the action whose +.run+ is stubbed
59
+ # @param opts [Object] {#actionable_skip_result}'s keywords; +code:+ defaults to +:skipped+
60
+ # @return [Skipped] the stubbed result
61
+ # @raise [ArgumentError] when the action declares skips but not the code
62
+ def stub_actionable_skip(action_class, **opts)
63
+ expect_run(action_class, declared_skip_result(action_class, **opts))
64
+ end
65
+
66
+ # Build a real {Success} from a hash (no stubbing).
67
+ #
68
+ # @param message [String, nil]
69
+ # @param output [Hash{Symbol=>Object}, FieldStruct::Base]
70
+ # @param errors [Hash{Symbol=>String, Array<String>}]
71
+ # @return [Success]
72
+ def actionable_success_result(message: nil, output: {}, errors: {})
73
+ apply_errors(Actionable::Success.new(message: message, output: output), errors)
74
+ end
75
+
76
+ # Build a real {Failure} from a hash (no stubbing).
77
+ #
78
+ # @param code [Symbol]
79
+ # @param message [String, nil]
80
+ # @param output [Hash{Symbol=>Object}, FieldStruct::Base]
81
+ # @param errors [Hash{Symbol=>String, Array<String>}]
82
+ # @param metadata [Hash{Symbol=>Object}] the failure's per-code metadata
83
+ # @return [Failure]
84
+ def actionable_failure_result(code: :error, message: nil, output: {}, errors: {}, metadata: {})
85
+ failure = Actionable::Failure.new(code: code, message: message, output: output, metadata: metadata)
86
+ apply_errors(failure, errors)
87
+ end
88
+
89
+ # Build a real {Skipped} from a hash (no stubbing).
90
+ #
91
+ # @param code [Symbol]
92
+ # @param message [String, nil]
93
+ # @param output [Hash{Symbol=>Object}, FieldStruct::Base]
94
+ # @param metadata [Hash{Symbol=>Object}] the skip's per-code metadata
95
+ # @return [Skipped]
96
+ def actionable_skip_result(code: :skipped, message: nil, output: {}, metadata: {})
97
+ Actionable::Skipped.new(code: code, message: message, output: output, metadata: metadata)
98
+ end
99
+
100
+ private
101
+
102
+ # A {Failure} for +action_class+: the declared metadata and message for
103
+ # its code, or a refusal when the action declares failures but not it.
104
+ #
105
+ # @param action_class [Class<Actionable::Action>]
106
+ # @param code [Symbol, String]
107
+ # @param opts [Object] the rest of {#actionable_failure_result}'s keywords
108
+ # @raise [ArgumentError] when the action declares failures but not +code+
109
+ # @return [Failure]
110
+ def declared_failure_result(action_class, code: :error, **opts)
111
+ unless action_class.allows_failure_code?(code)
112
+ raise ArgumentError, Actionable::RSpec.undeclared_code_message(action_class, code)
113
+ end
114
+
115
+ declaration = action_class.failure_declarations.fetch(code.to_sym, {message: nil, metadata: {}})
116
+ opts[:message] ||= declaration[:message]
117
+ actionable_failure_result(code: code, metadata: declaration[:metadata], **opts)
118
+ end
119
+
120
+ # A {Skipped} for +action_class+: the declared metadata and message for
121
+ # its code, or a refusal when the action declares skips but not it.
122
+ #
123
+ # @param action_class [Class<Actionable::Action>]
124
+ # @param code [Symbol, String]
125
+ # @param opts [Object] the rest of {#actionable_skip_result}'s keywords
126
+ # @raise [ArgumentError] when the action declares skips but not +code+
127
+ # @return [Skipped]
128
+ def declared_skip_result(action_class, code: :skipped, **opts)
129
+ unless action_class.allows_skip_code?(code)
130
+ raise ArgumentError, Actionable::RSpec.undeclared_code_message(action_class, code, outcome: :skip)
131
+ end
132
+
133
+ declaration = action_class.skip_declarations.fetch(code.to_sym, {message: nil, metadata: {}})
134
+ opts[:message] ||= declaration[:message]
135
+ actionable_skip_result(code: code, metadata: declaration[:metadata], **opts)
136
+ end
137
+
138
+ # Permit +action_class.run+, answering +result+ as a real run would.
139
+ #
140
+ # @param action_class [Class<Actionable::Action>]
141
+ # @param result [Result]
142
+ # @return [Result] the result the stub answers
143
+ def allow_run(action_class, result)
144
+ result = as_action_result(action_class, result)
145
+ allow(action_class).to receive(:run) { stubbed_run(action_class, result) }
146
+ result
147
+ end
148
+
149
+ # Expect +action_class.run+ (verified on the example), answering +result+
150
+ # as a real run would.
151
+ #
152
+ # @param action_class [Class<Actionable::Action>]
153
+ # @param result [Result]
154
+ # @return [Result] the result the stub answers
155
+ def expect_run(action_class, result)
156
+ result = as_action_result(action_class, result)
157
+ expect(action_class).to receive(:run) { stubbed_run(action_class, result) }
158
+ result
159
+ end
160
+
161
+ # The stubbed result as a real run would return it (decision D23): its
162
+ # output shaped by the schema it is held to, then as the action's own
163
+ # result class when it has typed outcomes.
164
+ #
165
+ # @param action_class [Class<Actionable::Action>]
166
+ # @param result [Result]
167
+ # @return [Result]
168
+ def as_action_result(action_class, result)
169
+ shape_output(action_class, result)
170
+ action_class.__send__(:typed_result, result)
171
+ end
172
+
173
+ # Mirror the runner: output for a code with a schema becomes that schema
174
+ # (refused when it breaks it, since a real run would end as
175
+ # +invalid_output+ instead), and under typed outcomes a code without one
176
+ # carries an empty output (refused when output is given, as a real run
177
+ # refuses it). The gem's own failure codes keep what they are given.
178
+ #
179
+ # @param action_class [Class<Actionable::Action>]
180
+ # @param result [Result] updated in place
181
+ # @raise [ArgumentError] when the output breaks what a real run allows
182
+ # @return [void]
183
+ def shape_output(action_class, result)
184
+ schema = stubbed_output_schema(action_class, result)
185
+ return result.output = build_stubbed_output(action_class, result, schema) if schema
186
+ return unless action_class.typed_outcomes? && !result.success?
187
+ return if result.failure? && Actionable::Action::GEM_FAILURE_CODES.include?(result.code)
188
+
189
+ unless result.output.respond_to?(:empty?) && result.output.empty?
190
+ raise ArgumentError, "#{action_class.name || "action"} output for #{result.code.inspect}, which declares no " \
191
+ 'output schema; a real run refuses it'
192
+ end
193
+ result.output = {}
194
+ end
195
+
196
+ # @return [Class<FieldStruct::Base>, nil] the schema the result's output
197
+ # is held to: its code's, or the success schema under typed outcomes
198
+ # @param action_class [Class<Actionable::Action>]
199
+ # @param result [Result]
200
+ def stubbed_output_schema(action_class, result)
201
+ return action_class.skip_output_schema(result.code) if result.skipped?
202
+ return action_class.failure_output_schema(result.code) if result.failure?
203
+
204
+ action_class.output_schema if action_class.typed_outcomes?
205
+ end
206
+
207
+ # The stub's output as an instance of +schema+, built from the fields of
208
+ # +result+'s output that the schema declares.
209
+ #
210
+ # @param action_class [Class<Actionable::Action>]
211
+ # @param result [Result]
212
+ # @param schema [Class<FieldStruct::Base>]
213
+ # @raise [ArgumentError] when the output breaks +schema+
214
+ # @return [FieldStruct::Base]
215
+ def build_stubbed_output(action_class, result, schema)
216
+ given = result.output.is_a?(FieldStruct::Base) ? result.output.attributes : result.output.to_h
217
+ given = given.slice(*schema.attribute_names)
218
+ # A parent absorbs only the fields a run set, so a stub sets the ones it was given.
219
+ result.output_fields_set = given.keys
220
+ output = schema.new(**given)
221
+ return output if output.valid?
222
+
223
+ raise ArgumentError, "#{action_class.name || "action"} output for #{result.code.inspect} breaks its schema " \
224
+ "(#{output.errors.full_messages.join(", ")}); a real run would end as invalid_output"
225
+ end
226
+
227
+ # The stub's response: +result+, noted as a stubbed run when outcome
228
+ # coverage is loaded (decision D22), so a report can tell an outcome
229
+ # only ever stubbed from one never reached at all.
230
+ #
231
+ # @param action_class [Class<Actionable::Action>]
232
+ # @param result [Result]
233
+ # @return [Result] +result+
234
+ def stubbed_run(action_class, result)
235
+ Actionable::Coverage.record_stub(action_class, result) if defined?(Actionable::Coverage)
236
+ result
237
+ end
238
+
239
+ # Add +errors+ to +result+'s errors.
240
+ #
241
+ # @param result [Result]
242
+ # @param errors [Hash{Symbol=>String, Array<String>}]
243
+ # @return [Result] +result+
244
+ def apply_errors(result, errors)
245
+ errors.each { |field, messages| Array(messages).each { |message| result.errors.add(field, message) } }
246
+ result
247
+ end
248
+ end
249
+ end
250
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Optional RSpec integration (decision D12). Load it explicitly with
4
+ # `require 'actionable/rspec'` (typically in spec_helper, after rspec). Never
5
+ # loaded by the core entry point.
6
+ require_relative '../actionable' # ensure the core is loaded (self-contained entry)
7
+ require_relative 'rspec/matchers'
8
+ require_relative 'rspec/stubs'
9
+
10
+ module Actionable
11
+ # RSpec matchers and stubs for actions (decision D12).
12
+ module RSpec
13
+ # The refusal for a stub, +and_fail+ or +and_skip+ naming a code an action
14
+ # that declares failures (decision D20) or skips (decision D21) doesn't
15
+ # declare.
16
+ #
17
+ # @param action_class [Class<Actionable::Action>]
18
+ # @param code [Symbol, String]
19
+ # @param outcome [:failure, :skip] which declarations the code was checked against
20
+ # @return [String]
21
+ def self.undeclared_code_message(action_class, code, outcome: :failure)
22
+ if outcome == :skip
23
+ declared = action_class.declared_skips.keys.map(&:inspect).join(', ')
24
+ return "#{action_class.name || "action"} does not declare skip #{code.to_sym.inspect} " \
25
+ "(declared: #{declared}); a stub or and_skip with it tests a skip the action can never return"
26
+ end
27
+
28
+ declared = action_class.declared_failures.keys.map(&:inspect).join(', ')
29
+ "#{action_class.name || "action"} does not declare #{code.to_sym.inspect} (declared: #{declared}); " \
30
+ 'a stub or and_fail with it tests a failure the action can never return'
31
+ end
32
+ end
33
+ end
34
+
35
+ if defined?(RSpec)
36
+ RSpec.configure do |config|
37
+ config.include Actionable::RSpec::Matchers
38
+ config.include Actionable::RSpec::Stubs
39
+ end
40
+ end
@@ -0,0 +1,8 @@
1
+ Actionable/VerbNotLast:
2
+ Description: 'A plain fail/fail_with/skip/succeed must be the last expression of its step or hook method.'
3
+ Enabled: true
4
+ # Subclasses of these (or of a class in the same file that reaches one) are
5
+ # checked; any other class, e.g. a dry-transaction with its own `step`, is not.
6
+ ActionBaseClasses:
7
+ - Actionable::Action
8
+ - ApplicationAction
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module Actionable
6
+ # A plain recording verb (+fail+ / +fail_with+ / +skip+ / +succeed+) must
7
+ # be the last expression of its step or hook method: it records the
8
+ # outcome and keeps running, so code after it still runs (owner rulings
9
+ # R5, R10). The
10
+ # same rules as the gem's first-run check, from the same analyzer
11
+ # (+Actionable::VerbPosition+); USAGE (Control flow) has the table.
12
+ #
13
+ # It sees one file: a class whose superclass is an action base (see
14
+ # +ActionBaseClasses+) and that declares +step+ / +case_step+, and the
15
+ # methods that class names in a step, case-step branch or hook
16
+ # declaration. A class with another +step+ DSL (dry-transaction) is
17
+ # left alone.
18
+ #
19
+ # +ActionBaseClasses+ (default +Actionable::Action+, +ApplicationAction+)
20
+ # lists the base classes by full name; a class defined in the same file
21
+ # whose superclass chain reaches one counts too. A superclass name
22
+ # resolves lexically, as Ruby looks it up, so two same-named classes
23
+ # in different modules are told apart.
24
+ #
25
+ # @example
26
+ # # bad
27
+ # def charge
28
+ # fail :card_expired if card.expired?
29
+ # notify
30
+ # end
31
+ #
32
+ # # good
33
+ # def charge
34
+ # fail! :card_expired if card.expired?
35
+ # notify
36
+ # end
37
+ #
38
+ # # good
39
+ # def charge
40
+ # notify
41
+ # fail :card_expired if card.expired?
42
+ # end
43
+ class VerbNotLast < Base
44
+ # @return [String]
45
+ MSG = '`%<verb>s` records the outcome and keeps running, and more code runs after it: ' \
46
+ "use `%<verb>s!` to stop here, or make it the method's last expression."
47
+
48
+ # @return [void]
49
+ def on_new_investigation
50
+ result = ::Prism.parse(processed_source.raw_source)
51
+ return unless result.errors.empty?
52
+
53
+ base_classes = Array(cop_config.fetch('ActionBaseClasses', ::Actionable::VerbPosition::ACTION_BASE_CLASSES))
54
+ ::Actionable::VerbPosition.declared_step_offenses(result.value, base_classes: base_classes).each do |offense|
55
+ range = Parser::Source::Range.new(processed_source.buffer, offense.start_character, offense.end_character)
56
+ add_offense(range, message: format(MSG, verb: offense.verb))
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'pathname'
4
+
5
+ module Actionable
6
+ # The lint_roller plugin RuboCop >= 1.72 loads for
7
+ # +plugins: [{actionable-rb: {require_path: actionable/rubocop}}]+ (the gemspec
8
+ # names it in +default_lint_roller_plugin+). It enables
9
+ # +Actionable/VerbNotLast+ through +config.yml+ beside this file.
10
+ class RubocopPlugin < LintRoller::Plugin
11
+ # @return [LintRoller::About]
12
+ def about
13
+ LintRoller::About.new(
14
+ name: 'actionable-rb', version: VERSION, homepage: 'https://github.com/Paymentbox-com/actionable',
15
+ description: 'Actionable/VerbNotLast: a plain fail/fail_with/skip/succeed must end its step or hook method.'
16
+ )
17
+ end
18
+
19
+ # @param context [LintRoller::Context]
20
+ # @return [Boolean]
21
+ def supported?(context)
22
+ context.engine == :rubocop
23
+ end
24
+
25
+ # @param _context [LintRoller::Context]
26
+ # @return [LintRoller::Rules]
27
+ def rules(_context)
28
+ LintRoller::Rules.new(type: :path, config_format: :rubocop, value: Pathname.new(__dir__).join('config.yml'))
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The opt-in RuboCop integration: the +Actionable/VerbNotLast+ cop (owner
4
+ # ruling R5). Never loaded by +require 'actionable'+; RuboCop loads it from
5
+ # a project's +.rubocop.yml+:
6
+ #
7
+ # plugins:
8
+ # - actionable-rb:
9
+ # require_path: actionable/rubocop
10
+ #
11
+ # or, on RuboCop < 1.72, +require: [actionable/rubocop]+. RuboCop depends on
12
+ # Prism, so the cop always has it. Loads only the rule analyzer, not the gem.
13
+ require 'rubocop'
14
+ require 'prism'
15
+ require_relative 'version'
16
+ require_relative 'verb_position'
17
+ require_relative 'rubocop/cop/actionable/verb_not_last'
18
+
19
+ begin
20
+ require 'lint_roller' # RuboCop >= 1.72's plugin API
21
+ rescue LoadError
22
+ nil
23
+ else
24
+ require_relative 'rubocop/plugin'
25
+ end