@sun-asterisk/sunlint 1.3.47 → 1.3.49
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/config/rules/rules-registry-generated.json +1717 -282
- package/core/architecture-integration.js +57 -15
- package/core/cli-action-handler.js +51 -36
- package/core/config-manager.js +6 -0
- package/core/config-merger.js +33 -0
- package/core/config-validator.js +37 -2
- package/core/file-targeting-service.js +148 -15
- package/core/init-command.js +118 -70
- package/core/output-service.js +12 -3
- package/core/project-detector.js +517 -0
- package/core/scoring-service.js +12 -6
- package/core/summary-report-service.js +9 -4
- package/core/tui-select.js +245 -0
- package/engines/arch-detect/rules/layered/l001-presentation-layer.js +7 -15
- package/engines/arch-detect/rules/layered/l002-business-layer.js +7 -15
- package/engines/arch-detect/rules/layered/l003-data-layer.js +7 -15
- package/engines/arch-detect/rules/layered/l004-model-layer.js +7 -15
- package/engines/arch-detect/rules/layered/l005-layer-separation.js +22 -2
- package/engines/arch-detect/rules/layered/l006-dependency-direction.js +8 -5
- package/engines/arch-detect/rules/modular/m005-no-deep-imports.js +67 -29
- package/engines/arch-detect/rules/presentation/pr001-view-layer.js +16 -9
- package/engines/arch-detect/rules/presentation/pr006-router-layer.js +33 -8
- package/engines/arch-detect/rules/presentation/pr007-interactor-layer.js +35 -6
- package/engines/arch-detect/rules/project-scanner/ps003-framework-detection.js +56 -10
- package/engines/impact/cli.js +54 -39
- package/engines/impact/config/default-config.js +105 -5
- package/engines/impact/core/impact-analyzer.js +12 -15
- package/engines/impact/core/utils/gitignore-parser.js +123 -0
- package/engines/impact/core/utils/method-call-graph.js +272 -87
- package/origin-rules/dart-en.md +1 -1
- package/origin-rules/go-en.md +231 -0
- package/origin-rules/php-en.md +107 -0
- package/origin-rules/python-en.md +113 -0
- package/origin-rules/ruby-en.md +607 -0
- package/package.json +1 -1
- package/scripts/copy-arch-detect.js +5 -1
- package/scripts/copy-impact-analyzer.js +5 -1
- package/scripts/generate-rules-registry.js +30 -14
- package/skill-assets/sunlint-code-quality/SKILL.md +3 -2
- package/skill-assets/sunlint-code-quality/rules/dart/C006-verb-noun-functions.md +45 -0
- package/skill-assets/sunlint-code-quality/rules/dart/C013-no-dead-code.md +53 -0
- package/skill-assets/sunlint-code-quality/rules/dart/C014-dependency-injection.md +92 -0
- package/skill-assets/sunlint-code-quality/rules/dart/C017-no-constructor-logic.md +62 -0
- package/skill-assets/sunlint-code-quality/rules/dart/C018-generic-errors.md +57 -0
- package/skill-assets/sunlint-code-quality/rules/dart/C019-error-log-level.md +50 -0
- package/skill-assets/sunlint-code-quality/rules/dart/C020-no-unused-imports.md +46 -0
- package/skill-assets/sunlint-code-quality/rules/dart/C022-no-unused-variables.md +50 -0
- package/skill-assets/sunlint-code-quality/rules/dart/C023-no-duplicate-names.md +56 -0
- package/skill-assets/sunlint-code-quality/rules/dart/C024-centralize-constants.md +75 -0
- package/skill-assets/sunlint-code-quality/rules/dart/C029-catch-log-root-cause.md +53 -0
- package/skill-assets/sunlint-code-quality/rules/dart/C030-custom-error-classes.md +86 -0
- package/skill-assets/sunlint-code-quality/rules/dart/C033-separate-data-access.md +90 -0
- package/skill-assets/sunlint-code-quality/rules/dart/C035-error-context-logging.md +62 -0
- package/skill-assets/sunlint-code-quality/rules/dart/C041-no-hardcoded-secrets.md +75 -0
- package/skill-assets/sunlint-code-quality/rules/dart/C042-boolean-naming.md +73 -0
- package/skill-assets/sunlint-code-quality/rules/dart/C052-widget-parsing.md +84 -0
- package/skill-assets/sunlint-code-quality/rules/dart/C060-superclass-logic.md +91 -0
- package/skill-assets/sunlint-code-quality/rules/dart/C067-no-hardcoded-config.md +108 -0
- package/skill-assets/sunlint-code-quality/rules/go/G001-explicit-error-handling.md +53 -0
- package/skill-assets/sunlint-code-quality/rules/go/G002-context-first-argument.md +44 -0
- package/skill-assets/sunlint-code-quality/rules/go/G003-receiver-consistency.md +38 -0
- package/skill-assets/sunlint-code-quality/rules/go/G004-avoid-panic.md +49 -0
- package/skill-assets/sunlint-code-quality/rules/go/G005-goroutine-leak-prevention.md +49 -0
- package/skill-assets/sunlint-code-quality/rules/go/G006-interface-consumer-definition.md +45 -0
- package/skill-assets/sunlint-code-quality/rules/go/GN001-gin-binding-validation.md +57 -0
- package/skill-assets/sunlint-code-quality/rules/go/GN002-gin-error-response.md +48 -0
- package/skill-assets/sunlint-code-quality/rules/go/GN003-graceful-shutdown.md +57 -0
- package/skill-assets/sunlint-code-quality/rules/go/GN004-gin-route-logical-grouping.md +54 -0
- package/skill-assets/sunlint-code-quality/rules/go-gin/AGENTS.md +149 -0
- package/skill-assets/sunlint-code-quality/rules/go-gin/GN001-abort-after-response.md +75 -0
- package/skill-assets/sunlint-code-quality/rules/go-gin/GN002-request-context.md +64 -0
- package/skill-assets/sunlint-code-quality/rules/go-gin/GN003-bind-error-handling.md +70 -0
- package/skill-assets/sunlint-code-quality/rules/go-gin/GN004-dependency-injection.md +78 -0
- package/skill-assets/sunlint-code-quality/rules/go-gin/GN005-route-groups-middleware.md +71 -0
- package/skill-assets/sunlint-code-quality/rules/go-gin/GN006-http-status-codes.md +91 -0
- package/skill-assets/sunlint-code-quality/rules/go-gin/GN007-release-mode.md +64 -0
- package/skill-assets/sunlint-code-quality/rules/go-gin/GN008-struct-validation-tags.md +90 -0
- package/skill-assets/sunlint-code-quality/rules/go-gin/GN009-recovery-middleware.md +68 -0
- package/skill-assets/sunlint-code-quality/rules/go-gin/GN010-context-scope.md +68 -0
- package/skill-assets/sunlint-code-quality/rules/go-gin/GN011-middleware-concerns.md +92 -0
- package/skill-assets/sunlint-code-quality/rules/go-gin/GN012-no-log-sensitive.md +84 -0
- package/skill-assets/sunlint-code-quality/rules/java/J001-try-with-resources.md +86 -0
- package/skill-assets/sunlint-code-quality/rules/java/J002-equals-and-hashcode.md +88 -0
- package/skill-assets/sunlint-code-quality/rules/java/J003-string-comparison.md +72 -0
- package/skill-assets/sunlint-code-quality/rules/java/J004-use-java-time.md +91 -0
- package/skill-assets/sunlint-code-quality/rules/java/J005-no-print-stack-trace.md +80 -0
- package/skill-assets/sunlint-code-quality/rules/java/J006-no-system-println.md +89 -0
- package/skill-assets/sunlint-code-quality/rules/java/J007-proper-logger.md +91 -0
- package/skill-assets/sunlint-code-quality/rules/java/J008-thread-safe-singleton.md +119 -0
- package/skill-assets/sunlint-code-quality/rules/java/J009-utility-class-constructor.md +82 -0
- package/skill-assets/sunlint-code-quality/rules/java/J010-preserve-stack-trace.md +119 -0
- package/skill-assets/sunlint-code-quality/rules/java/J011-null-safe-compare.md +88 -0
- package/skill-assets/sunlint-code-quality/rules/java/J012-use-enum-collections.md +104 -0
- package/skill-assets/sunlint-code-quality/rules/java/J013-return-empty-not-null.md +102 -0
- package/skill-assets/sunlint-code-quality/rules/java/J014-hardcoded-crypto-key.md +108 -0
- package/skill-assets/sunlint-code-quality/rules/java/J015-optional-instead-of-null.md +109 -0
- package/skill-assets/sunlint-code-quality/rules/php-laravel/AGENTS.md +124 -0
- package/skill-assets/sunlint-code-quality/rules/php-laravel/LV001-form-request-validation.md +64 -0
- package/skill-assets/sunlint-code-quality/rules/php-laravel/LV002-eager-load-no-n-plus-1.md +58 -0
- package/skill-assets/sunlint-code-quality/rules/php-laravel/LV003-config-not-env.md +54 -0
- package/skill-assets/sunlint-code-quality/rules/php-laravel/LV004-fillable-mass-assignment.md +51 -0
- package/skill-assets/sunlint-code-quality/rules/php-laravel/LV005-policies-gates-authorization.md +71 -0
- package/skill-assets/sunlint-code-quality/rules/php-laravel/LV006-queue-heavy-tasks.md +68 -0
- package/skill-assets/sunlint-code-quality/rules/php-laravel/LV007-hash-passwords.md +51 -0
- package/skill-assets/sunlint-code-quality/rules/php-laravel/LV008-route-model-binding.md +67 -0
- package/skill-assets/sunlint-code-quality/rules/php-laravel/LV009-api-resources.md +72 -0
- package/skill-assets/sunlint-code-quality/rules/php-laravel/LV010-chunk-large-datasets.md +58 -0
- package/skill-assets/sunlint-code-quality/rules/php-laravel/LV011-db-transactions.md +73 -0
- package/skill-assets/sunlint-code-quality/rules/php-laravel/LV012-service-layer.md +78 -0
- package/skill-assets/sunlint-code-quality/rules/php-laravel/LV013-testing-factories.md +75 -0
- package/skill-assets/sunlint-code-quality/rules/php-laravel/LV014-service-container.md +61 -0
- package/skill-assets/sunlint-code-quality/rules/python/P001-mutable-default-argument.md +55 -0
- package/skill-assets/sunlint-code-quality/rules/python/P002-specify-file-encoding.md +45 -0
- package/skill-assets/sunlint-code-quality/rules/python/P003-context-manager-for-resources.md +54 -0
- package/skill-assets/sunlint-code-quality/rules/python/P004-no-bare-except.md +65 -0
- package/skill-assets/sunlint-code-quality/rules/python/P005-use-isinstance.md +60 -0
- package/skill-assets/sunlint-code-quality/rules/python/P006-timezone-aware-datetime.md +58 -0
- package/skill-assets/sunlint-code-quality/rules/python/P007-use-pathlib.md +62 -0
- package/skill-assets/sunlint-code-quality/rules/python/P008-no-wildcard-import.md +52 -0
- package/skill-assets/sunlint-code-quality/rules/python/P009-logging-lazy-format.md +50 -0
- package/skill-assets/sunlint-code-quality/rules/python/P010-exception-chaining.md +57 -0
- package/skill-assets/sunlint-code-quality/rules/python/P011-subprocess-check.md +59 -0
- package/skill-assets/sunlint-code-quality/rules/python/P012-requests-timeout.md +70 -0
- package/skill-assets/sunlint-code-quality/rules/python/P013-no-global-statement.md +73 -0
- package/skill-assets/sunlint-code-quality/rules/python/P014-no-modify-collection-while-iterating.md +66 -0
- package/skill-assets/sunlint-code-quality/rules/python/P015-prefer-fstrings.md +61 -0
- package/skill-assets/sunlint-code-quality/rules/ruby-rails/AGENTS.md +121 -0
- package/skill-assets/sunlint-code-quality/rules/ruby-rails/RR001-strong-parameters.md +55 -0
- package/skill-assets/sunlint-code-quality/rules/ruby-rails/RR002-eager-load-includes.md +51 -0
- package/skill-assets/sunlint-code-quality/rules/ruby-rails/RR003-service-objects.md +99 -0
- package/skill-assets/sunlint-code-quality/rules/ruby-rails/RR004-active-job-background.md +67 -0
- package/skill-assets/sunlint-code-quality/rules/ruby-rails/RR005-pagination.md +53 -0
- package/skill-assets/sunlint-code-quality/rules/ruby-rails/RR006-find-each-batches.md +53 -0
- package/skill-assets/sunlint-code-quality/rules/ruby-rails/RR007-http-status-codes.md +76 -0
- package/skill-assets/sunlint-code-quality/rules/ruby-rails/RR008-before-action-auth.md +77 -0
- package/skill-assets/sunlint-code-quality/rules/ruby-rails/RR009-rails-credentials.md +61 -0
- package/skill-assets/sunlint-code-quality/rules/ruby-rails/RR010-scopes.md +57 -0
- package/skill-assets/sunlint-code-quality/rules/ruby-rails/RR011-counter-cache.md +59 -0
- package/skill-assets/sunlint-code-quality/rules/ruby-rails/RR012-render-json-status.md +42 -0
- package/skill-assets/sunlint-code-quality/rules/swift/C006-verb-noun-functions.md +37 -0
- package/skill-assets/sunlint-code-quality/rules/swift/C013-no-dead-code.md +55 -0
- package/skill-assets/sunlint-code-quality/rules/swift/C014-dependency-injection.md +69 -0
- package/skill-assets/sunlint-code-quality/rules/swift/C017-no-constructor-logic.md +66 -0
- package/skill-assets/sunlint-code-quality/rules/swift/C018-generic-errors.md +64 -0
- package/skill-assets/sunlint-code-quality/rules/swift/C019-error-log-level.md +64 -0
- package/skill-assets/sunlint-code-quality/rules/swift/C020-no-unused-imports.md +47 -0
- package/skill-assets/sunlint-code-quality/rules/swift/C022-no-unused-variables.md +46 -0
- package/skill-assets/sunlint-code-quality/rules/swift/C023-no-duplicate-names.md +55 -0
- package/skill-assets/sunlint-code-quality/rules/swift/C024-centralize-constants.md +68 -0
- package/skill-assets/sunlint-code-quality/rules/swift/C029-catch-log-root-cause.md +69 -0
- package/skill-assets/sunlint-code-quality/rules/swift/C030-custom-error-classes.md +77 -0
- package/skill-assets/sunlint-code-quality/rules/swift/C033-separate-data-access.md +89 -0
- package/skill-assets/sunlint-code-quality/rules/swift/C035-error-context-logging.md +66 -0
- package/skill-assets/sunlint-code-quality/rules/swift/C041-no-hardcoded-secrets.md +65 -0
- package/skill-assets/sunlint-code-quality/rules/swift/C042-boolean-naming.md +60 -0
- package/skill-assets/sunlint-code-quality/rules/swift/C052-controller-parsing.md +67 -0
- package/skill-assets/sunlint-code-quality/rules/swift/C060-superclass-logic.md +95 -0
- package/skill-assets/sunlint-code-quality/rules/swift/C067-no-hardcoded-config.md +80 -0
- package/skill-assets/sunlint-code-quality/rules/swift/S003-sql-injection.md +65 -0
- package/skill-assets/sunlint-code-quality/rules/swift/S004-no-log-credentials.md +67 -0
- package/skill-assets/sunlint-code-quality/rules/swift/S005-server-authorization.md +73 -0
- package/skill-assets/sunlint-code-quality/rules/swift/S006-default-credentials.md +76 -0
- package/skill-assets/sunlint-code-quality/rules/swift/S007-output-encoding.md +96 -0
- package/skill-assets/sunlint-code-quality/rules/swift/S009-approved-crypto.md +86 -0
- package/skill-assets/sunlint-code-quality/rules/swift/S010-csprng.md +71 -0
- package/skill-assets/sunlint-code-quality/rules/swift/S011-insecure-deserialization.md +74 -0
- package/skill-assets/sunlint-code-quality/rules/swift/S012-secrets-management.md +81 -0
- package/skill-assets/sunlint-code-quality/rules/swift/S013-tls-connections.md +67 -0
- package/skill-assets/sunlint-code-quality/rules/swift/S017-parameterized-queries.md +86 -0
- package/skill-assets/sunlint-code-quality/rules/swift/S019-session-management.md +131 -0
- package/skill-assets/sunlint-code-quality/rules/swift/S020-kvc-injection.md +91 -0
- package/skill-assets/sunlint-code-quality/rules/swift/S025-input-validation.md +125 -0
- package/skill-assets/sunlint-code-quality/rules/swift/S029-brute-force-protection.md +120 -0
- package/skill-assets/sunlint-code-quality/rules/swift/S036-path-traversal.md +102 -0
- package/skill-assets/sunlint-code-quality/rules/swift/S039-tls-certificate-validation.md +109 -0
- package/skill-assets/sunlint-code-quality/rules/swift/S041-logout-invalidation.md +103 -0
- package/skill-assets/sunlint-code-quality/rules/swift/S043-password-hashing.md +116 -0
- package/skill-assets/sunlint-code-quality/rules/swift/S044-critical-changes-reauth.md +145 -0
- package/skill-assets/sunlint-code-quality/rules/swift/S045-debug-info-exposure.md +116 -0
- package/skill-assets/sunlint-code-quality/rules/swift/S046-unvalidated-redirect.md +140 -0
- package/skill-assets/sunlint-code-quality/rules/swift/S051-token-expiry.md +134 -0
- package/skill-assets/sunlint-code-quality/rules/swift/S053-jwt-validation.md +139 -0
- package/skill-assets/sunlint-code-quality/rules/swift/S059-background-snapshot-protection.md +113 -0
- package/skill-assets/sunlint-code-quality/rules/swift/S060-data-protection-api.md +106 -0
- package/skill-assets/sunlint-code-quality/rules/swift/S061-jailbreak-detection.md +132 -0
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "RR003 – Extract Business Logic into Service Objects"
|
|
3
|
+
impact: high
|
|
4
|
+
impactDescription: "Fat models and fat controllers are untestable, hard to reuse, and prone to circular dependencies. Service objects isolate business logic into a single callable unit."
|
|
5
|
+
tags: [ruby, rails, architecture, service-layer]
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# RR003 – Extract Business Logic into Service Objects
|
|
9
|
+
|
|
10
|
+
## Rule
|
|
11
|
+
|
|
12
|
+
Business logic that involves more than a single model update, an external API call, or conditional branching belongs in a dedicated service object under `app/services/`, not in models or controllers.
|
|
13
|
+
|
|
14
|
+
## Why
|
|
15
|
+
|
|
16
|
+
- Fat models accumulate unrelated behaviour, making them hard to test in isolation.
|
|
17
|
+
- Fat controllers mix HTTP concerns (params, redirects) with domain rules.
|
|
18
|
+
- Service objects are plain Ruby objects (POROs) — no Rails magic needed to test them.
|
|
19
|
+
|
|
20
|
+
## Wrong
|
|
21
|
+
|
|
22
|
+
```ruby
|
|
23
|
+
# Fat controller: business logic mixed with HTTP handling
|
|
24
|
+
class OrdersController < ApplicationController
|
|
25
|
+
def create
|
|
26
|
+
@order = Order.new(order_params)
|
|
27
|
+
@order.total = @order.items.sum(&:price) * (1 - current_user.discount)
|
|
28
|
+
if @order.save
|
|
29
|
+
Stripe::Charge.create(amount: @order.total_cents, ...)
|
|
30
|
+
OrderMailer.confirmation(@order).deliver_later
|
|
31
|
+
redirect_to @order
|
|
32
|
+
else
|
|
33
|
+
render :new
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Fat model: calling external APIs from ActiveRecord callbacks
|
|
39
|
+
class Order < ApplicationRecord
|
|
40
|
+
after_create :charge_card
|
|
41
|
+
def charge_card
|
|
42
|
+
Stripe::Charge.create(...)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Correct
|
|
48
|
+
|
|
49
|
+
```ruby
|
|
50
|
+
# app/services/place_order_service.rb
|
|
51
|
+
class PlaceOrderService
|
|
52
|
+
Result = Struct.new(:order, :success?, :error, keyword_init: true)
|
|
53
|
+
|
|
54
|
+
def initialize(user:, params:, payment_gateway: Stripe)
|
|
55
|
+
@user = user
|
|
56
|
+
@params = params
|
|
57
|
+
@payment_gateway = payment_gateway
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def call
|
|
61
|
+
order = build_order
|
|
62
|
+
return Result.new(order: order, success?: false, error: order.errors) unless order.save
|
|
63
|
+
|
|
64
|
+
charge_payment(order)
|
|
65
|
+
OrderMailer.confirmation(order).deliver_later
|
|
66
|
+
Result.new(order: order, success?: true)
|
|
67
|
+
rescue PaymentError => e
|
|
68
|
+
order.destroy
|
|
69
|
+
Result.new(success?: false, error: e.message)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
def build_order
|
|
75
|
+
Order.new(**@params, total: calculate_total, user: @user)
|
|
76
|
+
end
|
|
77
|
+
# ...
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Controller stays thin
|
|
81
|
+
class OrdersController < ApplicationController
|
|
82
|
+
def create
|
|
83
|
+
result = PlaceOrderService.new(user: current_user, params: order_params).call
|
|
84
|
+
if result.success?
|
|
85
|
+
redirect_to result.order
|
|
86
|
+
else
|
|
87
|
+
@order = result.order
|
|
88
|
+
render :new
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## Notes
|
|
95
|
+
|
|
96
|
+
- Service objects should be callable via `#call` (standard Ruby convention, works with `Proc#call` duck-typing).
|
|
97
|
+
- Return a `Result` struct or use `dry-monads` for explicit success/failure signalling.
|
|
98
|
+
- Place in `app/services/` — Rails autoloads from this directory by default.
|
|
99
|
+
- Keep one public method (`call`) — additional extraction goes into private methods.
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "RR004 – Use ActiveJob for Background Work"
|
|
3
|
+
impact: high
|
|
4
|
+
impactDescription: "Spawning raw threads or doing heavy work inline blocks the Puma thread, causing request timeouts and memory leaks."
|
|
5
|
+
tags: [ruby, rails, performance, background-jobs]
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# RR004 – Use ActiveJob for Background Work
|
|
9
|
+
|
|
10
|
+
## Rule
|
|
11
|
+
|
|
12
|
+
Any operation that is slow (email, external API, report generation, file processing) or must retry on failure must be enqueued via `ActiveJob`, not executed inline in the request or in a raw `Thread.new`.
|
|
13
|
+
|
|
14
|
+
## Why
|
|
15
|
+
|
|
16
|
+
- `Thread.new` in a Rails controller shares the request's database connection until it's reclaimed, causing connection pool exhaustion.
|
|
17
|
+
- Inline work blocks the Puma thread until completion — the user waits.
|
|
18
|
+
- ActiveJob provides retry semantics, dead-letter queues, and observable failure via your queue backend (Sidekiq, GoodJob, Solid Queue).
|
|
19
|
+
|
|
20
|
+
## Wrong
|
|
21
|
+
|
|
22
|
+
```ruby
|
|
23
|
+
# Inline — user waits for CSV export to complete
|
|
24
|
+
def export
|
|
25
|
+
@orders = Order.all
|
|
26
|
+
csv = generate_csv(@orders) # could take 10+ seconds
|
|
27
|
+
send_data csv, filename: 'orders.csv'
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Raw thread — races on connection pool, no retry, no observability
|
|
31
|
+
def send_welcome_email
|
|
32
|
+
Thread.new { UserMailer.welcome(@user).deliver_now }
|
|
33
|
+
redirect_to root_path
|
|
34
|
+
end
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Correct
|
|
38
|
+
|
|
39
|
+
```ruby
|
|
40
|
+
# app/jobs/generate_order_export_job.rb
|
|
41
|
+
class GenerateOrderExportJob < ApplicationJob
|
|
42
|
+
queue_as :exports
|
|
43
|
+
retry_on Net::TimeoutError, wait: 5.seconds, attempts: 3
|
|
44
|
+
discard_on ActiveRecord::RecordNotFound
|
|
45
|
+
|
|
46
|
+
def perform(user_id, filters)
|
|
47
|
+
user = User.find(user_id)
|
|
48
|
+
csv = OrderExportService.new(filters).generate
|
|
49
|
+
UserMailer.export_ready(user, csv).deliver_now
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Controller
|
|
54
|
+
def export
|
|
55
|
+
GenerateOrderExportJob.perform_later(current_user.id, export_params.to_h)
|
|
56
|
+
redirect_to orders_path, notice: 'Export will be emailed to you.'
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Mailer — always defer with deliver_later
|
|
60
|
+
UserMailer.welcome(@user).deliver_later
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Notes
|
|
64
|
+
|
|
65
|
+
- Use `perform_later` (async) in production; `perform_now` only in tests / sync scenarios.
|
|
66
|
+
- Configure the queue backend in `config/application.rb`: `config.active_job.queue_adapter = :sidekiq`.
|
|
67
|
+
- Serialize only primitive-safe arguments (IDs, not AR objects) — AR objects go stale between enqueue and perform.
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "RR005 – Paginate All Collection Endpoints"
|
|
3
|
+
impact: medium
|
|
4
|
+
impactDescription: "Loading unbounded collections into memory causes OOM crashes and unacceptably slow responses at scale."
|
|
5
|
+
tags: [ruby, rails, performance, pagination]
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# RR005 – Paginate All Collection Endpoints
|
|
9
|
+
|
|
10
|
+
## Rule
|
|
11
|
+
|
|
12
|
+
Every controller action that returns a list must apply pagination. Never return an unbounded collection with `Model.all` or `.where(...)` without a `.page` or `.limit` call.
|
|
13
|
+
|
|
14
|
+
## Why
|
|
15
|
+
|
|
16
|
+
A table that currently has 1,000 rows will have millions. An unguarded `Order.all` will load all records into memory on each request. Pagination (Kaminari or Pagy) is the standard Rails solution.
|
|
17
|
+
|
|
18
|
+
## Wrong
|
|
19
|
+
|
|
20
|
+
```ruby
|
|
21
|
+
def index
|
|
22
|
+
@orders = Order.all # ❌ unbounded
|
|
23
|
+
@users = User.where(active: true) # ❌ unbounded
|
|
24
|
+
render json: @orders
|
|
25
|
+
end
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Correct
|
|
29
|
+
|
|
30
|
+
```ruby
|
|
31
|
+
# Gemfile: gem 'pagy' —OR— gem 'kaminari'
|
|
32
|
+
|
|
33
|
+
# With Pagy (preferred — fastest)
|
|
34
|
+
include Pagy::Backend
|
|
35
|
+
|
|
36
|
+
def index
|
|
37
|
+
@pagy, @orders = pagy(Order.order(created_at: :desc), items: 25)
|
|
38
|
+
render json: { orders: @orders, meta: pagy_metadata(@pagy) }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# With Kaminari
|
|
42
|
+
def index
|
|
43
|
+
@orders = Order.order(created_at: :desc).page(params[:page]).per(25)
|
|
44
|
+
render json: @orders
|
|
45
|
+
end
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Notes
|
|
49
|
+
|
|
50
|
+
- Default page size should be configurable but capped (e.g., max 100 per page).
|
|
51
|
+
- Pass `params[:per_page]` through an explicit allowlist — never directly into `.per()`.
|
|
52
|
+
- Pagy is significantly faster than Kaminari for large tables; prefer it in new projects.
|
|
53
|
+
- For cursor-based APIs (infinite scroll / mobile), use `pagy_cursor` or write manual `WHERE id > last_id LIMIT n` queries.
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "RR006 – Use find_each / in_batches for Bulk Iteration"
|
|
3
|
+
impact: high
|
|
4
|
+
impactDescription: "find_each limits memory to batch size (1000 rows) instead of loading all records at once, preventing OOM in background jobs and rake tasks."
|
|
5
|
+
tags: [ruby, rails, performance, database]
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# RR006 – Use find_each / in_batches for Bulk Iteration
|
|
9
|
+
|
|
10
|
+
## Rule
|
|
11
|
+
|
|
12
|
+
When iterating over all records (in jobs, scripts, migrations, Rake tasks), always use `find_each` or `in_batches` instead of `all.each` or `where(...).each`.
|
|
13
|
+
|
|
14
|
+
## Why
|
|
15
|
+
|
|
16
|
+
`Model.all.each` fetches all matching rows in one SQL query and holds them all in memory. For large tables this causes OOM. `find_each` internally runs batched queries (`LIMIT 1000 ORDER BY id`) and yields one record at a time.
|
|
17
|
+
|
|
18
|
+
## Wrong
|
|
19
|
+
|
|
20
|
+
```ruby
|
|
21
|
+
# Loads every user into memory at once
|
|
22
|
+
User.all.each do |user|
|
|
23
|
+
UserMailer.renewal(user).deliver_later
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Same problem with a where scope
|
|
27
|
+
Order.where(status: 'pending').each { |o| processOrder(o) }
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Correct
|
|
31
|
+
|
|
32
|
+
```ruby
|
|
33
|
+
# find_each: yields one record at a time, batched under the hood
|
|
34
|
+
User.find_each(batch_size: 500) do |user|
|
|
35
|
+
UserMailer.renewal(user).deliver_later
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# in_batches: yields an ActiveRecord::Relation per batch (useful for bulk updates)
|
|
39
|
+
Order.where(status: 'pending').in_batches(of: 200) do |batch|
|
|
40
|
+
batch.update_all(processed: true)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# start: / finish: let you resume from a checkpoint
|
|
44
|
+
User.find_each(start: last_processed_id) do |user|
|
|
45
|
+
# ...
|
|
46
|
+
end
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Notes
|
|
50
|
+
|
|
51
|
+
- `find_each` requires a primary key — does not work with `select` that omits the PK.
|
|
52
|
+
- Avoid adding an `ORDER BY` other than PK with `find_each`; use `in_batches` if you need custom ordering.
|
|
53
|
+
- For read-only iteration on PostgreSQL, consider `cursor()` from the `activerecord-cursor-pagination` gem for server-side cursors.
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "RR007 – Return Correct HTTP Status Codes"
|
|
3
|
+
impact: medium
|
|
4
|
+
impactDescription: "Returning 200 OK for errors silently breaks API clients, prevents correct retry logic, and hides failures in monitoring."
|
|
5
|
+
tags: [ruby, rails, api, http]
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# RR007 – Return Correct HTTP Status Codes
|
|
9
|
+
|
|
10
|
+
## Rule
|
|
11
|
+
|
|
12
|
+
Always pass an explicit `status:` argument to `render json:`. Never return HTTP 200 for validation errors, authorization failures, or not-found resources.
|
|
13
|
+
|
|
14
|
+
## Wrong
|
|
15
|
+
|
|
16
|
+
```ruby
|
|
17
|
+
def create
|
|
18
|
+
@user = User.new(user_params)
|
|
19
|
+
if @user.save
|
|
20
|
+
render json: @user # ❌ no status — defaults to 200
|
|
21
|
+
else
|
|
22
|
+
render json: { errors: @user.errors } # ❌ 200 on failure
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def show
|
|
27
|
+
@user = User.find_by(id: params[:id])
|
|
28
|
+
render json: @user # ❌ returns 200 with null body if not found
|
|
29
|
+
end
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Correct
|
|
33
|
+
|
|
34
|
+
```ruby
|
|
35
|
+
def create
|
|
36
|
+
@user = User.new(user_params)
|
|
37
|
+
if @user.save
|
|
38
|
+
render json: UserResource.new(@user), status: :created # 201
|
|
39
|
+
else
|
|
40
|
+
render json: { errors: @user.errors.full_messages }, status: :unprocessable_entity # 422
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def show
|
|
45
|
+
@user = User.find(params[:id]) # raises ActiveRecord::RecordNotFound → rescued to 404
|
|
46
|
+
render json: UserResource.new(@user), status: :ok
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# In ApplicationController
|
|
50
|
+
rescue_from ActiveRecord::RecordNotFound, with: :not_found
|
|
51
|
+
rescue_from Pundit::NotAuthorizedError, with: :forbidden
|
|
52
|
+
|
|
53
|
+
def not_found
|
|
54
|
+
render json: { error: 'Not found' }, status: :not_found # 404
|
|
55
|
+
end
|
|
56
|
+
def forbidden
|
|
57
|
+
render json: { error: 'Forbidden' }, status: :forbidden # 403
|
|
58
|
+
end
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Status code reference
|
|
62
|
+
|
|
63
|
+
| Scenario | Status |
|
|
64
|
+
|---|---|
|
|
65
|
+
| Create success | `201 :created` |
|
|
66
|
+
| Update/delete success | `200 :ok` |
|
|
67
|
+
| No content | `204 :no_content` |
|
|
68
|
+
| Validation failed | `422 :unprocessable_entity` |
|
|
69
|
+
| Not found | `404 :not_found` |
|
|
70
|
+
| Unauthorized (not logged in) | `401 :unauthorized` |
|
|
71
|
+
| Forbidden (not allowed) | `403 :forbidden` |
|
|
72
|
+
|
|
73
|
+
## Notes
|
|
74
|
+
|
|
75
|
+
- Use symbol form (`:created`) — it's self-documenting and immune to numeric typos.
|
|
76
|
+
- Centralize rescue_from in `ApplicationController` or a concern rather than per-action `rescue`.
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "RR008 – Enforce Authentication with before_action"
|
|
3
|
+
impact: high
|
|
4
|
+
impactDescription: "Missing before_action :authenticate_user! leaves actions publicly accessible by default — opt-out is safer than opt-in."
|
|
5
|
+
tags: [ruby, rails, security, authentication]
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# RR008 – Enforce Authentication with before_action
|
|
9
|
+
|
|
10
|
+
## Rule
|
|
11
|
+
|
|
12
|
+
Place `before_action :authenticate_user!` in `ApplicationController`. Use `skip_before_action` only for explicitly public endpoints. Never rely on per-action checks or assume private routes are protected.
|
|
13
|
+
|
|
14
|
+
## Why
|
|
15
|
+
|
|
16
|
+
If authentication is applied per-action (opt-in), a new action will be public by default — a common source of auth bypass vulnerabilities. Opt-out (skip for public endpoints) is the safe default.
|
|
17
|
+
|
|
18
|
+
## Wrong
|
|
19
|
+
|
|
20
|
+
```ruby
|
|
21
|
+
# Opt-in per controller — new actions are public by default
|
|
22
|
+
class PostsController < ApplicationController
|
|
23
|
+
def index
|
|
24
|
+
authenticate_user! # ❌ developer must remember to call this
|
|
25
|
+
@posts = Post.all
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def create
|
|
29
|
+
# ❌ forgot authenticate_user! — publicly writable
|
|
30
|
+
Post.create!(post_params)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# ApplicationController with no global hook
|
|
35
|
+
class ApplicationController < ActionController::API
|
|
36
|
+
# nothing — every action is public until protected
|
|
37
|
+
end
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Correct
|
|
41
|
+
|
|
42
|
+
```ruby
|
|
43
|
+
# ApplicationController — everything requires auth
|
|
44
|
+
class ApplicationController < ActionController::API
|
|
45
|
+
include Devise::Controllers::Helpers # or your auth layer
|
|
46
|
+
before_action :authenticate_user!
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def current_user_id
|
|
51
|
+
current_user.id
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Public endpoints explicitly opt out
|
|
56
|
+
class SessionsController < ApplicationController
|
|
57
|
+
skip_before_action :authenticate_user!, only: [:create, :destroy]
|
|
58
|
+
|
|
59
|
+
def create
|
|
60
|
+
# login — intentionally public
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
class HealthController < ApplicationController
|
|
65
|
+
skip_before_action :authenticate_user!
|
|
66
|
+
|
|
67
|
+
def show
|
|
68
|
+
render json: { status: 'ok' }
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Notes
|
|
74
|
+
|
|
75
|
+
- For Devise APIs, use `authenticate_user!` from `Devise::Controllers::Helpers` or the Devise-JWT extension.
|
|
76
|
+
- Documenting `skip_before_action` with a comment explaining WHY the action is public improves auditability.
|
|
77
|
+
- Add an integration test that requests every route without auth and asserts `status: 401` for protected ones.
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "RR009 – Store Secrets in Rails Credentials, Not in Code"
|
|
3
|
+
impact: high
|
|
4
|
+
impactDescription: "Hardcoded secrets in source code are committed to Git history and exposed permanently even after deletion."
|
|
5
|
+
tags: [ruby, rails, security, secrets]
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# RR009 – Store Secrets in Rails Credentials
|
|
9
|
+
|
|
10
|
+
## Rule
|
|
11
|
+
|
|
12
|
+
All secret values (API keys, tokens, signing secrets, passwords) must live in `config/credentials.yml.enc` or environment variables. Never hardcode them in source files, `config/initializers/`, or `database.yml`.
|
|
13
|
+
|
|
14
|
+
## Why
|
|
15
|
+
|
|
16
|
+
Once a secret is committed, it lives in Git history forever. Rotating it requires a force-push and re-access revocation. Rails credentials are encrypted at rest; the master key (`config/master.key` or `RAILS_MASTER_KEY`) is the only secret that must stay outside source control.
|
|
17
|
+
|
|
18
|
+
## Wrong
|
|
19
|
+
|
|
20
|
+
```ruby
|
|
21
|
+
# config/initializers/stripe.rb
|
|
22
|
+
Stripe.api_key = 'sk_live_XXXXXXXXXXXXX' # ❌ hardcoded
|
|
23
|
+
|
|
24
|
+
# config/database.yml
|
|
25
|
+
production:
|
|
26
|
+
password: 'my_db_password_123' # ❌ hardcoded
|
|
27
|
+
|
|
28
|
+
# app/services/sendgrid_service.rb
|
|
29
|
+
API_KEY = 'SG.xxxxxxxxxxxx' # ❌ constant with embedded secret
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Correct
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
# Add / edit secrets (encrypts automatically)
|
|
36
|
+
bin/rails credentials:edit --environment production
|
|
37
|
+
|
|
38
|
+
# config/credentials/production.yml.enc (decrypted view)
|
|
39
|
+
stripe:
|
|
40
|
+
api_key: sk_live_XXXXXXXXXXXXX
|
|
41
|
+
sendgrid:
|
|
42
|
+
api_key: SG.xxxxxxxxxxxx
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
```ruby
|
|
46
|
+
# Access anywhere in application code
|
|
47
|
+
Stripe.api_key = Rails.application.credentials.stripe[:api_key]
|
|
48
|
+
|
|
49
|
+
# Or via environment variable (suitable for container deployments)
|
|
50
|
+
Stripe.api_key = ENV.fetch('STRIPE_API_KEY')
|
|
51
|
+
|
|
52
|
+
# config/database.yml (env var form)
|
|
53
|
+
production:
|
|
54
|
+
password: <%= ENV['DATABASE_PASSWORD'] %>
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Notes
|
|
58
|
+
|
|
59
|
+
- `config/master.key` and `config/credentials/production.key` must be in `.gitignore` and never committed.
|
|
60
|
+
- In CI/CD, inject `RAILS_MASTER_KEY` as a masked environment variable, not in cleartext config.
|
|
61
|
+
- For team environments with multiple people needing access, prefer environment variables or a vault (e.g., HashiCorp Vault, AWS Secrets Manager) over shared credentials files.
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "RR010 – Define Reusable Query Scopes in Models"
|
|
3
|
+
impact: medium
|
|
4
|
+
impactDescription: "Inline where() chains scattered across controllers make conditions hard to reuse, test, and change in one place."
|
|
5
|
+
tags: [ruby, rails, code-quality, database]
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# RR010 – Define Reusable Query Scopes in Models
|
|
9
|
+
|
|
10
|
+
## Rule
|
|
11
|
+
|
|
12
|
+
Encapsulate frequently used `where`, `order`, and `joins` conditions as named scopes on the model. Controllers call scopes by name — they do not build query logic inline.
|
|
13
|
+
|
|
14
|
+
## Why
|
|
15
|
+
|
|
16
|
+
Query conditions duplicated across controllers and services violate DRY and make maintenance hazardous. A scope is a named, composable, chainable query fragment that belongs to the model (the owner of the data).
|
|
17
|
+
|
|
18
|
+
## Wrong
|
|
19
|
+
|
|
20
|
+
```ruby
|
|
21
|
+
# Controller 1
|
|
22
|
+
@posts = Post.where(published: true).where('created_at > ?', 30.days.ago).order(created_at: :desc)
|
|
23
|
+
|
|
24
|
+
# Controller 2 (duplicate, slightly different)
|
|
25
|
+
@featured = Post.where(published: true).where(featured: true).order(created_at: :desc)
|
|
26
|
+
|
|
27
|
+
# Controller 3 (wrong date, bug introduced by copy-paste)
|
|
28
|
+
@recent = Post.where(published: true).where('created_at > ?', 7.days.ago).order(created_at: :asc)
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Correct
|
|
32
|
+
|
|
33
|
+
```ruby
|
|
34
|
+
# app/models/post.rb
|
|
35
|
+
class Post < ApplicationRecord
|
|
36
|
+
scope :published, -> { where(published: true) }
|
|
37
|
+
scope :featured, -> { where(featured: true) }
|
|
38
|
+
scope :recent, -> { where('created_at > ?', 30.days.ago) }
|
|
39
|
+
scope :by_newest, -> { order(created_at: :desc) }
|
|
40
|
+
|
|
41
|
+
# Scopes with parameters
|
|
42
|
+
scope :created_after, ->(date) { where('created_at > ?', date) }
|
|
43
|
+
scope :by_author, ->(author_id) { where(author_id: author_id) }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Controllers compose scopes — clean, readable, no SQL inline
|
|
47
|
+
@posts = Post.published.recent.by_newest
|
|
48
|
+
@featured = Post.published.featured.by_newest
|
|
49
|
+
@mine = Post.published.by_author(current_user.id).recent
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Notes
|
|
53
|
+
|
|
54
|
+
- Scopes return `ActiveRecord::Relation` — they are chainable by design.
|
|
55
|
+
- Scopes should be tested independently in model specs: `Post.published` should only return published posts.
|
|
56
|
+
- For complex multi-table logic, consider a Query Object (like a service object) that wraps the relation.
|
|
57
|
+
- Avoid scopes with side effects (no writes, callbacks, or network calls).
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "RR011 – Use counter_cache to Avoid Count N+1"
|
|
3
|
+
impact: medium
|
|
4
|
+
impactDescription: "post.comments.count in a collection loop fires one COUNT query per row; counter_cache collapses this to zero extra queries."
|
|
5
|
+
tags: [ruby, rails, performance, database]
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# RR011 – Use counter_cache to Avoid Count N+1
|
|
9
|
+
|
|
10
|
+
## Rule
|
|
11
|
+
|
|
12
|
+
When displaying association counts in collection views or API responses (comment counts, like counts, etc.), use `counter_cache: true` on the `belongs_to` side instead of computing `association.count` in-loop.
|
|
13
|
+
|
|
14
|
+
## Why
|
|
15
|
+
|
|
16
|
+
`post.comments.count` (or `.size` when collection is not loaded) executes a `COUNT(*)` SQL query. In a list of 50 posts this fires 50 extra queries. `counter_cache` maintains a denormalized `comments_count` column on the parent table updated atomically by Rails on create/destroy.
|
|
17
|
+
|
|
18
|
+
## Wrong
|
|
19
|
+
|
|
20
|
+
```ruby
|
|
21
|
+
# In API serializer or view — fires N COUNT queries
|
|
22
|
+
posts.each do |post|
|
|
23
|
+
count = post.comments.count # ❌ 1 COUNT query per post
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# JSON response that triggers N+1
|
|
27
|
+
render json: @posts.map { |p| { id: p.id, comment_count: p.comments.count } }
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Correct
|
|
31
|
+
|
|
32
|
+
```ruby
|
|
33
|
+
# Migration: add the cache column
|
|
34
|
+
add_column :posts, :comments_count, :integer, default: 0, null: false
|
|
35
|
+
# Backfill: Post.find_each { |p| Post.reset_counters(p.id, :comments) }
|
|
36
|
+
|
|
37
|
+
# Model association
|
|
38
|
+
class Comment < ApplicationRecord
|
|
39
|
+
belongs_to :post, counter_cache: true
|
|
40
|
+
# Rails now auto-increments/decrements posts.comments_count
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
class Post < ApplicationRecord
|
|
44
|
+
has_many :comments
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Usage — no extra query
|
|
48
|
+
posts.each do |post|
|
|
49
|
+
count = post.comments_count # ✅ reads the cached column, zero extra queries
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
render json: @posts.map { |p| { id: p.id, comment_count: p.comments_count } }
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Notes
|
|
56
|
+
|
|
57
|
+
- Reset cache after backfills: `Post.find_each { |p| Post.reset_counters(p.id, :comments) }`.
|
|
58
|
+
- If using soft-delete (`acts_as_paranoid`, `discard`), counter_cache won't exclude soft-deleted records — use a custom query in that case.
|
|
59
|
+
- Alternative when migration is not possible: `Post.includes(:comments)` + `post.comments.size` (`.size` uses already-loaded collection, no extra query).
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "RR012 – Always Include status: in render json:"
|
|
3
|
+
impact: medium
|
|
4
|
+
impactDescription: "Omitting status: defaults to 200, masking errors and breaking API client error-handling."
|
|
5
|
+
tags: [ruby, rails, api, http]
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# RR012 – Always Include `status:` in `render json:`
|
|
9
|
+
|
|
10
|
+
## Rule
|
|
11
|
+
|
|
12
|
+
Every `render json:` call must include an explicit `status:` keyword argument. Do not rely on the implicit `200` default.
|
|
13
|
+
|
|
14
|
+
## Why
|
|
15
|
+
|
|
16
|
+
This is a codified subset of RR007 focused on a single easy-to-miss omission. Implicit 200 is the default only because it's the most common case — it doesn't mean it's always correct. Explicit status makes intent clear in code review and prevents errors from silently appearing as successes to API clients.
|
|
17
|
+
|
|
18
|
+
## Wrong
|
|
19
|
+
|
|
20
|
+
```ruby
|
|
21
|
+
render json: @user # ❌ implicit 200
|
|
22
|
+
render json: { errors: @user.errors.full_messages } # ❌ looks like success to client
|
|
23
|
+
render json: { message: 'Deleted' } # ❌ should be 200 or 204 — unclear
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Correct
|
|
27
|
+
|
|
28
|
+
```ruby
|
|
29
|
+
render json: UserResource.new(@user), status: :ok # 200
|
|
30
|
+
render json: UserResource.new(@user), status: :created # 201 for POST
|
|
31
|
+
render json: { errors: @user.errors.full_messages },
|
|
32
|
+
status: :unprocessable_entity # 422
|
|
33
|
+
render json: { message: 'Deleted' }, status: :ok # 200
|
|
34
|
+
# OR for no-body deletes:
|
|
35
|
+
head :no_content # 204, no body
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Notes
|
|
39
|
+
|
|
40
|
+
- Use Rails symbol names (`:ok`, `:created`, `:no_content`) — not integers — for readability.
|
|
41
|
+
- `head :no_content` is the cleanest response for DELETE with no return body.
|
|
42
|
+
- Pair this rule with consistent `rescue_from` in ApplicationController so all error paths also have correct status codes (see RR007).
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Tên hàm phải là động từ hoặc động từ + danh từ
|
|
3
|
+
impact: MEDIUM
|
|
4
|
+
impactDescription: Tên hàm mô tả đúng hành động giúp code dễ đọc và giảm cognitive load khi review PR.
|
|
5
|
+
tags: swift, ios, naming, readability, code-quality
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## Tên hàm phải là động từ hoặc động từ + danh từ
|
|
9
|
+
|
|
10
|
+
Tên hàm phải bắt đầu bằng động từ hoặc cụm động từ + danh từ, mô tả rõ ràng hành động mà hàm thực hiện. Tránh đặt tên mơ hồ như `data()`, `handle()`, `process()`.
|
|
11
|
+
|
|
12
|
+
**Incorrect (tên không rõ hành động):**
|
|
13
|
+
|
|
14
|
+
```swift
|
|
15
|
+
class UserManager {
|
|
16
|
+
func data() -> User? { ... } // Không rõ là lấy hay tạo
|
|
17
|
+
func handle(_ error: Error) { ... } // handle là gì?
|
|
18
|
+
func process(_ payment: Payment) { ... } // process không nói lên kết quả
|
|
19
|
+
func userInfo(for id: String) { ... } // thiếu động từ
|
|
20
|
+
}
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
**Correct (tên thể hiện hành động rõ ràng):**
|
|
24
|
+
|
|
25
|
+
```swift
|
|
26
|
+
class UserManager {
|
|
27
|
+
func fetchUser(by id: String) -> User? { ... }
|
|
28
|
+
func logError(_ error: Error) { ... }
|
|
29
|
+
func submitPayment(_ payment: Payment) throws { ... }
|
|
30
|
+
func loadUserProfile(userId: String) async throws -> UserProfile { ... }
|
|
31
|
+
func validateEmail(_ email: String) -> Bool { ... }
|
|
32
|
+
func clearCache() { ... }
|
|
33
|
+
}
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
**Tools:** SwiftLint custom rule, Code Review
|
|
37
|
+
|