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.
- checksums.yaml +7 -0
- data/AGENTS.md +180 -0
- data/CHANGELOG.md +21 -0
- data/CLAUDE.md +126 -0
- data/LICENSE.txt +21 -0
- data/README.md +144 -0
- data/lib/statesman_scaffold/concern.rb +87 -0
- data/lib/statesman_scaffold/generator.rb +80 -0
- data/lib/statesman_scaffold/installer.rb +60 -0
- data/lib/statesman_scaffold/railtie.rb +15 -0
- data/lib/statesman_scaffold/version.rb +5 -0
- data/lib/statesman_scaffold.rb +12 -0
- data/lib/tasks/statesman_scaffold.rake +52 -0
- data/lib/templates/statesman/migration.rb.erb +31 -0
- data/lib/templates/statesman/state_machine.rb.erb +38 -0
- data/lib/templates/statesman/transition.rb.erb +16 -0
- data/llms/overview.md +256 -0
- data/llms/usage.md +375 -0
- metadata +145 -0
|
@@ -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,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.
|