Skip to content

Commit 932f4ea

Browse files
[2.16.0] Expand DHH Rails/Ruby style skills with 37signals patterns
Massively enhanced reference documentation for both dhh-rails-style and dhh-ruby-style skills by incorporating patterns from Marc Köhlbrugge's Unofficial 37signals Coding Style Guide. dhh-rails-style additions: - controllers.md: Authorization patterns, rate limiting, Sec-Fetch-Site CSRF - models.md: Validation philosophy, bang methods, Rails 7.1+ patterns - frontend.md: Turbo morphing, Stimulus controllers, broadcasting patterns - architecture.md: Multi-tenancy, database patterns, security, Active Storage - gems.md: Testing philosophy, expanded what-they-avoid section dhh-ruby-style additions: - Development philosophy (ship/validate/refine) - Rails 7.1+ idioms (params.expect, StringInquirer) - Extraction guidelines (rule of three) Credit: Marc Köhlbrugge's unofficial-37signals-coding-style-guide 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 5652426 commit 932f4ea

File tree

8 files changed

+1162
-13
lines changed

8 files changed

+1162
-13
lines changed

plugins/compound-engineering/.claude-plugin/plugin.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "compound-engineering",
3-
"version": "2.15.2",
3+
"version": "2.16.0",
44
"description": "AI-powered development tools. 27 agents, 19 commands, 13 skills, 2 MCP servers for code review, research, design, and workflow automation.",
55
"author": {
66
"name": "Kieran Klaassen",

plugins/compound-engineering/CHANGELOG.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,26 @@ All notable changes to the compound-engineering plugin will be documented in thi
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [2.16.0] - 2025-12-21
9+
10+
### Enhanced
11+
12+
- **`dhh-rails-style` skill** - Massively expanded reference documentation incorporating patterns from Marc Köhlbrugge's Unofficial 37signals Coding Style Guide:
13+
- **controllers.md** - Added authorization patterns, rate limiting, Sec-Fetch-Site CSRF protection, request context concerns
14+
- **models.md** - Added validation philosophy, let it crash philosophy (bang methods), default values with lambdas, Rails 7.1+ patterns (normalizes, delegated types, store accessor), concern guidelines with touch chains
15+
- **frontend.md** - Added Turbo morphing best practices, Turbo frames patterns, 6 new Stimulus controllers (auto-submit, dialog, local-time, etc.), Stimulus best practices, view helpers, caching with personalization, broadcasting patterns
16+
- **architecture.md** - Added path-based multi-tenancy, database patterns (UUIDs, state as records, hard deletes, counter caches), background job patterns (transaction safety, error handling, batch processing), email patterns, security patterns (XSS, SSRF, CSP), Active Storage patterns
17+
- **gems.md** - Added expanded what-they-avoid section (service objects, form objects, decorators, CSS preprocessors, React/Vue), testing philosophy with Minitest/fixtures patterns
18+
19+
- **`dhh-ruby-style` skill** - Expanded patterns.md with:
20+
- Development philosophy (ship/validate/refine, fix root causes, vanilla Rails first)
21+
- Rails 7.1+ idioms (params.expect, StringInquirer, positive naming conventions)
22+
- Extraction guidelines (rule of three, start in controller extract when complex)
23+
24+
### Credits
25+
26+
- Reference patterns derived from [Marc Köhlbrugge's Unofficial 37signals Coding Style Guide](https://github.com/marckohlbrugge/unofficial-37signals-coding-style-guide)
27+
828
## [2.15.2] - 2025-12-21
929

1030
### Fixed

plugins/compound-engineering/skills/dhh-rails-style/references/architecture.md

Lines changed: 321 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -19,21 +19,100 @@ Rails.application.routes.draw do
1919
end
2020
```
2121

22-
**Multi-tenancy** via URL (not subdomain):
22+
**Verb-to-noun conversion:**
23+
| Action | Resource |
24+
|--------|----------|
25+
| close a card | `card.closure` |
26+
| watch a board | `board.watching` |
27+
| mark as golden | `card.goldness` |
28+
| archive a card | `card.archival` |
29+
30+
**Shallow nesting** - avoid deep URLs:
2331
```ruby
24-
# /{account_id}/boards/...
25-
scope "/:account_id" do
26-
resources :boards
32+
resources :boards do
33+
resources :cards, shallow: true # /boards/:id/cards, but /cards/:id
2734
end
2835
```
2936

30-
Benefits:
31-
- No subdomain DNS complexity
32-
- Deep links work naturally
33-
- Middleware extracts account_id, moves to SCRIPT_NAME
34-
- `Current.account` available everywhere
37+
**Singular resources** for one-per-parent:
38+
```ruby
39+
resource :closure # not resources
40+
resource :goldness
41+
```
42+
43+
**Resolve for URL generation:**
44+
```ruby
45+
# config/routes.rb
46+
resolve("Comment") { |comment| [comment.card, anchor: dom_id(comment)] }
47+
48+
# Now url_for(@comment) works correctly
49+
```
3550
</routing>
3651

52+
<multi_tenancy>
53+
## Multi-Tenancy (Path-Based)
54+
55+
**Middleware extracts tenant** from URL prefix:
56+
57+
```ruby
58+
# lib/tenant_extractor.rb
59+
class TenantExtractor
60+
def initialize(app)
61+
@app = app
62+
end
63+
64+
def call(env)
65+
path = env["PATH_INFO"]
66+
if match = path.match(%r{^/(\d+)(/.*)?$})
67+
env["SCRIPT_NAME"] = "/#{match[1]}"
68+
env["PATH_INFO"] = match[2] || "/"
69+
end
70+
@app.call(env)
71+
end
72+
end
73+
```
74+
75+
**Cookie scoping** per tenant:
76+
```ruby
77+
# Cookies scoped to tenant path
78+
cookies.signed[:session_id] = {
79+
value: session.id,
80+
path: "/#{Current.account.id}"
81+
}
82+
```
83+
84+
**Background job context** - serialize tenant:
85+
```ruby
86+
class ApplicationJob < ActiveJob::Base
87+
around_perform do |job, block|
88+
Current.set(account: job.arguments.first.account) { block.call }
89+
end
90+
end
91+
```
92+
93+
**Recurring jobs** must iterate all tenants:
94+
```ruby
95+
class DailyDigestJob < ApplicationJob
96+
def perform
97+
Account.find_each do |account|
98+
Current.set(account: account) do
99+
send_digest_for(account)
100+
end
101+
end
102+
end
103+
end
104+
```
105+
106+
**Controller security** - always scope through tenant:
107+
```ruby
108+
# Good - scoped through user's accessible records
109+
@card = Current.user.accessible_cards.find(params[:id])
110+
111+
# Avoid - direct lookup
112+
@card = Card.find(params[:id])
113+
```
114+
</multi_tenancy>
115+
37116
<authentication>
38117
## Authentication
39118

@@ -130,8 +209,98 @@ end
130209
- No Redis required
131210
- Same transactional guarantees as your data
132211
- Simpler infrastructure
212+
213+
**Transaction safety:**
214+
```ruby
215+
# config/application.rb
216+
config.active_job.enqueue_after_transaction_commit = true
217+
```
218+
219+
**Error handling** by type:
220+
```ruby
221+
class DeliveryJob < ApplicationJob
222+
# Transient errors - retry with backoff
223+
retry_on Net::OpenTimeout, Net::ReadTimeout,
224+
Resolv::ResolvError,
225+
wait: :polynomially_longer
226+
227+
# Permanent errors - log and discard
228+
discard_on Net::SMTPSyntaxError do |job, error|
229+
Sentry.capture_exception(error, level: :info)
230+
end
231+
end
232+
```
233+
234+
**Batch processing** with continuable:
235+
```ruby
236+
class ProcessCardsJob < ApplicationJob
237+
include ActiveJob::Continuable
238+
239+
def perform
240+
Card.in_batches.each_record do |card|
241+
checkpoint! # Resume from here if interrupted
242+
process(card)
243+
end
244+
end
245+
end
246+
```
133247
</background_jobs>
134248

249+
<database_patterns>
250+
## Database Patterns
251+
252+
**UUIDs as primary keys** (time-sortable UUIDv7):
253+
```ruby
254+
# migration
255+
create_table :cards, id: :uuid do |t|
256+
t.references :board, type: :uuid, foreign_key: true
257+
end
258+
```
259+
260+
Benefits: No ID enumeration, distributed-friendly, client-side generation.
261+
262+
**State as records** (not booleans):
263+
```ruby
264+
# Instead of closed: boolean
265+
class Card::Closure < ApplicationRecord
266+
belongs_to :card
267+
belongs_to :creator, class_name: "User"
268+
end
269+
270+
# Queries become joins
271+
Card.joins(:closure) # closed
272+
Card.where.missing(:closure) # open
273+
```
274+
275+
**Hard deletes** - no soft delete:
276+
```ruby
277+
# Just destroy
278+
card.destroy!
279+
280+
# Use events for history
281+
card.record_event(:deleted, by: Current.user)
282+
```
283+
284+
Simplifies queries, uses event logs for auditing.
285+
286+
**Counter caches** for performance:
287+
```ruby
288+
class Comment < ApplicationRecord
289+
belongs_to :card, counter_cache: true
290+
end
291+
292+
# card.comments_count available without query
293+
```
294+
295+
**Account scoping** on every table:
296+
```ruby
297+
class Card < ApplicationRecord
298+
belongs_to :account
299+
default_scope { where(account: Current.account) }
300+
end
301+
```
302+
</database_patterns>
303+
135304
<current_attributes>
136305
## Current Attributes
137306

@@ -339,3 +508,146 @@ end
339508

340509
**Webhooks driven by events** - events are the canonical source.
341510
</events>
511+
512+
<email_patterns>
513+
## Email Patterns
514+
515+
**Multi-tenant URL helpers:**
516+
```ruby
517+
class ApplicationMailer < ActionMailer::Base
518+
def default_url_options
519+
options = super
520+
if Current.account
521+
options[:script_name] = "/#{Current.account.id}"
522+
end
523+
options
524+
end
525+
end
526+
```
527+
528+
**Timezone-aware delivery:**
529+
```ruby
530+
class NotificationMailer < ApplicationMailer
531+
def daily_digest(user)
532+
Time.use_zone(user.timezone) do
533+
@user = user
534+
@digest = user.digest_for_today
535+
mail(to: user.email, subject: "Daily Digest")
536+
end
537+
end
538+
end
539+
```
540+
541+
**Batch delivery:**
542+
```ruby
543+
emails = users.map { |user| NotificationMailer.digest(user) }
544+
ActiveJob.perform_all_later(emails.map(&:deliver_later))
545+
```
546+
547+
**One-click unsubscribe (RFC 8058):**
548+
```ruby
549+
class ApplicationMailer < ActionMailer::Base
550+
after_action :set_unsubscribe_headers
551+
552+
private
553+
def set_unsubscribe_headers
554+
headers["List-Unsubscribe-Post"] = "List-Unsubscribe=One-Click"
555+
headers["List-Unsubscribe"] = "<#{unsubscribe_url}>"
556+
end
557+
end
558+
```
559+
</email_patterns>
560+
561+
<security_patterns>
562+
## Security Patterns
563+
564+
**XSS prevention** - escape in helpers:
565+
```ruby
566+
def formatted_content(text)
567+
# Escape first, then mark safe
568+
simple_format(h(text)).html_safe
569+
end
570+
```
571+
572+
**SSRF protection:**
573+
```ruby
574+
# Resolve DNS once, pin the IP
575+
def fetch_safely(url)
576+
uri = URI.parse(url)
577+
ip = Resolv.getaddress(uri.host)
578+
579+
# Block private networks
580+
raise "Private IP" if private_ip?(ip)
581+
582+
# Use pinned IP for request
583+
Net::HTTP.start(uri.host, uri.port, ipaddr: ip) { |http| ... }
584+
end
585+
586+
def private_ip?(ip)
587+
ip.start_with?("127.", "10.", "192.168.") ||
588+
ip.match?(/^172\.(1[6-9]|2[0-9]|3[0-1])\./)
589+
end
590+
```
591+
592+
**Content Security Policy:**
593+
```ruby
594+
# config/initializers/content_security_policy.rb
595+
Rails.application.configure do
596+
config.content_security_policy do |policy|
597+
policy.default_src :self
598+
policy.script_src :self
599+
policy.style_src :self, :unsafe_inline
600+
policy.base_uri :none
601+
policy.form_action :self
602+
policy.frame_ancestors :self
603+
end
604+
end
605+
```
606+
607+
**ActionText sanitization:**
608+
```ruby
609+
# config/initializers/action_text.rb
610+
Rails.application.config.after_initialize do
611+
ActionText::ContentHelper.allowed_tags = %w[
612+
strong em a ul ol li p br h1 h2 h3 h4 blockquote
613+
]
614+
end
615+
```
616+
</security_patterns>
617+
618+
<active_storage>
619+
## Active Storage Patterns
620+
621+
**Variant preprocessing:**
622+
```ruby
623+
class User < ApplicationRecord
624+
has_one_attached :avatar do |attachable|
625+
attachable.variant :thumb, resize_to_limit: [100, 100], preprocessed: true
626+
attachable.variant :medium, resize_to_limit: [300, 300], preprocessed: true
627+
end
628+
end
629+
```
630+
631+
**Direct upload expiry** - extend for slow connections:
632+
```ruby
633+
# config/initializers/active_storage.rb
634+
Rails.application.config.active_storage.service_urls_expire_in = 48.hours
635+
```
636+
637+
**Avatar optimization** - redirect to blob:
638+
```ruby
639+
def show
640+
expires_in 1.year, public: true
641+
redirect_to @user.avatar.variant(:thumb).processed.url, allow_other_host: true
642+
end
643+
```
644+
645+
**Mirror service** for migrations:
646+
```yaml
647+
# config/storage.yml
648+
production:
649+
service: Mirror
650+
primary: amazon
651+
mirrors: [google]
652+
```
653+
</active_storage>

0 commit comments

Comments
 (0)