rubocop-dev_doc 0.3.1 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/config/default.yml +30 -3
- data/lib/rubocop/cop/dev_doc/auth/current_user_branching.rb +47 -4
- data/lib/rubocop/cop/dev_doc/migration/avoid_column_default.rb +53 -2
- data/lib/rubocop/cop/dev_doc/migration/avoid_vague_column_names.rb +7 -1
- data/lib/rubocop/cop/dev_doc/rails/avoid_lifecycle_method_override.rb +107 -0
- data/lib/rubocop/cop/dev_doc/rails/avoid_ordering_by_id.rb +167 -0
- data/lib/rubocop/cop/dev_doc/rails/avoid_rails_callbacks.rb +14 -4
- data/lib/rubocop/cop/dev_doc/rails/avoid_raw_sql.rb +227 -0
- data/lib/rubocop/cop/dev_doc/rails/strong_parameters_expect.rb +9 -3
- data/lib/rubocop/cop/dev_doc/style/prefer_public_send.rb +86 -0
- data/lib/rubocop/cop/dev_doc/style/tap_block_ignores_value.rb +123 -0
- data/lib/rubocop/cop/dev_doc/test/avoid_unit_test.rb +24 -0
- data/lib/rubocop/dev_doc/version.rb +1 -1
- metadata +7 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2076123d07503e775a29287eafcd8dc9d8cf6e344eeb4b462d29120fdef12879
|
|
4
|
+
data.tar.gz: 910414d217e37c808b7626add2b876d24f6ef214a725b373e86dc736acb24ff7
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 7681307dae1b8d09d0344c199d4be497bc4c986b4314711ae39550d7d8ced8d8b811d07d98997f8e3fc85d294e6fde1b97c8cb6cdc3c33a40ad3074cda985d62
|
|
7
|
+
data.tar.gz: a047b648abfcaeb1842158a562c17028601a2f24c67e1f0aa2cdc548377cccd1658dae4b6b68cee1cb4906275a59822f2870160380e71cb5d730c04ce5d44196
|
data/config/default.yml
CHANGED
|
@@ -79,11 +79,12 @@ DevDoc/Migration/DateColumnNaming:
|
|
|
79
79
|
- "db/migrate/**/*.rb"
|
|
80
80
|
|
|
81
81
|
DevDoc/Migration/AvoidVagueColumnNames:
|
|
82
|
-
Description: "Avoid vague column names like `status` or `
|
|
82
|
+
Description: "Avoid vague column names like `status`, `group`, or `kind`. Use more specific names."
|
|
83
83
|
Enabled: true
|
|
84
84
|
VagueNames:
|
|
85
85
|
- status
|
|
86
86
|
- group
|
|
87
|
+
- kind
|
|
87
88
|
Include:
|
|
88
89
|
- "db/migrate/*.rb"
|
|
89
90
|
- "db/migrate/**/*.rb"
|
|
@@ -146,10 +147,10 @@ DevDoc/Rails/EnumColumnNotNull:
|
|
|
146
147
|
DevDoc/Rails/NoBlockPredicateOnRelation:
|
|
147
148
|
Description: "Avoid block-form `count`/`reject`/`select`/`find`/`any?` on AR relations; push the predicate into SQL with `.where(...)` or a scope."
|
|
148
149
|
Enabled: true
|
|
150
|
+
AdditionalNonRelationMethods: []
|
|
149
151
|
Exclude:
|
|
150
152
|
- "spec/**/*"
|
|
151
153
|
- "test/**/*"
|
|
152
|
-
- "db/seeds.rb"
|
|
153
154
|
- "db/seeds/**/*.rb"
|
|
154
155
|
- "lib/tasks/**/*.rb"
|
|
155
156
|
|
|
@@ -165,6 +166,10 @@ DevDoc/Style/AvoidSend:
|
|
|
165
166
|
Description: "Avoid `send`/`public_send` with an explicit receiver; prefer direct calls or safer alternatives."
|
|
166
167
|
Enabled: true
|
|
167
168
|
|
|
169
|
+
DevDoc/Style/PreferPublicSend:
|
|
170
|
+
Description: "Prefer `public_send` over `send` — visibility-respecting dispatch surfaces typos/misconfig and documents intent."
|
|
171
|
+
Enabled: true
|
|
172
|
+
|
|
168
173
|
DevDoc/Style/RepeatedSafeNavigationReceiver:
|
|
169
174
|
Description: "Avoid using `&.` on the same receiver more than once in a method body — assign once and use `.` after."
|
|
170
175
|
Enabled: true
|
|
@@ -177,6 +182,10 @@ DevDoc/Style/MinimizeVariableScope:
|
|
|
177
182
|
Description: "Assign a variable inside the `if` condition that guards it, to keep its scope local to the branch."
|
|
178
183
|
Enabled: true
|
|
179
184
|
|
|
185
|
+
DevDoc/Style/TapBlockIgnoresValue:
|
|
186
|
+
Description: "Flag `tap` whose block ignores the yielded value — `tap` is for operating on the receiver, not eliding a temp var."
|
|
187
|
+
Enabled: true
|
|
188
|
+
|
|
180
189
|
DevDoc/Style/AvoidHeadResponse:
|
|
181
190
|
Description: "Avoid `head()` with error statuses; delegate error handling to Rails exceptions or model validations."
|
|
182
191
|
Enabled: true
|
|
@@ -247,6 +256,17 @@ DevDoc/Rails/AvoidRailsCallbacks:
|
|
|
247
256
|
Include:
|
|
248
257
|
- "app/models/**/*.rb"
|
|
249
258
|
|
|
259
|
+
DevDoc/Rails/AvoidLifecycleMethodOverride:
|
|
260
|
+
Description: "Avoid overriding Rails validation/persistence lifecycle methods (run_validations!, valid?, etc.) to dodge AvoidRailsCallbacks."
|
|
261
|
+
Enabled: true
|
|
262
|
+
Include:
|
|
263
|
+
- "app/models/**/*.rb"
|
|
264
|
+
Methods:
|
|
265
|
+
- run_validations!
|
|
266
|
+
- valid?
|
|
267
|
+
- invalid?
|
|
268
|
+
- perform_validations
|
|
269
|
+
|
|
250
270
|
DevDoc/Rails/ApplicationRecordTransaction:
|
|
251
271
|
Description: "Use `ApplicationRecord.transaction` instead of `SomeModel.transaction` outside model files."
|
|
252
272
|
Enabled: true
|
|
@@ -296,6 +316,14 @@ DevDoc/Rails/StrongParametersExpect:
|
|
|
296
316
|
Include:
|
|
297
317
|
- "app/controllers/**/*.rb"
|
|
298
318
|
|
|
319
|
+
DevDoc/Rails/AvoidRawSql:
|
|
320
|
+
Description: "Avoid raw SQL — prefer the hash/array form, parameterized fragments, or Arel; disable with a reason when raw SQL is genuinely required."
|
|
321
|
+
Enabled: true
|
|
322
|
+
|
|
323
|
+
DevDoc/Rails/AvoidOrderingById:
|
|
324
|
+
Description: "Avoid `order`/`reorder` by `id` as the primary (sole or leftmost) sort — order by a business column, or use `id` only as a secondary tiebreaker."
|
|
325
|
+
Enabled: true
|
|
326
|
+
|
|
299
327
|
# `save!` / `update!` raise on persistence failure, which is what
|
|
300
328
|
# we want in jobs/services/rake tasks — silent `false` returns
|
|
301
329
|
# hide bugs. Excluded for controllers, which lean on `save`
|
|
@@ -368,4 +396,3 @@ DevDoc/Auth/CurrentUserBranching:
|
|
|
368
396
|
- "app/helpers/**/*.rb"
|
|
369
397
|
- "app/controllers/concerns/**/*.rb"
|
|
370
398
|
- "app/views/layouts/**/*"
|
|
371
|
-
- "app/controllers/application_controller.rb"
|
|
@@ -56,13 +56,56 @@ module RuboCop
|
|
|
56
56
|
#
|
|
57
57
|
# ## Allowed paths (Exclude:)
|
|
58
58
|
# By default the cop is silent in:
|
|
59
|
-
# app/policies/**/*.rb
|
|
59
|
+
# app/policies/**/*.rb ← auth-dependent checks belong here
|
|
60
60
|
# app/helpers/**/*.rb
|
|
61
61
|
# app/controllers/concerns/**/*.rb
|
|
62
|
-
# app/views/layouts/**/*
|
|
63
|
-
# app/controllers/application_controller.rb
|
|
62
|
+
# app/views/layouts/**/* ← display branching (nav bars, etc.)
|
|
64
63
|
#
|
|
65
|
-
# Override via `Exclude:` in your `.rubocop.yml`.
|
|
64
|
+
# Override via `Exclude:` in your `.rubocop.yml`. Note: literal file
|
|
65
|
+
# paths (e.g. `app/controllers/application_controller.rb`) in
|
|
66
|
+
# `.rubocop.yml` `Exclude:` lists are flagged by the
|
|
67
|
+
# `DevDoc::Test::Lints::NoFileExcludes` lint — they hide the
|
|
68
|
+
# suppression from readers of that file. If `ApplicationController`
|
|
69
|
+
# needs an exception, use an inline `# rubocop:disable` at the
|
|
70
|
+
# specific line with a reason.
|
|
71
|
+
#
|
|
72
|
+
# ## Before disabling inline, consider the Policy
|
|
73
|
+
# The Policy exclusion is not accidental — it's the canonical
|
|
74
|
+
# Rails home for auth-dependent branching. If the flagged line
|
|
75
|
+
# is an authorization check (`return if @record.accessible_by?(current_user)`,
|
|
76
|
+
# `if current_user.admin?`, etc.), the right move is almost
|
|
77
|
+
# always to push that check into a Pundit policy method, not
|
|
78
|
+
# disable inline. The controller becomes
|
|
79
|
+
# `return unless policy(@record).access?` — same behaviour,
|
|
80
|
+
# no `current_user` reference in the controller, and the auth
|
|
81
|
+
# logic is reusable + testable in isolation.
|
|
82
|
+
#
|
|
83
|
+
# Even better: declare `glib_authorize_resource` at the
|
|
84
|
+
# controller level. glib-web runs the appropriate policy
|
|
85
|
+
# method before each action automatically, so the per-action
|
|
86
|
+
# `verify_access` / `authorize @record` line is usually not
|
|
87
|
+
# needed at all — the controller body no longer references
|
|
88
|
+
# `current_user` because the auth check has moved out of the
|
|
89
|
+
# action entirely. See `best_practices/backend/en/05_controller.md`
|
|
90
|
+
# item #7 for the canonical pattern.
|
|
91
|
+
#
|
|
92
|
+
# ❌ Auth check in the controller — flagged
|
|
93
|
+
# def verify_access
|
|
94
|
+
# return if @support_question.accessible_by?(current_user)
|
|
95
|
+
# # ... token fallback ...
|
|
96
|
+
# end
|
|
97
|
+
#
|
|
98
|
+
# ✔️ Same check in the Policy — silently allowed
|
|
99
|
+
# # app/policies/support_question_policy.rb
|
|
100
|
+
# def access?
|
|
101
|
+
# user.present? && record.accessible_by?(user)
|
|
102
|
+
# end
|
|
103
|
+
#
|
|
104
|
+
# # in the controller:
|
|
105
|
+
# def verify_access
|
|
106
|
+
# return if policy(@support_question).access?
|
|
107
|
+
# # ... token fallback ...
|
|
108
|
+
# end
|
|
66
109
|
#
|
|
67
110
|
# NOTE: The cop does not autocorrect — there is no mechanical fix. The
|
|
68
111
|
# right response depends on developer intent: drop the branch (if auth
|
|
@@ -43,6 +43,13 @@ module RuboCop
|
|
|
43
43
|
# end
|
|
44
44
|
# end
|
|
45
45
|
#
|
|
46
|
+
# ## Forms covered
|
|
47
|
+
# The cop catches `default:` set at column-creation time AND `default:`
|
|
48
|
+
# set later via `change_column_default(..., to: <non-nil>)`. Both are
|
|
49
|
+
# the same anti-pattern — a permanent default living in the schema.
|
|
50
|
+
# `change_column_default(..., to: nil)` (removing a default) is not
|
|
51
|
+
# flagged; that is the cleanup form.
|
|
52
|
+
#
|
|
46
53
|
# ## Exception (auto-detected)
|
|
47
54
|
# For performance reasons (large tables with millions of records) or when
|
|
48
55
|
# using `null: false`, you may temporarily set a default and then
|
|
@@ -51,6 +58,10 @@ module RuboCop
|
|
|
51
58
|
# for the same table and column anywhere in the same method body (`def
|
|
52
59
|
# change` / `def up`), including inside `reversible do |dir| dir.up`.
|
|
53
60
|
#
|
|
61
|
+
# The exception applies symmetrically:
|
|
62
|
+
# - `add_column ... default: X` paired with `change_column_default ... to: nil` → no offense
|
|
63
|
+
# - `change_column_default ... to: X` paired with `change_column_default ... to: nil` → no offense
|
|
64
|
+
#
|
|
54
65
|
# ✔ (no offense — two-step pattern auto-detected)
|
|
55
66
|
# add_column :users, :profile_completion_rate, :float, default: 0.0
|
|
56
67
|
# change_column_default :users, :profile_completion_rate, from: 0.0, to: nil
|
|
@@ -62,14 +73,22 @@ module RuboCop
|
|
|
62
73
|
# # bad
|
|
63
74
|
# t.string :status, default: 'active'
|
|
64
75
|
#
|
|
76
|
+
# # bad — permanent default set via change_column_default
|
|
77
|
+
# change_column_default :users, :score, from: nil, to: 0
|
|
78
|
+
#
|
|
65
79
|
# # good
|
|
66
80
|
# add_column :users, :score, :integer
|
|
67
81
|
#
|
|
68
82
|
# # good (temporary default immediately removed — two-step pattern)
|
|
69
83
|
# add_column :users, :score, :integer, default: 0
|
|
70
84
|
# change_column_default :users, :score, from: 0, to: nil
|
|
85
|
+
#
|
|
86
|
+
# # good (removing an existing default)
|
|
87
|
+
# change_column_default :users, :score, from: 0, to: nil
|
|
71
88
|
class AvoidColumnDefault < Base
|
|
72
89
|
MSG = 'Avoid setting `default:` in migrations. Keep business logic defaults in the application layer.'.freeze
|
|
90
|
+
MSG_CHANGE = 'Avoid setting a non-nil default via `change_column_default`. ' \
|
|
91
|
+
'Keep business logic defaults in the application layer.'.freeze
|
|
73
92
|
|
|
74
93
|
COLUMN_METHODS = %i[
|
|
75
94
|
string integer float boolean datetime date text binary decimal
|
|
@@ -84,9 +103,22 @@ module RuboCop
|
|
|
84
103
|
(send _ :change_column_default (sym $_) (sym $_) (hash <(pair (sym :to) (nil)) ...>))
|
|
85
104
|
PATTERN
|
|
86
105
|
|
|
106
|
+
# change_column_default(:table, :col, ..., to: <non-nil>, ...) — captures table + col.
|
|
107
|
+
def_node_matcher :change_column_default_to_non_nil?, <<~PATTERN
|
|
108
|
+
(send _ :change_column_default (sym $_) (sym $_) (hash <(pair (sym :to) !nil) ...>))
|
|
109
|
+
PATTERN
|
|
110
|
+
|
|
87
111
|
def on_send(node)
|
|
88
|
-
|
|
112
|
+
if node.method?(:change_column_default)
|
|
113
|
+
check_change_column_default(node)
|
|
114
|
+
elsif column_method?(node)
|
|
115
|
+
check_column_method(node)
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
private
|
|
89
120
|
|
|
121
|
+
def check_column_method(node)
|
|
90
122
|
options = node.arguments.find(&:hash_type?)
|
|
91
123
|
return unless options && has_default_option?(options)
|
|
92
124
|
return if two_step_pattern?(node)
|
|
@@ -94,7 +126,16 @@ module RuboCop
|
|
|
94
126
|
add_offense(find_default_pair(options))
|
|
95
127
|
end
|
|
96
128
|
|
|
97
|
-
|
|
129
|
+
def check_change_column_default(node)
|
|
130
|
+
captures = change_column_default_to_non_nil?(node)
|
|
131
|
+
return unless captures
|
|
132
|
+
|
|
133
|
+
table_name, col_name = captures
|
|
134
|
+
return if cancelled_in_same_method?(node, table_name, col_name)
|
|
135
|
+
|
|
136
|
+
options = node.arguments.find(&:hash_type?)
|
|
137
|
+
add_offense(find_to_pair(options), message: MSG_CHANGE)
|
|
138
|
+
end
|
|
98
139
|
|
|
99
140
|
def column_method?(node)
|
|
100
141
|
node.method?(:add_column) || COLUMN_METHODS.include?(node.method_name)
|
|
@@ -104,10 +145,20 @@ module RuboCop
|
|
|
104
145
|
options.pairs.find { |p| p.key.sym_type? && p.key.value == :default }
|
|
105
146
|
end
|
|
106
147
|
|
|
148
|
+
def find_to_pair(options)
|
|
149
|
+
options.pairs.find { |p| p.key.sym_type? && p.key.value == :to }
|
|
150
|
+
end
|
|
151
|
+
|
|
107
152
|
def two_step_pattern?(node)
|
|
108
153
|
table_name, col_name = extract_table_and_column(node)
|
|
109
154
|
return false unless table_name && col_name
|
|
110
155
|
|
|
156
|
+
cancelled_in_same_method?(node, table_name, col_name)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Is there a `change_column_default(..., to: nil)` for the same
|
|
160
|
+
# (table, col) anywhere in the enclosing method? Used by both forms.
|
|
161
|
+
def cancelled_in_same_method?(node, table_name, col_name)
|
|
111
162
|
enclosing_def = node.each_ancestor(:def).first
|
|
112
163
|
return false unless enclosing_def
|
|
113
164
|
|
|
@@ -2,20 +2,24 @@ module RuboCop
|
|
|
2
2
|
module Cop
|
|
3
3
|
module DevDoc
|
|
4
4
|
module Migration
|
|
5
|
-
# Avoid vague column names like `status` or `
|
|
5
|
+
# Avoid vague column names like `status`, `group`, or `kind`.
|
|
6
6
|
#
|
|
7
7
|
# ## Rationale
|
|
8
8
|
# Use more specific names that describe the domain context. A column
|
|
9
9
|
# named `status` reveals nothing about *what* status it tracks; a column
|
|
10
10
|
# named `processing_status` makes the intent obvious at the call site.
|
|
11
|
+
# `kind` is the same shape — a content-free "what variety" label that
|
|
12
|
+
# reads no better than `type` (which Rails reserves for STI).
|
|
11
13
|
#
|
|
12
14
|
# ❌
|
|
13
15
|
# t.string :status
|
|
14
16
|
# add_column :orders, :group, :integer
|
|
17
|
+
# t.integer :kind
|
|
15
18
|
#
|
|
16
19
|
# ✔️
|
|
17
20
|
# t.string :processing_status
|
|
18
21
|
# add_column :orders, :user_group, :integer
|
|
22
|
+
# t.integer :membership_kind
|
|
19
23
|
#
|
|
20
24
|
# ## Note about `type`
|
|
21
25
|
# `type` is reserved by Rails for Single Table Inheritance (STI). Even
|
|
@@ -29,10 +33,12 @@ module RuboCop
|
|
|
29
33
|
# # bad
|
|
30
34
|
# t.string :status
|
|
31
35
|
# add_column :orders, :group, :integer
|
|
36
|
+
# t.integer :kind
|
|
32
37
|
#
|
|
33
38
|
# # good
|
|
34
39
|
# t.string :processing_status
|
|
35
40
|
# add_column :orders, :user_group, :integer
|
|
41
|
+
# t.integer :membership_kind
|
|
36
42
|
class AvoidVagueColumnNames < Base
|
|
37
43
|
MSG = 'Avoid vague column name `%<name>s`. Use a more specific name that includes context.'.freeze
|
|
38
44
|
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
module RuboCop
|
|
2
|
+
module Cop
|
|
3
|
+
module DevDoc
|
|
4
|
+
module Rails
|
|
5
|
+
# Avoid overriding Rails validation/persistence *lifecycle* methods in
|
|
6
|
+
# model files (`run_validations!`, `valid?`, etc.).
|
|
7
|
+
#
|
|
8
|
+
# ## Rationale
|
|
9
|
+
# `DevDoc/Rails/AvoidRailsCallbacks` bans the callback DSL so lifecycle
|
|
10
|
+
# behaviour stays visible at explicit call sites. Overriding a lifecycle
|
|
11
|
+
# method is a loophole — it reproduces the same hidden control flow
|
|
12
|
+
# without tripping that cop:
|
|
13
|
+
#
|
|
14
|
+
# # Flagged by AvoidRailsCallbacks:
|
|
15
|
+
# before_validation :do_something
|
|
16
|
+
#
|
|
17
|
+
# # NOT flagged by it — but functionally identical, and worse:
|
|
18
|
+
# def run_validations!
|
|
19
|
+
# do_something
|
|
20
|
+
# super
|
|
21
|
+
# end
|
|
22
|
+
#
|
|
23
|
+
# `def run_validations!; do_something; super; end` is a
|
|
24
|
+
# `before_validation :do_something` in disguise, and it's strictly worse:
|
|
25
|
+
#
|
|
26
|
+
# - **Silent on typo.** Misspell the method name (drop the `!`, or
|
|
27
|
+
# `run_validatons!`) and it's just a never-called method — no load-time
|
|
28
|
+
# error, no override in effect, behaviour silently reverts to stock
|
|
29
|
+
# Rails. The callback DSL fails loudly (`NoMethodError`) on a mistyped
|
|
30
|
+
# macro or symbol, caught by the first test that validates.
|
|
31
|
+
# - **More obscure.** Overriding an internal Rails method hides the
|
|
32
|
+
# lifecycle hook even more than the DSL it's avoiding.
|
|
33
|
+
#
|
|
34
|
+
# This cop pairs with (it does not replace) `AvoidRailsCallbacks`: the two
|
|
35
|
+
# cover the two ways to inject lifecycle behaviour — the DSL, and the
|
|
36
|
+
# override.
|
|
37
|
+
#
|
|
38
|
+
# ## What to do instead
|
|
39
|
+
# 1. Prefer an explicit method at the call site (e.g. `save_with_*`) that
|
|
40
|
+
# makes the behaviour visible, or a plain `validate :some_check` where
|
|
41
|
+
# a validation-time check is all you need.
|
|
42
|
+
# 2. If an override is genuinely required (e.g. a model whose validators
|
|
43
|
+
# read access-gated attributes and must wrap the *entire* validation
|
|
44
|
+
# run — something `validate` can't express), disable this cop inline
|
|
45
|
+
# with a written justification:
|
|
46
|
+
#
|
|
47
|
+
# def run_validations! # rubocop:disable DevDoc/Rails/AvoidLifecycleMethodOverride
|
|
48
|
+
# # Reason: <explanation>
|
|
49
|
+
# with_access { super }
|
|
50
|
+
# end
|
|
51
|
+
#
|
|
52
|
+
# ## Not airtight
|
|
53
|
+
# A determined dodge can still `prepend` a module, `alias_method`, or use
|
|
54
|
+
# `method_missing`. This cop raises the bar and makes the documented path
|
|
55
|
+
# (inline disable + justification) the easy one; it does not seal every
|
|
56
|
+
# hole.
|
|
57
|
+
#
|
|
58
|
+
# ## Configuration
|
|
59
|
+
# The flagged methods are configurable via `Methods`. The default is the
|
|
60
|
+
# validation-lifecycle set — the cleanest 1:1 substitute for the
|
|
61
|
+
# validation callbacks `AvoidRailsCallbacks` bans, with the least
|
|
62
|
+
# false-positive noise. Projects that also want to guard persistence
|
|
63
|
+
# verbs can add `save`/`save!`/`create`/`update`/`destroy` etc.
|
|
64
|
+
#
|
|
65
|
+
# @example
|
|
66
|
+
# # bad
|
|
67
|
+
# def run_validations!
|
|
68
|
+
# normalize
|
|
69
|
+
# super
|
|
70
|
+
# end
|
|
71
|
+
#
|
|
72
|
+
# # bad
|
|
73
|
+
# def valid?(context = nil)
|
|
74
|
+
# toggle_access { super }
|
|
75
|
+
# end
|
|
76
|
+
#
|
|
77
|
+
# # good — an explicit method / a plain validation declaration
|
|
78
|
+
# validate :assert_consistent
|
|
79
|
+
#
|
|
80
|
+
# # good — a non-lifecycle override is fine
|
|
81
|
+
# def to_param
|
|
82
|
+
# slug
|
|
83
|
+
# end
|
|
84
|
+
class AvoidLifecycleMethodOverride < Base
|
|
85
|
+
MSG = 'Avoid overriding the Rails lifecycle method `%<method>s` — it hides ' \
|
|
86
|
+
'callback-like control flow and silently no-ops if mistyped. Prefer an ' \
|
|
87
|
+
'explicit method or a `validate` declaration; if an override is genuinely ' \
|
|
88
|
+
'required, disable this cop inline with a written justification.'.freeze
|
|
89
|
+
|
|
90
|
+
DEFAULT_METHODS = %w[run_validations! valid? invalid? perform_validations].freeze
|
|
91
|
+
|
|
92
|
+
def on_def(node)
|
|
93
|
+
return unless flagged_methods.include?(node.method_name)
|
|
94
|
+
|
|
95
|
+
add_offense(node.loc.name, message: format(MSG, method: node.method_name))
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
private
|
|
99
|
+
|
|
100
|
+
def flagged_methods
|
|
101
|
+
@flagged_methods ||= Array(cop_config['Methods'] || DEFAULT_METHODS).map(&:to_sym)
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
module RuboCop
|
|
2
|
+
module Cop
|
|
3
|
+
module DevDoc
|
|
4
|
+
module Rails
|
|
5
|
+
# Avoid ordering by `id` as the primary (sole or leftmost) sort.
|
|
6
|
+
#
|
|
7
|
+
# ## Rationale
|
|
8
|
+
# `order(id: ...)` / `order(:id)` sorts by the primary key, which
|
|
9
|
+
# reflects insertion order (and, for UUIDs, random order) rather
|
|
10
|
+
# than a column the reader can reason about. It makes a list read
|
|
11
|
+
# as "most recent" by accident, hides the real ordering intent,
|
|
12
|
+
# and silently breaks the moment rows are imported out of sequence
|
|
13
|
+
# or the primary key type changes.
|
|
14
|
+
#
|
|
15
|
+
# Order by a meaningful business column instead, and reach for
|
|
16
|
+
# `id` only as a **secondary** key to break ties deterministically:
|
|
17
|
+
#
|
|
18
|
+
# ❌ Primary key is not a sort the reader can reason about
|
|
19
|
+
# Snapshot.order(id: :desc)
|
|
20
|
+
#
|
|
21
|
+
# ✔️ Business column carries the intent
|
|
22
|
+
# Snapshot.order(created_at: :desc)
|
|
23
|
+
#
|
|
24
|
+
# ✔️ `id` as a deterministic tiebreaker — never the primary sort
|
|
25
|
+
# Snapshot.order(created_at: :desc, id: :desc)
|
|
26
|
+
#
|
|
27
|
+
# See `best_practices/backend/en/05_controller.md` — always `.order()`
|
|
28
|
+
# when displaying more than one record, and reserve `order(id: ...)`
|
|
29
|
+
# for the rare case where id-ordering is genuinely intended.
|
|
30
|
+
#
|
|
31
|
+
# ## Scope
|
|
32
|
+
# Flags `order` and `reorder` where `:id` is the **sole** ordering
|
|
33
|
+
# column or the **leftmost** (primary) one, in hash form
|
|
34
|
+
# (`order(id: :desc)`), symbol form (`order(:id)`,
|
|
35
|
+
# `order(:id, :name)`), or array form (`order([:id])`).
|
|
36
|
+
#
|
|
37
|
+
# Only the FIRST argument is inspected — it is the primary sort;
|
|
38
|
+
# every later argument is a tiebreaker. Raw-SQL string ordering
|
|
39
|
+
# (`order("id")`, `order("id DESC")`) is the domain of
|
|
40
|
+
# `DevDoc/Rails/AvoidRawSql`, so this cop defers the moment its
|
|
41
|
+
# leading argument is a string, an `Arel.sql(...)`, or anything
|
|
42
|
+
# other than a hash/symbol/array — the primary sort can't be read
|
|
43
|
+
# statically, and flagging would risk a false positive on a
|
|
44
|
+
# tiebreaker.
|
|
45
|
+
#
|
|
46
|
+
# NOTE: Each `order`/`reorder` call is inspected in isolation, so a
|
|
47
|
+
# chained form like `rel.order(:created_at).order(id: :desc)` is
|
|
48
|
+
# flagged even though Rails *appends* chained `order` calls — making
|
|
49
|
+
# `id` a secondary tiebreaker there. The deterministic-tiebreaker
|
|
50
|
+
# idiom this cop endorses is the single-call hash/symbol form
|
|
51
|
+
# (`order(created_at: :desc, id: :desc)`); prefer that, or disable
|
|
52
|
+
# inline with a reason when the chained form is genuinely intended.
|
|
53
|
+
#
|
|
54
|
+
# ## `id` as a secondary key is allowed
|
|
55
|
+
# `order(created_at: :desc, id: :desc)` is the standard pattern for
|
|
56
|
+
# deterministic ordering — `id` only breaks ties after the business
|
|
57
|
+
# column. The cop allows `:id` in any non-primary position:
|
|
58
|
+
#
|
|
59
|
+
# ✔️ `id` breaks ties only — primary sort is `created_at`
|
|
60
|
+
# Snapshot.order(created_at: :desc, id: :desc)
|
|
61
|
+
# Snapshot.order(:created_at, :id)
|
|
62
|
+
#
|
|
63
|
+
# ## Exception
|
|
64
|
+
# When `id` is genuinely the intended primary sort (rare — usually a
|
|
65
|
+
# sign something is special about the code), suppress the offense
|
|
66
|
+
# with an inline disable comment and a brief reason — e.g. an
|
|
67
|
+
# append-only audit log where `id` is creation order, so
|
|
68
|
+
# id-ordering is the real intent rather than an accident.
|
|
69
|
+
#
|
|
70
|
+
# NOTE: A named scope or model method that wraps the offense hides
|
|
71
|
+
# it at the call site — `scope :newest, -> { order(id: :desc) }` is
|
|
72
|
+
# flagged in the model file, but callers of `.newest` are not. The
|
|
73
|
+
# cop flags the definition, which is where the decision belongs.
|
|
74
|
+
#
|
|
75
|
+
# NOTE: Prefer `order(created_at: :desc)` for deterministic
|
|
76
|
+
# results — see `best_practices/backend/en/05_controller.md` #4.
|
|
77
|
+
# The rare legitimate `order(id:)` cases (see Exception above) —
|
|
78
|
+
# e.g. a test grabbing a record a controller just created, where
|
|
79
|
+
# `created_at` ties across one request and `id` is the only
|
|
80
|
+
# reliable insertion-order proxy — take an inline disable with a
|
|
81
|
+
# reason, not a blanket `Exclude` of `spec/**`/`test/**` (that
|
|
82
|
+
# silences accidental id-ordering in tests).
|
|
83
|
+
#
|
|
84
|
+
# @example
|
|
85
|
+
# # bad — `id` is the sole or primary sort
|
|
86
|
+
# Snapshot.order(id: :desc)
|
|
87
|
+
# Snapshot.order(:id)
|
|
88
|
+
# Snapshot.order(id: :desc, created_at: :desc)
|
|
89
|
+
# Snapshot.order(:id, :created_at)
|
|
90
|
+
# submission.snapshots.reorder(id: :asc)
|
|
91
|
+
#
|
|
92
|
+
# # good — business column primary, `id` as a tiebreaker
|
|
93
|
+
# Snapshot.order(created_at: :desc)
|
|
94
|
+
# Snapshot.order(created_at: :desc, id: :desc)
|
|
95
|
+
# Snapshot.order(:created_at, :id)
|
|
96
|
+
class AvoidOrderingById < Base
|
|
97
|
+
MSG = 'Ordering by `id` as the primary sort exposes the primary ' \
|
|
98
|
+
"key's insertion order rather than a meaningful column. " \
|
|
99
|
+
'Order by a business column (e.g. `created_at:`), or move ' \
|
|
100
|
+
'`id` to a secondary position as a deterministic tiebreaker. ' \
|
|
101
|
+
'Disable with a reason when `id` is genuinely the intended ' \
|
|
102
|
+
'primary sort.'.freeze
|
|
103
|
+
|
|
104
|
+
RESTRICT_ON_SEND = %i[order reorder].freeze
|
|
105
|
+
|
|
106
|
+
def on_send(node)
|
|
107
|
+
first = first_ordering_column(node)
|
|
108
|
+
return unless first
|
|
109
|
+
return unless id_column?(first)
|
|
110
|
+
|
|
111
|
+
add_offense(node.loc.selector)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Safe navigation (`rel&.order(id: :desc)`) parses as a `:csend`
|
|
115
|
+
# node, which `on_send` does not visit — alias it so the same
|
|
116
|
+
# check covers both dispatch forms.
|
|
117
|
+
alias on_csend on_send
|
|
118
|
+
|
|
119
|
+
private
|
|
120
|
+
|
|
121
|
+
# The leftmost ordering column, or nil when the primary sort
|
|
122
|
+
# isn't statically knowable. Only the FIRST argument is inspected
|
|
123
|
+
# — it alone is the primary sort; every later argument is a
|
|
124
|
+
# tiebreaker.
|
|
125
|
+
#
|
|
126
|
+
# order(:created_at, id: :desc) → :created_at (id secondary)
|
|
127
|
+
# order(id: :desc) → :id pair key
|
|
128
|
+
# order([:id]) → :id (array recurses)
|
|
129
|
+
#
|
|
130
|
+
# Returns nil (defers) for a raw-SQL string (`DevDoc/Rails/
|
|
131
|
+
# AvoidRawSql` owns it) AND for any other non-structured leading
|
|
132
|
+
# arg — an `Arel.sql` node, a helper call, a splat, a literal —
|
|
133
|
+
# because the primary sort can't be read statically and flagging
|
|
134
|
+
# would risk a false positive on what may be a tiebreaker.
|
|
135
|
+
def first_ordering_column(node)
|
|
136
|
+
ordering_column_of(node.arguments.first)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Resolve the primary sort from a single argument node, recursing
|
|
140
|
+
# into an array (`order([:id])`) so the array form matches the
|
|
141
|
+
# bare-symbol form.
|
|
142
|
+
def ordering_column_of(arg)
|
|
143
|
+
return nil unless arg
|
|
144
|
+
return nil if arg.str_type? || arg.dstr_type?
|
|
145
|
+
return first_hash_key(arg) if arg.hash_type?
|
|
146
|
+
return ordering_column_of(arg.children.first) if arg.array_type?
|
|
147
|
+
return arg if arg.sym_type?
|
|
148
|
+
|
|
149
|
+
nil
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def first_hash_key(hash_node)
|
|
153
|
+
pair = hash_node.pairs.first
|
|
154
|
+
return nil unless pair
|
|
155
|
+
|
|
156
|
+
key = pair.key
|
|
157
|
+
key if key&.sym_type?
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def id_column?(node)
|
|
161
|
+
node.sym_type? && node.value == :id
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
@@ -22,12 +22,19 @@ module RuboCop
|
|
|
22
22
|
# class Order < ApplicationRecord
|
|
23
23
|
# def save_with_confirmation_email
|
|
24
24
|
# transaction do
|
|
25
|
-
# save
|
|
26
|
-
#
|
|
25
|
+
# saved = save
|
|
26
|
+
# invoice.save! if saved
|
|
27
27
|
# end
|
|
28
|
+
# OrderMailer.confirmation(self).deliver_later if saved
|
|
28
29
|
# end
|
|
29
30
|
# end
|
|
30
31
|
#
|
|
32
|
+
# The `deliver_later` is *outside* the transaction deliberately —
|
|
33
|
+
# `DevDoc/Rails/NoDeliverLaterInTransaction` flags mailers/jobs
|
|
34
|
+
# queued inside a transaction (the job may run before commit and
|
|
35
|
+
# read stale data). The `saved` local leaks out of the block
|
|
36
|
+
# (Ruby's normal scoping), gating the mailer on actual success.
|
|
37
|
+
#
|
|
31
38
|
# ## When you have multiple call sites needing the same guard
|
|
32
39
|
# The single-method-per-place pattern above works when there is one
|
|
33
40
|
# natural call site. When several controllers/jobs/bulk operations all
|
|
@@ -101,8 +108,11 @@ module RuboCop
|
|
|
101
108
|
#
|
|
102
109
|
# # good
|
|
103
110
|
# def save_with_confirmation
|
|
104
|
-
# transaction
|
|
105
|
-
#
|
|
111
|
+
# transaction do
|
|
112
|
+
# saved = save
|
|
113
|
+
# invoice.save! if saved
|
|
114
|
+
# end
|
|
115
|
+
# send_confirmation if saved
|
|
106
116
|
# end
|
|
107
117
|
class AvoidRailsCallbacks < Base
|
|
108
118
|
CALLBACKS = %i[
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
module RuboCop
|
|
2
|
+
module Cop
|
|
3
|
+
module DevDoc
|
|
4
|
+
module Rails
|
|
5
|
+
# Avoid raw SQL in query methods and `connection.execute`.
|
|
6
|
+
#
|
|
7
|
+
# ## Rationale
|
|
8
|
+
# Raw SQL bypasses Rails' adapter abstraction, hides typos in
|
|
9
|
+
# column and table names (a misspelled column silently returns
|
|
10
|
+
# nothing instead of raising `NameError`), is non-reversible in
|
|
11
|
+
# migrations, and ships no audit trail for why raw SQL was chosen
|
|
12
|
+
# over the Rails idiom. Every raw-SQL site should be a deliberate,
|
|
13
|
+
# reviewable choice — not a default.
|
|
14
|
+
#
|
|
15
|
+
# Prefer, in order:
|
|
16
|
+
#
|
|
17
|
+
# - The hash/array form: `where(status: "active")`,
|
|
18
|
+
# `where(status: ["active", "pending"])`.
|
|
19
|
+
# - Parameterized fragments: `where("col = ?", val)`,
|
|
20
|
+
# `where("col = :sym", sym: val)`. The `?` / `:name` placeholder
|
|
21
|
+
# binds the value safely and the column name is the only
|
|
22
|
+
# user-controlled surface.
|
|
23
|
+
# - Arel nodes for database-specific constructs that the hash
|
|
24
|
+
# form can't express.
|
|
25
|
+
#
|
|
26
|
+
# ❌ Raw SQL — typo in `statu` returns nothing, silently
|
|
27
|
+
# User.where("statu = 'active'")
|
|
28
|
+
#
|
|
29
|
+
# ✔️ Hash form — typo raises `ArgumentError` at the query layer
|
|
30
|
+
# User.where(status: "active")
|
|
31
|
+
#
|
|
32
|
+
# ✔️ Parameterized fragment — value bound, column name spelled
|
|
33
|
+
# User.where("status = ?", status)
|
|
34
|
+
#
|
|
35
|
+
# ## Scope
|
|
36
|
+
# The cop covers any string argument to:
|
|
37
|
+
#
|
|
38
|
+
# - **Execution methods** — `execute`, `exec_query`, `exec_update`,
|
|
39
|
+
# `exec_delete`, `exec_insert`, `find_by_sql`, `select_rows`,
|
|
40
|
+
# `select_values`, `select_one`, `select_all`, `update_sql`,
|
|
41
|
+
# `insert_sql`, `delete_sql`, `update_all`, `delete_all`.
|
|
42
|
+
# - **Query fragment methods** — `where`, `having`, `order`,
|
|
43
|
+
# `reorder`, `joins`, `select`, `group`, `from`, `lock`, `pluck`.
|
|
44
|
+
# - **`Arel.sql`** — the explicit opt-in to raw SQL (see below).
|
|
45
|
+
#
|
|
46
|
+
# Applies everywhere — application code AND migrations. A
|
|
47
|
+
# migration's `execute "UPDATE ..."` is the same smell as an
|
|
48
|
+
# app code `where("...")`; both bypass the adapter and offer no
|
|
49
|
+
# audit trail.
|
|
50
|
+
#
|
|
51
|
+
# ## `Arel.sql`
|
|
52
|
+
# Always flagged. `Arel.sql` exists specifically to bypass Rails'
|
|
53
|
+
# raw-SQL protection — reaching for it should require
|
|
54
|
+
# justification, not be a reflex. If a fragment is genuinely
|
|
55
|
+
# needed, disable inline with a reason; otherwise express it as
|
|
56
|
+
# an Arel AST node or a parameterized query.
|
|
57
|
+
#
|
|
58
|
+
# ## Counter updates (`update_all("col = col + 1")`)
|
|
59
|
+
# For **single records**, use `Model.increment_counter(:col, id)`
|
|
60
|
+
# or `Model.update_counters(id, col: 1)` — both generate
|
|
61
|
+
# parameterized SQL and surface typos in the column name. Neither
|
|
62
|
+
# is flagged by `DevDoc/Migration/AvoidBypassingValidation` (they
|
|
63
|
+
# are the Rails-blessed atomic-counter primitives).
|
|
64
|
+
#
|
|
65
|
+
# For **bulk counter updates**, the only clean option is
|
|
66
|
+
# `find_each { |m| Model.increment_counter(:col, m.id) }` — N
|
|
67
|
+
# queries, slow on large tables, but free of the
|
|
68
|
+
# validation-bypass smell. If the N-query cost is genuinely
|
|
69
|
+
# unacceptable, `update_all("col = col + 1")` requires disabling
|
|
70
|
+
# BOTH this cop AND `DevDoc/Migration/AvoidBypassingValidation`
|
|
71
|
+
# with reasons — the friction is the audit trail (locking
|
|
72
|
+
# implications, idempotency on re-runs, and the like are worth
|
|
73
|
+
# a second look).
|
|
74
|
+
#
|
|
75
|
+
# ❌ Single-record counter update via raw SQL
|
|
76
|
+
# User.where(id: id).update_all("login_count = login_count + 1")
|
|
77
|
+
#
|
|
78
|
+
# ✔️ Parameterized — generates safe SQL
|
|
79
|
+
# User.increment_counter(:login_count, id)
|
|
80
|
+
#
|
|
81
|
+
# ## Exception
|
|
82
|
+
# Genuine raw-SQL needs — database-specific DDL
|
|
83
|
+
# (`CREATE INDEX CONCURRENTLY`, `ALTER TYPE ... ADD VALUE`),
|
|
84
|
+
# complex joins that don't fit Arel, analytic queries with CTEs
|
|
85
|
+
# — go through an inline disable comment with a brief reason.
|
|
86
|
+
# The friction is the feature: every raw-SQL site ends up in
|
|
87
|
+
# code review with an articulated justification.
|
|
88
|
+
#
|
|
89
|
+
# ## Migration backfills are NOT an exception
|
|
90
|
+
# A common AI-agent reflex is to justify
|
|
91
|
+
# `execute "UPDATE users SET role = 0 ..."` with "models can
|
|
92
|
+
# drift, so raw SQL is safer." That justification is rejected
|
|
93
|
+
# here. The data-integrity cost of bypassing validations and
|
|
94
|
+
# callbacks outweighs the (rare, usually catchable) risk of a
|
|
95
|
+
# migration failing because a model changed.
|
|
96
|
+
#
|
|
97
|
+
# The correct backfill pattern goes through the model with
|
|
98
|
+
# `save!` — `DevDoc/Migration/AvoidBypassingValidation`
|
|
99
|
+
# enforces this, and `best_practices/backend/en/02_migration.md`
|
|
100
|
+
# shows the shape:
|
|
101
|
+
#
|
|
102
|
+
# ✔️ Model through the migration — validations run, callbacks fire
|
|
103
|
+
# User.where(role: nil).find_each do |user|
|
|
104
|
+
# user.role = 0
|
|
105
|
+
# user.save!
|
|
106
|
+
# end
|
|
107
|
+
#
|
|
108
|
+
# If existing rows would fail validation, fix the data first
|
|
109
|
+
# rather than bypassing validation — that's the signal, not an
|
|
110
|
+
# obstacle.
|
|
111
|
+
#
|
|
112
|
+
# NOTE: Parameterized fragments are detected by the presence of
|
|
113
|
+
# a `?` or `:name` placeholder plus at least one additional
|
|
114
|
+
# argument. A `?` appearing in literal SQL that isn't a
|
|
115
|
+
# placeholder (`where("name LIKE '%?%'")`) is a residual false
|
|
116
|
+
# negative; inline-disable if it surfaces.
|
|
117
|
+
#
|
|
118
|
+
# NOTE: Interpolated strings (`where("col = '#{val}'")`) are
|
|
119
|
+
# ALWAYS flagged — they cannot be parameterized by construction
|
|
120
|
+
# and are a SQL-injection risk. Use the `?` placeholder form.
|
|
121
|
+
#
|
|
122
|
+
# NOTE: Method calls returning strings (`where(some_helper)`)
|
|
123
|
+
# are not flagged — the cop only inspects literal strings.
|
|
124
|
+
# Helper methods that wrap raw SQL should themselves carry the
|
|
125
|
+
# disable.
|
|
126
|
+
#
|
|
127
|
+
# @example
|
|
128
|
+
# # bad — raw SQL
|
|
129
|
+
# execute "UPDATE users SET role = 0 WHERE role IS NULL"
|
|
130
|
+
# User.where("status = 'active'")
|
|
131
|
+
# User.where("status = '#{status}'")
|
|
132
|
+
# User.order("created_at")
|
|
133
|
+
# User.joins("INNER JOIN items ON items.user_id = users.id")
|
|
134
|
+
# User.update_all("login_count = login_count + 1")
|
|
135
|
+
# User.select("DISTINCT status")
|
|
136
|
+
# Arel.sql("NULLS LAST")
|
|
137
|
+
#
|
|
138
|
+
# # good — hash / array / symbol form
|
|
139
|
+
# User.where(status: "active")
|
|
140
|
+
# User.where(status: ["active", "pending"])
|
|
141
|
+
# User.order(:created_at)
|
|
142
|
+
# User.joins(:items)
|
|
143
|
+
# User.update_all(status: "active")
|
|
144
|
+
# User.select(:status)
|
|
145
|
+
#
|
|
146
|
+
# # good — parameterized fragment
|
|
147
|
+
# User.where("status = ?", status)
|
|
148
|
+
# User.where("status = :sym", sym: status)
|
|
149
|
+
# User.find_by_sql("SELECT * FROM users WHERE id = ?", id)
|
|
150
|
+
#
|
|
151
|
+
# # good — counter update on a single record
|
|
152
|
+
# User.increment_counter(:login_count, id)
|
|
153
|
+
class AvoidRawSql < Base
|
|
154
|
+
MSG = 'Avoid raw SQL — prefer the hash/array form, a ' \
|
|
155
|
+
'parameterized fragment, or an Arel node. Disable with ' \
|
|
156
|
+
'a reason when raw SQL is genuinely required.'.freeze
|
|
157
|
+
|
|
158
|
+
EXECUTION_METHODS = %i[
|
|
159
|
+
execute exec_query exec_update exec_delete exec_insert
|
|
160
|
+
find_by_sql select_rows select_values select_one select_all
|
|
161
|
+
update_sql insert_sql delete_sql update_all delete_all
|
|
162
|
+
].freeze
|
|
163
|
+
|
|
164
|
+
FRAGMENT_METHODS = %i[
|
|
165
|
+
where having order reorder joins select group from lock pluck
|
|
166
|
+
].freeze
|
|
167
|
+
|
|
168
|
+
SQL_METHODS = (EXECUTION_METHODS + FRAGMENT_METHODS).freeze
|
|
169
|
+
|
|
170
|
+
RESTRICT_ON_SEND = (SQL_METHODS + [:sql]).freeze
|
|
171
|
+
|
|
172
|
+
def on_send(node)
|
|
173
|
+
if arel_sql?(node)
|
|
174
|
+
check_arel_sql(node)
|
|
175
|
+
elsif SQL_METHODS.include?(node.method_name)
|
|
176
|
+
check_sql_method(node)
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
private
|
|
181
|
+
|
|
182
|
+
# `Arel.sql("...")` — the explicit opt-in to raw SQL. The
|
|
183
|
+
# receiver must be the `Arel` constant so unrelated
|
|
184
|
+
# `obj.sql(...)` calls don't false-positive.
|
|
185
|
+
def arel_sql?(node)
|
|
186
|
+
node.method_name == :sql &&
|
|
187
|
+
node.receiver&.const_type? &&
|
|
188
|
+
node.receiver.const_name == 'Arel'
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# Arel.sql is always flagged (no parameterized exemption) —
|
|
192
|
+
# reaching for it is itself the smell being audited.
|
|
193
|
+
def check_arel_sql(node)
|
|
194
|
+
arg = node.first_argument
|
|
195
|
+
return unless arg && string?(arg)
|
|
196
|
+
|
|
197
|
+
add_offense(arg)
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def check_sql_method(node)
|
|
201
|
+
arg = node.first_argument
|
|
202
|
+
return unless arg && string?(arg)
|
|
203
|
+
return if parameterized?(node, arg)
|
|
204
|
+
|
|
205
|
+
add_offense(arg)
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def string?(arg)
|
|
209
|
+
arg.str_type? || arg.dstr_type?
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# A literal string (`str`) is parameterized when it contains a
|
|
213
|
+
# `?` or `:name` placeholder AND there's at least one more
|
|
214
|
+
# argument to bind. An interpolated string (`dstr`) is NEVER
|
|
215
|
+
# parameterized — the value is concatenated in, not bound.
|
|
216
|
+
def parameterized?(send_node, string_arg)
|
|
217
|
+
return false if string_arg.dstr_type?
|
|
218
|
+
return false unless send_node.arguments.count > 1
|
|
219
|
+
|
|
220
|
+
value = string_arg.value
|
|
221
|
+
value.include?('?') || value.match?(/:[a-z_][a-z0-9_]*/i)
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
end
|
|
@@ -120,15 +120,21 @@ module RuboCop
|
|
|
120
120
|
end
|
|
121
121
|
|
|
122
122
|
# Build the replacement for `params.require(:key).permit(*args)`.
|
|
123
|
-
# Symbol args stay as-is; hash args
|
|
124
|
-
#
|
|
123
|
+
# Symbol args stay as-is; hash args need explicit braces when placed
|
|
124
|
+
# inside the `expect` array literal.
|
|
125
125
|
def build_require_permit_replacement(params_node, key, permit_args)
|
|
126
126
|
inner = permit_args.map { |arg| permit_arg_source(arg) }.join(', ')
|
|
127
127
|
"#{params_node.source}.expect(#{key}: [#{inner}])"
|
|
128
128
|
end
|
|
129
129
|
|
|
130
|
+
# A hash arg written WITHOUT braces (the trailing-kwargs form,
|
|
131
|
+
# `permit(:a, b: [1])`) has a brace-less source, so add them. A hash
|
|
132
|
+
# arg written WITH explicit braces (`permit(:a, { b: [1] })`) already
|
|
133
|
+
# includes them in its source — wrapping again would emit `{ {...} }`.
|
|
130
134
|
def permit_arg_source(arg)
|
|
131
|
-
|
|
135
|
+
return arg.source unless arg.hash_type?
|
|
136
|
+
|
|
137
|
+
arg.braces? ? arg.source : "{ #{arg.source} }"
|
|
132
138
|
end
|
|
133
139
|
end
|
|
134
140
|
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
module RuboCop
|
|
2
|
+
module Cop
|
|
3
|
+
module DevDoc
|
|
4
|
+
module Style
|
|
5
|
+
# Prefer `public_send` over `send` when dispatching dynamically.
|
|
6
|
+
#
|
|
7
|
+
# ## Rationale
|
|
8
|
+
# `public_send` respects Ruby's method visibility. When the dispatched
|
|
9
|
+
# name should resolve to a public API method (the common case for
|
|
10
|
+
# generic validators, form helpers, attribute readers, etc.), using
|
|
11
|
+
# `public_send`:
|
|
12
|
+
#
|
|
13
|
+
# - **Surfaces typos / misconfig immediately.** A name that drifts to
|
|
14
|
+
# a private internal raises `NoMethodError` instead of silently
|
|
15
|
+
# invoking it.
|
|
16
|
+
# - **Future-proofs the call site.** If the target method later moves
|
|
17
|
+
# to private (refactor, contract narrowing), `public_send` raises
|
|
18
|
+
# on the next run; `send` keeps silently working — potentially
|
|
19
|
+
# masking a real bug.
|
|
20
|
+
# - **Documents intent.** A reader of `public_send` knows the author
|
|
21
|
+
# meant a public-API call. `send` leaves it ambiguous whether
|
|
22
|
+
# private access was wanted.
|
|
23
|
+
#
|
|
24
|
+
# ## Relationship to AvoidSend
|
|
25
|
+
# `DevDoc/Style/AvoidSend` flags *dynamic* dispatch (both `send` and
|
|
26
|
+
# `public_send`) as risky. This cop is orthogonal: it flags `send`
|
|
27
|
+
# specifically, including the cases AvoidSend exempts (literal-symbol
|
|
28
|
+
# args, prefix-string args). The friction asymmetry is intentional —
|
|
29
|
+
# `send` is the deeper exception, so it costs an extra disable:
|
|
30
|
+
#
|
|
31
|
+
# obj.public_send(method_name) # AvoidSend: 1 disable
|
|
32
|
+
# obj.public_send(:foo) # clean
|
|
33
|
+
# obj.send(method_name) # AvoidSend + this cop: 2 disables
|
|
34
|
+
# obj.send(:foo) # this cop only: 1 disable
|
|
35
|
+
#
|
|
36
|
+
# ## When `send` IS the right choice
|
|
37
|
+
# Reach for `send` only when you have an articulable reason to bypass
|
|
38
|
+
# visibility — and leave a comment so the next reader knows it's
|
|
39
|
+
# deliberate:
|
|
40
|
+
#
|
|
41
|
+
# - **Tests** calling a private helper:
|
|
42
|
+
#
|
|
43
|
+
# # rubocop:disable DevDoc/Style/PreferPublicSend -- unit-testing private method
|
|
44
|
+
# assert_equal 5, calculator.send(:internal_carry)
|
|
45
|
+
# # rubocop:enable DevDoc/Style/PreferPublicSend
|
|
46
|
+
#
|
|
47
|
+
# - **Framework / introspection** code that intentionally calls
|
|
48
|
+
# private callbacks (e.g. `record.send(:_run_save_callbacks)`).
|
|
49
|
+
#
|
|
50
|
+
# ## Receiver-less `send` is exempt
|
|
51
|
+
# `send(:foo)` (no explicit receiver) calls a method on `self` and is
|
|
52
|
+
# commonly used inside a class to dispatch to its own private methods.
|
|
53
|
+
# Rewriting to `public_send` would either change semantics (the
|
|
54
|
+
# method must become public) or fail. Same exemption as `AvoidSend`.
|
|
55
|
+
#
|
|
56
|
+
# @example
|
|
57
|
+
# # bad — prefer public_send
|
|
58
|
+
# record.send(attribute)
|
|
59
|
+
# instance.send(:public_method)
|
|
60
|
+
# obj.send("export_#{x}")
|
|
61
|
+
#
|
|
62
|
+
# # good — visibility-respecting dispatch
|
|
63
|
+
# record.public_send(attribute)
|
|
64
|
+
# instance.public_send(:public_method)
|
|
65
|
+
# obj.public_send("export_#{x}")
|
|
66
|
+
#
|
|
67
|
+
# # good — receiver-less send (calling self's own method)
|
|
68
|
+
# send(:internal_helper)
|
|
69
|
+
class PreferPublicSend < Base
|
|
70
|
+
MSG = 'Prefer `public_send` over `send` — it respects method ' \
|
|
71
|
+
'visibility, surfacing typos/misconfig that would otherwise ' \
|
|
72
|
+
'silently invoke a private method. Disable with a reason ' \
|
|
73
|
+
'when bypassing visibility is intentional.'.freeze
|
|
74
|
+
|
|
75
|
+
RESTRICT_ON_SEND = %i[send].freeze
|
|
76
|
+
|
|
77
|
+
def on_send(node)
|
|
78
|
+
return if node.receiver.nil?
|
|
79
|
+
|
|
80
|
+
add_offense(node.loc.selector)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
module RuboCop
|
|
2
|
+
module Cop
|
|
3
|
+
module DevDoc
|
|
4
|
+
module Style
|
|
5
|
+
# Flag `Object#tap` whose block ignores the yielded value.
|
|
6
|
+
#
|
|
7
|
+
# ## Rationale
|
|
8
|
+
# `tap` exists to **operate on the yielded receiver and return it** —
|
|
9
|
+
# `obj.tap { |x| configure(x) }`. The yielded value is the whole
|
|
10
|
+
# point of the method.
|
|
11
|
+
#
|
|
12
|
+
# When the block **ignores** the yielded value, `tap` is no longer
|
|
13
|
+
# doing that job. It's being used purely as a temp-variable-elision
|
|
14
|
+
# trick for "run a side effect, then return the receiver":
|
|
15
|
+
#
|
|
16
|
+
# ❌ Block never references the yielded value — `tap` is eliding a temp var
|
|
17
|
+
# read_value.tap { do_side_effect }
|
|
18
|
+
#
|
|
19
|
+
# This costs readability for no benefit:
|
|
20
|
+
#
|
|
21
|
+
# - **It hides the return value in the receiver position.** `tap`'s
|
|
22
|
+
# "returns self" behaviour is its *less obvious* property. A reader
|
|
23
|
+
# has to stop and recall that the expression returns the receiver,
|
|
24
|
+
# not the block's value. The explicit form puts the return value on
|
|
25
|
+
# its own line where "what does this return" is literal.
|
|
26
|
+
# - **Footgun magnet.** Return-value semantics here are subtle — it's
|
|
27
|
+
# easy to nearby-edit the block into something whose value you think
|
|
28
|
+
# matters, or to confuse it with the broken `expr; side_effect`
|
|
29
|
+
# sequence (which returns the side effect's value, not `expr`'s).
|
|
30
|
+
# Code whose contract is "return this value" reads and edits more
|
|
31
|
+
# safely when the return is explicit.
|
|
32
|
+
# - **Equal stakes → prefer the duller form.** Both forms are correct
|
|
33
|
+
# and neither is faster. With no functional difference, the more
|
|
34
|
+
# obvious one wins in a shared codebase.
|
|
35
|
+
#
|
|
36
|
+
# ✔️ Explicit intent, return value on its own line
|
|
37
|
+
# result = read_value
|
|
38
|
+
# do_side_effect
|
|
39
|
+
# result
|
|
40
|
+
#
|
|
41
|
+
# ## When `tap` IS the right choice
|
|
42
|
+
# Reserve `tap` for its canonical use — when the block references the
|
|
43
|
+
# yielded value:
|
|
44
|
+
#
|
|
45
|
+
# ✔️ Block uses the yielded value — this is what `tap` is for
|
|
46
|
+
# Model.new.tap { |m| m.role = :admin; m.save }
|
|
47
|
+
# record.tap { |r| logger.debug(r.to_sql) }
|
|
48
|
+
#
|
|
49
|
+
# ## Exception
|
|
50
|
+
# Genuine intentional uses (e.g. a logging side effect that reads
|
|
51
|
+
# clearer in tap position) go through an inline `# rubocop:disable`
|
|
52
|
+
# with a reason. The friction is the feature — it forces the choice
|
|
53
|
+
# to be articulated and reviewed.
|
|
54
|
+
#
|
|
55
|
+
# NOTE: Numbered-param (`_1`) and implicit-`it` (Ruby 3.4+) forms are
|
|
56
|
+
# not flagged — by construction, those forms reference the yielded
|
|
57
|
+
# value, which is the canonical use of `tap`. Block-pass (`&:sym`) is
|
|
58
|
+
# likewise not flagged; it isn't a block node.
|
|
59
|
+
#
|
|
60
|
+
# NOTE: If a nested block *shadows* the outer arg name
|
|
61
|
+
# (`tap { |v| items.each { |v| v.foo } }`), the inner reference is
|
|
62
|
+
# treated as covering the outer — a rare residual false negative.
|
|
63
|
+
# Inline-disable if it ever matters.
|
|
64
|
+
#
|
|
65
|
+
# @example
|
|
66
|
+
# # bad — block ignores the yielded value
|
|
67
|
+
# read_value.tap { do_side_effect }
|
|
68
|
+
# record.tap { |r| log_event }
|
|
69
|
+
# value.tap { }
|
|
70
|
+
#
|
|
71
|
+
# # good — block references the yielded value
|
|
72
|
+
# Model.new.tap { |m| m.role = :admin; m.save }
|
|
73
|
+
# record.tap { |r| logger.debug(r.to_sql) }
|
|
74
|
+
#
|
|
75
|
+
# # good — a reference inside a nested block still counts
|
|
76
|
+
# items.tap { |i| [1, 2].each { i << _1 } }
|
|
77
|
+
#
|
|
78
|
+
# # good — preferred explicit form when the value is unused
|
|
79
|
+
# result = read_value
|
|
80
|
+
# do_side_effect
|
|
81
|
+
# result
|
|
82
|
+
class TapBlockIgnoresValue < Base
|
|
83
|
+
MSG = '`tap` is for operating on the yielded value — when the ' \
|
|
84
|
+
'block ignores it, prefer an explicit assign-and-return. ' \
|
|
85
|
+
'Disable with a reason when the side effect reads clearer ' \
|
|
86
|
+
'in `tap` position.'.freeze
|
|
87
|
+
|
|
88
|
+
def on_block(node)
|
|
89
|
+
return unless node.send_node.method?(:tap)
|
|
90
|
+
return if block_uses_yielded_value?(node)
|
|
91
|
+
|
|
92
|
+
add_offense(node.send_node.loc.selector)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
private
|
|
96
|
+
|
|
97
|
+
def block_uses_yielded_value?(node)
|
|
98
|
+
args = node.arguments
|
|
99
|
+
return false unless args
|
|
100
|
+
return false if args.children.empty? # no param declared → value ignored
|
|
101
|
+
|
|
102
|
+
# `tap` yields one value; multi-arg / destructuring is unusual.
|
|
103
|
+
# Be conservative — don't flag what we can't confidently call
|
|
104
|
+
# "ignoring the value".
|
|
105
|
+
return true if args.children.length >= 2
|
|
106
|
+
|
|
107
|
+
arg = args.children.first
|
|
108
|
+
return true unless arg.respond_to?(:name)
|
|
109
|
+
|
|
110
|
+
return false unless node.body # empty body with declared arg → flag
|
|
111
|
+
|
|
112
|
+
# Walk from the block node (not `body`): `each_descendant`
|
|
113
|
+
# excludes the receiver, so a body that *is* an lvar
|
|
114
|
+
# (`tap { |v| v }`) would be missed otherwise. Nested blocks
|
|
115
|
+
# are walked too, so a reference inside one counts as "used".
|
|
116
|
+
# The shadowing edge case is documented above as a NOTE.
|
|
117
|
+
node.each_descendant(:lvar).any? { |n| n.children.first == arg.name }
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
@@ -13,6 +13,18 @@ module RuboCop
|
|
|
13
13
|
# controller test (very rare) — e.g. a search-ranking detail the
|
|
14
14
|
# controller never exposes.
|
|
15
15
|
#
|
|
16
|
+
# The danger a unit test hides: it can stay green while the feature is
|
|
17
|
+
# broken in production. It proves a method works in isolation — NOT that
|
|
18
|
+
# the real request path calls that method, in the right order, inside the
|
|
19
|
+
# transaction it needs. A model method can be flawless in a unit test
|
|
20
|
+
# while the controller calls the wrong method, skips it, or runs it
|
|
21
|
+
# outside its transaction; the unit test stays green and the feature is
|
|
22
|
+
# broken. Controller tests fail when the *wiring* is wrong — which is
|
|
23
|
+
# where regressions actually live. So a passing unit test is not evidence
|
|
24
|
+
# the feature works; it is false confidence about production. Reach for
|
|
25
|
+
# one only when you are sure a controller test genuinely cannot reach the
|
|
26
|
+
# path, not because it is quicker to write.
|
|
27
|
+
#
|
|
16
28
|
# This cop flags only the literal `< ActiveSupport::TestCase`
|
|
17
29
|
# superclass. The blessed blackbox bases —
|
|
18
30
|
# `ActionDispatch::IntegrationTest`, `Glib::IntegrationTest`,
|
|
@@ -20,6 +32,18 @@ module RuboCop
|
|
|
20
32
|
# though they inherit from `ActiveSupport::TestCase` transitively.
|
|
21
33
|
#
|
|
22
34
|
# ## Escape hatch
|
|
35
|
+
# Before reaching for a unit test, assume a controller test IS possible
|
|
36
|
+
# and look harder — that conclusion is almost always premature. Behaviour
|
|
37
|
+
# that feels inherently unit-level is usually reachable end-to-end:
|
|
38
|
+
# - Transaction rollback / "a failure mid-request": inject the failure
|
|
39
|
+
# at a class-method chokepoint the gem/service calls (stub it to
|
|
40
|
+
# raise), drive the real request, and assert the observable rollback
|
|
41
|
+
# (e.g. `assert_no_difference` on the record count). Even atomicity,
|
|
42
|
+
# which feels inherently unit-level, is reachable this way.
|
|
43
|
+
# - "The controller wraps it in a transaction so I can't isolate the
|
|
44
|
+
# model's own": you usually don't need to — assert the *observable*
|
|
45
|
+
# guarantee through the real path; that is what matters in production.
|
|
46
|
+
#
|
|
23
47
|
# When a unit test is genuinely necessary, suppress with a reason that
|
|
24
48
|
# explains why a controller test can't cover the path. That reason IS the
|
|
25
49
|
# required justification — keep it specific and reviewable:
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: rubocop-dev_doc
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.5.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- dev-doc contributors
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-06-
|
|
11
|
+
date: 2026-06-27 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: activesupport
|
|
@@ -107,7 +107,10 @@ files:
|
|
|
107
107
|
- lib/rubocop/cop/dev_doc/migration/require_primary_key.rb
|
|
108
108
|
- lib/rubocop/cop/dev_doc/migration/require_timestamps.rb
|
|
109
109
|
- lib/rubocop/cop/dev_doc/rails/application_record_transaction.rb
|
|
110
|
+
- lib/rubocop/cop/dev_doc/rails/avoid_lifecycle_method_override.rb
|
|
111
|
+
- lib/rubocop/cop/dev_doc/rails/avoid_ordering_by_id.rb
|
|
110
112
|
- lib/rubocop/cop/dev_doc/rails/avoid_rails_callbacks.rb
|
|
113
|
+
- lib/rubocop/cop/dev_doc/rails/avoid_raw_sql.rb
|
|
111
114
|
- lib/rubocop/cop/dev_doc/rails/bang_save_in_transaction.rb
|
|
112
115
|
- lib/rubocop/cop/dev_doc/rails/enum_column_not_null.rb
|
|
113
116
|
- lib/rubocop/cop/dev_doc/rails/enum_must_be_symbolized.rb
|
|
@@ -123,9 +126,11 @@ files:
|
|
|
123
126
|
- lib/rubocop/cop/dev_doc/style/avoid_send.rb
|
|
124
127
|
- lib/rubocop/cop/dev_doc/style/minimize_variable_scope.rb
|
|
125
128
|
- lib/rubocop/cop/dev_doc/style/no_unscoped_method_definitions.rb
|
|
129
|
+
- lib/rubocop/cop/dev_doc/style/prefer_public_send.rb
|
|
126
130
|
- lib/rubocop/cop/dev_doc/style/repeated_bracket_read.rb
|
|
127
131
|
- lib/rubocop/cop/dev_doc/style/repeated_safe_navigation_receiver.rb
|
|
128
132
|
- lib/rubocop/cop/dev_doc/style/string_symbol_comparison.rb
|
|
133
|
+
- lib/rubocop/cop/dev_doc/style/tap_block_ignores_value.rb
|
|
129
134
|
- lib/rubocop/cop/dev_doc/test/avoid_glib_travel_freeze.rb
|
|
130
135
|
- lib/rubocop/cop/dev_doc/test/avoid_unit_test.rb
|
|
131
136
|
- lib/rubocop/cop/dev_doc/test/response_assert_equal.rb
|