@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.
@@ -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.37.0",
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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Reads application version from the VERSION file at project root.
4
+ # The VERSION file is managed by standard-version and bumped during releases.
5
+ APP_VERSION = Rails.root.join('VERSION').read.strip.freeze
@@ -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:schema:load
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
@@ -24,6 +24,9 @@ directories:
24
24
  'app/helpers':
25
25
  UtilityFunction:
26
26
  enabled: false
27
+ 'app/jobs':
28
+ UtilityFunction:
29
+ enabled: false
27
30
  'db/migrate':
28
31
  IrresponsibleModule:
29
32
  enabled: false
@@ -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
- # Example:
6
- # Metrics/MethodLength:
7
- # Max: 25
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
- # - 'app/controllers/api/v1/**/*'
20
+ # - 'db/queue_schema.rb'
21
+ # - 'db/cable_schema.rb'
22
+ # - 'db/cache_schema.rb'
@@ -0,0 +1 @@
1
+ 0.0.1