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,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Actionable
4
+ # ActiveModel validations applied to a separate delegate object (decision
5
+ # D7). Subclass it, declare rules with the ActiveModel +validates+ DSL, and
6
+ # construct it with any object — a plain PORO or an ActiveRecord model — whose
7
+ # attributes the rules read. This keeps validation rules separate from the
8
+ # data they check.
9
+ #
10
+ # class InvoiceRules < Actionable::ProxyValidator
11
+ # validates :amount, presence: true, numericality: { greater_than: 0 }
12
+ # end
13
+ #
14
+ # rules = InvoiceRules.new(invoice)
15
+ # fail(:invalid, **rules.formatted_errors) unless rules.valid?
16
+ #
17
+ # Part of the optional Rails adapter — available only after
18
+ # +require 'actionable/rails'+.
19
+ class ProxyValidator
20
+ include ActiveModel::Validations
21
+
22
+ # ActiveModel needs a model name to build i18n error messages; supply a
23
+ # fallback so anonymous subclasses (and the base) still produce messages.
24
+ #
25
+ # @return [ActiveModel::Name]
26
+ def self.model_name
27
+ @model_name ||= ActiveModel::Name.new(self, nil, name || 'ProxyValidator')
28
+ end
29
+
30
+ # @return [Object] the object whose attributes the rules validate
31
+ attr_reader :delegate
32
+
33
+ # @param delegate [Object] the object to validate
34
+ def initialize(delegate)
35
+ @delegate = delegate
36
+ end
37
+
38
+ # @return [Hash{Symbol=>Array<String>}] validation errors keyed by
39
+ # attribute, after running the rules — ready to splat into +fail+
40
+ def formatted_errors
41
+ valid?
42
+ errors.to_hash
43
+ end
44
+
45
+ private
46
+
47
+ # Read attribute values from the delegate so the ActiveModel validators see
48
+ # the delegate's data.
49
+ def method_missing(name, *args, &block)
50
+ return @delegate.public_send(name, *args, &block) if @delegate.respond_to?(name)
51
+
52
+ super
53
+ end
54
+
55
+ def respond_to_missing?(name, include_private = false)
56
+ @delegate.respond_to?(name, include_private) || super
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Actionable
4
+ module Rails
5
+ # Internal signal raised inside a transaction block to trigger a rollback
6
+ # without surfacing as a genuine error. The runner wrapper swallows it; a
7
+ # real ActiveRecord transaction rolls back and re-raises it (as it does for
8
+ # any exception), and the wrapper catches it outside the block.
9
+ class Rollback < StandardError; end
10
+
11
+ # The +transactional+ class macro (decision D9). Extended onto
12
+ # {Actionable::Action} by +actionable/rails+.
13
+ module Transactions
14
+ # Wrap this action's run in +model.transaction(**options)+.
15
+ #
16
+ # transactional model: :invoice
17
+ # transactional model: Invoice, requires_new: false # join an outer one
18
+ #
19
+ # Run inside a transaction that is already open, the action gets its own
20
+ # savepoint (+requires_new: true+) unless +requires_new:+ is given, so a
21
+ # recorded +Failure+ rolls back its own writes rather than leaving them for
22
+ # the outer transaction to commit.
23
+ #
24
+ # @param model [Symbol, Class] the model whose +transaction+ is used; a
25
+ # Symbol is resolved to a constant (+:invoice+ → +Invoice+)
26
+ # @param options [Object] options forwarded to +transaction+
27
+ # @return [void]
28
+ def transactional(model:, **options)
29
+ @transaction_config = {model: model, options: options}
30
+ end
31
+
32
+ # The transaction config for this action, inherited from ancestors when
33
+ # not set locally (decision D9: inherited, overridable).
34
+ #
35
+ # @return [Hash{Symbol=>Object}, nil] +{model:, options:}+, or +nil+ when
36
+ # neither this action nor an ancestor is +transactional+
37
+ def transaction_config
38
+ return @transaction_config if defined?(@transaction_config) && @transaction_config
39
+ return superclass.transaction_config if superclass.respond_to?(:transaction_config)
40
+
41
+ nil
42
+ end
43
+ end
44
+
45
+ # Prepended onto {Actionable::Runner} so a +transactional+ action runs its
46
+ # whole pipeline inside a transaction. A raised exception or a recorded
47
+ # +Failure+ rolls everything back; a +Success+ commits (decision D9).
48
+ module RunnerTransaction
49
+ # @return [Result]
50
+ def run
51
+ config = transaction_config
52
+ return super unless config
53
+
54
+ result = nil
55
+ model = resolve_model(config[:model])
56
+ model.transaction(**transaction_options(model, config[:options])) do
57
+ result = super
58
+ raise Rollback if result.failure?
59
+ end
60
+ result
61
+ rescue Rollback
62
+ result
63
+ end
64
+
65
+ private
66
+
67
+ # @return [Hash{Symbol=>Object}, nil] the running action's transaction config
68
+ def transaction_config
69
+ klass = @instance.class
70
+ klass.respond_to?(:transaction_config) ? klass.transaction_config : nil
71
+ end
72
+
73
+ # The configured options, plus +requires_new: true+ when a transaction is
74
+ # already open and the action did not say otherwise. Joining the outer
75
+ # transaction would swallow this run's rollback, so a recorded failure's
76
+ # writes would commit with it (decision D9).
77
+ #
78
+ # @param model [Class]
79
+ # @param options [Hash{Symbol=>Object}]
80
+ # @return [Hash{Symbol=>Object}]
81
+ def transaction_options(model, options)
82
+ return options if options.key?(:requires_new)
83
+ return options unless model.respond_to?(:connection) && model.connection.transaction_open?
84
+
85
+ options.merge(requires_new: true)
86
+ end
87
+
88
+ # @param model [Symbol, Class]
89
+ # @return [Class] the resolved model class
90
+ def resolve_model(model)
91
+ model.is_a?(Class) ? model : model.to_s.camelize.constantize
92
+ end
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Optional Rails adapter (decisions D8/D9). This file is NEVER required by the
4
+ # core entry point — load it explicitly with `require 'actionable/rails'` (in a
5
+ # Rails app, the Railtie does so during boot). It is the only place that pulls
6
+ # in active_*.
7
+ require_relative '../actionable' # ensure the core is loaded (self-contained entry)
8
+ require 'active_model'
9
+ require 'active_support/core_ext/string/inflections' # camelize/constantize
10
+
11
+ require_relative 'rails/transactions'
12
+ require_relative 'rails/proxy_validator'
13
+ require_relative 'rails/after_commit'
14
+
15
+ # Wire the adapter into the core: the transactional and after_commit macros
16
+ # onto Action, and their behavior onto the Runner. RunnerAfterCommit is
17
+ # prepended last so it wraps RunnerTransaction: a run registers its
18
+ # after_commit hooks only once its own transaction is over.
19
+ Actionable::Action.extend(Actionable::Rails::Transactions)
20
+ Actionable::Action.singleton_class.prepend(Actionable::Rails::AfterCommit)
21
+ Actionable::Runner.prepend(Actionable::Rails::RunnerTransaction)
22
+ Actionable::Runner.prepend(Actionable::Rails::RunnerAfterCommit)
23
+
24
+ require_relative 'railtie' if defined?(Rails::Railtie)
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Actionable
4
+ # Integrates the optional Rails adapter with the host application's boot
5
+ # process (decision D8). Loaded by +actionable/rails+ only when running inside
6
+ # a Rails application; it is the hook point for future Rails-specific wiring
7
+ # (generators, initializers). The core gem never references Rails.
8
+ class Railtie < ::Rails::Railtie
9
+ end
10
+ end