@sun-asterisk/sunlint 1.3.48 → 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/core/file-targeting-service.js +148 -15
- package/core/init-command.js +118 -70
- package/core/project-detector.js +517 -0
- 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/package.json +1 -1
- 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-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,73 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Avoid global Statement in Functions
|
|
3
|
+
impact: MEDIUM
|
|
4
|
+
impactDescription: The global statement creates hidden dependencies between functions and module-level state, making code order-dependent, untestable, and unsafe for concurrent use.
|
|
5
|
+
tags: python, global, state, quality, concurrency
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## Avoid global Statement in Functions
|
|
9
|
+
|
|
10
|
+
Using `global var` inside a function creates an implicit dependency on module-level mutable state. The function's behavior changes depending on which other functions have already run, making it nearly impossible to test in isolation. In multi-threaded or async contexts, it introduces race conditions.
|
|
11
|
+
|
|
12
|
+
Pass state explicitly through parameters and return values, or encapsulate it in a class.
|
|
13
|
+
|
|
14
|
+
**Incorrect:**
|
|
15
|
+
```python
|
|
16
|
+
counter = 0
|
|
17
|
+
last_user = None
|
|
18
|
+
|
|
19
|
+
def process_request(user_id: int) -> None:
|
|
20
|
+
global counter, last_user # hidden mutation of module state
|
|
21
|
+
counter += 1
|
|
22
|
+
last_user = user_id
|
|
23
|
+
do_work(user_id)
|
|
24
|
+
|
|
25
|
+
def get_stats() -> dict:
|
|
26
|
+
return {"total": counter, "last": last_user}
|
|
27
|
+
|
|
28
|
+
# Tests are order-dependent and cannot run in parallel
|
|
29
|
+
def test_process_request():
|
|
30
|
+
process_request(42)
|
|
31
|
+
assert counter == 1 # depends on no other test having run first
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
**Correct:**
|
|
35
|
+
```python
|
|
36
|
+
from dataclasses import dataclass, field
|
|
37
|
+
from threading import Lock
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class RequestTracker:
|
|
41
|
+
_count: int = 0
|
|
42
|
+
_last_user: int | None = None
|
|
43
|
+
_lock: Lock = field(default_factory=Lock, compare=False)
|
|
44
|
+
|
|
45
|
+
def record(self, user_id: int) -> None:
|
|
46
|
+
with self._lock:
|
|
47
|
+
self._count += 1
|
|
48
|
+
self._last_user = user_id
|
|
49
|
+
|
|
50
|
+
def stats(self) -> dict:
|
|
51
|
+
return {"total": self._count, "last": self._last_user}
|
|
52
|
+
|
|
53
|
+
tracker = RequestTracker()
|
|
54
|
+
|
|
55
|
+
def process_request(user_id: int, tracker: RequestTracker) -> None:
|
|
56
|
+
tracker.record(user_id)
|
|
57
|
+
do_work(user_id)
|
|
58
|
+
|
|
59
|
+
# Tests are isolated
|
|
60
|
+
def test_process_request():
|
|
61
|
+
t = RequestTracker()
|
|
62
|
+
process_request(42, t)
|
|
63
|
+
assert t.stats()["total"] == 1
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
**Acceptable module-level constants (no `global` needed):**
|
|
67
|
+
```python
|
|
68
|
+
# Constants never mutated — global statement not needed or used
|
|
69
|
+
MAX_RETRIES = 3
|
|
70
|
+
DEFAULT_TIMEOUT = 30.0
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
**Tools:** Ruff `PLW0603` (global-statement), Pylint `W0603`, SonarQube Python `S1854`, flake8
|
package/skill-assets/sunlint-code-quality/rules/python/P014-no-modify-collection-while-iterating.md
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Do Not Modify Collection While Iterating Over It
|
|
3
|
+
impact: HIGH
|
|
4
|
+
impactDescription: Adding or removing items from a list, dict, or set while iterating over it causes RuntimeError, skipped elements, or undefined behavior depending on the collection type.
|
|
5
|
+
tags: python, iteration, bugs, pitfalls, quality
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## Do Not Modify Collection While Iterating Over It
|
|
9
|
+
|
|
10
|
+
Modifying a `dict` or `set` while iterating raises `RuntimeError: dictionary changed size during iteration` / `set changed size during iteration`. Modifying a `list` during iteration does **not** raise an error but silently skips or processes elements multiple times, producing wrong results.
|
|
11
|
+
|
|
12
|
+
Always iterate over a copy, build a new collection, or collect modifications and apply them after the loop.
|
|
13
|
+
|
|
14
|
+
**Incorrect:**
|
|
15
|
+
```python
|
|
16
|
+
# dict — raises RuntimeError
|
|
17
|
+
config = {"debug": True, "deprecatedKey": "val", "timeout": 30}
|
|
18
|
+
|
|
19
|
+
for key in config:
|
|
20
|
+
if key.startswith("deprecated"):
|
|
21
|
+
del config[key] # RuntimeError: dictionary changed size during iteration
|
|
22
|
+
|
|
23
|
+
# set — raises RuntimeError
|
|
24
|
+
seen = {1, 2, 3, 4, 5}
|
|
25
|
+
|
|
26
|
+
for item in seen:
|
|
27
|
+
if item % 2 == 0:
|
|
28
|
+
seen.discard(item) # RuntimeError: set changed size during iteration
|
|
29
|
+
|
|
30
|
+
# list — silent bug: skips elements
|
|
31
|
+
items = [1, 2, 2, 3, 4]
|
|
32
|
+
|
|
33
|
+
for i, item in enumerate(items):
|
|
34
|
+
if item == 2:
|
|
35
|
+
items.remove(item) # skips the second 2 silently
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
**Correct:**
|
|
39
|
+
```python
|
|
40
|
+
# dict — iterate over a copy of keys
|
|
41
|
+
config = {"debug": True, "deprecatedKey": "val", "timeout": 30}
|
|
42
|
+
|
|
43
|
+
for key in list(config.keys()):
|
|
44
|
+
if key.startswith("deprecated"):
|
|
45
|
+
del config[key]
|
|
46
|
+
|
|
47
|
+
# Or build a new dict with dict comprehension (preferred for clarity)
|
|
48
|
+
config = {k: v for k, v in config.items() if not k.startswith("deprecated")}
|
|
49
|
+
|
|
50
|
+
# set — iterate over a copy
|
|
51
|
+
seen = {1, 2, 3, 4, 5}
|
|
52
|
+
to_remove = {item for item in seen if item % 2 == 0}
|
|
53
|
+
seen -= to_remove
|
|
54
|
+
|
|
55
|
+
# list — collect removals, apply after loop
|
|
56
|
+
items = [1, 2, 2, 3, 4]
|
|
57
|
+
items = [item for item in items if item != 2] # list comprehension
|
|
58
|
+
|
|
59
|
+
# Or for in-place modification, iterate in reverse
|
|
60
|
+
items = [1, 2, 2, 3, 4]
|
|
61
|
+
for i in range(len(items) - 1, -1, -1):
|
|
62
|
+
if items[i] == 2:
|
|
63
|
+
del items[i]
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
**Tools:** Ruff `E4702` (modified-iterating-dict), `E4703` (modified-iterating-set), `W4701` (modified-iterating-list), Pylint `modified_iteration` checker, flake8
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Prefer f-strings Over % and str.format()
|
|
3
|
+
impact: MEDIUM
|
|
4
|
+
impactDescription: f-strings introduced in Python 3.6 are more readable, faster at runtime, and catch errors at parse time rather than producing cryptic runtime errors from mismatched argument counts.
|
|
5
|
+
tags: python, fstrings, readability, performance, quality, python3
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## Prefer f-strings Over % and str.format()
|
|
9
|
+
|
|
10
|
+
Python 3.6 introduced f-strings (`f"..."`) as the modern, preferred way to embed expressions in string literals. They are:
|
|
11
|
+
- **More readable** — expression and string are co-located
|
|
12
|
+
- **Faster** — evaluated at the bytecode level without function dispatch
|
|
13
|
+
- **Safer** — syntax errors caught at parse time; no mismatched `%s` args
|
|
14
|
+
- **More powerful** — support arbitrary expressions and format specs inline
|
|
15
|
+
|
|
16
|
+
**Incorrect:**
|
|
17
|
+
```python
|
|
18
|
+
name = "Alice"
|
|
19
|
+
score = 98.567
|
|
20
|
+
items = ["apple", "banana"]
|
|
21
|
+
|
|
22
|
+
# %-formatting (Python 2 style) — argument count mismatches cause runtime TypeError
|
|
23
|
+
msg = "Hello, %s! Your score is %.2f" % (name, score)
|
|
24
|
+
debug = "Processing %d items: %s" % len(items) # runtime error: int not iterable
|
|
25
|
+
|
|
26
|
+
# str.format() — verbose and error-prone with positional indices
|
|
27
|
+
msg = "Hello, {}! Your score is {:.2f}".format(name, score)
|
|
28
|
+
debug = "User {0} has {1} points ({0} is great)".format(name, score)
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
**Correct:**
|
|
32
|
+
```python
|
|
33
|
+
name = "Alice"
|
|
34
|
+
score = 98.567
|
|
35
|
+
user_id = 1042
|
|
36
|
+
|
|
37
|
+
# f-strings — expression evaluated inline
|
|
38
|
+
msg = f"Hello, {name}! Your score is {score:.2f}"
|
|
39
|
+
debug = f"Processing {len(items)} items: {items}"
|
|
40
|
+
|
|
41
|
+
# Supports format specs
|
|
42
|
+
padded = f"{user_id:05d}" # "01042"
|
|
43
|
+
percent = f"{score / 100:.1%}" # "98.6%"
|
|
44
|
+
repr_v = f"Object: {obj!r}" # calls repr()
|
|
45
|
+
|
|
46
|
+
# Multi-line f-string
|
|
47
|
+
report = (
|
|
48
|
+
f"User: {name}\n"
|
|
49
|
+
f"Score: {score:.2f}\n"
|
|
50
|
+
f"Rank: {get_rank(score)}"
|
|
51
|
+
)
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
**Exception — logging calls should NOT use f-strings:**
|
|
55
|
+
```python
|
|
56
|
+
# In logging, keep lazy % formatting for performance (see P009)
|
|
57
|
+
logger.debug("Processing user %s", user_id) # correct
|
|
58
|
+
logger.debug(f"Processing user {user_id}") # incorrect — always evaluates
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
**Tools:** Ruff `UP031` (printf-string-formatting), `UP032` (f-string), `G004` (logging-f-string), pyupgrade, flynt
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# Ruby on Rails Framework — SunLint Agent Guide
|
|
2
|
+
|
|
3
|
+
> Priority directives for AI agents working on Rails projects.
|
|
4
|
+
> Rule files: `.agent/skills/sunlint-code-quality/rules/`
|
|
5
|
+
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## Critical Patterns — Apply Every Time
|
|
9
|
+
|
|
10
|
+
### Input Filtering
|
|
11
|
+
- **Always** whitelist params with `params.require(:model).permit(:field1, :field2)`
|
|
12
|
+
- **Never** use `params[:model].to_unsafe_h`, `params.permit!`, or `Model.create(params[:model])`
|
|
13
|
+
- See: `RR001-strong-parameters.md`
|
|
14
|
+
|
|
15
|
+
### N+1 Queries
|
|
16
|
+
- Any controller loading a collection that renders association data → add `includes(:relation)`
|
|
17
|
+
- Use `eager_load(:relation)` when filtering by association columns (SQL `WHERE`)
|
|
18
|
+
- Count in loops → use `counter_cache: true` + `model.relation_count` column
|
|
19
|
+
- See: `RR002-eager-load-includes.md`, `RR011-counter-cache.md`
|
|
20
|
+
|
|
21
|
+
### Authentication
|
|
22
|
+
- `before_action :authenticate_user!` in `ApplicationController` — opt-out for public actions with `skip_before_action`
|
|
23
|
+
- **Never** add authentication checks per-action (opt-in) — new actions are public by default
|
|
24
|
+
- See: `RR008-before-action-auth.md`
|
|
25
|
+
|
|
26
|
+
### Secrets
|
|
27
|
+
- **Never** hardcode API keys, tokens, passwords in source files
|
|
28
|
+
- `Rails.application.credentials.section[:key]` OR `ENV.fetch('KEY')` only
|
|
29
|
+
- `config/master.key` and `config/credentials/*.key` must be in `.gitignore`
|
|
30
|
+
- See: `RR009-rails-credentials.md`
|
|
31
|
+
|
|
32
|
+
---
|
|
33
|
+
|
|
34
|
+
## Architecture Patterns
|
|
35
|
+
|
|
36
|
+
### Controller responsibility — thin controllers only
|
|
37
|
+
Controllers do exactly: authenticate → authorize → parse strong params → call service → render response.
|
|
38
|
+
```ruby
|
|
39
|
+
def create
|
|
40
|
+
result = PlaceOrderService.new(user: current_user, params: order_params).call
|
|
41
|
+
if result.success?
|
|
42
|
+
render json: OrderResource.new(result.order), status: :created
|
|
43
|
+
else
|
|
44
|
+
render json: { errors: result.errors }, status: :unprocessable_entity
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
```
|
|
48
|
+
See: `RR003-service-objects.md`
|
|
49
|
+
|
|
50
|
+
### Service Objects
|
|
51
|
+
- Business logic with multi-model writes, external calls, or branching → `app/services/` PORO
|
|
52
|
+
- Expose one public method: `#call`
|
|
53
|
+
- Return a `Result` struct: `Result = Struct.new(:value, :success?, :error, keyword_init: true)`
|
|
54
|
+
- See: `RR003-service-objects.md`
|
|
55
|
+
|
|
56
|
+
### Background Jobs
|
|
57
|
+
- Email, external API calls, file processing, report generation → `ActiveJob` + `perform_later`
|
|
58
|
+
- **Never** `Thread.new` in controllers, **never** inline slow work in the request cycle
|
|
59
|
+
- Pass only primitive/serializable arguments to jobs (IDs, strings) — not AR objects
|
|
60
|
+
- See: `RR004-active-job-background.md`
|
|
61
|
+
|
|
62
|
+
### Pagination
|
|
63
|
+
- Every collection endpoint must call `.page(params[:page]).per(n)` (Kaminari) or `pagy(...)` (Pagy)
|
|
64
|
+
- **Never** `Model.all` without a page limit returned to the client
|
|
65
|
+
- See: `RR005-pagination.md`
|
|
66
|
+
|
|
67
|
+
### Bulk Data Iteration (jobs/scripts/rake)
|
|
68
|
+
- `Model.all.each` → replace with `Model.find_each(batch_size: 500)`
|
|
69
|
+
- Bulk updates → `Model.in_batches(of: 200) { |batch| batch.update_all(...) }`
|
|
70
|
+
- See: `RR006-find-each-batches.md`
|
|
71
|
+
|
|
72
|
+
### Query Scopes
|
|
73
|
+
- Named conditions (`published`, `recent`, `by_author`) → `scope :name, -> { where(...) }` on model
|
|
74
|
+
- **Never** inline `where('...')` chains in controllers — define a scope, call the scope
|
|
75
|
+
- See: `RR010-scopes.md`
|
|
76
|
+
|
|
77
|
+
### HTTP Status Codes
|
|
78
|
+
- Every `render json:` must include `status:` — use Rails symbol names
|
|
79
|
+
- `POST` success → `:created` (201), validation failure → `:unprocessable_entity` (422)
|
|
80
|
+
- DELETE with no body → `head :no_content` (204)
|
|
81
|
+
- Centralize `rescue_from ActiveRecord::RecordNotFound` → `:not_found` in ApplicationController
|
|
82
|
+
- See: `RR007-http-status-codes.md`, `RR012-render-json-status.md`
|
|
83
|
+
|
|
84
|
+
---
|
|
85
|
+
|
|
86
|
+
## Run Commands
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
rails generate model ModelName field:type
|
|
90
|
+
rails generate service OrderService # custom generator if installed
|
|
91
|
+
rails generate job ProcessExportJob
|
|
92
|
+
rails generate migration AddCounterToTable
|
|
93
|
+
|
|
94
|
+
rails test test/models/post_test.rb # run specific test file
|
|
95
|
+
rails test -n /test_name_pattern/ # run by name match
|
|
96
|
+
bundle exec rspec spec/services/ # RSpec equivalent
|
|
97
|
+
|
|
98
|
+
rails credentials:edit --environment production # edit secrets
|
|
99
|
+
rails credentials:show # view decrypted
|
|
100
|
+
|
|
101
|
+
bundle exec rubocop --autocorrect # lint + fix
|
|
102
|
+
bundle exec brakeman # security scanner
|
|
103
|
+
bundle exec bundle-audit check # dependency CVE scanner
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
---
|
|
107
|
+
|
|
108
|
+
## What NOT to do (quick reference)
|
|
109
|
+
|
|
110
|
+
| ❌ Wrong | ✅ Correct |
|
|
111
|
+
|---|---|
|
|
112
|
+
| `params[:user].to_unsafe_h` | `params.require(:user).permit(:name, :email)` |
|
|
113
|
+
| `Model.create(params[:model])` | `Model.create(user_params)` (permitted) |
|
|
114
|
+
| `@posts = Post.all` (unbounded) | `@posts = Post.published.page(params[:page])` |
|
|
115
|
+
| `post.comments.count` in loop | `counter_cache: true` → `post.comments_count` |
|
|
116
|
+
| `Thread.new { ... }` | `MyJob.perform_later(...)` |
|
|
117
|
+
| `after_create :call_stripe` on model | Service object wrapping the transaction |
|
|
118
|
+
| Auth check per action (opt-in) | `before_action :authenticate_user!` in ApplicationController |
|
|
119
|
+
| `render json: { errors: ... }` | `render json: { errors: ... }, status: :unprocessable_entity` |
|
|
120
|
+
| `API_KEY = 'sk_live_...'` in source | `Rails.application.credentials.stripe[:api_key]` |
|
|
121
|
+
| Inline `where('published = true')` in controller | `scope :published, -> { where(published: true) }` |
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "RR001 – Use Strong Parameters (params.require().permit())"
|
|
3
|
+
impact: high
|
|
4
|
+
impactDescription: "Mass assignment via permit() prevents unfiltered user input from modifying forbidden attributes such as roles, admin flags, or foreign keys."
|
|
5
|
+
tags: [ruby, rails, security, input-validation]
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# RR001 – Use Strong Parameters
|
|
9
|
+
|
|
10
|
+
## Rule
|
|
11
|
+
|
|
12
|
+
Always whitelist params with `require().permit()`. Never permit all params or bypass strong parameters.
|
|
13
|
+
|
|
14
|
+
## Why
|
|
15
|
+
|
|
16
|
+
`params.to_unsafe_h` and raw `params` bypass Rails' Mass Assignment protection. An attacker can inject `admin: true`, `role: 'admin'`, or arbitrary foreign keys.
|
|
17
|
+
|
|
18
|
+
## Wrong
|
|
19
|
+
|
|
20
|
+
```ruby
|
|
21
|
+
# Bypasses strong parameters entirely
|
|
22
|
+
def create
|
|
23
|
+
@user = User.create(params[:user].to_unsafe_h)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Permits everything
|
|
27
|
+
def user_params
|
|
28
|
+
params.require(:user).permit!
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Missing permit — may raise ForbiddenAttributesError or pass raw hash
|
|
32
|
+
def create
|
|
33
|
+
@user = User.create(user_params: params[:user])
|
|
34
|
+
end
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Correct
|
|
38
|
+
|
|
39
|
+
```ruby
|
|
40
|
+
def create
|
|
41
|
+
@user = User.create!(user_params)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def user_params
|
|
47
|
+
params.require(:user).permit(:name, :email, :password, :password_confirmation)
|
|
48
|
+
end
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Notes
|
|
52
|
+
|
|
53
|
+
- `permit!` is only acceptable in internal admin scripts where input is completely trusted and audited.
|
|
54
|
+
- Nested attributes need explicit permit: `permit(:name, addresses_attributes: [:street, :city])`.
|
|
55
|
+
- Never store the result of `to_unsafe_h` into a model.
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "RR002 – Prevent N+1 with includes/eager_load"
|
|
3
|
+
impact: high
|
|
4
|
+
impactDescription: "Unaddressed N+1 queries grow linearly with page size; a 100-row page fires 101 queries instead of 2."
|
|
5
|
+
tags: [ruby, rails, performance, database]
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# RR002 – Prevent N+1 with includes / eager_load
|
|
9
|
+
|
|
10
|
+
## Rule
|
|
11
|
+
|
|
12
|
+
Always eager-load associations when iterating over a collection. Use `includes`, `eager_load`, or `preload` based on whether a WHERE on the association is needed.
|
|
13
|
+
|
|
14
|
+
## Why
|
|
15
|
+
|
|
16
|
+
Accessing `post.author` inside an `.each` loop fires one SELECT per record — the classic N+1. Rails provides declarative eager loading to collapse N+1 into one extra query.
|
|
17
|
+
|
|
18
|
+
## Wrong
|
|
19
|
+
|
|
20
|
+
```ruby
|
|
21
|
+
# N+1: fires 1 + N queries for authors
|
|
22
|
+
@posts = Post.all
|
|
23
|
+
# view iterates: @posts.each { |p| p.author.name }
|
|
24
|
+
|
|
25
|
+
# N+1: fires 1 + N queries for comment counts
|
|
26
|
+
@posts = Post.all
|
|
27
|
+
# view: post.comments.count inside loop
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Correct
|
|
31
|
+
|
|
32
|
+
```ruby
|
|
33
|
+
# Two queries total: one for posts, one for authors
|
|
34
|
+
@posts = Post.includes(:author).all
|
|
35
|
+
|
|
36
|
+
# Three queries: posts + authors + tags
|
|
37
|
+
@posts = Post.includes(:author, :tags).page(params[:page])
|
|
38
|
+
|
|
39
|
+
# Need to filter by association column → use eager_load (LEFT JOIN)
|
|
40
|
+
@posts = Post.eager_load(:author).where(authors: { verified: true })
|
|
41
|
+
|
|
42
|
+
# Avoid count N+1 with counter_cache or withCount equivalent
|
|
43
|
+
@posts = Post.includes(:comments)
|
|
44
|
+
# or add counter_cache: true to the belongs_to and use post.comments_count
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Notes
|
|
48
|
+
|
|
49
|
+
- `includes` automatically selects between `preload` (separate queries) and `eager_load` (LEFT JOIN). Prefer `includes` unless you explicitly need one over the other.
|
|
50
|
+
- Use the `bullet` gem in development to surface N+1 automatically.
|
|
51
|
+
- Nested associations: `Post.includes(comments: :author)`.
|
|
@@ -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.
|