@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.
Files changed (185) hide show
  1. package/config/rules/rules-registry-generated.json +1717 -282
  2. package/core/architecture-integration.js +57 -15
  3. package/core/cli-action-handler.js +51 -36
  4. package/core/config-manager.js +6 -0
  5. package/core/config-merger.js +33 -0
  6. package/core/config-validator.js +37 -2
  7. package/core/file-targeting-service.js +148 -15
  8. package/core/init-command.js +118 -70
  9. package/core/output-service.js +12 -3
  10. package/core/project-detector.js +517 -0
  11. package/core/scoring-service.js +12 -6
  12. package/core/summary-report-service.js +9 -4
  13. package/core/tui-select.js +245 -0
  14. package/engines/arch-detect/rules/layered/l001-presentation-layer.js +7 -15
  15. package/engines/arch-detect/rules/layered/l002-business-layer.js +7 -15
  16. package/engines/arch-detect/rules/layered/l003-data-layer.js +7 -15
  17. package/engines/arch-detect/rules/layered/l004-model-layer.js +7 -15
  18. package/engines/arch-detect/rules/layered/l005-layer-separation.js +22 -2
  19. package/engines/arch-detect/rules/layered/l006-dependency-direction.js +8 -5
  20. package/engines/arch-detect/rules/modular/m005-no-deep-imports.js +67 -29
  21. package/engines/arch-detect/rules/presentation/pr001-view-layer.js +16 -9
  22. package/engines/arch-detect/rules/presentation/pr006-router-layer.js +33 -8
  23. package/engines/arch-detect/rules/presentation/pr007-interactor-layer.js +35 -6
  24. package/engines/arch-detect/rules/project-scanner/ps003-framework-detection.js +56 -10
  25. package/engines/impact/cli.js +54 -39
  26. package/engines/impact/config/default-config.js +105 -5
  27. package/engines/impact/core/impact-analyzer.js +12 -15
  28. package/engines/impact/core/utils/gitignore-parser.js +123 -0
  29. package/engines/impact/core/utils/method-call-graph.js +272 -87
  30. package/origin-rules/dart-en.md +1 -1
  31. package/origin-rules/go-en.md +231 -0
  32. package/origin-rules/php-en.md +107 -0
  33. package/origin-rules/python-en.md +113 -0
  34. package/origin-rules/ruby-en.md +607 -0
  35. package/package.json +1 -1
  36. package/scripts/copy-arch-detect.js +5 -1
  37. package/scripts/copy-impact-analyzer.js +5 -1
  38. package/scripts/generate-rules-registry.js +30 -14
  39. package/skill-assets/sunlint-code-quality/SKILL.md +3 -2
  40. package/skill-assets/sunlint-code-quality/rules/dart/C006-verb-noun-functions.md +45 -0
  41. package/skill-assets/sunlint-code-quality/rules/dart/C013-no-dead-code.md +53 -0
  42. package/skill-assets/sunlint-code-quality/rules/dart/C014-dependency-injection.md +92 -0
  43. package/skill-assets/sunlint-code-quality/rules/dart/C017-no-constructor-logic.md +62 -0
  44. package/skill-assets/sunlint-code-quality/rules/dart/C018-generic-errors.md +57 -0
  45. package/skill-assets/sunlint-code-quality/rules/dart/C019-error-log-level.md +50 -0
  46. package/skill-assets/sunlint-code-quality/rules/dart/C020-no-unused-imports.md +46 -0
  47. package/skill-assets/sunlint-code-quality/rules/dart/C022-no-unused-variables.md +50 -0
  48. package/skill-assets/sunlint-code-quality/rules/dart/C023-no-duplicate-names.md +56 -0
  49. package/skill-assets/sunlint-code-quality/rules/dart/C024-centralize-constants.md +75 -0
  50. package/skill-assets/sunlint-code-quality/rules/dart/C029-catch-log-root-cause.md +53 -0
  51. package/skill-assets/sunlint-code-quality/rules/dart/C030-custom-error-classes.md +86 -0
  52. package/skill-assets/sunlint-code-quality/rules/dart/C033-separate-data-access.md +90 -0
  53. package/skill-assets/sunlint-code-quality/rules/dart/C035-error-context-logging.md +62 -0
  54. package/skill-assets/sunlint-code-quality/rules/dart/C041-no-hardcoded-secrets.md +75 -0
  55. package/skill-assets/sunlint-code-quality/rules/dart/C042-boolean-naming.md +73 -0
  56. package/skill-assets/sunlint-code-quality/rules/dart/C052-widget-parsing.md +84 -0
  57. package/skill-assets/sunlint-code-quality/rules/dart/C060-superclass-logic.md +91 -0
  58. package/skill-assets/sunlint-code-quality/rules/dart/C067-no-hardcoded-config.md +108 -0
  59. package/skill-assets/sunlint-code-quality/rules/go/G001-explicit-error-handling.md +53 -0
  60. package/skill-assets/sunlint-code-quality/rules/go/G002-context-first-argument.md +44 -0
  61. package/skill-assets/sunlint-code-quality/rules/go/G003-receiver-consistency.md +38 -0
  62. package/skill-assets/sunlint-code-quality/rules/go/G004-avoid-panic.md +49 -0
  63. package/skill-assets/sunlint-code-quality/rules/go/G005-goroutine-leak-prevention.md +49 -0
  64. package/skill-assets/sunlint-code-quality/rules/go/G006-interface-consumer-definition.md +45 -0
  65. package/skill-assets/sunlint-code-quality/rules/go/GN001-gin-binding-validation.md +57 -0
  66. package/skill-assets/sunlint-code-quality/rules/go/GN002-gin-error-response.md +48 -0
  67. package/skill-assets/sunlint-code-quality/rules/go/GN003-graceful-shutdown.md +57 -0
  68. package/skill-assets/sunlint-code-quality/rules/go/GN004-gin-route-logical-grouping.md +54 -0
  69. package/skill-assets/sunlint-code-quality/rules/go-gin/AGENTS.md +149 -0
  70. package/skill-assets/sunlint-code-quality/rules/go-gin/GN001-abort-after-response.md +75 -0
  71. package/skill-assets/sunlint-code-quality/rules/go-gin/GN002-request-context.md +64 -0
  72. package/skill-assets/sunlint-code-quality/rules/go-gin/GN003-bind-error-handling.md +70 -0
  73. package/skill-assets/sunlint-code-quality/rules/go-gin/GN004-dependency-injection.md +78 -0
  74. package/skill-assets/sunlint-code-quality/rules/go-gin/GN005-route-groups-middleware.md +71 -0
  75. package/skill-assets/sunlint-code-quality/rules/go-gin/GN006-http-status-codes.md +91 -0
  76. package/skill-assets/sunlint-code-quality/rules/go-gin/GN007-release-mode.md +64 -0
  77. package/skill-assets/sunlint-code-quality/rules/go-gin/GN008-struct-validation-tags.md +90 -0
  78. package/skill-assets/sunlint-code-quality/rules/go-gin/GN009-recovery-middleware.md +68 -0
  79. package/skill-assets/sunlint-code-quality/rules/go-gin/GN010-context-scope.md +68 -0
  80. package/skill-assets/sunlint-code-quality/rules/go-gin/GN011-middleware-concerns.md +92 -0
  81. package/skill-assets/sunlint-code-quality/rules/go-gin/GN012-no-log-sensitive.md +84 -0
  82. package/skill-assets/sunlint-code-quality/rules/java/J001-try-with-resources.md +86 -0
  83. package/skill-assets/sunlint-code-quality/rules/java/J002-equals-and-hashcode.md +88 -0
  84. package/skill-assets/sunlint-code-quality/rules/java/J003-string-comparison.md +72 -0
  85. package/skill-assets/sunlint-code-quality/rules/java/J004-use-java-time.md +91 -0
  86. package/skill-assets/sunlint-code-quality/rules/java/J005-no-print-stack-trace.md +80 -0
  87. package/skill-assets/sunlint-code-quality/rules/java/J006-no-system-println.md +89 -0
  88. package/skill-assets/sunlint-code-quality/rules/java/J007-proper-logger.md +91 -0
  89. package/skill-assets/sunlint-code-quality/rules/java/J008-thread-safe-singleton.md +119 -0
  90. package/skill-assets/sunlint-code-quality/rules/java/J009-utility-class-constructor.md +82 -0
  91. package/skill-assets/sunlint-code-quality/rules/java/J010-preserve-stack-trace.md +119 -0
  92. package/skill-assets/sunlint-code-quality/rules/java/J011-null-safe-compare.md +88 -0
  93. package/skill-assets/sunlint-code-quality/rules/java/J012-use-enum-collections.md +104 -0
  94. package/skill-assets/sunlint-code-quality/rules/java/J013-return-empty-not-null.md +102 -0
  95. package/skill-assets/sunlint-code-quality/rules/java/J014-hardcoded-crypto-key.md +108 -0
  96. package/skill-assets/sunlint-code-quality/rules/java/J015-optional-instead-of-null.md +109 -0
  97. package/skill-assets/sunlint-code-quality/rules/php-laravel/AGENTS.md +124 -0
  98. package/skill-assets/sunlint-code-quality/rules/php-laravel/LV001-form-request-validation.md +64 -0
  99. package/skill-assets/sunlint-code-quality/rules/php-laravel/LV002-eager-load-no-n-plus-1.md +58 -0
  100. package/skill-assets/sunlint-code-quality/rules/php-laravel/LV003-config-not-env.md +54 -0
  101. package/skill-assets/sunlint-code-quality/rules/php-laravel/LV004-fillable-mass-assignment.md +51 -0
  102. package/skill-assets/sunlint-code-quality/rules/php-laravel/LV005-policies-gates-authorization.md +71 -0
  103. package/skill-assets/sunlint-code-quality/rules/php-laravel/LV006-queue-heavy-tasks.md +68 -0
  104. package/skill-assets/sunlint-code-quality/rules/php-laravel/LV007-hash-passwords.md +51 -0
  105. package/skill-assets/sunlint-code-quality/rules/php-laravel/LV008-route-model-binding.md +67 -0
  106. package/skill-assets/sunlint-code-quality/rules/php-laravel/LV009-api-resources.md +72 -0
  107. package/skill-assets/sunlint-code-quality/rules/php-laravel/LV010-chunk-large-datasets.md +58 -0
  108. package/skill-assets/sunlint-code-quality/rules/php-laravel/LV011-db-transactions.md +73 -0
  109. package/skill-assets/sunlint-code-quality/rules/php-laravel/LV012-service-layer.md +78 -0
  110. package/skill-assets/sunlint-code-quality/rules/php-laravel/LV013-testing-factories.md +75 -0
  111. package/skill-assets/sunlint-code-quality/rules/php-laravel/LV014-service-container.md +61 -0
  112. package/skill-assets/sunlint-code-quality/rules/python/P001-mutable-default-argument.md +55 -0
  113. package/skill-assets/sunlint-code-quality/rules/python/P002-specify-file-encoding.md +45 -0
  114. package/skill-assets/sunlint-code-quality/rules/python/P003-context-manager-for-resources.md +54 -0
  115. package/skill-assets/sunlint-code-quality/rules/python/P004-no-bare-except.md +65 -0
  116. package/skill-assets/sunlint-code-quality/rules/python/P005-use-isinstance.md +60 -0
  117. package/skill-assets/sunlint-code-quality/rules/python/P006-timezone-aware-datetime.md +58 -0
  118. package/skill-assets/sunlint-code-quality/rules/python/P007-use-pathlib.md +62 -0
  119. package/skill-assets/sunlint-code-quality/rules/python/P008-no-wildcard-import.md +52 -0
  120. package/skill-assets/sunlint-code-quality/rules/python/P009-logging-lazy-format.md +50 -0
  121. package/skill-assets/sunlint-code-quality/rules/python/P010-exception-chaining.md +57 -0
  122. package/skill-assets/sunlint-code-quality/rules/python/P011-subprocess-check.md +59 -0
  123. package/skill-assets/sunlint-code-quality/rules/python/P012-requests-timeout.md +70 -0
  124. package/skill-assets/sunlint-code-quality/rules/python/P013-no-global-statement.md +73 -0
  125. package/skill-assets/sunlint-code-quality/rules/python/P014-no-modify-collection-while-iterating.md +66 -0
  126. package/skill-assets/sunlint-code-quality/rules/python/P015-prefer-fstrings.md +61 -0
  127. package/skill-assets/sunlint-code-quality/rules/ruby-rails/AGENTS.md +121 -0
  128. package/skill-assets/sunlint-code-quality/rules/ruby-rails/RR001-strong-parameters.md +55 -0
  129. package/skill-assets/sunlint-code-quality/rules/ruby-rails/RR002-eager-load-includes.md +51 -0
  130. package/skill-assets/sunlint-code-quality/rules/ruby-rails/RR003-service-objects.md +99 -0
  131. package/skill-assets/sunlint-code-quality/rules/ruby-rails/RR004-active-job-background.md +67 -0
  132. package/skill-assets/sunlint-code-quality/rules/ruby-rails/RR005-pagination.md +53 -0
  133. package/skill-assets/sunlint-code-quality/rules/ruby-rails/RR006-find-each-batches.md +53 -0
  134. package/skill-assets/sunlint-code-quality/rules/ruby-rails/RR007-http-status-codes.md +76 -0
  135. package/skill-assets/sunlint-code-quality/rules/ruby-rails/RR008-before-action-auth.md +77 -0
  136. package/skill-assets/sunlint-code-quality/rules/ruby-rails/RR009-rails-credentials.md +61 -0
  137. package/skill-assets/sunlint-code-quality/rules/ruby-rails/RR010-scopes.md +57 -0
  138. package/skill-assets/sunlint-code-quality/rules/ruby-rails/RR011-counter-cache.md +59 -0
  139. package/skill-assets/sunlint-code-quality/rules/ruby-rails/RR012-render-json-status.md +42 -0
  140. package/skill-assets/sunlint-code-quality/rules/swift/C006-verb-noun-functions.md +37 -0
  141. package/skill-assets/sunlint-code-quality/rules/swift/C013-no-dead-code.md +55 -0
  142. package/skill-assets/sunlint-code-quality/rules/swift/C014-dependency-injection.md +69 -0
  143. package/skill-assets/sunlint-code-quality/rules/swift/C017-no-constructor-logic.md +66 -0
  144. package/skill-assets/sunlint-code-quality/rules/swift/C018-generic-errors.md +64 -0
  145. package/skill-assets/sunlint-code-quality/rules/swift/C019-error-log-level.md +64 -0
  146. package/skill-assets/sunlint-code-quality/rules/swift/C020-no-unused-imports.md +47 -0
  147. package/skill-assets/sunlint-code-quality/rules/swift/C022-no-unused-variables.md +46 -0
  148. package/skill-assets/sunlint-code-quality/rules/swift/C023-no-duplicate-names.md +55 -0
  149. package/skill-assets/sunlint-code-quality/rules/swift/C024-centralize-constants.md +68 -0
  150. package/skill-assets/sunlint-code-quality/rules/swift/C029-catch-log-root-cause.md +69 -0
  151. package/skill-assets/sunlint-code-quality/rules/swift/C030-custom-error-classes.md +77 -0
  152. package/skill-assets/sunlint-code-quality/rules/swift/C033-separate-data-access.md +89 -0
  153. package/skill-assets/sunlint-code-quality/rules/swift/C035-error-context-logging.md +66 -0
  154. package/skill-assets/sunlint-code-quality/rules/swift/C041-no-hardcoded-secrets.md +65 -0
  155. package/skill-assets/sunlint-code-quality/rules/swift/C042-boolean-naming.md +60 -0
  156. package/skill-assets/sunlint-code-quality/rules/swift/C052-controller-parsing.md +67 -0
  157. package/skill-assets/sunlint-code-quality/rules/swift/C060-superclass-logic.md +95 -0
  158. package/skill-assets/sunlint-code-quality/rules/swift/C067-no-hardcoded-config.md +80 -0
  159. package/skill-assets/sunlint-code-quality/rules/swift/S003-sql-injection.md +65 -0
  160. package/skill-assets/sunlint-code-quality/rules/swift/S004-no-log-credentials.md +67 -0
  161. package/skill-assets/sunlint-code-quality/rules/swift/S005-server-authorization.md +73 -0
  162. package/skill-assets/sunlint-code-quality/rules/swift/S006-default-credentials.md +76 -0
  163. package/skill-assets/sunlint-code-quality/rules/swift/S007-output-encoding.md +96 -0
  164. package/skill-assets/sunlint-code-quality/rules/swift/S009-approved-crypto.md +86 -0
  165. package/skill-assets/sunlint-code-quality/rules/swift/S010-csprng.md +71 -0
  166. package/skill-assets/sunlint-code-quality/rules/swift/S011-insecure-deserialization.md +74 -0
  167. package/skill-assets/sunlint-code-quality/rules/swift/S012-secrets-management.md +81 -0
  168. package/skill-assets/sunlint-code-quality/rules/swift/S013-tls-connections.md +67 -0
  169. package/skill-assets/sunlint-code-quality/rules/swift/S017-parameterized-queries.md +86 -0
  170. package/skill-assets/sunlint-code-quality/rules/swift/S019-session-management.md +131 -0
  171. package/skill-assets/sunlint-code-quality/rules/swift/S020-kvc-injection.md +91 -0
  172. package/skill-assets/sunlint-code-quality/rules/swift/S025-input-validation.md +125 -0
  173. package/skill-assets/sunlint-code-quality/rules/swift/S029-brute-force-protection.md +120 -0
  174. package/skill-assets/sunlint-code-quality/rules/swift/S036-path-traversal.md +102 -0
  175. package/skill-assets/sunlint-code-quality/rules/swift/S039-tls-certificate-validation.md +109 -0
  176. package/skill-assets/sunlint-code-quality/rules/swift/S041-logout-invalidation.md +103 -0
  177. package/skill-assets/sunlint-code-quality/rules/swift/S043-password-hashing.md +116 -0
  178. package/skill-assets/sunlint-code-quality/rules/swift/S044-critical-changes-reauth.md +145 -0
  179. package/skill-assets/sunlint-code-quality/rules/swift/S045-debug-info-exposure.md +116 -0
  180. package/skill-assets/sunlint-code-quality/rules/swift/S046-unvalidated-redirect.md +140 -0
  181. package/skill-assets/sunlint-code-quality/rules/swift/S051-token-expiry.md +134 -0
  182. package/skill-assets/sunlint-code-quality/rules/swift/S053-jwt-validation.md +139 -0
  183. package/skill-assets/sunlint-code-quality/rules/swift/S059-background-snapshot-protection.md +113 -0
  184. package/skill-assets/sunlint-code-quality/rules/swift/S060-data-protection-api.md +106 -0
  185. package/skill-assets/sunlint-code-quality/rules/swift/S061-jailbreak-detection.md +132 -0
