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
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 45242641b6c3a755cb601f0ee6307381e641775560e94bc710eb75dd8f2959dc
4
+ data.tar.gz: 4d914d784d7066d12a4240842ba4daef80ce8fa11276d7c15a4c731a6f2ea6ef
5
+ SHA512:
6
+ metadata.gz: 1ec848dc0cdef6029a58719528263875682085fdc37f4200711581efccf92a971270271dde8abed11d8c17caf5072223109a061bc68a103097319624caec375d
7
+ data.tar.gz: ade35f4c5112e57198169a4088f407e6249e37b56c4801dccd7beda0bed870b7807afaf7cb65c467812f3bed96a4a16e226739c0b45a591464a635cc2ff50837
@@ -0,0 +1,15 @@
1
+ {
2
+ "name": "actionable",
3
+ "owner": {
4
+ "name": "Adrian Madrid",
5
+ "email": "amadrid@pmtbox.com"
6
+ },
7
+ "plugins": [
8
+ {
9
+ "name": "actionable",
10
+ "source": ".",
11
+ "description": "Guidance for building, debugging, and testing Actionable service objects (the actionable gem's typed step/result DSL).",
12
+ "category": "development"
13
+ }
14
+ ]
15
+ }
@@ -0,0 +1,9 @@
1
+ {
2
+ "name": "actionable",
3
+ "description": "Guidance for building, debugging, and testing Actionable service objects — the actionable gem's typed step/result DSL.",
4
+ "author": {
5
+ "name": "Adrian Madrid",
6
+ "email": "amadrid@pmtbox.com"
7
+ },
8
+ "homepage": "https://github.com/Paymentbox-com/actionable"
9
+ }
data/.rspec ADDED
@@ -0,0 +1,3 @@
1
+ --format documentation
2
+ --color
3
+ --require spec_helper
data/AGENTS.md ADDED
@@ -0,0 +1,150 @@
1
+ # Working with the actionable gem
2
+
3
+ This repo provides **Actionable** — a Ruby gem for service objects: subclass
4
+ `Actionable::Action`, declare ordered **steps**, implement each as a method, and run
5
+ it to get a single typed **result**. This file routes AI coding agents to the right
6
+ docs and the things most often gotten wrong. (For *contributing to the gem itself*,
7
+ see `CLAUDE.md`.)
8
+
9
+ ## Read these
10
+
11
+ - **[`docs/getting_started.md`](docs/getting_started.md)** — how to use it in a repo
12
+ (Ruby + Rails): a task→API map, an error→fix table, and a worked example.
13
+ - **[`USAGE.md`](USAGE.md)** — dense reference: every step type, option, and macro.
14
+ - **[`README.md`](README.md)** — feature overview.
15
+ - `skills/actionable/SKILL.md` — the bundled Claude Code skill. Install it with
16
+ `/plugin marketplace add Paymentbox-com/actionable` then
17
+ `/plugin install actionable@actionable` (the repo is a single-plugin marketplace).
18
+
19
+ ## The shape
20
+
21
+ ```ruby
22
+ class CreateInvoice < Actionable::Action
23
+ input { required :amount, :big_decimal; optional :name, :string }
24
+ output { required :invoice, Invoice }
25
+
26
+ step :build
27
+ step :persist
28
+
29
+ def build = @invoice = Invoice.new(amount: input.amount, name: input.name)
30
+ def persist = @invoice.save!
31
+ end
32
+
33
+ result = CreateInvoice.run(amount: 19.99, name: 'Acme')
34
+ result.success? # => true
35
+ result.code # => :success
36
+ result.output.invoice # => #<Invoice ...> (typed)
37
+ result.invoice # => same, via convenience delegation
38
+ ```
39
+
40
+ ## The gotchas
41
+
42
+ 1. **`fail`/`succeed` vs `fail!`/`succeed!`.** The plain versions *record* an outcome
43
+ and keep running; the bang versions *also stop* the pipeline (`halt!` stops without
44
+ recording). Last write wins; with no call, the action auto-succeeds.
45
+ **`fail` is not `raise` here:** inside an action it is the gem's verb, so
46
+ `fail 'boom'` records a `Failure` with code `:boom` and keeps running (a
47
+ sentence code like `'card expired'` raises `ArgumentError`). Use `raise` for
48
+ a bug, `fail!(:code, 'msg')` to stop with a failure.
49
+ **A plain verb must be the last expression of its step/hook method.**
50
+ `fail :x if bad?` followed by more code raises `DefinitionError` on the
51
+ action's first run: write `fail!` to stop there, or move the verb last
52
+ (`return fail(:x) if bad?` also works). Lint it with the opt-in
53
+ `Actionable/VerbNotLast` cop (`require_path: actionable/rubocop`). See
54
+ [USAGE, Control flow](USAGE.md#where-a-plain-verb-may-go).
55
+ 2. **Output is declared, not magic.** Only fields in `output do … end` appear on the
56
+ result. Setting an undeclared `@foo` does nothing for the caller — declare it.
57
+ 3. **Genuine exceptions propagate.** A `raise` inside a step is *not* turned into a
58
+ failure. Catch it yourself, or `fail`/`fail!` deliberately. Assert on it in specs
59
+ with `perform_actionable(...).and_raise(SomeError)`.
60
+ 4. **Transactions need the Rails adapter.** `transactional model: :invoice` only
61
+ works after `require 'actionable/rails'` (auto-loaded when ActiveRecord is present).
62
+ The core never wraps a transaction on its own. The same goes for
63
+ `after_commit :method` (ActiveRecord >= 7.2): it runs a successful run's side
64
+ effect only after the outermost transaction commits — use it, not `on_success`,
65
+ for emails/webhooks/enqueues from a transactional action.
66
+ 5. **Nested actions thread values explicitly.** `step Child, input: %i[invoice]`
67
+ passes `invoice` in; `expose: %i[receipt]` limits what comes back. Without
68
+ `expose:`, all of the child's outputs are absorbed.
69
+ 6. **A skip is `ok?`, not a failure — and now carries output.** `skip(code, msg,
70
+ **output)` mirrors `succeed` (captured best-effort, unvalidated), so an
71
+ idempotent hit can return the existing record's id. Don't model "already done"
72
+ as a `fail`.
73
+ 7. **Declared failures are strict.** Once an action says `fails :refused, kind:
74
+ :rejected`, every code it fails with must be declared, or `fail` raises
75
+ `DefinitionError`. `:invalid_input` / `:invalid_output` are exempt. The
76
+ metadata after the message is the app's, read back as `result.metadata`;
77
+ `fail`'s keywords are only ever errors.
78
+ 8. **Declared skips are strict too, the default included.** Once an action says
79
+ `skips :not_ready`, every `skip` / `skip!` must use a declared code, or it
80
+ raises `DefinitionError`. Nothing is exempt, so a bare `skip!` (code
81
+ `:skipped`) on a declaring action needs `skips :skipped`. A child's skip
82
+ continues the parent, so a parent never declares its children's skip codes.
83
+ 9. **Redeclaring overrides, last wins.** A second `step :x` (or `case_step`,
84
+ nested `step Child`, or hook) with the same name, in the class or a
85
+ subclass, replaces the earlier options and keeps the earlier position. You
86
+ can't remove an inherited step; guard it with `if:` / `unless:`.
87
+ 10. **`always` hooks run even after a hook halts.** A `fail!` in `on_success`
88
+ stops the rest of the outcome hooks and becomes the result, but the
89
+ `always` hooks still run and see it. Put cleanup and audit in `always`;
90
+ check `result` there if the outcome matters.
91
+
92
+ ## Fast paths
93
+
94
+ - Declare what an action can fail with: `fails :not_found, kind: :not_found`;
95
+ read it back with `Action.declared_failures` or off the result as `result.metadata`.
96
+ - Declare what an action can skip with: `skips :not_ready, retry: true`; read it
97
+ back with `Action.declared_skips` or off the skipped result as `result.metadata`.
98
+ - See an action's contract without reading source: `pp CreateInvoice.describe`
99
+ (or `CreateInvoice.describe_text` for a readable summary).
100
+ - Run over a collection: `Action.run_each(items) { |i| {…} }` → a `BatchResult`
101
+ with `all_ok?` / `any_failure?` / `partial?` and `to_api_h`.
102
+ - Pick the input schema at runtime: `input_for(:event_type) { on 'sale', SaleShape; default … }`.
103
+ - Validate a side struct and fail with its errors: `fail_with(EventShape.new(payload), code: :invalid_event)`.
104
+ - Render a result for an HTTP response: `result.to_h` / `result.to_api_h(index:)`.
105
+ - Run an action from a background job: `include Actionable::Job::Mixin` and call
106
+ `run_actionable(...)` in `perform` (requires `actionable/job`). Add
107
+ `honor_retry_metadata` to the action to make declared `retry: true` /
108
+ `retry: false` metadata decide whether the job retries (off by default).
109
+ - Type your actions for Steep/Solargraph: `Actionable::RBS.generate(CreateInvoice)`.
110
+ - Test a caller without running the action: `stub_actionable_success CreateInvoice, invoice: an_invoice`
111
+ (requires `actionable/rspec`).
112
+ - Find outcomes no spec reaches: `require 'actionable/rspec/coverage'` in a spec
113
+ support file. The suite ends with a table of each action's never-reached
114
+ outcomes; a stubbed result doesn't count. `Coverage.baseline = path` makes CI
115
+ fail on a new gap or a stale entry (see USAGE, "Outcome coverage").
116
+ - Profile a run: declare `measure :all` (or `:sampled, rate: 0.1` in prod), then
117
+ read `result.history.to_json`.
118
+
119
+ ## Upgrading
120
+
121
+ Bumping actionable in a host app? Work from [`UPGRADING.md`](UPGRADING.md), not
122
+ from memory: it has a Symptom / Find / Fix / Why entry for every change a caller
123
+ can see since 1.5.x. Procedure:
124
+
125
+ 1. **From / to.** Read the current version from `Gemfile.lock` (`actionable
126
+ (x.y.z)`) and pick the target tag. Read UPGRADING.md's TL;DR and only the
127
+ `## A → B` sections between the two.
128
+ 2. **field_struct first.** If the target needs a newer one (>= 0.11 for 1.5.2,
129
+ >= 0.12 from 1.6.0), bump it, follow its own UPGRADING.md, and get the suite
130
+ green before touching actionable.
131
+ 3. **Bump actionable.** Check `rg --pcre2-version` works in your shell. Run the
132
+ **Find** of each entry UPGRADING.md lists under "Which Finds to run" (they
133
+ change behaviour without raising), even if nothing failed. Run the others for
134
+ code the suite doesn't reach. Keep the hits per entry.
135
+ 4. **Enable the cop** (`plugins: [{actionable-rb: {require_path: actionable/rubocop}}]`
136
+ in `.rubocop.yml`; list in `ActionBaseClasses` the two defaults and every app
137
+ base class defined in its own file, by full name, intermediate ones too) and run
138
+ `bundle exec rubocop --only Actionable/VerbNotLast`.
139
+ 5. **Apply each entry's Fix.** `Actionable.verb_position_check = false` is only
140
+ for a temporary green baseline: set it in `spec_helper`, not an initializer
141
+ (an initializer switches it off in production too), and remove it before you
142
+ finish. The identifier
143
+ code check has no switch.
144
+ 6. **Regenerate RBS** the app owns with `Actionable::RBS.generate`, then run
145
+ `rbs validate` / Steep if the app uses them.
146
+ 7. **Run the full suite with the check on.**
147
+ 8. **Report** the entries applied, hits left unchanged and why, the entries
148
+ whose Find says no reliable pattern exists (say what you reviewed by hand),
149
+ and the residual risk: behaviour changes the suite may not exercise
150
+ (savepoints, bang verbs in hooks, `always` after a halt, redeclared steps).