statesman_scaffold 0.1.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.
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+
5
+ module StatesmanScaffold
6
+ # Manages the Rails initializer file that configures Statesman to use the
7
+ # ActiveRecord adapter and auto-includes {StatesmanScaffold::Concern} into
8
+ # every ActiveRecord model via +ActiveSupport.on_load(:active_record)+.
9
+ #
10
+ # This class is intentionally decoupled from Rails so it can be exercised in
11
+ # unit tests without booting a full Rails application.
12
+ class Installer
13
+ # Path of the generated initializer, relative to the Rails root.
14
+ INITIALIZER_PATH = Pathname.new("config/initializers/statesman_scaffold.rb")
15
+
16
+ # Content written to the initializer file.
17
+ INITIALIZER_CONTENT = <<~RUBY
18
+ # frozen_string_literal: true
19
+
20
+ # Auto-include StatesmanScaffold::Concern into every ActiveRecord model
21
+ # so that the `with_state_machine` class macro is available application-wide.
22
+ #
23
+ # Generated by: rails statesman_scaffold:install
24
+ require "statesman"
25
+
26
+ Statesman.configure { storage_adapter(Statesman::Adapters::ActiveRecord) }
27
+
28
+ ActiveSupport.on_load(:active_record) do
29
+ include StatesmanScaffold::Concern
30
+ end
31
+ RUBY
32
+
33
+ # Creates the initializer file under +rails_root+.
34
+ #
35
+ # @param rails_root [Pathname, String] Root of the Rails application.
36
+ # @return [:created] when the file was written.
37
+ # @return [:skipped] when the file already exists.
38
+ def self.install!(rails_root)
39
+ target = Pathname(rails_root).join(INITIALIZER_PATH)
40
+ return :skipped if target.exist?
41
+
42
+ target.dirname.mkpath
43
+ target.write(INITIALIZER_CONTENT)
44
+ :created
45
+ end
46
+
47
+ # Removes the initializer file from +rails_root+.
48
+ #
49
+ # @param rails_root [Pathname, String] Root of the Rails application.
50
+ # @return [:removed] when the file was deleted.
51
+ # @return [:skipped] when the file did not exist.
52
+ def self.uninstall!(rails_root)
53
+ target = Pathname(rails_root).join(INITIALIZER_PATH)
54
+ return :skipped unless target.exist?
55
+
56
+ target.delete
57
+ :removed
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StatesmanScaffold
4
+ # Hooks StatesmanScaffold into a Rails application.
5
+ #
6
+ # When Rails loads, this Railtie exposes the +statesman_scaffold:install+,
7
+ # +statesman_scaffold:uninstall+, and +statesman_scaffold:generate+ rake tasks.
8
+ class Railtie < Rails::Railtie
9
+ railtie_name :statesman_scaffold
10
+
11
+ rake_tasks do
12
+ load File.expand_path("../tasks/statesman_scaffold.rake", __dir__)
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StatesmanScaffold
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "statesman_scaffold/version"
4
+ require_relative "statesman_scaffold/concern"
5
+ require_relative "statesman_scaffold/generator"
6
+ require_relative "statesman_scaffold/installer"
7
+
8
+ module StatesmanScaffold
9
+ class Error < StandardError; end
10
+ end
11
+
12
+ require "statesman_scaffold/railtie" if defined?(Rails::Railtie)
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ STATESMAN_SCAFFOLD_INITIALIZER_PATH = StatesmanScaffold::Installer::INITIALIZER_PATH.to_s
4
+
5
+ namespace :statesman_scaffold do
6
+ desc "Install the StatesmanScaffold initializer (Statesman AR adapter + auto-include concern)"
7
+ task :install do
8
+ result = StatesmanScaffold::Installer.install!(Rails.root)
9
+ case result
10
+ when :created then puts " #{"create".ljust(10)} #{STATESMAN_SCAFFOLD_INITIALIZER_PATH}"
11
+ when :skipped then puts " #{"skip".ljust(10)} #{STATESMAN_SCAFFOLD_INITIALIZER_PATH} already exists"
12
+ end
13
+ end
14
+
15
+ desc "Remove the StatesmanScaffold initializer"
16
+ task :uninstall do
17
+ result = StatesmanScaffold::Installer.uninstall!(Rails.root)
18
+ case result
19
+ when :removed then puts " #{"remove".ljust(10)} #{STATESMAN_SCAFFOLD_INITIALIZER_PATH}"
20
+ when :skipped then puts " #{"skip".ljust(10)} #{STATESMAN_SCAFFOLD_INITIALIZER_PATH} not found"
21
+ end
22
+ end
23
+
24
+ desc "Generate StateMachine, Transition class, and migration for a model"
25
+ task :generate, [:model] => :environment do |_, args|
26
+ model = args[:model].to_s.camelize
27
+ abort "Model name required. Usage: rails \"statesman_scaffold:generate[ModelName]\"" if model.blank?
28
+
29
+ file_path = model.underscore
30
+ output_path = Rails.root.join("app/models", file_path)
31
+ migration_path = Rails.root.join("db/migrate")
32
+ version = ActiveRecord::Migration.current_version.to_s[0..2]
33
+
34
+ result = StatesmanScaffold::Generator.call(
35
+ model: model,
36
+ output_path: output_path,
37
+ migration_path: migration_path,
38
+ migration_version: version
39
+ )
40
+
41
+ puts " #{"create".ljust(10)} #{result.state_machine_path.relative_path_from(Rails.root)}"
42
+ puts " #{"create".ljust(10)} #{result.transition_path.relative_path_from(Rails.root)}"
43
+ puts " #{"create".ljust(10)} #{result.migration_path.relative_path_from(Rails.root)}"
44
+ puts ""
45
+ puts "Don't forget to update your #{model} model by adding:"
46
+ puts ""
47
+ puts " include StatesmanScaffold::Concern"
48
+ puts " with_state_machine"
49
+ puts ""
50
+ puts "If you haven't already, run: rails statesman_scaffold:install"
51
+ end
52
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+ <% underscored_model = model.underscore.tr("/", "_") -%>
3
+ <% demodulized_model = model.demodulize.underscore -%>
4
+
5
+ class Create<%= class_name.gsub("::", "") %>Transitions < ActiveRecord::Migration[<%= version %>]
6
+ def change
7
+ # Instead of t.references with foreign_key: true, you can use:
8
+ # t.string :<%= demodulized_model %>_id, null: false
9
+ # And separately add foreign key:
10
+ # add_foreign_key :<%= underscored_model %>_transitions, :<%= demodulized_model %>s , column: :<%= demodulized_model %>_id, primary_key: :id
11
+
12
+ create_table :<%= underscored_model %>_transitions do |t|
13
+ t.references :<%= demodulized_model %>, null: false, foreign_key: <%= underscored_model == demodulized_model ? "true" : "{ to_table: :#{underscored_model.pluralize} }" %> # Use type when referencing model uses non-default primary key type. Example: type: :string
14
+ t.string :to_state, null: false
15
+ t.json :metadata, default: {}
16
+ t.boolean :most_recent, default: false
17
+ t.integer :sort_key, null: false
18
+ t.timestamps null: false
19
+ end
20
+
21
+ add_index(:<%= underscored_model %>_transitions,
22
+ %i[<%= demodulized_model %>_id sort_key],
23
+ unique: true,
24
+ name: "index_<%= underscored_model %>_transition_parent_sort")
25
+ add_index(:<%= underscored_model %>_transitions,
26
+ %i[<%= demodulized_model %>_id most_recent],
27
+ unique: true,
28
+ where: "most_recent",
29
+ name: "index_<%= underscored_model %>_transition_parent_most_recent")
30
+ end
31
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ class <%= class_name %>::StateMachine
4
+ include Statesman::Machine
5
+
6
+ state :pending, initial: true
7
+ # state :checking_out
8
+ # state :purchased
9
+ # state :shipped
10
+ # state :cancelled
11
+ # state :failed
12
+ # state :refunded
13
+ #
14
+ # transition from: :pending, to: [:checking_out, :cancelled]
15
+ # transition from: :checking_out, to: [:purchased, :cancelled]
16
+ # transition from: :purchased, to: [:shipped, :failed]
17
+ # transition from: :shipped, to: :refunded
18
+ #
19
+ # after_transition do |model, transition|
20
+ # model.update!(status: transition.to_state)
21
+ # end
22
+ #
23
+ # guard_transition(to: :checking_out) do |order|
24
+ # order.products_in_stock?
25
+ # end
26
+ #
27
+ # before_transition(from: :checking_out, to: :cancelled) do |order, transition|
28
+ # order.reallocate_stock
29
+ # end
30
+ #
31
+ # before_transition(to: :purchased) do |order, transition|
32
+ # PaymentService.new(order).submit
33
+ # end
34
+ #
35
+ # after_transition(to: :purchased) do |order, transition|
36
+ # MailerService.order_confirmation(order).deliver
37
+ # end
38
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+ <% underscored_model = model.underscore.tr("/", "_") -%>
3
+ <% demodulized_model = model.demodulize.underscore -%>
4
+
5
+ class <%= class_name %>::Transition < ApplicationRecord
6
+ self.table_name = "<%= underscored_model %>_transitions"
7
+
8
+ belongs_to :<%= demodulized_model %>, class_name: "<%= class_name %>"
9
+
10
+ attribute :most_recent, :boolean, default: false
11
+ attribute :to_state, :string
12
+ attribute :sort_key, :integer
13
+ attribute :metadata, :json, default: {}
14
+
15
+ validates :to_state, inclusion: { in: <%= class_name %>::StateMachine.states }
16
+ end
data/llms/overview.md ADDED
@@ -0,0 +1,256 @@
1
+ # StatesmanScaffold -- Architecture Overview
2
+
3
+ > Intended audience: LLMs and AI agents that need a precise mental model of
4
+ > the gem internals before generating code.
5
+
6
+ ---
7
+
8
+ ## Purpose
9
+
10
+ `statesman_scaffold` provides two things for Rails applications using the
11
+ [Statesman](https://github.com/gocardless/statesman) gem:
12
+
13
+ 1. **Code generation** — a rake task that creates StateMachine, Transition
14
+ classes, and a migration for any ActiveRecord model.
15
+ 2. **A concern** — `with_state_machine` class macro that wires up Statesman
16
+ delegation, `has_many :transitions`, query scopes, and convenience predicates.
17
+
18
+ It works exclusively with `Statesman::Adapters::ActiveRecord`.
19
+
20
+ ---
21
+
22
+ ## Module structure
23
+
24
+ ```
25
+ StatesmanScaffold (top-level namespace, lib/statesman_scaffold.rb)
26
+ ├── Concern (lib/statesman_scaffold/concern.rb)
27
+ │ ├── included block
28
+ │ │ ├── in_state?(*states) → checks current state against multiple states
29
+ │ │ ├── not_in_state?(state) → negation of in_state?
30
+ │ │ └── delegate :current_state, :transition_to!, :transition_to, :can_transition_to?
31
+ │ └── class_methods
32
+ │ └── with_state_machine(...) → wires up has_many, ActiveRecordQueries, state_machine method
33
+ ├── Generator (lib/statesman_scaffold/generator.rb)
34
+ │ ├── TEMPLATE_DIR → path to lib/templates/statesman/
35
+ │ ├── Result → Struct(state_machine_path, transition_path, migration_path)
36
+ │ └── .call(model:, output_path:, migration_path:, migration_version:, timestamp:)
37
+ ├── Installer (lib/statesman_scaffold/installer.rb)
38
+ │ ├── INITIALIZER_PATH → Pathname("config/initializers/statesman_scaffold.rb")
39
+ │ ├── INITIALIZER_CONTENT → the exact string written to disk
40
+ │ ├── .install!(rails_root) → :created or :skipped
41
+ │ └── .uninstall!(rails_root) → :removed or :skipped
42
+ ├── Railtie < Rails::Railtie (lib/statesman_scaffold/railtie.rb)
43
+ │ └── rake_tasks { load ... } → exposes all statesman_scaffold:* rake tasks
44
+ └── Error < StandardError
45
+ ```
46
+
47
+ ---
48
+
49
+ ## How `with_state_machine` works
50
+
51
+ ```ruby
52
+ class Project < ApplicationRecord
53
+ with_state_machine
54
+ end
55
+ ```
56
+
57
+ When `with_state_machine` is called, it:
58
+
59
+ 1. **Resolves class names dynamically** from the model's `name`:
60
+ - `"Project"` → `"Project::StateMachine"` and `"Project::Transition"`
61
+ - `"Admin::Order"` → `"Admin::Order::StateMachine"` and `"Admin::Order::Transition"`
62
+
63
+ 2. **Adds `has_many :transitions`** with `autosave: false`, `dependent: :destroy`,
64
+ and the resolved `class_name`.
65
+
66
+ 3. **Includes `Statesman::Adapters::ActiveRecordQueries`** parameterised with
67
+ `transition_class` and `initial_state`, giving the model `.in_state` and
68
+ `.not_in_state` class-level scopes.
69
+
70
+ 4. **Defines a `state_machine` instance method** that lazily initialises and
71
+ caches a Statesman machine instance via `instance_variable_set(:@state_machine, ...)`.
72
+
73
+ The `included` block adds `in_state?`, `not_in_state?`, and delegates
74
+ `current_state`, `transition_to!`, `transition_to`, `can_transition_to?` to
75
+ `state_machine`.
76
+
77
+ ### Class name resolution
78
+
79
+ ```ruby
80
+ model_name = name # e.g. "Admin::Order"
81
+ model_namespace = model_name.deconstantize # e.g. "Admin"
82
+ model_base = model_name.demodulize # e.g. "Order"
83
+
84
+ transition_class_name = [model_namespace.presence, model_base, "Transition"].compact.join("::")
85
+ state_machine_class_name = [model_namespace.presence, model_base, "StateMachine"].compact.join("::")
86
+ ```
87
+
88
+ Both class names are constantised at call time (`constantize`), so the nested
89
+ classes must already be defined (autoloaded) when the model is first used.
90
+
91
+ ### `in_state?` / `not_in_state?`
92
+
93
+ ```ruby
94
+ def in_state?(*states)
95
+ states.any? { |s| current_state.to_sym == s.to_sym }
96
+ end
97
+
98
+ def not_in_state?(state)
99
+ !in_state?(state)
100
+ end
101
+ ```
102
+
103
+ Both accept symbols or strings. `in_state?` accepts multiple states (returns
104
+ `true` if current state matches any). `not_in_state?` accepts a single state.
105
+
106
+ ---
107
+
108
+ ## Generator architecture
109
+
110
+ `StatesmanScaffold::Generator` is a pure Ruby class with no Rails dependency.
111
+ It reads ERB templates from `lib/templates/statesman/` and writes rendered
112
+ output to caller-specified paths.
113
+
114
+ ### Templates
115
+
116
+ **`state_machine.rb.erb`** — creates `Model::StateMachine` with
117
+ `include Statesman::Machine`, an initial `:pending` state, and commented
118
+ examples of states, transitions, guards, and callbacks.
119
+
120
+ **`transition.rb.erb`** — creates `Model::Transition < ApplicationRecord` with:
121
+ - `self.table_name` set to `"<underscored_model>_transitions"`
122
+ - `belongs_to` the parent model
123
+ - Attributes: `most_recent`, `to_state`, `sort_key`, `metadata`
124
+ - Validates `to_state` inclusion against `Model::StateMachine.states`
125
+
126
+ **`migration.rb.erb`** — creates a migration with:
127
+ - `create_table` for `<underscored_model>_transitions`
128
+ - `t.references` with foreign key (handles namespaced models with `to_table:`)
129
+ - `to_state`, `metadata`, `most_recent`, `sort_key`, `timestamps`
130
+ - Two unique indexes: `parent_sort` and `parent_most_recent` (with `WHERE most_recent`)
131
+
132
+ ### Template variables
133
+
134
+ | Variable | Example | Source |
135
+ |----------|---------|--------|
136
+ | `model` | `"Admin::Order"` | Input parameter |
137
+ | `class_name` | `"Admin::Order"` | Same as `model` (camelized) |
138
+ | `underscored_model` | `"admin_order"` | `model.underscore.tr("/", "_")` |
139
+ | `demodulized_model` | `"order"` | `model.demodulize.underscore` |
140
+ | `version` | `"8.0"` | Migration version |
141
+
142
+ ### Namespaced model handling
143
+
144
+ For `Admin::Order`:
145
+ - Table name: `admin_order_transitions`
146
+ - `belongs_to :order` (demodulized)
147
+ - Foreign key: `{ to_table: :admin_orders }` (because `underscored_model != demodulized_model`)
148
+ - Migration class: `CreateAdminOrderTransitions`
149
+
150
+ ---
151
+
152
+ ## Installer architecture
153
+
154
+ ```
155
+ Rails app statesman_scaffold gem
156
+ ───────────────── ──────────────────────────────────────────────────
157
+ Gemfile ──requires──▶ lib/statesman_scaffold.rb
158
+ └── lib/statesman_scaffold/railtie.rb (only if Rails::Railtie defined)
159
+ └── rake_tasks { load 'lib/tasks/statesman_scaffold.rake' }
160
+
161
+ $ rails statesman_scaffold:install
162
+ ──▶ task :install
163
+ └── StatesmanScaffold::Installer.install!(Rails.root)
164
+ └── writes config/initializers/statesman_scaffold.rb
165
+ ```
166
+
167
+ **Why Installer is a separate class:** The rake task calls `Rails.root`, which is
168
+ unavailable in tests without a full Rails boot. By pushing all logic into
169
+ `Installer.install!(root)`, tests can pass any `Pathname` as the root — no
170
+ stubbing, no Rake DSL needed.
171
+
172
+ **The generated initializer** contains:
173
+
174
+ ```ruby
175
+ require "statesman"
176
+
177
+ Statesman.configure { storage_adapter(Statesman::Adapters::ActiveRecord) }
178
+
179
+ ActiveSupport.on_load(:active_record) do
180
+ include StatesmanScaffold::Concern
181
+ end
182
+ ```
183
+
184
+ The `require "statesman"` ensures the Statesman constant is available when the
185
+ initializer runs. Without it, if Statesman is autoloaded later, the initializer
186
+ would fail with `NameError: uninitialized constant Statesman`.
187
+
188
+ ---
189
+
190
+ ## Rake tasks
191
+
192
+ | Task | Depends on | Description |
193
+ |------|-----------|-------------|
194
+ | `statesman_scaffold:install` | — | Delegates to `Installer.install!(Rails.root)` |
195
+ | `statesman_scaffold:uninstall` | — | Delegates to `Installer.uninstall!(Rails.root)` |
196
+ | `statesman_scaffold:generate[Model]` | `:environment` | Delegates to `Generator.call(...)` |
197
+
198
+ The `generate` task:
199
+ 1. Camelizes the model argument
200
+ 2. Computes `output_path` as `Rails.root.join("app/models", model.underscore)`
201
+ 3. Sets `migration_path` to `Rails.root.join("db/migrate")`
202
+ 4. Reads `migration_version` from `ActiveRecord::Migration.current_version`
203
+ 5. Calls `Generator.call` and prints relative paths of created files
204
+ 6. Prints a reminder to add `with_state_machine` to the model
205
+
206
+ ---
207
+
208
+ ## Testing architecture
209
+
210
+ | Component | Tool | Isolation |
211
+ |-----------|------|-----------|
212
+ | Concern (with_state_machine) | Minitest | SQLite in-memory tables |
213
+ | Generator | Minitest | `Dir.mktmpdir` filesystem |
214
+ | Installer | Minitest | `Dir.mktmpdir` filesystem |
215
+
216
+ `test_helper.rb` boots ActiveRecord against SQLite, configures Statesman with
217
+ the AR adapter, and calls `ActiveSupport.on_load(:active_record) { include
218
+ StatesmanScaffold::Concern }` to replicate what the Rails initializer does.
219
+
220
+ ### Concern test setup
221
+
222
+ Test models are defined in a `SmTestModels` namespace with inline
223
+ `StateMachine` and `Transition` classes. Tables are created in `setup` and
224
+ dropped in `teardown`:
225
+
226
+ - `sm_projects` — main model table
227
+ - `sm_project_transitions` — transitions table
228
+
229
+ This avoids polluting the global namespace and provides full test isolation.
230
+
231
+ ### Generator test setup
232
+
233
+ Uses `Dir.mktmpdir` to create a temporary directory. Calls `Generator.call`
234
+ with temporary paths. Asserts file existence, content patterns, and Ruby syntax
235
+ validity (`ruby -c`).
236
+
237
+ ---
238
+
239
+ ## File inclusion in gem package
240
+
241
+ ```ruby
242
+ spec.files = Dir[
243
+ "lib/**/*.rb",
244
+ "lib/templates/**/*.erb",
245
+ "lib/tasks/*.rake",
246
+ "llms/**/*.md",
247
+ "AGENTS.md",
248
+ "CLAUDE.md",
249
+ "README.md",
250
+ "LICENSE.txt",
251
+ "CHANGELOG.md"
252
+ ]
253
+ ```
254
+
255
+ LLM context files (`llms/`, `AGENTS.md`, `CLAUDE.md`) ship inside the released
256
+ gem so that tools that install the gem can serve them as context to AI assistants.