@@ -0,0 +1,50 @@
1
+ ---
2
+ title: Use Lazy Formatting in Logging Calls
3
+ impact: MEDIUM
4
+ impactDescription: Eager string interpolation in logging (f-strings or %) evaluates and allocates the string even when the log level is disabled, wasting CPU and memory in production.
5
+ tags: python, logging, performance, quality
6
+ ---
7
+
8
+ ## Use Lazy Formatting in Logging Calls
9
+
10
+ Python's `logging` module accepts a format string and arguments **separately**. The string interpolation only happens if the message will actually be emitted (i.e., the log level is enabled). Using f-strings or `%` interpolation beforehand evaluates the string unconditionally, even if `DEBUG` is disabled.
11
+
12
+ **Incorrect:**
13
+ ```python
14
+ import logging
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+ user_id = get_user_id()
19
+ payload = serialize(data)
20
+
21
+ # These always evaluate the f-string / % formatting, even if DEBUG is off
22
+ logger.debug(f"Processing user {user_id} with payload {payload}")
23
+ logger.info("Request from %s: %s" % (ip_address, request.path))
24
+ logger.warning("Slow query: " + str(query_time) + "ms")
25
+ ```
26
+
27
+ **Correct:**
28
+ ```python
29
+ import logging
30
+
31
+ logger = logging.getLogger(__name__)
32
+
33
+ # Formatting deferred — only if DEBUG is enabled
34
+ logger.debug("Processing user %s with payload %s", user_id, payload)
35
+ logger.info("Request from %s: %s", ip_address, request.path)
36
+ logger.warning("Slow query: %sms", query_time)
37
+
38
+ # For complex objects, use lazy repr via %r
39
+ logger.debug("Event received: %r", event)
40
+
41
+ # Exception info — use exc_info=True or logger.exception()
42
+ try:
43
+ call_external_api()
44
+ except requests.RequestException as e:
45
+ logger.error("External API failed for user %s: %s", user_id, e, exc_info=True)
46
+ # or equivalently inside an except block:
47
+ # logger.exception("External API failed for user %s", user_id)
48
+ ```
49
+
50
+ **Tools:** Ruff `G004` (logging-f-string), `W1201` in Pylint (logging-not-lazy), `W1202` (logging-format-interpolation), `TRY400` (error-instead-of-exception), flake8-logging-format
@@ -0,0 +1,57 @@
1
+ ---
2
+ title: Use raise...from to Chain Exceptions
3
+ impact: MEDIUM
4
+ impactDescription: Rethrowing an exception without raise...from loses the original traceback, making it impossible to diagnose the root cause in production logs.
5
+ tags: python, exceptions, error-handling, traceability, quality
6
+ ---
7
+
8
+ ## Use raise...from to Chain Exceptions
9
+
10
+ When catching an exception and raising a different (or more specific) one, use `raise NewException("msg") from original_exception` to preserve the full causal chain. Without `from`, Python 3 still shows an implicit chain, but the context can be confusing. Using `from` makes the chain explicit and intentional.
11
+
12
+ Using `raise ... from None` deliberately suppresses the original context when it adds no value to the end user.
13
+
14
+ **Incorrect:**
15
+ ```python
16
+ import json
17
+
18
+ def load_config(path: str) -> dict:
19
+ try:
20
+ with open(path, encoding="utf-8") as f:
21
+ return json.load(f)
22
+ except json.JSONDecodeError:
23
+ raise ValueError("Config file is malformed") # original traceback lost
24
+
25
+ def get_user(user_id: int) -> dict:
26
+ try:
27
+ return db.find_by_id(user_id)
28
+ except DatabaseError:
29
+ raise RuntimeError("Failed to fetch user") # loses DB error context
30
+ ```
31
+
32
+ **Correct:**
33
+ ```python
34
+ import json
35
+
36
+ def load_config(path: str) -> dict:
37
+ try:
38
+ with open(path, encoding="utf-8") as f:
39
+ return json.load(f)
40
+ except json.JSONDecodeError as e:
41
+ raise ValueError(f"Config file '{path}' is malformed") from e # chain preserved
42
+
43
+ def get_user(user_id: int) -> dict:
44
+ try:
45
+ return db.find_by_id(user_id)
46
+ except DatabaseError as e:
47
+ raise UserNotFoundError(f"User {user_id} could not be fetched") from e
48
+
49
+ # Suppress context intentionally (e.g., to hide internal DB details from callers)
50
+ def validate_token(token: str) -> None:
51
+ try:
52
+ jwt.decode(token, SECRET)
53
+ except jwt.ExpiredSignatureError:
54
+ raise AuthenticationError("Token has expired") from None
55
+ ```
56
+
57
+ **Tools:** Ruff `W0707` / `raise-missing-from`, `TRY200` (reraise-no-cause), `B904` (raise-without-from-inside-except), Pylint `W0707`, flake8-bugbear
@@ -0,0 +1,59 @@
1
+ ---
2
+ title: Pass Explicit check= to subprocess.run
3
+ impact: HIGH
4
+ impactDescription: subprocess.run silently ignores non-zero exit codes by default; a failed command goes undetected and subsequent code runs on corrupt or missing output.
5
+ tags: python, subprocess, error-handling, quality, security
6
+ ---
7
+
8
+ ## Pass Explicit check= to subprocess.run
9
+
10
+ `subprocess.run()` has `check=False` by default, meaning a command that exits with a non-zero status (indicating failure) does not raise an exception. Code that follows assumes success and may operate on missing or corrupt output without any error ever being raised.
11
+
12
+ Always pass `check=True` unless you explicitly intend to handle failures yourself by inspecting `returncode`.
13
+
14
+ **Incorrect:**
15
+ ```python
16
+ import subprocess
17
+
18
+ # Exit code ignored — if ffmpeg fails, output file doesn't exist
19
+ subprocess.run(["ffmpeg", "-i", "input.mp4", "output.mp4"])
20
+
21
+ # result.returncode is never checked
22
+ result = subprocess.run(["git", "pull"])
23
+ print("Done") # runs even if git pull failed
24
+
25
+ # capture_output=True but still no check
26
+ result = subprocess.run(
27
+ ["python", "migrate.py"],
28
+ capture_output=True,
29
+ text=True,
30
+ )
31
+ apply_migrations() # runs even if migrate.py failed
32
+ ```
33
+
34
+ **Correct:**
35
+ ```python
36
+ import subprocess
37
+
38
+ # Raises CalledProcessError if command fails
39
+ subprocess.run(
40
+ ["ffmpeg", "-i", "input.mp4", "output.mp4"],
41
+ check=True,
42
+ )
43
+
44
+ # Or capture output and check explicitly
45
+ result = subprocess.run(
46
+ ["python", "migrate.py"],
47
+ capture_output=True,
48
+ text=True,
49
+ check=True, # raises on non-zero exit code
50
+ )
51
+ apply_migrations() # only reached if migration succeeded
52
+
53
+ # When you WANT to handle failures yourself — explicit intent:
54
+ result = subprocess.run(["git", "pull"], check=False)
55
+ if result.returncode != 0:
56
+ logger.warning("git pull failed with code %d", result.returncode)
57
+ ```
58
+
59
+ **Tools:** Ruff `PLW1510` (subprocess-run-without-check), Pylint `W1510`, Bandit `S603`, flake8-bugbear
@@ -0,0 +1,70 @@
1
+ ---
2
+ title: Always Set timeout for HTTP Requests
3
+ impact: HIGH
4
+ impactDescription: HTTP requests without a timeout can hang indefinitely, exhausting thread pools and connection pools, causing cascading failures and outages in production services.
5
+ tags: python, requests, http, reliability, quality
6
+ ---
7
+
8
+ ## Always Set timeout for HTTP Requests
9
+
10
+ The `requests` library and similar HTTP clients have **no default timeout**. A single slow or unresponsive server can cause a thread to block indefinitely, exhausting thread pools and causing the entire service to become unresponsive. Always set an explicit `timeout` on every outbound HTTP call.
11
+
12
+ **Incorrect:**
13
+ ```python
14
+ import requests
15
+
16
+ # Hangs forever if server is unresponsive
17
+ response = requests.get("https://api.example.com/data")
18
+
19
+ # Also no timeout
20
+ session = requests.Session()
21
+ response = session.post(
22
+ "https://api.example.com/webhook",
23
+ json=payload,
24
+ )
25
+
26
+ # httpx — same risk
27
+ import httpx
28
+ response = httpx.get("https://api.example.com/items")
29
+ ```
30
+
31
+ **Correct:**
32
+ ```python
33
+ import requests
34
+
35
+ # Set both connect and read timeouts as a tuple
36
+ response = requests.get(
37
+ "https://api.example.com/data",
38
+ timeout=(3.05, 27), # (connect_timeout, read_timeout) in seconds
39
+ )
40
+
41
+ # Or a single value for both
42
+ response = requests.post(
43
+ "https://api.example.com/webhook",
44
+ json=payload,
45
+ timeout=10, # applies to both connect and read
46
+ )
47
+ response.raise_for_status()
48
+
49
+ # httpx — also requires explicit timeout
50
+ import httpx
51
+
52
+ with httpx.Client(timeout=httpx.Timeout(connect=3.0, read=30.0)) as client:
53
+ response = client.get("https://api.example.com/items")
54
+ ```
55
+
56
+ **Configure timeouts at the session level:**
57
+ ```python
58
+ # Apply uniformly to all requests from the session
59
+ session = requests.Session()
60
+ adapter = requests.adapters.HTTPAdapter(max_retries=3)
61
+ session.mount("https://", adapter)
62
+
63
+ DEFAULT_TIMEOUT = (3.05, 27)
64
+
65
+ def get_with_timeout(url: str, **kwargs) -> requests.Response:
66
+ kwargs.setdefault("timeout", DEFAULT_TIMEOUT)
67
+ return session.get(url, **kwargs)
68
+ ```
69
+
70
+ **Tools:** Ruff `W3101` (missing-timeout), Pylint `W3101`, Bandit `S113`, flake8
@@ -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
@@ -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)`.