ariadna 1.3.0 → 2.0.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 +4 -4
- data/ariadna.gemspec +0 -1
- data/data/agents/ariadna-codebase-mapper.md +34 -722
- data/data/agents/ariadna-debugger.md +44 -1139
- data/data/agents/ariadna-executor.md +75 -396
- data/data/agents/ariadna-planner.md +78 -1215
- data/data/agents/ariadna-roadmapper.md +55 -582
- data/data/agents/ariadna-verifier.md +60 -702
- data/data/ariadna/templates/config.json +8 -33
- data/data/ariadna/workflows/debug.md +28 -0
- data/data/ariadna/workflows/execute-phase.md +31 -513
- data/data/ariadna/workflows/map-codebase.md +20 -319
- data/data/ariadna/workflows/new-milestone.md +20 -365
- data/data/ariadna/workflows/new-project.md +19 -880
- data/data/ariadna/workflows/plan-phase.md +24 -443
- data/data/ariadna/workflows/progress.md +20 -376
- data/data/ariadna/workflows/quick.md +19 -221
- data/data/ariadna/workflows/roadmap-ops.md +28 -0
- data/data/ariadna/workflows/verify-work.md +23 -560
- data/data/commands/ariadna/add-phase.md +11 -22
- data/data/commands/ariadna/debug.md +11 -143
- data/data/commands/ariadna/execute-phase.md +12 -30
- data/data/commands/ariadna/insert-phase.md +7 -14
- data/data/commands/ariadna/map-codebase.md +16 -49
- data/data/commands/ariadna/new-milestone.md +12 -25
- data/data/commands/ariadna/new-project.md +22 -26
- data/data/commands/ariadna/plan-phase.md +13 -22
- data/data/commands/ariadna/progress.md +16 -6
- data/data/commands/ariadna/quick.md +9 -11
- data/data/commands/ariadna/remove-phase.md +9 -12
- data/data/commands/ariadna/verify-work.md +14 -19
- data/data/skills/rails-backend/API.md +138 -0
- data/data/skills/rails-backend/CONTROLLERS.md +154 -0
- data/data/skills/rails-backend/JOBS.md +132 -0
- data/data/skills/rails-backend/MODELS.md +213 -0
- data/data/skills/rails-backend/SKILL.md +169 -0
- data/data/skills/rails-frontend/ASSETS.md +154 -0
- data/data/skills/rails-frontend/COMPONENTS.md +253 -0
- data/data/skills/rails-frontend/SKILL.md +187 -0
- data/data/skills/rails-frontend/VIEWS.md +168 -0
- data/data/skills/rails-performance/PROFILING.md +106 -0
- data/data/skills/rails-performance/SKILL.md +217 -0
- data/data/skills/rails-security/AUDIT.md +118 -0
- data/data/skills/rails-security/SKILL.md +422 -0
- data/data/skills/rails-testing/FIXTURES.md +78 -0
- data/data/skills/rails-testing/SKILL.md +160 -0
- data/data/skills/rails-testing/SYSTEM-TESTS.md +73 -0
- data/lib/ariadna/installer.rb +11 -15
- data/lib/ariadna/tools/cli.rb +0 -12
- data/lib/ariadna/tools/config_manager.rb +10 -72
- data/lib/ariadna/tools/frontmatter.rb +23 -1
- data/lib/ariadna/tools/init.rb +201 -401
- data/lib/ariadna/tools/model_profiles.rb +6 -14
- data/lib/ariadna/tools/phase_manager.rb +1 -10
- data/lib/ariadna/tools/state_manager.rb +170 -451
- data/lib/ariadna/tools/template_filler.rb +4 -12
- data/lib/ariadna/tools/verification.rb +21 -399
- data/lib/ariadna/uninstaller.rb +9 -0
- data/lib/ariadna/version.rb +1 -1
- data/lib/ariadna.rb +1 -0
- metadata +20 -91
- data/data/agents/ariadna-backend-executor.md +0 -261
- data/data/agents/ariadna-frontend-executor.md +0 -259
- data/data/agents/ariadna-integration-checker.md +0 -418
- data/data/agents/ariadna-phase-researcher.md +0 -469
- data/data/agents/ariadna-plan-checker.md +0 -622
- data/data/agents/ariadna-project-researcher.md +0 -618
- data/data/agents/ariadna-research-synthesizer.md +0 -236
- data/data/agents/ariadna-test-executor.md +0 -266
- data/data/ariadna/references/checkpoints.md +0 -772
- data/data/ariadna/references/continuation-format.md +0 -249
- data/data/ariadna/references/decimal-phase-calculation.md +0 -65
- data/data/ariadna/references/git-integration.md +0 -248
- data/data/ariadna/references/git-planning-commit.md +0 -38
- data/data/ariadna/references/model-profile-resolution.md +0 -32
- data/data/ariadna/references/model-profiles.md +0 -73
- data/data/ariadna/references/phase-argument-parsing.md +0 -61
- data/data/ariadna/references/planning-config.md +0 -194
- data/data/ariadna/references/questioning.md +0 -153
- data/data/ariadna/references/rails-conventions.md +0 -416
- data/data/ariadna/references/tdd.md +0 -267
- data/data/ariadna/references/ui-brand.md +0 -160
- data/data/ariadna/references/verification-patterns.md +0 -853
- data/data/ariadna/templates/codebase/architecture.md +0 -481
- data/data/ariadna/templates/codebase/concerns.md +0 -380
- data/data/ariadna/templates/codebase/conventions.md +0 -434
- data/data/ariadna/templates/codebase/integrations.md +0 -328
- data/data/ariadna/templates/codebase/stack.md +0 -189
- data/data/ariadna/templates/codebase/structure.md +0 -418
- data/data/ariadna/templates/codebase/testing.md +0 -606
- data/data/ariadna/templates/context.md +0 -283
- data/data/ariadna/templates/continue-here.md +0 -78
- data/data/ariadna/templates/debug-subagent-prompt.md +0 -91
- data/data/ariadna/templates/phase-prompt.md +0 -609
- data/data/ariadna/templates/planner-subagent-prompt.md +0 -117
- data/data/ariadna/templates/research-project/ARCHITECTURE.md +0 -439
- data/data/ariadna/templates/research-project/FEATURES.md +0 -168
- data/data/ariadna/templates/research-project/PITFALLS.md +0 -406
- data/data/ariadna/templates/research-project/STACK.md +0 -251
- data/data/ariadna/templates/research-project/SUMMARY.md +0 -247
- data/data/ariadna/templates/state.md +0 -176
- data/data/ariadna/templates/summary-complex.md +0 -59
- data/data/ariadna/templates/summary-minimal.md +0 -41
- data/data/ariadna/templates/summary-standard.md +0 -48
- data/data/ariadna/templates/user-setup.md +0 -310
- data/data/ariadna/workflows/add-phase.md +0 -111
- data/data/ariadna/workflows/add-todo.md +0 -157
- data/data/ariadna/workflows/audit-milestone.md +0 -241
- data/data/ariadna/workflows/check-todos.md +0 -176
- data/data/ariadna/workflows/complete-milestone.md +0 -644
- data/data/ariadna/workflows/diagnose-issues.md +0 -219
- data/data/ariadna/workflows/discovery-phase.md +0 -289
- data/data/ariadna/workflows/discuss-phase.md +0 -408
- data/data/ariadna/workflows/execute-plan.md +0 -448
- data/data/ariadna/workflows/help.md +0 -470
- data/data/ariadna/workflows/insert-phase.md +0 -129
- data/data/ariadna/workflows/list-phase-assumptions.md +0 -178
- data/data/ariadna/workflows/pause-work.md +0 -122
- data/data/ariadna/workflows/plan-milestone-gaps.md +0 -256
- data/data/ariadna/workflows/remove-phase.md +0 -154
- data/data/ariadna/workflows/research-phase.md +0 -74
- data/data/ariadna/workflows/resume-project.md +0 -306
- data/data/ariadna/workflows/set-profile.md +0 -80
- data/data/ariadna/workflows/settings.md +0 -145
- data/data/ariadna/workflows/transition.md +0 -493
- data/data/ariadna/workflows/update.md +0 -212
- data/data/ariadna/workflows/verify-phase.md +0 -226
- data/data/commands/ariadna/add-todo.md +0 -42
- data/data/commands/ariadna/audit-milestone.md +0 -42
- data/data/commands/ariadna/check-todos.md +0 -41
- data/data/commands/ariadna/complete-milestone.md +0 -136
- data/data/commands/ariadna/discuss-phase.md +0 -86
- data/data/commands/ariadna/help.md +0 -22
- data/data/commands/ariadna/list-phase-assumptions.md +0 -50
- data/data/commands/ariadna/pause-work.md +0 -35
- data/data/commands/ariadna/plan-milestone-gaps.md +0 -40
- data/data/commands/ariadna/reapply-patches.md +0 -110
- data/data/commands/ariadna/research-phase.md +0 -187
- data/data/commands/ariadna/resume-work.md +0 -40
- data/data/commands/ariadna/set-profile.md +0 -34
- data/data/commands/ariadna/settings.md +0 -36
- data/data/commands/ariadna/update.md +0 -37
- data/data/guides/backend.md +0 -3069
- data/data/guides/frontend.md +0 -1479
- data/data/guides/performance.md +0 -1193
- data/data/guides/security.md +0 -1522
- data/data/guides/style-guide.md +0 -1091
- data/data/guides/testing.md +0 -504
- data/data/templates.md +0 -94
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: rails-frontend
|
|
3
|
+
description: Ruby on Rails frontend conventions — Hotwire, Turbo, Stimulus, views, components, assets. Use when implementing frontend features, building views, or working with JavaScript/CSS.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Rails Frontend
|
|
7
|
+
|
|
8
|
+
Core conventions for Rails frontend work using the Hotwire stack: Turbo Drive, Turbo Frames, Turbo Streams, and Stimulus controllers.
|
|
9
|
+
|
|
10
|
+
## Sub-files
|
|
11
|
+
|
|
12
|
+
- [VIEWS.md](VIEWS.md) — ERB conventions, layouts, partials, Turbo Frame wrapping
|
|
13
|
+
- [COMPONENTS.md](COMPONENTS.md) — Stimulus controllers, presenter pattern, component composition
|
|
14
|
+
- [ASSETS.md](ASSETS.md) — CSS architecture, design tokens, asset pipeline
|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
## Stack
|
|
19
|
+
|
|
20
|
+
- **Turbo Drive** — replaces full page loads with fetch + DOM swap
|
|
21
|
+
- **Turbo Frames** — scope navigation to a region; swap on response
|
|
22
|
+
- **Turbo Streams** — targeted DOM mutations via `<turbo-stream>` elements
|
|
23
|
+
- **Stimulus** — lightweight JS controllers bound to DOM elements
|
|
24
|
+
- **No React/Vue** — Hotwire is the default; reach for Stimulus before any SPA framework
|
|
25
|
+
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
## Turbo Essentials
|
|
29
|
+
|
|
30
|
+
### HTTP Status Codes
|
|
31
|
+
|
|
32
|
+
Always use these — Turbo depends on them:
|
|
33
|
+
|
|
34
|
+
| Status | Meaning | When to Use |
|
|
35
|
+
|--------|---------|-------------|
|
|
36
|
+
| `303 See Other` | Redirect after success | After any create/update/destroy |
|
|
37
|
+
| `422 Unprocessable Entity` | Validation failure | Re-render form in frame |
|
|
38
|
+
| Never `200` | For redirect-expecting forms | — |
|
|
39
|
+
|
|
40
|
+
```ruby
|
|
41
|
+
def create
|
|
42
|
+
if @card.save
|
|
43
|
+
redirect_to @card, status: :see_other
|
|
44
|
+
else
|
|
45
|
+
render :new, status: :unprocessable_entity
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
### Turbo Frames
|
|
51
|
+
|
|
52
|
+
Scope to the **smallest rerenderable unit**. Always use `dom_id` for IDs:
|
|
53
|
+
|
|
54
|
+
```erb
|
|
55
|
+
<turbo-frame id="<%= dom_id(card) %>">
|
|
56
|
+
<%= render "cards/card", card: card %>
|
|
57
|
+
</turbo-frame>
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Lazy loading with placeholder:
|
|
61
|
+
|
|
62
|
+
```erb
|
|
63
|
+
<turbo-frame id="activity_feed" src="<%= activity_feed_path %>" loading="lazy">
|
|
64
|
+
<p>Loading...</p>
|
|
65
|
+
</turbo-frame>
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Style the loading state with the auto-added `[busy]` attribute:
|
|
69
|
+
|
|
70
|
+
```css
|
|
71
|
+
turbo-frame[busy] { opacity: 0.5; pointer-events: none; }
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
### Turbo Streams
|
|
75
|
+
|
|
76
|
+
Prefer the 8 built-in actions: `append`, `prepend`, `replace`, `update`, `remove`, `before`, `after`, `refresh`.
|
|
77
|
+
|
|
78
|
+
Broadcast from model callbacks:
|
|
79
|
+
|
|
80
|
+
```ruby
|
|
81
|
+
class Card < ApplicationRecord
|
|
82
|
+
after_create_commit -> { broadcast_append_to board, target: "cards" }
|
|
83
|
+
after_update_commit -> { broadcast_replace_to board }
|
|
84
|
+
after_destroy_commit -> { broadcast_remove_to board }
|
|
85
|
+
end
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Custom stream actions register on `StreamActions`; `this` is the `<turbo-stream>` element:
|
|
89
|
+
|
|
90
|
+
```javascript
|
|
91
|
+
import { StreamActions } from "@hotwired/turbo"
|
|
92
|
+
|
|
93
|
+
StreamActions.flash = function () {
|
|
94
|
+
const flash = document.createElement("div")
|
|
95
|
+
flash.className = `flash flash--${this.getAttribute("type") || "notice"}`
|
|
96
|
+
flash.textContent = this.getAttribute("message")
|
|
97
|
+
document.getElementById("flash_container").appendChild(flash)
|
|
98
|
+
}
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
### Turbo Drive Events
|
|
102
|
+
|
|
103
|
+
| Event | Use For |
|
|
104
|
+
|-------|---------|
|
|
105
|
+
| `turbo:before-cache` | Clean transient UI (close dropdowns, remove flashes) |
|
|
106
|
+
| `turbo:before-render` | Page transition animations — **pausable** via `preventDefault()` + `detail.resume()` |
|
|
107
|
+
| `turbo:load` | Post-navigation setup (equivalent to `DOMContentLoaded`) |
|
|
108
|
+
| `turbo:frame-load` | Frame navigation complete — update active states here, NOT on `turbo:click` |
|
|
109
|
+
|
|
110
|
+
Always guard animations against `data-turbo-preview` (cached snapshot renders):
|
|
111
|
+
|
|
112
|
+
```javascript
|
|
113
|
+
document.addEventListener("turbo:before-render", (event) => {
|
|
114
|
+
if (document.documentElement.hasAttribute("data-turbo-preview")) return
|
|
115
|
+
event.preventDefault()
|
|
116
|
+
document.documentElement.classList.add("page-leaving")
|
|
117
|
+
document.documentElement.addEventListener("animationend", () => event.detail.resume(), { once: true })
|
|
118
|
+
})
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
### Optimistic UI
|
|
122
|
+
|
|
123
|
+
1. Store markup in a `<template>` containing a `<turbo-stream>` (prevents premature execution)
|
|
124
|
+
2. On `turbo:submit-start`, clone and append to the DOM
|
|
125
|
+
3. Server responds with `turbo_stream.refresh` to reconcile
|
|
126
|
+
|
|
127
|
+
Use client-side ULIDs for optimistic IDs (time-ordered, collision-resistant):
|
|
128
|
+
|
|
129
|
+
```javascript
|
|
130
|
+
function generateULID() {
|
|
131
|
+
const time = Date.now().toString(36).padStart(10, "0")
|
|
132
|
+
const rand = Array.from(crypto.getRandomValues(new Uint8Array(10)))
|
|
133
|
+
.map((b) => b.toString(36).padStart(2, "0")).join("").slice(0, 16)
|
|
134
|
+
return (time + rand).toUpperCase()
|
|
135
|
+
}
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
---
|
|
139
|
+
|
|
140
|
+
## Presenter Pattern
|
|
141
|
+
|
|
142
|
+
Use plain Ruby presenter classes in `app/models/` (not a separate `app/presenters/` directory) to keep view logic out of ERB templates.
|
|
143
|
+
|
|
144
|
+
**Create a presenter when:** a view needs 3+ conditionals, multiple computed values, HTML generation, or fragment caching.
|
|
145
|
+
|
|
146
|
+
Key anatomy:
|
|
147
|
+
|
|
148
|
+
```ruby
|
|
149
|
+
class User::Filtering
|
|
150
|
+
def initialize(user, filter, expanded: false)
|
|
151
|
+
@user, @filter, @expanded = user, filter, expanded
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def boards = @boards ||= user.boards.ordered_by_recently_accessed # memoized
|
|
155
|
+
def show_tags? = filter.tags.any? # boolean for display
|
|
156
|
+
def cache_key # for fragment caching
|
|
157
|
+
ActiveSupport::Cache.expand_cache_key([user, filter, boards], "user-filtering")
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
- **Domain-organized names**: `User::Filtering`, not `FilteringPresenter`
|
|
163
|
+
- **Include ActionView helpers** when generating HTML: `include ActionView::Helpers::TagHelper`
|
|
164
|
+
- **Factory methods** on models: `event.description_for(user)` for discoverable APIs
|
|
165
|
+
- **Controller concerns** for cross-controller instantiation
|
|
166
|
+
|
|
167
|
+
---
|
|
168
|
+
|
|
169
|
+
## View Transitions API
|
|
170
|
+
|
|
171
|
+
Enable in the layout:
|
|
172
|
+
|
|
173
|
+
```erb
|
|
174
|
+
<meta name="view-transition" content="same-origin">
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
Direction-aware transitions: capture in `turbo:click`, apply in `turbo:before-render`, clean up in `turbo:load`.
|
|
178
|
+
|
|
179
|
+
---
|
|
180
|
+
|
|
181
|
+
## Key Rules
|
|
182
|
+
|
|
183
|
+
- No query calls (`where`, `find`, `count`) in ERB — push to presenters or controllers
|
|
184
|
+
- No conditionals deeper than one level in templates
|
|
185
|
+
- Always pass locals explicitly to partials — never rely on instance variables inside partials
|
|
186
|
+
- Use `dom_id` for all Turbo Frame and Stream target IDs
|
|
187
|
+
- Clean transient UI on `turbo:before-cache` (dropdowns, flash messages, open modals)
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
# Views — ERB, Layouts, Partials
|
|
2
|
+
|
|
3
|
+
Templates are rendering surfaces, not logic containers. Delegate decisions to presenters or model methods.
|
|
4
|
+
|
|
5
|
+
## ERB Conventions
|
|
6
|
+
|
|
7
|
+
**Rules:**
|
|
8
|
+
- No conditionals deeper than one level
|
|
9
|
+
- No query calls (`where`, `find`, `count`) — use presenters
|
|
10
|
+
- Use `content_for` to inject section-specific content into layouts
|
|
11
|
+
- Prefer `tag.div` helpers inside presenters over inline ERB for complex HTML
|
|
12
|
+
|
|
13
|
+
```erb
|
|
14
|
+
<%# Bad — logic in template %>
|
|
15
|
+
<% if user.avatar.attached? && user.avatar.variable? %>
|
|
16
|
+
<%= image_tag user.avatar.variant(resize_to_limit: [100, 100]) %>
|
|
17
|
+
<% else %>
|
|
18
|
+
<%= image_tag "default_avatar.png" %>
|
|
19
|
+
<% end %>
|
|
20
|
+
|
|
21
|
+
<%# Good — delegate to presenter %>
|
|
22
|
+
<%= presenter.avatar_tag %>
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
### `content_for` Pattern
|
|
26
|
+
|
|
27
|
+
```erb
|
|
28
|
+
<%# app/views/messages/show.html.erb %>
|
|
29
|
+
<% content_for :title, @message.subject %>
|
|
30
|
+
<% content_for :head do %>
|
|
31
|
+
<%= javascript_include_tag "trix" %>
|
|
32
|
+
<% end %>
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
```erb
|
|
36
|
+
<%# app/views/layouts/application.html.erb %>
|
|
37
|
+
<title><%= content_for(:title) || "App" %></title>
|
|
38
|
+
<%= yield :head %>
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
---
|
|
42
|
+
|
|
43
|
+
## Partials
|
|
44
|
+
|
|
45
|
+
**Extract when:** the same markup appears in 2+ templates, or a clear UI component boundary exists.
|
|
46
|
+
|
|
47
|
+
**Naming:**
|
|
48
|
+
- Leading underscore: `_card.html.erb`
|
|
49
|
+
- Named after the UI concept, not the model: `_card.html.erb` not `_message_display.html.erb`
|
|
50
|
+
- Cross-controller partials in `app/views/shared/`
|
|
51
|
+
|
|
52
|
+
**Always pass explicit locals — never rely on instance variables inside partials:**
|
|
53
|
+
|
|
54
|
+
```erb
|
|
55
|
+
<%# Good %>
|
|
56
|
+
<%= render partial: "messages/card", locals: { message: message, show_actions: true } %>
|
|
57
|
+
|
|
58
|
+
<%# Good — short form for collections %>
|
|
59
|
+
<%= render partial: "messages/message", collection: @messages, as: :message %>
|
|
60
|
+
|
|
61
|
+
<%# Bad — implicit instance variable dependency %>
|
|
62
|
+
<%= render partial: "messages/card" %>
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
---
|
|
66
|
+
|
|
67
|
+
## Turbo Frame Wrapping
|
|
68
|
+
|
|
69
|
+
Wrap the **smallest rerenderable unit**. Frame IDs must match between the source page and the server response.
|
|
70
|
+
|
|
71
|
+
```erb
|
|
72
|
+
<%# app/views/messages/show.html.erb %>
|
|
73
|
+
<%= turbo_frame_tag dom_id(message) do %>
|
|
74
|
+
<h2><%= message.subject %></h2>
|
|
75
|
+
<%= link_to "Edit", edit_message_path(message) %>
|
|
76
|
+
<% end %>
|
|
77
|
+
|
|
78
|
+
<%# app/views/messages/edit.html.erb — same ID, swaps in place %>
|
|
79
|
+
<%= turbo_frame_tag dom_id(message) do %>
|
|
80
|
+
<%= render "form", message: message %>
|
|
81
|
+
<% end %>
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Lazy-loaded frame with placeholder:
|
|
85
|
+
|
|
86
|
+
```erb
|
|
87
|
+
<%= turbo_frame_tag "comments", src: message_comments_path(message), loading: :lazy do %>
|
|
88
|
+
<p>Loading comments...</p>
|
|
89
|
+
<% end %>
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Tabbed navigation with history:
|
|
93
|
+
|
|
94
|
+
```erb
|
|
95
|
+
<a href="<%= tab_path %>" data-turbo-frame="tab_content" data-turbo-action="advance">
|
|
96
|
+
Tab Name
|
|
97
|
+
</a>
|
|
98
|
+
<turbo-frame id="tab_content"><%= yield %></turbo-frame>
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Update active state on `turbo:frame-load` (not `turbo:click`):
|
|
102
|
+
|
|
103
|
+
```javascript
|
|
104
|
+
document.addEventListener("turbo:frame-load", (event) => {
|
|
105
|
+
if (event.target.id !== "tab_content") return
|
|
106
|
+
document.querySelectorAll("[data-turbo-frame='tab_content']").forEach((link) => {
|
|
107
|
+
link.classList.toggle("active", link.href === event.target.src)
|
|
108
|
+
})
|
|
109
|
+
})
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
---
|
|
113
|
+
|
|
114
|
+
## Cache-Safe Views
|
|
115
|
+
|
|
116
|
+
Turbo caches pages before navigating. Transient UI reappears as stale artifacts unless cleaned up.
|
|
117
|
+
|
|
118
|
+
```javascript
|
|
119
|
+
document.addEventListener("turbo:before-cache", () => {
|
|
120
|
+
document.querySelectorAll("[data-expanded]").forEach(el => el.removeAttribute("data-expanded"))
|
|
121
|
+
document.querySelectorAll(".flash").forEach(el => el.remove())
|
|
122
|
+
document.querySelectorAll("form").forEach(form => form.reset())
|
|
123
|
+
})
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
Guard content that depends on fresh data against preview (cached) renders:
|
|
127
|
+
|
|
128
|
+
```erb
|
|
129
|
+
<% unless request.headers["Purpose"] == "preview" %>
|
|
130
|
+
<div data-controller="polling"><%= render "metrics", stats: @stats %></div>
|
|
131
|
+
<% end %>
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
Fragment caching with presenter keys:
|
|
135
|
+
|
|
136
|
+
```erb
|
|
137
|
+
<% cache presenter.cache_key do %>
|
|
138
|
+
<%= render partial: "filters/tags", locals: { tags: presenter.tags } %>
|
|
139
|
+
<% end %>
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
---
|
|
143
|
+
|
|
144
|
+
## Template-Based Optimistic UI
|
|
145
|
+
|
|
146
|
+
Store `<turbo-stream>` inside a `<template>` to prevent premature execution:
|
|
147
|
+
|
|
148
|
+
```html
|
|
149
|
+
<template data-optimistic-stream>
|
|
150
|
+
<turbo-stream action="append" target="messages">
|
|
151
|
+
<template>
|
|
152
|
+
<div class="message message--pending" id="pending_PLACEHOLDER">
|
|
153
|
+
<p data-placeholder>Sending...</p>
|
|
154
|
+
</div>
|
|
155
|
+
</template>
|
|
156
|
+
</turbo-stream>
|
|
157
|
+
</template>
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
Clone and dispatch from a Stimulus controller on submit:
|
|
161
|
+
|
|
162
|
+
```javascript
|
|
163
|
+
submit() {
|
|
164
|
+
const template = this.templateTarget.content.cloneNode(true)
|
|
165
|
+
template.querySelector(".message").id = `pending_${Date.now()}`
|
|
166
|
+
document.body.append(template)
|
|
167
|
+
}
|
|
168
|
+
```
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# Profiling & Benchmarking
|
|
2
|
+
|
|
3
|
+
Measure first, optimize second. Profile in the production Ruby version with production-sized data.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Finding N+1 Queries in Development
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
# Gemfile
|
|
11
|
+
gem "bullet", group: :development
|
|
12
|
+
|
|
13
|
+
# config/environments/development.rb
|
|
14
|
+
config.after_initialize do
|
|
15
|
+
Bullet.enable = true
|
|
16
|
+
Bullet.rails_logger = true
|
|
17
|
+
Bullet.add_footer = true
|
|
18
|
+
end
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Check `log/bullet.log` after exercising a feature. Bullet reports N+1 alerts and unused eager loads.
|
|
22
|
+
|
|
23
|
+
Enable query log tags to trace queries to their source in staging:
|
|
24
|
+
|
|
25
|
+
```ruby
|
|
26
|
+
# config/application.rb
|
|
27
|
+
config.active_record.query_log_tags_enabled = true
|
|
28
|
+
config.active_record.query_log_tags = [:controller, :action]
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+
## Benchmarking with `benchmark-ips`
|
|
34
|
+
|
|
35
|
+
Compare two implementations before choosing one:
|
|
36
|
+
|
|
37
|
+
```ruby
|
|
38
|
+
require "benchmark/ips"
|
|
39
|
+
|
|
40
|
+
Benchmark.ips do |x|
|
|
41
|
+
x.warmup = 2
|
|
42
|
+
x.time = 5
|
|
43
|
+
x.report("pluck") { User.where(active: true).pluck(:email) }
|
|
44
|
+
x.report("map") { User.where(active: true).map(&:email) }
|
|
45
|
+
x.compare!
|
|
46
|
+
end
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Always call `.compare!`. Always warm up. Test with representative data volume.
|
|
50
|
+
|
|
51
|
+
---
|
|
52
|
+
|
|
53
|
+
## Rack Mini Profiler
|
|
54
|
+
|
|
55
|
+
```ruby
|
|
56
|
+
# Gemfile — development and staging only
|
|
57
|
+
gem "rack-mini-profiler"
|
|
58
|
+
gem "stackprof" # CPU flamegraphs
|
|
59
|
+
gem "memory_profiler" # allocation profiling
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Append `?pp=flamegraph` for CPU flamegraph. Use `?pp=profile-memory` for allocations.
|
|
63
|
+
|
|
64
|
+
---
|
|
65
|
+
|
|
66
|
+
## Query Analysis with EXPLAIN
|
|
67
|
+
|
|
68
|
+
```ruby
|
|
69
|
+
# rails console
|
|
70
|
+
puts Order.where(user_id: 1, status: "pending").order(:created_at).explain
|
|
71
|
+
|
|
72
|
+
# PostgreSQL — actual execution stats
|
|
73
|
+
ActiveRecord::Base.connection.execute(
|
|
74
|
+
"EXPLAIN (ANALYZE, BUFFERS) #{Order.where(user_id: 1).to_sql}"
|
|
75
|
+
).each { |r| puts r["QUERY PLAN"] }
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Warning signs: `Seq Scan` on large tables (missing index), `rows=` estimate vs actual mismatch (run `ANALYZE`), `Sort` without an index.
|
|
79
|
+
|
|
80
|
+
---
|
|
81
|
+
|
|
82
|
+
## Memory Profiling
|
|
83
|
+
|
|
84
|
+
```ruby
|
|
85
|
+
require "memory_profiler"
|
|
86
|
+
|
|
87
|
+
report = MemoryProfiler.report { User.where(active: true).map(&:email) }
|
|
88
|
+
report.pretty_print(to_file: "/tmp/mem.txt")
|
|
89
|
+
# Review: allocated_memory_by_gem, allocated_objects_by_location
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Allocation reduction checklist:
|
|
93
|
+
- `# frozen_string_literal: true` on every file
|
|
94
|
+
- Extract regex to constants outside loops
|
|
95
|
+
- `pluck` instead of AR objects when only scalars are needed
|
|
96
|
+
- `User.select(:id, :email).find_each` to limit columns in batch jobs
|
|
97
|
+
|
|
98
|
+
---
|
|
99
|
+
|
|
100
|
+
## Review Thresholds
|
|
101
|
+
|
|
102
|
+
| Scenario | Target |
|
|
103
|
+
|---|---|
|
|
104
|
+
| Index page (dev, seeded data) | < 10 queries, < 50ms DB time |
|
|
105
|
+
| Show page | < 5 queries, < 20ms DB time |
|
|
106
|
+
| Batch job over 10k records | Uses `find_each`, < 256MB peak RSS |
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: rails-performance
|
|
3
|
+
description: Ruby on Rails performance conventions — N+1 prevention, caching, database tuning, benchmarking. Use when optimizing queries, adding caching, or profiling performance.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Rails Performance Skill
|
|
7
|
+
|
|
8
|
+
Opinionated conventions for Rails performance. Every pattern has a clear unsafe anti-pattern and safe fix.
|
|
9
|
+
|
|
10
|
+
**Sub-files:**
|
|
11
|
+
- [PROFILING.md](PROFILING.md) — Profiling, benchmarking, and measurement workflows
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## N+1 Query Prevention
|
|
16
|
+
|
|
17
|
+
Always eager load associations accessed in loops:
|
|
18
|
+
|
|
19
|
+
```ruby
|
|
20
|
+
@posts = Post.includes(:author) # single association
|
|
21
|
+
@post = Post.includes(comments: :author).find(id) # nested
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Counter caches instead of `.count` in loops:
|
|
25
|
+
|
|
26
|
+
```ruby
|
|
27
|
+
# Model: belongs_to :board, counter_cache: true
|
|
28
|
+
# Migration: add_column :boards, :cards_count, :integer, default: 0, null: false
|
|
29
|
+
board.cards_count # reads column — no query
|
|
30
|
+
board.cards.count # fires COUNT — N+1 in loops
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
## Efficient Queries
|
|
36
|
+
|
|
37
|
+
```ruby
|
|
38
|
+
# pluck returns plain arrays — no AR objects allocated
|
|
39
|
+
emails = User.where(active: true).pluck(:email)
|
|
40
|
+
ids = Order.where(status: "pending").ids
|
|
41
|
+
|
|
42
|
+
# exists? → SELECT 1 LIMIT 1 (not .present?, .any?, .count > 0)
|
|
43
|
+
user.orders.where(status: "pending").exists?
|
|
44
|
+
|
|
45
|
+
# SQL aggregation — never load records to compute in Ruby
|
|
46
|
+
User.order(created_at: :desc) # NOT .all.sort_by(&:created_at).reverse
|
|
47
|
+
Order.distinct.pluck(:status) # NOT .all.map(&:status).uniq
|
|
48
|
+
Order.sum(:total_price) # NOT .all.sum(&:total_price)
|
|
49
|
+
Product.group(:category_id).count # preload all counts in one query
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
**Batch processing for large result sets:**
|
|
53
|
+
|
|
54
|
+
```ruby
|
|
55
|
+
User.find_each { |u| process(u) } # 1000 at a time
|
|
56
|
+
Order.where("created_at < ?", 1.year.ago).find_each(&:archive!)
|
|
57
|
+
Product.where(discontinued: true).find_in_batches(batch_size: 100) do |batch|
|
|
58
|
+
Index.bulk_delete(batch)
|
|
59
|
+
end
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
---
|
|
63
|
+
|
|
64
|
+
## Database Indexing
|
|
65
|
+
|
|
66
|
+
Index every FK column and every column used in `where`, `order`, `find_by`:
|
|
67
|
+
|
|
68
|
+
```ruby
|
|
69
|
+
# t.references adds index by default
|
|
70
|
+
create_table :comments do |t|
|
|
71
|
+
t.references :post, foreign_key: true
|
|
72
|
+
t.references :user, foreign_key: true
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
add_index :articles, :status
|
|
76
|
+
add_index :articles, :slug, unique: true
|
|
77
|
+
add_index :users, :email, where: "active = true", unique: true # partial
|
|
78
|
+
|
|
79
|
+
# Composite for multi-column queries
|
|
80
|
+
add_index :orders, [:user_id, :status, :created_at]
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Concurrent index creation on live tables:
|
|
84
|
+
|
|
85
|
+
```ruby
|
|
86
|
+
class AddIndexToOrdersStatus < ActiveRecord::Migration[7.1]
|
|
87
|
+
disable_ddl_transaction!
|
|
88
|
+
def change
|
|
89
|
+
add_index :orders, :status, algorithm: :concurrently
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
---
|
|
95
|
+
|
|
96
|
+
## Caching
|
|
97
|
+
|
|
98
|
+
**Production cache store — Redis, Memcached, or Solid Cache. Never `:file_store` or `:memory_store`:**
|
|
99
|
+
|
|
100
|
+
```ruby
|
|
101
|
+
# config/environments/production.rb
|
|
102
|
+
config.cache_store = :redis_cache_store, { url: ENV.fetch("REDIS_URL"), expires_in: 1.hour }
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
**Fragment caching with auto cache-busting via `cache_key_with_version`:**
|
|
106
|
+
|
|
107
|
+
```erb
|
|
108
|
+
<% cache(@project) do %>
|
|
109
|
+
<%= render "projects/detail", project: @project %>
|
|
110
|
+
<% end %>
|
|
111
|
+
|
|
112
|
+
<%# Collection multi-fetch — one Redis round-trip %>
|
|
113
|
+
<%= render partial: "products/product", collection: @products, cached: true %>
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
**Application-level caching and per-request memoization:**
|
|
117
|
+
|
|
118
|
+
```ruby
|
|
119
|
+
def stats
|
|
120
|
+
Rails.cache.fetch("dashboard/stats", expires_in: 15.minutes) do
|
|
121
|
+
{ revenue: Order.sum(:total), active: User.where("last_sign_in_at > ?", 30.days.ago).count }
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def active_subscription
|
|
126
|
+
@active_subscription ||= subscriptions.where(active: true).order(created_at: :desc).first
|
|
127
|
+
end
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
---
|
|
131
|
+
|
|
132
|
+
## Memory Management
|
|
133
|
+
|
|
134
|
+
Stream large exports — never build in memory:
|
|
135
|
+
|
|
136
|
+
```ruby
|
|
137
|
+
def export
|
|
138
|
+
headers["Content-Disposition"] = 'attachment; filename="users.csv"'
|
|
139
|
+
headers["Content-Type"] = "text/csv"
|
|
140
|
+
response.status = 200
|
|
141
|
+
self.response_body = Enumerator.new do |y|
|
|
142
|
+
y << CSV.generate_line(["Name", "Email"])
|
|
143
|
+
User.find_each { |u| y << CSV.generate_line([u.name, u.email]) }
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
Always use `deliver_later` and background jobs for slow work:
|
|
149
|
+
|
|
150
|
+
```ruby
|
|
151
|
+
OrderMailer.confirmation(@order).deliver_later # NOT deliver_now
|
|
152
|
+
GenerateInvoiceJob.perform_later(@order) # NOT inline PDF generation
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
Freeze string literals; extract regex constants out of loops:
|
|
156
|
+
|
|
157
|
+
```ruby
|
|
158
|
+
# frozen_string_literal: true
|
|
159
|
+
CAPITAL = /\A[A-Z]/
|
|
160
|
+
users.each { |u| u.name.match?(CAPITAL) }
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
---
|
|
164
|
+
|
|
165
|
+
## View & Response
|
|
166
|
+
|
|
167
|
+
```erb
|
|
168
|
+
<%# Single render call with collection caching %>
|
|
169
|
+
<%= render partial: "products/product", collection: @products, cached: true %>
|
|
170
|
+
|
|
171
|
+
<%# Lazy-load expensive sections %>
|
|
172
|
+
<%= turbo_frame_tag "stats", src: dashboard_stats_path, loading: :lazy do %>
|
|
173
|
+
<p>Loading...</p>
|
|
174
|
+
<% end %>
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
Always paginate index actions:
|
|
178
|
+
|
|
179
|
+
```ruby
|
|
180
|
+
def index
|
|
181
|
+
@orders = Order.order(created_at: :desc).page(params[:page]).per(25)
|
|
182
|
+
end
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
---
|
|
186
|
+
|
|
187
|
+
## Production Configuration
|
|
188
|
+
|
|
189
|
+
```ruby
|
|
190
|
+
# config/puma.rb
|
|
191
|
+
threads ENV.fetch("RAILS_MIN_THREADS", 5).to_i, ENV.fetch("RAILS_MAX_THREADS", 5).to_i
|
|
192
|
+
workers ENV.fetch("WEB_CONCURRENCY", 2).to_i
|
|
193
|
+
preload_app!
|
|
194
|
+
RubyVM::YJIT.enable if defined?(RubyVM::YJIT) && RubyVM::YJIT.respond_to?(:enable)
|
|
195
|
+
|
|
196
|
+
# config/environments/production.rb
|
|
197
|
+
config.cache_classes = true
|
|
198
|
+
config.eager_load = true
|
|
199
|
+
config.assets.compile = false
|
|
200
|
+
config.assets.digest = true
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
---
|
|
204
|
+
|
|
205
|
+
## Check-to-File Mapping
|
|
206
|
+
|
|
207
|
+
| Changed files | Priority checks |
|
|
208
|
+
|---|---|
|
|
209
|
+
| `app/controllers/**/*.rb` | N+1 includes, `exists?`, pagination, `deliver_later` |
|
|
210
|
+
| `app/models/**/*.rb` | `pluck` vs `map`, SQL aggregation, memoization |
|
|
211
|
+
| `app/views/**/*.erb` | Fragment caching, `render collection:`, lazy frames |
|
|
212
|
+
| `app/jobs/**/*.rb` | `find_each`, `find_in_batches`, memory |
|
|
213
|
+
| `db/migrate/**/*.rb` | FK indexes, concurrent index, composite indexes |
|
|
214
|
+
| `config/environments/production.rb` | Cache store, `eager_load`, assets |
|
|
215
|
+
| `config/puma.rb` | Threads/workers, YJIT |
|
|
216
|
+
|
|
217
|
+
See [PROFILING.md](PROFILING.md) for measurement, benchmarking, and query analysis.
|