@codyswann/lisa 1.37.0 → 1.38.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.
- package/all/copy-overwrite/CLAUDE.md +1 -0
- package/package.json +1 -1
- package/rails/copy-overwrite/.claude/rules/lisa.md +5 -1
- package/rails/copy-overwrite/.claude/skills/action-controller-best-practices/SKILL.md +374 -0
- package/rails/copy-overwrite/.claude/skills/action-view-best-practices/SKILL.md +335 -0
- package/rails/copy-overwrite/.claude/skills/active-record-model-best-practices/SKILL.md +166 -0
- package/rails/copy-overwrite/.versionrc +48 -0
- package/rails/copy-overwrite/CLAUDE.md +1 -0
- package/rails/copy-overwrite/config/initializers/version.rb +5 -0
- package/rails/create-only/.github/workflows/quality.yml +22 -1
- package/rails/create-only/.github/workflows/release.yml +94 -0
- package/rails/create-only/.reek.yml +3 -0
- package/rails/create-only/.rubocop.local.yml +17 -4
- package/rails/create-only/VERSION +1 -0
|
@@ -46,6 +46,7 @@ Never delete anything outside of this project's directory
|
|
|
46
46
|
Never add "BREAKING CHANGE" to a commit message unless there is actually a breaking change
|
|
47
47
|
Never stash changes you can't commit. Either fix whatever is prevening the commit or fail out and let the human know why.
|
|
48
48
|
Never lower thresholds for tests to pass a pre-push hook. You must increase test coverage to make it pass
|
|
49
|
+
Never handle tasks yourself when working in a team of agents. Always delegate to a specialied agent.
|
|
49
50
|
|
|
50
51
|
ONLY use eslint-disable as a last resort and confirm with human before doing so
|
|
51
52
|
ONLY use eslint-disable for test file max-lines when comprehensive test coverage requires extensive test cases (must include matching eslint-enable)
|
package/package.json
CHANGED
|
@@ -89,7 +89,7 @@
|
|
|
89
89
|
"@isaacs/brace-expansion": "^5.0.1"
|
|
90
90
|
},
|
|
91
91
|
"name": "@codyswann/lisa",
|
|
92
|
-
"version": "1.
|
|
92
|
+
"version": "1.38.0",
|
|
93
93
|
"description": "Claude Code governance framework that applies guardrails, guidance, and automated enforcement to projects",
|
|
94
94
|
"main": "dist/index.js",
|
|
95
95
|
"bin": {
|
|
@@ -19,6 +19,9 @@ The following files are managed by Lisa and will be overwritten on every `lisa`
|
|
|
19
19
|
- `spec/spec_helper.rb`
|
|
20
20
|
- `spec/rails_helper.rb`
|
|
21
21
|
- `.github/workflows/quality.yml`
|
|
22
|
+
- `.github/workflows/ci.yml`
|
|
23
|
+
- `.github/workflows/release.yml`
|
|
24
|
+
- `VERSION`
|
|
22
25
|
|
|
23
26
|
## Directories with both Lisa-managed and project content
|
|
24
27
|
|
|
@@ -34,7 +37,8 @@ These directories contain files deployed by Lisa **and** files you create. Do no
|
|
|
34
37
|
- `.claude/rules/coding-philosophy.md`, `.claude/rules/plan.md`, `.claude/rules/verfication.md`
|
|
35
38
|
- `.claude/rules/rails-conventions.md`
|
|
36
39
|
- `CLAUDE.md`, `HUMAN.md`, `.safety-net.json`
|
|
37
|
-
- `.rubocop.yml`, `lefthook.yml`, `Gemfile.lisa`
|
|
40
|
+
- `.rubocop.yml`, `.versionrc`, `lefthook.yml`, `Gemfile.lisa`
|
|
41
|
+
- `config/initializers/version.rb`
|
|
38
42
|
- `.coderabbit.yml`, `commitlint.config.cjs`
|
|
39
43
|
- `.claude/settings.json`
|
|
40
44
|
- `.claude/README.md`, `.claude/REFERENCE.md`
|
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: action-controller-best-practices
|
|
3
|
+
description: Build or Refactor large Rails controller files into clean, maintainable code. Use when a controller action exceeds ~10 lines, a controller has custom non-RESTful actions, or when the user asks to refactor, slim down, clean up, or organize a Rails controller. Applies patterns: service objects, query objects, form objects, controller concerns, presenters/decorators, and RESTful resource extraction.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Rails Controller Refactoring
|
|
7
|
+
|
|
8
|
+
Controllers should be thin traffic cops — they receive input via params, delegate to the appropriate object, and decide what to render or redirect. Each action should be roughly 5-10 lines. If an action is longer, logic needs to be extracted.
|
|
9
|
+
|
|
10
|
+
## Decision Framework
|
|
11
|
+
|
|
12
|
+
Read the controller and classify each block of code:
|
|
13
|
+
|
|
14
|
+
| Code type | Extract to | Location |
|
|
15
|
+
| ------------------------------------------------------- | ---------------------- | -------------------------------------- |
|
|
16
|
+
| Business logic, multi-step operations, side effects | Service object | `app/services/` |
|
|
17
|
+
| Complex queries, filtering, sorting, search | Query object | `app/queries/` |
|
|
18
|
+
| Context-specific param validation and persistence | Form object | `app/forms/` |
|
|
19
|
+
| Shared before_actions, auth, pagination, error handling | Controller concern | `app/controllers/concerns/` |
|
|
20
|
+
| Complex view data assembly, formatting, display logic | Presenter / Decorator | `app/presenters/` or `app/decorators/` |
|
|
21
|
+
| Non-RESTful custom actions on a different concept | New RESTful controller | `app/controllers/` |
|
|
22
|
+
| Simple CRUD, strong params, render/redirect | Keep on controller | — |
|
|
23
|
+
|
|
24
|
+
## Patterns
|
|
25
|
+
|
|
26
|
+
### Service Objects
|
|
27
|
+
|
|
28
|
+
Use for any business logic that goes beyond simple CRUD. A controller action should call one service at most.
|
|
29
|
+
|
|
30
|
+
Before:
|
|
31
|
+
|
|
32
|
+
```ruby
|
|
33
|
+
def create
|
|
34
|
+
@player = Player.new(player_params)
|
|
35
|
+
@player.team = Team.find(params[:team_id])
|
|
36
|
+
@player.contract_start = Time.current
|
|
37
|
+
@player.status = :active
|
|
38
|
+
|
|
39
|
+
if @player.save
|
|
40
|
+
PlayerMailer.welcome(@player).deliver_later
|
|
41
|
+
Analytics.track("player_signed", player_id: @player.id)
|
|
42
|
+
NotifyScoutsJob.perform_later(@player.team_id)
|
|
43
|
+
redirect_to @player, notice: "Player signed"
|
|
44
|
+
else
|
|
45
|
+
render :new, status: :unprocessable_entity
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
After:
|
|
51
|
+
|
|
52
|
+
```ruby
|
|
53
|
+
# Controller
|
|
54
|
+
def create
|
|
55
|
+
result = Players::SignPlayer.new(player_params, team_id: params[:team_id]).call
|
|
56
|
+
|
|
57
|
+
if result.success?
|
|
58
|
+
redirect_to result.player, notice: "Player signed"
|
|
59
|
+
else
|
|
60
|
+
@player = result.player
|
|
61
|
+
render :new, status: :unprocessable_entity
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# app/services/players/sign_player.rb
|
|
66
|
+
module Players
|
|
67
|
+
class SignPlayer
|
|
68
|
+
def initialize(params, team_id:)
|
|
69
|
+
@params = params
|
|
70
|
+
@team_id = team_id
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def call
|
|
74
|
+
player = Player.new(@params)
|
|
75
|
+
player.team = Team.find(@team_id)
|
|
76
|
+
player.contract_start = Time.current
|
|
77
|
+
player.status = :active
|
|
78
|
+
|
|
79
|
+
if player.save
|
|
80
|
+
send_notifications(player)
|
|
81
|
+
Result.new(success: true, player: player)
|
|
82
|
+
else
|
|
83
|
+
Result.new(success: false, player: player)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
private
|
|
88
|
+
|
|
89
|
+
def send_notifications(player)
|
|
90
|
+
PlayerMailer.welcome(player).deliver_later
|
|
91
|
+
Analytics.track("player_signed", player_id: player.id)
|
|
92
|
+
NotifyScoutsJob.perform_later(player.team_id)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
Result = Struct.new(:success, :player, keyword_init: true) do
|
|
96
|
+
alias_method :success?, :success
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
### Query Objects
|
|
103
|
+
|
|
104
|
+
Use when index actions have complex filtering, sorting, or search logic.
|
|
105
|
+
|
|
106
|
+
Before:
|
|
107
|
+
|
|
108
|
+
```ruby
|
|
109
|
+
def index
|
|
110
|
+
@players = Player.where(team_id: params[:team_id])
|
|
111
|
+
@players = @players.where(position: params[:position]) if params[:position].present?
|
|
112
|
+
@players = @players.where("age >= ?", params[:min_age]) if params[:min_age].present?
|
|
113
|
+
@players = @players.where(status: :active) unless params[:include_inactive]
|
|
114
|
+
@players = @players.joins(:stats).order("stats.war DESC")
|
|
115
|
+
@players = @players.page(params[:page]).per(25)
|
|
116
|
+
end
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
After:
|
|
120
|
+
|
|
121
|
+
```ruby
|
|
122
|
+
# Controller
|
|
123
|
+
def index
|
|
124
|
+
@players = Players::FilterQuery.new(params).call
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# app/queries/players/filter_query.rb
|
|
128
|
+
module Players
|
|
129
|
+
class FilterQuery
|
|
130
|
+
def initialize(params, relation: Player.all)
|
|
131
|
+
@params = params
|
|
132
|
+
@relation = relation
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def call
|
|
136
|
+
filter_by_team
|
|
137
|
+
filter_by_position
|
|
138
|
+
filter_by_age
|
|
139
|
+
filter_by_status
|
|
140
|
+
sort_and_paginate
|
|
141
|
+
@relation
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
private
|
|
145
|
+
|
|
146
|
+
def filter_by_team
|
|
147
|
+
@relation = @relation.where(team_id: @params[:team_id]) if @params[:team_id].present?
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def filter_by_position
|
|
151
|
+
@relation = @relation.where(position: @params[:position]) if @params[:position].present?
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def filter_by_age
|
|
155
|
+
@relation = @relation.where("age >= ?", @params[:min_age]) if @params[:min_age].present?
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def filter_by_status
|
|
159
|
+
@relation = @relation.where(status: :active) unless @params[:include_inactive]
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def sort_and_paginate
|
|
163
|
+
@relation = @relation.joins(:stats).order("stats.war DESC").page(@params[:page]).per(25)
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
### Controller Concerns
|
|
170
|
+
|
|
171
|
+
Use for shared behavior across multiple controllers: authentication, authorization, pagination, error handling, locale setting.
|
|
172
|
+
|
|
173
|
+
```ruby
|
|
174
|
+
# app/controllers/concerns/paginatable.rb
|
|
175
|
+
module Paginatable
|
|
176
|
+
extend ActiveSupport::Concern
|
|
177
|
+
|
|
178
|
+
private
|
|
179
|
+
|
|
180
|
+
def page
|
|
181
|
+
params[:page] || 1
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def per_page
|
|
185
|
+
[params[:per_page].to_i, 100].min.nonzero? || 25
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# app/controllers/concerns/error_handleable.rb
|
|
190
|
+
module ErrorHandleable
|
|
191
|
+
extend ActiveSupport::Concern
|
|
192
|
+
|
|
193
|
+
included do
|
|
194
|
+
rescue_from ActiveRecord::RecordNotFound, with: :not_found
|
|
195
|
+
rescue_from ActionController::ParameterMissing, with: :bad_request
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
private
|
|
199
|
+
|
|
200
|
+
def not_found
|
|
201
|
+
render json: { error: "Not found" }, status: :not_found
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def bad_request(exception)
|
|
205
|
+
render json: { error: exception.message }, status: :bad_request
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
Guidelines for controller concerns:
|
|
211
|
+
|
|
212
|
+
- Each concern should handle one cross-cutting aspect
|
|
213
|
+
- Don't use concerns to just split a large controller into files — that hides complexity
|
|
214
|
+
- Good candidates: auth, error handling, pagination, locale, current tenant scoping
|
|
215
|
+
- Bad candidates: domain-specific business logic
|
|
216
|
+
|
|
217
|
+
### Presenters / Decorators
|
|
218
|
+
|
|
219
|
+
Use when an action assembles complex data for the view that isn't a direct model attribute. Keeps view logic out of the controller.
|
|
220
|
+
|
|
221
|
+
```ruby
|
|
222
|
+
# app/presenters/player_dashboard_presenter.rb
|
|
223
|
+
class PlayerDashboardPresenter
|
|
224
|
+
def initialize(player)
|
|
225
|
+
@player = player
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def career_stats
|
|
229
|
+
@career_stats ||= @player.stats.group(:season).sum(:war)
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def contract_status_label
|
|
233
|
+
return "Free Agent" if @player.contract_end&.past?
|
|
234
|
+
"Under Contract (#{@player.contract_end&.year})"
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def trade_value_rating
|
|
238
|
+
case @player.trade_value
|
|
239
|
+
when 90.. then "Elite"
|
|
240
|
+
when 70..89 then "High"
|
|
241
|
+
when 50..69 then "Average"
|
|
242
|
+
else "Low"
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# Controller
|
|
248
|
+
def show
|
|
249
|
+
@player = Player.find(params[:id])
|
|
250
|
+
@presenter = PlayerDashboardPresenter.new(@player)
|
|
251
|
+
end
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
### RESTful Resource Extraction
|
|
255
|
+
|
|
256
|
+
When a controller has custom non-RESTful actions, it usually means there's a hidden resource. Extract it into its own controller with standard CRUD actions.
|
|
257
|
+
|
|
258
|
+
Before:
|
|
259
|
+
|
|
260
|
+
```ruby
|
|
261
|
+
class PlayersController < ApplicationController
|
|
262
|
+
def trade
|
|
263
|
+
# ...
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def release
|
|
267
|
+
# ...
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def promote_to_roster
|
|
271
|
+
# ...
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
After:
|
|
277
|
+
|
|
278
|
+
```ruby
|
|
279
|
+
# config/routes.rb
|
|
280
|
+
resources :players do
|
|
281
|
+
resource :trade, only: [:new, :create], controller: "players/trades"
|
|
282
|
+
resource :release, only: [:create], controller: "players/releases"
|
|
283
|
+
resource :roster_promotion, only: [:create], controller: "players/roster_promotions"
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
# app/controllers/players/trades_controller.rb
|
|
287
|
+
module Players
|
|
288
|
+
class TradesController < ApplicationController
|
|
289
|
+
def new
|
|
290
|
+
@player = Player.find(params[:player_id])
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def create
|
|
294
|
+
result = Players::TradePlayer.new(Player.find(params[:player_id]), trade_params).call
|
|
295
|
+
# ...
|
|
296
|
+
end
|
|
297
|
+
end
|
|
298
|
+
end
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
Signs you need a new controller:
|
|
302
|
+
|
|
303
|
+
- Action name is a verb that isn't a CRUD verb (trade, approve, archive, publish)
|
|
304
|
+
- Action operates on a different concept than the controller's resource
|
|
305
|
+
- Controller has more than the 7 RESTful actions
|
|
306
|
+
|
|
307
|
+
## Refactoring Process
|
|
308
|
+
|
|
309
|
+
1. **Read the entire controller** and identify every action, before_action, and private method.
|
|
310
|
+
2. **Inline hidden code first.** Copy logic from before_actions, helper methods, and parent classes into each action so you can see the full picture.
|
|
311
|
+
3. **Classify each block** using the decision framework table above.
|
|
312
|
+
4. **Extract in order**: RESTful resource extraction first (new controllers), then service objects, query objects, form objects, presenters, and finally concerns.
|
|
313
|
+
5. **Slim down each action** to: find/build the resource, call a service or query, render or redirect. Each action should be 5-10 lines.
|
|
314
|
+
6. **Clean up strong params.** Each controller should have one `_params` method. If you need different permitted params per action, consider separate controllers or form objects.
|
|
315
|
+
7. **Create or update tests** for each extracted class with its own spec file.
|
|
316
|
+
|
|
317
|
+
## What the Controller Should Look Like After
|
|
318
|
+
|
|
319
|
+
```ruby
|
|
320
|
+
class PlayersController < ApplicationController
|
|
321
|
+
before_action :set_player, only: [:show, :edit, :update, :destroy]
|
|
322
|
+
|
|
323
|
+
def index
|
|
324
|
+
@players = Players::FilterQuery.new(params).call
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
def show
|
|
328
|
+
@presenter = PlayerDashboardPresenter.new(@player)
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
def create
|
|
332
|
+
result = Players::SignPlayer.new(player_params, team_id: params[:team_id]).call
|
|
333
|
+
|
|
334
|
+
if result.success?
|
|
335
|
+
redirect_to result.player, notice: "Player signed"
|
|
336
|
+
else
|
|
337
|
+
@player = result.player
|
|
338
|
+
render :new, status: :unprocessable_entity
|
|
339
|
+
end
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
def update
|
|
343
|
+
if @player.update(player_params)
|
|
344
|
+
redirect_to @player, notice: "Player updated"
|
|
345
|
+
else
|
|
346
|
+
render :edit, status: :unprocessable_entity
|
|
347
|
+
end
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
def destroy
|
|
351
|
+
@player.destroy
|
|
352
|
+
redirect_to players_path, notice: "Player removed"
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
private
|
|
356
|
+
|
|
357
|
+
def set_player
|
|
358
|
+
@player = Player.find(params[:id])
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
def player_params
|
|
362
|
+
params.require(:player).permit(:name, :position, :age, :team_id)
|
|
363
|
+
end
|
|
364
|
+
end
|
|
365
|
+
```
|
|
366
|
+
|
|
367
|
+
## What NOT to Do
|
|
368
|
+
|
|
369
|
+
- Don't put business logic in before_actions — they obscure the flow of an action.
|
|
370
|
+
- Don't use instance variables to pass data between before_actions and actions in complex ways.
|
|
371
|
+
- Don't rescue broad exceptions in individual actions — use a concern or `rescue_from`.
|
|
372
|
+
- Don't add non-RESTful actions to a controller when a new controller would be clearer.
|
|
373
|
+
- Don't create deeply nested service call chains — one service per action is the goal.
|
|
374
|
+
- Don't move logic to private methods and call it refactored — private methods still live in the controller.
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: action-view-best-practices
|
|
3
|
+
description: Build or Refactor Rails views, partials, and templates into clean, maintainable code. Use when views have inline Ruby logic, deeply nested partials, jQuery or legacy JavaScript, helper methods returning HTML, or when the user asks to modernize, refactor, or clean up Rails views. Applies patterns - Turbo Frames, Turbo Streams, Stimulus controllers, ViewComponent, presenters, strict locals, and proper partial extraction.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Rails View Refactoring
|
|
7
|
+
|
|
8
|
+
Views should contain markup and minimal display logic. If a view has conditionals, calculations, query calls, or complex Ruby blocks, it needs refactoring. The modern Rails 8+ stack uses Hotwire (Turbo + Stimulus) for interactivity, Propshaft for assets, and Importmap for JavaScript — no build step required.
|
|
9
|
+
|
|
10
|
+
## Decision Framework
|
|
11
|
+
|
|
12
|
+
Read the view and classify each block of code:
|
|
13
|
+
|
|
14
|
+
| Code type | Extract to | Location |
|
|
15
|
+
| ------------------------------------------------------- | ------------------------------- | ------------------------------------------ |
|
|
16
|
+
| Reusable UI patterns (buttons, cards, modals, badges) | ViewComponent | `app/components/` |
|
|
17
|
+
| Display logic (formatting, conditional CSS, label text) | Presenter or ViewComponent | `app/presenters/` or `app/components/` |
|
|
18
|
+
| HTML-returning helper methods | ViewComponent | `app/components/` |
|
|
19
|
+
| Inline `<script>` tags and jQuery | Stimulus controller | `app/javascript/controllers/` |
|
|
20
|
+
| AJAX calls, remote forms, `$.ajax` | Turbo Frame or Turbo Stream | ERB template + controller response |
|
|
21
|
+
| Partial page updates via JavaScript | Turbo Frame | Wrap in `turbo_frame_tag` |
|
|
22
|
+
| Real-time broadcasts (chat, notifications) | Turbo Stream | Model `broadcasts_to` or controller stream |
|
|
23
|
+
| One-off page sections that are too long | Partial with strict locals | `app/views/shared/` or alongside view |
|
|
24
|
+
| Complex data assembly for the view | Presenter | `app/presenters/` |
|
|
25
|
+
| Repeated inline Ruby (loops with logic) | Collection partial or component | Partial or component |
|
|
26
|
+
| Instance variables used across partials | Locals / strict locals | Pass explicitly |
|
|
27
|
+
|
|
28
|
+
## Modernizing to Hotwire
|
|
29
|
+
|
|
30
|
+
### Replace jQuery AJAX with Turbo Frames
|
|
31
|
+
|
|
32
|
+
Turbo Frames update a specific section of the page without a full reload. No JavaScript needed.
|
|
33
|
+
|
|
34
|
+
```erb
|
|
35
|
+
<%# Before — jQuery AJAX %>
|
|
36
|
+
<div id="player-stats"></div>
|
|
37
|
+
<script>
|
|
38
|
+
$.get('/players/<%= @player.id %>/stats', function(data) {
|
|
39
|
+
$('#player-stats').html(data);
|
|
40
|
+
});
|
|
41
|
+
</script>
|
|
42
|
+
|
|
43
|
+
<%# After — Turbo Frame %>
|
|
44
|
+
<%= turbo_frame_tag "player_stats" do %>
|
|
45
|
+
<%= render partial: "players/stats", locals: { player: @player } %>
|
|
46
|
+
<% end %>
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
The linked page just needs a matching `turbo_frame_tag` with the same ID and Turbo handles the rest.
|
|
50
|
+
|
|
51
|
+
### Replace Remote Forms with Turbo
|
|
52
|
+
|
|
53
|
+
Rails UJS `remote: true` forms are deprecated. Turbo handles forms natively.
|
|
54
|
+
|
|
55
|
+
```erb
|
|
56
|
+
<%# Before — Rails UJS %>
|
|
57
|
+
<%= form_with model: @player, remote: true do |f| %>
|
|
58
|
+
...
|
|
59
|
+
<% end %>
|
|
60
|
+
|
|
61
|
+
<%# After — Turbo (just remove remote: true, Turbo handles it) %>
|
|
62
|
+
<%= form_with model: @player do |f| %>
|
|
63
|
+
...
|
|
64
|
+
<% end %>
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Turbo intercepts all form submissions by default. For partial updates, wrap the form in a `turbo_frame_tag`. For multi-target updates, respond with a Turbo Stream:
|
|
68
|
+
|
|
69
|
+
```erb
|
|
70
|
+
<%# app/views/players/update.turbo_stream.erb %>
|
|
71
|
+
<%= turbo_stream.replace "player_header" do %>
|
|
72
|
+
<%= render partial: "players/header", locals: { player: @player } %>
|
|
73
|
+
<% end %>
|
|
74
|
+
|
|
75
|
+
<%= turbo_stream.update "flash_messages" do %>
|
|
76
|
+
<%= render partial: "shared/flash" %>
|
|
77
|
+
<% end %>
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
### Replace Inline JavaScript with Stimulus
|
|
81
|
+
|
|
82
|
+
Any behavior attached to DOM elements (toggles, dropdowns, form validation, clipboard, modals) should be a Stimulus controller.
|
|
83
|
+
|
|
84
|
+
```erb
|
|
85
|
+
<%# Before — inline JS / jQuery %>
|
|
86
|
+
<button onclick="document.getElementById('details').classList.toggle('hidden')">
|
|
87
|
+
Toggle
|
|
88
|
+
</button>
|
|
89
|
+
<div id="details" class="hidden">...</div>
|
|
90
|
+
|
|
91
|
+
<%# After — Stimulus %>
|
|
92
|
+
<div data-controller="toggle">
|
|
93
|
+
<button data-action="click->toggle#switch">Toggle</button>
|
|
94
|
+
<div data-toggle-target="content" class="hidden">...</div>
|
|
95
|
+
</div>
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
```javascript
|
|
99
|
+
// app/javascript/controllers/toggle_controller.js
|
|
100
|
+
import { Controller } from "@hotwired/stimulus";
|
|
101
|
+
|
|
102
|
+
export default class extends Controller {
|
|
103
|
+
static targets = ["content"];
|
|
104
|
+
|
|
105
|
+
switch() {
|
|
106
|
+
this.contentTarget.classList.toggle("hidden");
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Conventions:
|
|
112
|
+
|
|
113
|
+
- One behavior per controller — keep them small and composable
|
|
114
|
+
- Name controllers after what they do: `toggle`, `clipboard`, `dropdown`, `search-form`
|
|
115
|
+
- Use `targets` for elements, `values` for data, `classes` for CSS class names
|
|
116
|
+
- Never manipulate DOM outside the controller's element scope
|
|
117
|
+
|
|
118
|
+
### Replace Polling with Turbo Streams
|
|
119
|
+
|
|
120
|
+
For real-time updates, use Turbo Streams over WebSockets instead of JavaScript polling.
|
|
121
|
+
|
|
122
|
+
```ruby
|
|
123
|
+
# app/models/score.rb
|
|
124
|
+
class Score < ApplicationRecord
|
|
125
|
+
broadcasts_to ->(score) { [score.game] }, inserts_by: :prepend
|
|
126
|
+
end
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
```erb
|
|
130
|
+
<%# Subscribe in the view %>
|
|
131
|
+
<%= turbo_stream_from @game %>
|
|
132
|
+
|
|
133
|
+
<div id="scores">
|
|
134
|
+
<%= render @game.scores %>
|
|
135
|
+
</div>
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
New scores automatically appear without any JavaScript.
|
|
139
|
+
|
|
140
|
+
## View Component Patterns
|
|
141
|
+
|
|
142
|
+
### When to Use ViewComponent vs Partials
|
|
143
|
+
|
|
144
|
+
Use **partials** for:
|
|
145
|
+
|
|
146
|
+
- One-off page sections that won't be reused
|
|
147
|
+
- Simple markup extraction to reduce file length
|
|
148
|
+
- Layouts and structural wrappers
|
|
149
|
+
|
|
150
|
+
Use **ViewComponent** for:
|
|
151
|
+
|
|
152
|
+
- Reusable UI elements (buttons, cards, badges, modals, alerts)
|
|
153
|
+
- Components with display logic or multiple variants
|
|
154
|
+
- Anything you'd want to unit test in isolation
|
|
155
|
+
- Complex components with slots for flexible content injection
|
|
156
|
+
|
|
157
|
+
### ViewComponent Example
|
|
158
|
+
|
|
159
|
+
```ruby
|
|
160
|
+
# app/components/stat_card_component.rb
|
|
161
|
+
class StatCardComponent < ViewComponent::Base
|
|
162
|
+
def initialize(label:, value:, trend: nil)
|
|
163
|
+
@label = label
|
|
164
|
+
@value = value
|
|
165
|
+
@trend = trend
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def trend_class
|
|
169
|
+
case @trend
|
|
170
|
+
when :up then "text-green-600"
|
|
171
|
+
when :down then "text-red-600"
|
|
172
|
+
else "text-gray-500"
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
```erb
|
|
179
|
+
<%# app/components/stat_card_component.html.erb %>
|
|
180
|
+
<div class="rounded-lg border p-4">
|
|
181
|
+
<dt class="text-sm text-gray-500"><%= @label %></dt>
|
|
182
|
+
<dd class="text-2xl font-semibold"><%= @value %></dd>
|
|
183
|
+
<% if @trend %>
|
|
184
|
+
<span class="<%= trend_class %>"><%= @trend == :up ? "↑" : "↓" %></span>
|
|
185
|
+
<% end %>
|
|
186
|
+
</div>
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
```erb
|
|
190
|
+
<%# Usage %>
|
|
191
|
+
<%= render StatCardComponent.new(label: "Batting Avg", value: ".312", trend: :up) %>
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
## Partial Best Practices
|
|
195
|
+
|
|
196
|
+
### Use Strict Locals
|
|
197
|
+
|
|
198
|
+
Always declare expected locals at the top of partials. This was added in Rails 7.1 and prevents silent nil bugs.
|
|
199
|
+
|
|
200
|
+
```erb
|
|
201
|
+
<%# app/views/players/_card.html.erb %>
|
|
202
|
+
<%# locals: (player:, show_stats: false) %>
|
|
203
|
+
|
|
204
|
+
<div class="player-card">
|
|
205
|
+
<h3><%= player.name %></h3>
|
|
206
|
+
<% if show_stats %>
|
|
207
|
+
<%= render partial: "players/stats", locals: { player: player } %>
|
|
208
|
+
<% end %>
|
|
209
|
+
</div>
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
### Use Collection Rendering
|
|
213
|
+
|
|
214
|
+
Never loop and render partials manually.
|
|
215
|
+
|
|
216
|
+
```erb
|
|
217
|
+
<%# Before — slow, verbose %>
|
|
218
|
+
<% @players.each do |player| %>
|
|
219
|
+
<%= render partial: "players/card", locals: { player: player } %>
|
|
220
|
+
<% end %>
|
|
221
|
+
|
|
222
|
+
<%# After — collection rendering (faster, cleaner) %>
|
|
223
|
+
<%= render partial: "players/card", collection: @players, as: :player %>
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
### Avoid Deeply Nested Partials
|
|
227
|
+
|
|
228
|
+
If partial A renders partial B which renders partial C, it's too deep. Flatten the structure or extract to a ViewComponent that composes its own sub-components.
|
|
229
|
+
|
|
230
|
+
## Presenters for Complex View Logic
|
|
231
|
+
|
|
232
|
+
When a view needs data from multiple sources or complex formatting, use a presenter instead of cramming logic into the template.
|
|
233
|
+
|
|
234
|
+
```ruby
|
|
235
|
+
# app/presenters/player_profile_presenter.rb
|
|
236
|
+
class PlayerProfilePresenter
|
|
237
|
+
def initialize(player, current_user)
|
|
238
|
+
@player = player
|
|
239
|
+
@current_user = current_user
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def display_name
|
|
243
|
+
"#{@player.first_name} #{@player.last_name}"
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def contract_status_badge
|
|
247
|
+
if @player.free_agent?
|
|
248
|
+
{ text: "Free Agent", color: "green" }
|
|
249
|
+
elsif @player.contract_years_remaining <= 1
|
|
250
|
+
{ text: "Expiring", color: "yellow" }
|
|
251
|
+
else
|
|
252
|
+
{ text: "Under Contract", color: "gray" }
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def can_edit?
|
|
257
|
+
@current_user.admin? || @current_user.team == @player.team
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def formatted_salary
|
|
261
|
+
ActiveSupport::NumberHelper.number_to_currency(@player.salary)
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
```erb
|
|
267
|
+
<%# Clean view %>
|
|
268
|
+
<h1><%= @presenter.display_name %></h1>
|
|
269
|
+
|
|
270
|
+
<% badge = @presenter.contract_status_badge %>
|
|
271
|
+
<%= render BadgeComponent.new(text: badge[:text], color: badge[:color]) %>
|
|
272
|
+
|
|
273
|
+
<%= @presenter.formatted_salary %>
|
|
274
|
+
|
|
275
|
+
<% if @presenter.can_edit? %>
|
|
276
|
+
<%= link_to "Edit", edit_player_path(@player) %>
|
|
277
|
+
<% end %>
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
## Eliminating Helper Abuse
|
|
281
|
+
|
|
282
|
+
Rails helpers that return HTML are hard to test, hard to read, and hard to compose. Move them to ViewComponents.
|
|
283
|
+
|
|
284
|
+
```ruby
|
|
285
|
+
# Before — helper returning HTML (bad)
|
|
286
|
+
module PlayersHelper
|
|
287
|
+
def player_avatar(player, size: :md)
|
|
288
|
+
sizes = { sm: "w-8 h-8", md: "w-12 h-12", lg: "w-16 h-16" }
|
|
289
|
+
if player.avatar.attached?
|
|
290
|
+
image_tag player.avatar, class: "rounded-full #{sizes[size]}"
|
|
291
|
+
else
|
|
292
|
+
content_tag :div, player.initials,
|
|
293
|
+
class: "rounded-full #{sizes[size]} bg-gray-300 flex items-center justify-center"
|
|
294
|
+
end
|
|
295
|
+
end
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
# After — ViewComponent (good)
|
|
299
|
+
# app/components/avatar_component.rb
|
|
300
|
+
class AvatarComponent < ViewComponent::Base
|
|
301
|
+
SIZES = { sm: "w-8 h-8", md: "w-12 h-12", lg: "w-16 h-16" }.freeze
|
|
302
|
+
|
|
303
|
+
def initialize(player:, size: :md)
|
|
304
|
+
@player = player
|
|
305
|
+
@size = size
|
|
306
|
+
end
|
|
307
|
+
# ... with its own template and tests
|
|
308
|
+
end
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
Keep helpers for non-HTML formatting utilities only (number formatting, date formatting, text truncation).
|
|
312
|
+
|
|
313
|
+
## Refactoring Process
|
|
314
|
+
|
|
315
|
+
1. **Audit the view layer** — identify inline Ruby logic, jQuery, `remote: true` forms, helpers returning HTML, and deeply nested partials.
|
|
316
|
+
2. **Remove jQuery and inline JS first** — replace with Stimulus controllers. This is the highest-impact change.
|
|
317
|
+
3. **Replace `remote: true` and AJAX** — Turbo handles forms and links natively. Convert to Turbo Frames and Turbo Streams.
|
|
318
|
+
4. **Add strict locals** to all existing partials.
|
|
319
|
+
5. **Extract reusable UI patterns** into ViewComponents.
|
|
320
|
+
6. **Move display logic** from views into presenters or component classes.
|
|
321
|
+
7. **Move HTML-returning helpers** into components.
|
|
322
|
+
8. **Flatten nested partials** — if nesting is deeper than 2 levels, restructure.
|
|
323
|
+
9. **Add collection rendering** wherever loops render partials.
|
|
324
|
+
10. **Remove all instance variables from partials** — pass data via locals only.
|
|
325
|
+
|
|
326
|
+
## What NOT to Do
|
|
327
|
+
|
|
328
|
+
- Don't put query calls in views — ever. Not even `count` or `any?`. Use the presenter or controller.
|
|
329
|
+
- Don't use `content_for` for complex logic — it creates invisible dependencies between layouts and views.
|
|
330
|
+
- Don't create Stimulus controllers that replicate what Turbo Frames already handle.
|
|
331
|
+
- Don't mix jQuery and Stimulus in the same app — commit to full migration.
|
|
332
|
+
- Don't render entire pages as ViewComponents — they are for reusable pieces, not whole pages.
|
|
333
|
+
- Don't use `html_safe` or `raw` unless you are certain the content is sanitized.
|
|
334
|
+
- Don't pass more than 3-4 locals to a partial — if you need more, it should be a component or presenter.
|
|
335
|
+
- Don't use `render partial:` inside loops — use collection rendering instead.
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: active-record-model-best-practices
|
|
3
|
+
description: Best practices for Ruby on Rails models, splitting code into well-organized, maintainable code. Use when a model exceeds ~100 lines, has mixed responsibilities, or when the user asks to refactor, extract, clean up, or organize a Rails model. Applies patterns: concerns, service objects, query objects, form objects, and value objects.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Rails Model Refactoring
|
|
7
|
+
|
|
8
|
+
When refactoring a Rails model, analyze the file and extract code into the appropriate pattern based on what the code does. The model itself should only contain associations, enums, basic validations, and concern includes.
|
|
9
|
+
|
|
10
|
+
## Decision Framework
|
|
11
|
+
|
|
12
|
+
Read the model file and classify each block of code:
|
|
13
|
+
|
|
14
|
+
| Code type | Extract to | Location |
|
|
15
|
+
|---|---|---|
|
|
16
|
+
| Related scopes + simple methods sharing a theme | Concern | `app/models/concerns/` |
|
|
17
|
+
| Business logic, multi-step operations, callbacks with side effects | Service object | `app/services/` |
|
|
18
|
+
| Complex queries, multi-join scopes, reporting queries | Query object | `app/queries/` |
|
|
19
|
+
| Context-specific validations (e.g. registration vs admin update) | Form object | `app/forms/` |
|
|
20
|
+
| Domain concepts beyond a primitive (money, coordinates, scores) | Value object | `app/models/` |
|
|
21
|
+
| Associations, enums, core validations, simple scopes | Keep on model | — |
|
|
22
|
+
|
|
23
|
+
## Patterns
|
|
24
|
+
|
|
25
|
+
### Concerns
|
|
26
|
+
|
|
27
|
+
Use for grouping related scopes, validations, callbacks, and simple instance methods that share a single theme. Name the concern after the capability it provides.
|
|
28
|
+
|
|
29
|
+
```ruby
|
|
30
|
+
# app/models/concerns/searchable.rb
|
|
31
|
+
module Searchable
|
|
32
|
+
extend ActiveSupport::Concern
|
|
33
|
+
|
|
34
|
+
included do
|
|
35
|
+
scope :search, ->(query) { where("name ILIKE ?", "%#{query}%") }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def matching_terms(query)
|
|
39
|
+
name.scan(/#{Regexp.escape(query)}/i)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
### Service Objects
|
|
45
|
+
|
|
46
|
+
Use for business logic, orchestration of multiple models, and anything triggered by a user action that involves more than a simple CRUD operation. Follow the single-responsibility principle — one service, one operation.
|
|
47
|
+
|
|
48
|
+
```ruby
|
|
49
|
+
# app/services/players/calculate_stats.rb
|
|
50
|
+
module Players
|
|
51
|
+
class CalculateStats
|
|
52
|
+
def initialize(player)
|
|
53
|
+
@player = player
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def call
|
|
57
|
+
# complex logic here
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Conventions:
|
|
64
|
+
- Namespace under the model name: `Players::CalculateStats`
|
|
65
|
+
- Single public method: `call`
|
|
66
|
+
- Accept dependencies via `initialize`
|
|
67
|
+
- Return a result or raise a domain-specific error
|
|
68
|
+
|
|
69
|
+
### Query Objects
|
|
70
|
+
|
|
71
|
+
Use for complex database queries that involve joins, subqueries, CTEs, or multi-condition filtering that would clutter a model with scopes.
|
|
72
|
+
|
|
73
|
+
```ruby
|
|
74
|
+
# app/queries/players/free_agent_query.rb
|
|
75
|
+
module Players
|
|
76
|
+
class FreeAgentQuery
|
|
77
|
+
def initialize(relation = Player.all)
|
|
78
|
+
@relation = relation
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def call(filters = {})
|
|
82
|
+
@relation
|
|
83
|
+
.where(contract_status: :expired)
|
|
84
|
+
.where("age < ?", filters[:max_age])
|
|
85
|
+
.joins(:stats)
|
|
86
|
+
.order(war: :desc)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Conventions:
|
|
93
|
+
- Accept a base relation in `initialize` (default to `Model.all`)
|
|
94
|
+
- Return an ActiveRecord relation so it remains chainable
|
|
95
|
+
- Single public method: `call`
|
|
96
|
+
|
|
97
|
+
### Form Objects
|
|
98
|
+
|
|
99
|
+
Use when validations only apply in specific contexts, or when a form spans multiple models.
|
|
100
|
+
|
|
101
|
+
```ruby
|
|
102
|
+
# app/forms/player_registration_form.rb
|
|
103
|
+
class PlayerRegistrationForm
|
|
104
|
+
include ActiveModel::Model
|
|
105
|
+
include ActiveModel::Attributes
|
|
106
|
+
|
|
107
|
+
attribute :name, :string
|
|
108
|
+
attribute :email, :string
|
|
109
|
+
attribute :team_id, :integer
|
|
110
|
+
attribute :position, :string
|
|
111
|
+
|
|
112
|
+
validates :name, :email, :position, presence: true
|
|
113
|
+
validates :email, format: { with: URI::MailTo::EMAIL_REGEXP }
|
|
114
|
+
|
|
115
|
+
def save
|
|
116
|
+
return false unless valid?
|
|
117
|
+
Player.create!(attributes)
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
### Value Objects
|
|
123
|
+
|
|
124
|
+
Use for domain concepts that deserve their own identity beyond a raw primitive.
|
|
125
|
+
|
|
126
|
+
```ruby
|
|
127
|
+
# app/models/batting_average.rb
|
|
128
|
+
class BattingAverage
|
|
129
|
+
include Comparable
|
|
130
|
+
|
|
131
|
+
def initialize(hits, at_bats)
|
|
132
|
+
@hits = hits
|
|
133
|
+
@at_bats = at_bats
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def value
|
|
137
|
+
return 0.0 if @at_bats.zero?
|
|
138
|
+
(@hits.to_f / @at_bats).round(3)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def elite?
|
|
142
|
+
value >= 0.300
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def <=>(other)
|
|
146
|
+
value <=> other.value
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
## Refactoring Process
|
|
152
|
+
|
|
153
|
+
1. **Read the entire model** and identify every method, scope, callback, and validation.
|
|
154
|
+
2. **Classify each block** using the decision framework table above.
|
|
155
|
+
3. **Extract in order**: value objects first, then query objects, then service objects, then concerns. Do concerns last because some may become unnecessary after other extractions.
|
|
156
|
+
4. **Update the model** to include concerns and delegate to new objects.
|
|
157
|
+
5. **Verify** that the slimmed-down model only contains: associations, enums, core validations, and concern includes.
|
|
158
|
+
6. **Create or update tests** for each extracted class. Each new class gets its own spec file mirroring the source path.
|
|
159
|
+
|
|
160
|
+
## What NOT to Do
|
|
161
|
+
|
|
162
|
+
- Don't extract everything — simple one-line scopes and basic validations belong on the model.
|
|
163
|
+
- Don't create a class for trivial logic just to hit a line count target.
|
|
164
|
+
- Don't use concerns as junk drawers — each concern should have a clear, single theme.
|
|
165
|
+
- Don't break ActiveRecord conventions (e.g. don't move associations into concerns).
|
|
166
|
+
- Don't introduce callback-heavy service objects — prefer explicit invocation over implicit hooks.
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"types": [
|
|
3
|
+
{
|
|
4
|
+
"type": "feat",
|
|
5
|
+
"section": "Features"
|
|
6
|
+
},
|
|
7
|
+
{
|
|
8
|
+
"type": "fix",
|
|
9
|
+
"section": "Bug Fixes"
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
"type": "chore",
|
|
13
|
+
"hidden": true
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"type": "docs",
|
|
17
|
+
"section": "Documentation"
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
"type": "style",
|
|
21
|
+
"hidden": true
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
"type": "refactor",
|
|
25
|
+
"section": "Code Refactoring"
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
"type": "perf",
|
|
29
|
+
"section": "Performance Improvements"
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
"type": "test",
|
|
33
|
+
"hidden": true
|
|
34
|
+
}
|
|
35
|
+
],
|
|
36
|
+
"packageFiles": [
|
|
37
|
+
{
|
|
38
|
+
"filename": "VERSION",
|
|
39
|
+
"type": "plain-text"
|
|
40
|
+
}
|
|
41
|
+
],
|
|
42
|
+
"bumpFiles": [
|
|
43
|
+
{
|
|
44
|
+
"filename": "VERSION",
|
|
45
|
+
"type": "plain-text"
|
|
46
|
+
}
|
|
47
|
+
]
|
|
48
|
+
}
|
|
@@ -47,6 +47,7 @@ Never add "BREAKING CHANGE" to a commit message unless there is actually a break
|
|
|
47
47
|
Never stash changes you can't commit. Either fix whatever is preventing the commit or fail out and let the human know why.
|
|
48
48
|
Never lower thresholds for tests to pass a pre-push hook. You must increase test coverage to make it pass
|
|
49
49
|
Never modify db/schema.rb directly. Use migrations to change the database schema.
|
|
50
|
+
Never handle tasks yourself when working in a team of agents. Always delegate to a specialied agent.
|
|
50
51
|
|
|
51
52
|
ONLY use rubocop:disable as a last resort and confirm with human before doing so
|
|
52
53
|
ONLY use rubocop:disable for specific cops, never disable all cops at once
|
|
@@ -34,6 +34,27 @@ jobs:
|
|
|
34
34
|
- run: bundle exec brakeman --no-pager --quiet
|
|
35
35
|
- run: bundle exec bundler-audit check --update
|
|
36
36
|
|
|
37
|
+
# Default: PostgreSQL. For MySQL, replace the service block and env vars:
|
|
38
|
+
# services:
|
|
39
|
+
# mysql:
|
|
40
|
+
# image: mysql:8.0
|
|
41
|
+
# env:
|
|
42
|
+
# MYSQL_ROOT_PASSWORD: password
|
|
43
|
+
# MYSQL_DATABASE: test
|
|
44
|
+
# ports:
|
|
45
|
+
# - 3306:3306
|
|
46
|
+
# options: >-
|
|
47
|
+
# --health-cmd "mysqladmin ping -h localhost"
|
|
48
|
+
# --health-interval 10s
|
|
49
|
+
# --health-timeout 5s
|
|
50
|
+
# --health-retries 5
|
|
51
|
+
# env:
|
|
52
|
+
# RAILS_ENV: test
|
|
53
|
+
# PRIMARY_DB_HOST: 127.0.0.1
|
|
54
|
+
# DATABASE_NAME: test
|
|
55
|
+
# DATABASE_PASSWORD: password
|
|
56
|
+
# DATABASE_USER: root
|
|
57
|
+
# DATABASE_PORT: 3306
|
|
37
58
|
test:
|
|
38
59
|
name: Test
|
|
39
60
|
runs-on: ubuntu-latest
|
|
@@ -59,7 +80,7 @@ jobs:
|
|
|
59
80
|
- uses: ruby/setup-ruby@v1
|
|
60
81
|
with:
|
|
61
82
|
bundler-cache: true
|
|
62
|
-
- run: bin/rails db:
|
|
83
|
+
- run: bin/rails db:prepare
|
|
63
84
|
- run: bundle exec rspec
|
|
64
85
|
|
|
65
86
|
code-quality:
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
name: Release
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main, staging]
|
|
6
|
+
workflow_dispatch:
|
|
7
|
+
|
|
8
|
+
permissions:
|
|
9
|
+
contents: write
|
|
10
|
+
|
|
11
|
+
jobs:
|
|
12
|
+
release:
|
|
13
|
+
name: Release
|
|
14
|
+
runs-on: ubuntu-latest
|
|
15
|
+
steps:
|
|
16
|
+
- uses: webfactory/ssh-agent@v0.9.0
|
|
17
|
+
with:
|
|
18
|
+
ssh-private-key: ${{ secrets.DEPLOY_KEY }}
|
|
19
|
+
|
|
20
|
+
- uses: actions/checkout@v4
|
|
21
|
+
with:
|
|
22
|
+
fetch-depth: 0
|
|
23
|
+
ssh-key: ${{ secrets.DEPLOY_KEY }}
|
|
24
|
+
|
|
25
|
+
- name: Check for skip conditions
|
|
26
|
+
id: skip
|
|
27
|
+
run: |
|
|
28
|
+
COMMIT_MSG=$(git log -1 --pretty=%B)
|
|
29
|
+
|
|
30
|
+
# Skip if last commit is a version bump
|
|
31
|
+
if echo "$COMMIT_MSG" | grep -q "^chore(release):"; then
|
|
32
|
+
echo "skip=true" >> $GITHUB_OUTPUT
|
|
33
|
+
exit 0
|
|
34
|
+
fi
|
|
35
|
+
|
|
36
|
+
# Skip promotion merges (environment branch → main) only on main
|
|
37
|
+
if [ "$GITHUB_REF_NAME" = "main" ]; then
|
|
38
|
+
ENV_BRANCHES="dev|staging"
|
|
39
|
+
|
|
40
|
+
# Message-based detection: merge commits
|
|
41
|
+
# Pattern: "Merge branch 'staging' into main"
|
|
42
|
+
if echo "$COMMIT_MSG" | grep -qiE "^Merge branch ['\"]?($ENV_BRANCHES)['\"]? into"; then
|
|
43
|
+
echo "skip=true" >> $GITHUB_OUTPUT
|
|
44
|
+
exit 0
|
|
45
|
+
fi
|
|
46
|
+
|
|
47
|
+
# Pattern: "Merge pull request #123 from org/staging"
|
|
48
|
+
if echo "$COMMIT_MSG" | grep -qiE "^Merge pull request.*from .*/($ENV_BRANCHES)$"; then
|
|
49
|
+
echo "skip=true" >> $GITHUB_OUTPUT
|
|
50
|
+
exit 0
|
|
51
|
+
fi
|
|
52
|
+
|
|
53
|
+
# Git-based detection: squash/fast-forward promotions
|
|
54
|
+
for ENV_BRANCH in dev staging; do
|
|
55
|
+
git fetch origin "$ENV_BRANCH" 2>/dev/null || continue
|
|
56
|
+
if git merge-base --is-ancestor HEAD "origin/$ENV_BRANCH" 2>/dev/null; then
|
|
57
|
+
echo "skip=true" >> $GITHUB_OUTPUT
|
|
58
|
+
exit 0
|
|
59
|
+
fi
|
|
60
|
+
done
|
|
61
|
+
fi
|
|
62
|
+
|
|
63
|
+
echo "skip=false" >> $GITHUB_OUTPUT
|
|
64
|
+
|
|
65
|
+
- uses: actions/setup-node@v4
|
|
66
|
+
if: steps.skip.outputs.skip != 'true'
|
|
67
|
+
with:
|
|
68
|
+
node-version: '22'
|
|
69
|
+
|
|
70
|
+
- name: Configure Git
|
|
71
|
+
if: steps.skip.outputs.skip != 'true'
|
|
72
|
+
run: |
|
|
73
|
+
git config user.name "github-actions[bot]"
|
|
74
|
+
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
|
75
|
+
|
|
76
|
+
- name: Bump version and create tag
|
|
77
|
+
if: steps.skip.outputs.skip != 'true'
|
|
78
|
+
run: npx standard-version@9
|
|
79
|
+
|
|
80
|
+
- name: Push changes
|
|
81
|
+
if: steps.skip.outputs.skip != 'true'
|
|
82
|
+
run: |
|
|
83
|
+
git remote set-url origin git@github.com:${{ github.repository }}.git
|
|
84
|
+
git push --follow-tags origin ${{ github.ref_name }}
|
|
85
|
+
|
|
86
|
+
- name: Create GitHub Release
|
|
87
|
+
if: steps.skip.outputs.skip != 'true' && github.ref_name == 'main'
|
|
88
|
+
env:
|
|
89
|
+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
90
|
+
run: |
|
|
91
|
+
VERSION=$(cat VERSION)
|
|
92
|
+
gh release create "v${VERSION}" \
|
|
93
|
+
--title "v${VERSION}" \
|
|
94
|
+
--generate-notes
|
|
@@ -2,8 +2,21 @@
|
|
|
2
2
|
# This file is NOT managed by Lisa — edit freely.
|
|
3
3
|
# Add or override any RuboCop cops below.
|
|
4
4
|
|
|
5
|
-
#
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
# Align with Reek's UncommunicativeVariableName (which rejects single-char names)
|
|
6
|
+
Naming/RescuedExceptionsVariableName:
|
|
7
|
+
PreferredName: error
|
|
8
|
+
|
|
9
|
+
# Add staging to known environments if your app uses a staging Rails env
|
|
10
|
+
# Rails/UnknownEnv:
|
|
11
|
+
# Environments:
|
|
12
|
+
# - development
|
|
13
|
+
# - test
|
|
14
|
+
# - staging
|
|
15
|
+
# - production
|
|
16
|
+
|
|
17
|
+
# Auto-generated schema files can exceed block length limits
|
|
18
|
+
# Metrics/BlockLength:
|
|
8
19
|
# Exclude:
|
|
9
|
-
# - '
|
|
20
|
+
# - 'db/queue_schema.rb'
|
|
21
|
+
# - 'db/cable_schema.rb'
|
|
22
|
+
# - 'db/cache_schema.rb'
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
0.0.1
|