rubocop-dev_doc 0.17.0 → 0.18.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 +300 -60
- data/config/obsoletion.yml +7 -3
- data/lib/dev_doc/i18n/tasks.rb +68 -0
- data/lib/dev_doc/railtie.rb +19 -0
- data/lib/dev_doc/test/lints/cross_tenant_canary_sweep.rb +2 -0
- data/lib/dev_doc/test/lints/http_driven_controller_tests.rb +2 -0
- data/lib/dev_doc/test/pseudo_i18n_crawler.rb +5 -0
- data/lib/rubocop/cop/dev_doc/i18n/hardcoded_text.rb +243 -0
- data/lib/rubocop/cop/dev_doc/i18n/localizable_props.rb +69 -19
- data/lib/rubocop/cop/dev_doc/i18n/require_translation.rb +13 -17
- data/lib/rubocop/cop/dev_doc/i18n/text_shape.rb +94 -0
- data/lib/rubocop/cop/dev_doc/rails/avoid_bypassing_validation.rb +81 -26
- data/lib/rubocop/cop/dev_doc/rails/no_errors_add_in_controller.rb +177 -0
- data/lib/rubocop/cop/dev_doc/rails/no_form_param_lookup_in_controller.rb +236 -0
- data/lib/rubocop/cop/dev_doc/style/in_memory_association_find.rb +162 -0
- data/lib/rubocop/cop/dev_doc/style/no_silent_rescue.rb +269 -0
- data/lib/rubocop/cop/dev_doc/style/prefer_allowlist_symbol_param.rb +193 -0
- data/lib/rubocop/cop/dev_doc/style/use_g_log.rb +133 -0
- data/lib/rubocop/cop/dev_doc/test/prefer_find_sole_by.rb +146 -0
- data/lib/rubocop/cop/dev_doc/test/test_name_special_characters.rb +108 -0
- data/lib/rubocop/cop/dev_doc/view/prefer_prop_over_name.rb +11 -1
- data/lib/rubocop/dev_doc/version.rb +1 -1
- metadata +14 -3
- data/lib/rubocop/cop/dev_doc/i18n/report_text.rb +0 -76
|
@@ -7,8 +7,28 @@ module RuboCop
|
|
|
7
7
|
# ## Rationale
|
|
8
8
|
# Avoid bypassing validation unless absolutely necessary. Methods like
|
|
9
9
|
# `update_column`, `update_all`, `insert_all`, `upsert_all`, `delete_all`,
|
|
10
|
-
# and `
|
|
11
|
-
#
|
|
10
|
+
# and the `validate: false` option on `save`/`save!`/`create`/`create!`
|
|
11
|
+
# skip validations and callbacks, which hides data integrity issues
|
|
12
|
+
# rather than surfacing them. A validation refusal is INFORMATION: the
|
|
13
|
+
# record telling you its data is wrong — the response is to fix the
|
|
14
|
+
# data (backfill from what the ledger already knows), never to silence
|
|
15
|
+
# the gate (a real app, 2026-09-13: a `save!(validate: false)`
|
|
16
|
+
# membership write silenced the bank-account guard on legacy records;
|
|
17
|
+
# the cure was backfilling four purchases from their own transactions).
|
|
18
|
+
#
|
|
19
|
+
# ## Why bypasses are treated as hostile
|
|
20
|
+
# The guards that matter most are the ones the call site cannot see:
|
|
21
|
+
# same-account references, direction/state alignment, finalize gates.
|
|
22
|
+
# A bypassing write does not fail those guards — it never consults
|
|
23
|
+
# them, so their failure ships silently into the data (real case:
|
|
24
|
+
# a real app's `move_to`, 2026-09-12, relocated purchase items
|
|
25
|
+
# across accounts while their category references stayed behind —
|
|
26
|
+
# invisible to the suite, caught only by a manual audit).
|
|
27
|
+
#
|
|
28
|
+
# That is why the sanctioned disable protocol asks for more than a
|
|
29
|
+
# reason: name the COLUMNS being written and why record-by-record
|
|
30
|
+
# (`find_each` + `save!`) is not viable — a reviewer must be able to
|
|
31
|
+
# check the blast radius, not just the intent.
|
|
12
32
|
#
|
|
13
33
|
# Even in migrations, check the code to see if there is any blatant
|
|
14
34
|
# reason why existing records may be invalid. If there is, fix those
|
|
@@ -16,8 +36,9 @@ module RuboCop
|
|
|
16
36
|
#
|
|
17
37
|
# ## Relationship with Rails/SkipsModelValidations
|
|
18
38
|
# This cop is a strict SUPERSET of the stock `Rails/SkipsModelValidations`
|
|
19
|
-
# (it covers every method that cop flags, plus `delete_all` and
|
|
20
|
-
# `
|
|
39
|
+
# (it covers every method that cop flags, plus `delete_all` and the
|
|
40
|
+
# `validate: false` option on save/save!/create/create!), with
|
|
41
|
+
# per-method actionable messages instead
|
|
21
42
|
# of a generic one. Consumer projects should enable THIS cop and disable
|
|
22
43
|
# `Rails/SkipsModelValidations` — running both forces every disable
|
|
23
44
|
# comment to name two cops for the same offense. Methods that skip
|
|
@@ -44,6 +65,9 @@ module RuboCop
|
|
|
44
65
|
# # bad
|
|
45
66
|
# user.save(validate: false)
|
|
46
67
|
#
|
|
68
|
+
# # bad — the bang adds raise-on-failure, NOT validations
|
|
69
|
+
# user.save!(validate: false)
|
|
70
|
+
#
|
|
47
71
|
# # bad
|
|
48
72
|
# User.insert_all(rows)
|
|
49
73
|
#
|
|
@@ -53,22 +77,31 @@ module RuboCop
|
|
|
53
77
|
# faq.save!
|
|
54
78
|
# end
|
|
55
79
|
class AvoidBypassingValidation < Base
|
|
80
|
+
# The core clause shared by the message family: WHY a bypass is
|
|
81
|
+
# hostile, not just what it skips. A bypass does not fail the
|
|
82
|
+
# model's integrity guards — it never consults them.
|
|
83
|
+
GUARD_CLAUSE = 'it bypasses validations AND callbacks, so every model-level integrity guard ' \
|
|
84
|
+
'is silently inoperative for this write'.freeze
|
|
85
|
+
PROTOCOL = 'sanctioned bypasses: disable naming the COLUMNS written and why ' \
|
|
86
|
+
'record-by-record is not viable (see cop doc)'.freeze
|
|
87
|
+
|
|
56
88
|
MESSAGES = {
|
|
57
|
-
update_column:
|
|
58
|
-
update_columns:
|
|
59
|
-
update_attribute:
|
|
89
|
+
update_column: "Avoid `update_column`; #{GUARD_CLAUSE}. Use `update!` instead.",
|
|
90
|
+
update_columns: "Avoid `update_columns`; #{GUARD_CLAUSE}. Use `update!` instead.",
|
|
91
|
+
update_attribute: "Avoid `update_attribute`; #{GUARD_CLAUSE} (the singular is not " \
|
|
60
92
|
'a validating `update`). Use `update!` instead.',
|
|
61
|
-
update_all:
|
|
62
|
-
|
|
63
|
-
|
|
93
|
+
update_all: "Avoid `update_all`; #{GUARD_CLAUSE} for every matched row. " \
|
|
94
|
+
'Use `save!` in a loop instead.',
|
|
95
|
+
insert: "Avoid `insert`; #{GUARD_CLAUSE}. Use `create!` instead, " \
|
|
96
|
+
"or #{PROTOCOL} if a raw insert is intentional.",
|
|
64
97
|
insert!: 'Avoid `insert!`; it bypasses validations (the bang is about duplicate keys, ' \
|
|
65
98
|
'not validity). Use `create!` instead.',
|
|
66
|
-
insert_all:
|
|
67
|
-
|
|
68
|
-
upsert:
|
|
69
|
-
|
|
70
|
-
upsert_all:
|
|
71
|
-
|
|
99
|
+
insert_all: "Avoid `insert_all`; #{GUARD_CLAUSE} for every row. Use `create!` in a loop, " \
|
|
100
|
+
"or #{PROTOCOL} if bulk-insert is intentional.",
|
|
101
|
+
upsert: "Avoid `upsert`; #{GUARD_CLAUSE}. Use `create!`/`update!` instead, " \
|
|
102
|
+
"or #{PROTOCOL} if a raw upsert is intentional.",
|
|
103
|
+
upsert_all: "Avoid `upsert_all`; #{GUARD_CLAUSE} for every row. Use `create!`/`update!` in a loop, " \
|
|
104
|
+
"or #{PROTOCOL} if bulk-upsert is intentional.",
|
|
72
105
|
toggle!: 'Avoid `toggle!`; it saves without validations. Use `toggle(...).save!` instead.',
|
|
73
106
|
increment!: 'Avoid `increment!`; it writes without validations. Use `increment(...).save!` instead.',
|
|
74
107
|
decrement!: 'Avoid `decrement!`; it writes without validations. Use `decrement(...).save!` instead.',
|
|
@@ -85,18 +118,28 @@ module RuboCop
|
|
|
85
118
|
'(`updated_at` updates automatically), or `AllowedMethods` if bare timestamp bumps are ' \
|
|
86
119
|
'accepted in this project.',
|
|
87
120
|
touch_all: 'Avoid `touch_all`; it issues a bulk UPDATE that bypasses validations and callbacks. ' \
|
|
88
|
-
|
|
89
|
-
'is intentional.',
|
|
121
|
+
"Iterate with `save!`, or #{PROTOCOL} if a bulk timestamp bump is intentional.",
|
|
90
122
|
insert_all!: 'Avoid `insert_all!`; it bypasses validations (the bang is about duplicate keys, ' \
|
|
91
123
|
'not validity). Use `create!` in a loop, or `# rubocop:disable` with a reason if ' \
|
|
92
124
|
'bulk-insert is intentional.',
|
|
93
125
|
delete_all: 'Avoid `delete_all`; it bypasses callbacks. Use `destroy_all` to run callbacks, ' \
|
|
94
|
-
|
|
126
|
+
"or #{PROTOCOL} if bulk-delete is intentional."
|
|
95
127
|
}.freeze
|
|
96
128
|
|
|
97
|
-
SAVE_MSG = 'Avoid
|
|
129
|
+
SAVE_MSG = 'Avoid `%<selector>s(validate: false)`; validations are the only thing it skips — if a ' \
|
|
130
|
+
'guard refuses this save, that refusal is information: the record is telling you its ' \
|
|
131
|
+
'data is wrong. Fix the data (backfill), not the gate.'.freeze
|
|
132
|
+
|
|
133
|
+
# save! matters as much as save: a bang only adds raise-on-failure,
|
|
134
|
+
# it does NOT restore validations — `save!(validate: false)` is the
|
|
135
|
+
# exact shape that slipped past this cop's pre-2026-09-13 `:save`-
|
|
136
|
+
# only RESTRICT_ON_SEND (a real app, purchase 29: a membership
|
|
137
|
+
# write silenced the bank-account guard on legacy data, and the
|
|
138
|
+
# suite + lint both waved it through). create/create! accept the
|
|
139
|
+
# same option with the same effect.
|
|
140
|
+
VALIDATION_GATE_METHODS = %i[save save! create create!].freeze
|
|
98
141
|
|
|
99
|
-
RESTRICT_ON_SEND = (MESSAGES.keys +
|
|
142
|
+
RESTRICT_ON_SEND = (MESSAGES.keys + VALIDATION_GATE_METHODS).freeze
|
|
100
143
|
|
|
101
144
|
# insert/insert!/upsert are AR *class-level* APIs (User.insert(...)),
|
|
102
145
|
# while `insert` on a non-const receiver is everyday Array#insert —
|
|
@@ -111,10 +154,8 @@ module RuboCop
|
|
|
111
154
|
def on_send(node)
|
|
112
155
|
return if allowed_methods.include?(node.method_name.to_s)
|
|
113
156
|
|
|
114
|
-
if
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
add_offense(node.loc.selector, message: SAVE_MSG)
|
|
157
|
+
if VALIDATION_GATE_METHODS.include?(node.method_name)
|
|
158
|
+
validation_gate_offense(node)
|
|
118
159
|
else
|
|
119
160
|
return unless receiver_shape_matches?(node)
|
|
120
161
|
|
|
@@ -124,6 +165,17 @@ module RuboCop
|
|
|
124
165
|
|
|
125
166
|
private
|
|
126
167
|
|
|
168
|
+
# save/save!/create/create!: only offensive WITH the skip option —
|
|
169
|
+
# the plain forms are the validating writes this cop pushes toward.
|
|
170
|
+
def validation_gate_offense(node)
|
|
171
|
+
return unless skips_validation_option?(node)
|
|
172
|
+
|
|
173
|
+
add_offense(
|
|
174
|
+
node.loc.selector,
|
|
175
|
+
message: format(SAVE_MSG, selector: node.method_name)
|
|
176
|
+
)
|
|
177
|
+
end
|
|
178
|
+
|
|
127
179
|
def receiver_shape_matches?(node)
|
|
128
180
|
const_receiver = node.receiver&.const_type?
|
|
129
181
|
return const_receiver if CONST_RECEIVER_ONLY.include?(node.method_name)
|
|
@@ -139,7 +191,10 @@ module RuboCop
|
|
|
139
191
|
cop_config.fetch('AllowedMethods', []).map(&:to_s)
|
|
140
192
|
end
|
|
141
193
|
|
|
142
|
-
|
|
194
|
+
# Only a literal `validate: false` option is detected; a dynamic
|
|
195
|
+
# value (`validate: skip`) escapes this cop — deliberate trade to
|
|
196
|
+
# avoid false positives on non-AR objects.
|
|
197
|
+
def skips_validation_option?(node)
|
|
143
198
|
node.arguments.any? do |arg|
|
|
144
199
|
next unless arg.hash_type?
|
|
145
200
|
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
module RuboCop
|
|
2
|
+
module Cop
|
|
3
|
+
module DevDoc
|
|
4
|
+
module Rails
|
|
5
|
+
# Controllers must not write errors onto a model (`record.errors.add`);
|
|
6
|
+
# the validity rule belongs to the model (or the form-object PORO that
|
|
7
|
+
# owns the operation's validations), whose failures the save renders
|
|
8
|
+
# through the standard model-errors channel for every caller.
|
|
9
|
+
#
|
|
10
|
+
# ## Rationale
|
|
11
|
+
# A controller-side `errors.add` is one half of a validation that
|
|
12
|
+
# lives in the wrong layer, and both halves fail the same way:
|
|
13
|
+
#
|
|
14
|
+
# - **Hand-written validations drift from the model's.** The purest
|
|
15
|
+
# form is `record.errors.add(:title, :blank) if title.blank?` —
|
|
16
|
+
# symbol-for-symbol what `validates :title, presence: true`
|
|
17
|
+
# produces. The model-side rule protects every caller and every
|
|
18
|
+
# write path; the controller-side copy guards exactly one POST, so a
|
|
19
|
+
# later action (or a background writer) silently skips it — the
|
|
20
|
+
# "validation asymmetry" failure: create validates, update doesn't.
|
|
21
|
+
# - **Tests follow validators.** A model validation gets exercised by
|
|
22
|
+
# every save and gets its unhappy-path test; a controller guard
|
|
23
|
+
# exists only where someone thought to test that one branch, so
|
|
24
|
+
# blank-input crashes surface as 4xx/500s from users instead.
|
|
25
|
+
# - **State preconditions on an operation** ("only published articles
|
|
26
|
+
# can be archived") are domain knowledge too. The model pattern is a
|
|
27
|
+
# default-off flag accessor (`importing`, `archiving`) that gates a
|
|
28
|
+
# validation to the flow that must obey it — the same shape as a
|
|
29
|
+
# `moving` flag guarding a move operation.
|
|
30
|
+
#
|
|
31
|
+
# ❌ Hand-written presence validation
|
|
32
|
+
# def create
|
|
33
|
+
# return @article.errors.add(:title, :blank) if title.blank?
|
|
34
|
+
# @article.publish!
|
|
35
|
+
# end
|
|
36
|
+
#
|
|
37
|
+
# ❌ Operation precondition the controller enforces alone
|
|
38
|
+
# unless @article.published?
|
|
39
|
+
# @article.errors.add(:base, 'Only published articles can be archived')
|
|
40
|
+
# return
|
|
41
|
+
# end
|
|
42
|
+
#
|
|
43
|
+
# ✔️ The model owns the rule; the controller assigns and calls
|
|
44
|
+
# @article.category_id = params.dig(:article, :category_id)
|
|
45
|
+
# @article.publish!
|
|
46
|
+
#
|
|
47
|
+
# ## Delivery is not validation (auto-exemptions)
|
|
48
|
+
# Not every `errors.add` in a controller is a misplaced rule. When the
|
|
49
|
+
# check already lives in the right layer — a model method, a policy, a
|
|
50
|
+
# service — the controller line is only DELIVERING that outcome into
|
|
51
|
+
# the model-errors channel the responder renders. Moving such a line
|
|
52
|
+
# into a validation would couple the model to a policy or smuggle an
|
|
53
|
+
# acting user into it. The cop reads the guard of the branch
|
|
54
|
+
# containing the `errors.add` (nearest enclosing `if`/`unless`,
|
|
55
|
+
# including modifier form) and auto-exempts the three observed
|
|
56
|
+
# delivery shapes:
|
|
57
|
+
#
|
|
58
|
+
# - **rescue** — the branch catches an exception (`rescue
|
|
59
|
+
# ImportService::AlreadyImportedError`); the decision was the
|
|
60
|
+
# raiser's.
|
|
61
|
+
# - **policy guard** — the condition consults `policy(...)`: an
|
|
62
|
+
# authorization decision whose home is the policy layer.
|
|
63
|
+
# - **verification bang** — the condition invokes a `!`-suffixed
|
|
64
|
+
# method (`validate_and_consume_otp!`, `send_verification_email!`):
|
|
65
|
+
# the check lives inside that method; this branch renders its
|
|
66
|
+
# return value.
|
|
67
|
+
#
|
|
68
|
+
# These exemptions were calibrated by triaging every hit of the raw
|
|
69
|
+
# signal (any `errors.add` in a controller) across two consuming
|
|
70
|
+
# codebases: with them, every remaining hit was a misplaced rule or a
|
|
71
|
+
# judgment call worth a reviewer's eye.
|
|
72
|
+
#
|
|
73
|
+
# ## Exception
|
|
74
|
+
# A check that LOOKS like validation but is genuinely neither a model
|
|
75
|
+
# rule nor a delivered outcome (e.g. an attempt-verification method
|
|
76
|
+
# spelled with `?` instead of `!`) — disable inline, naming where the
|
|
77
|
+
# check lives:
|
|
78
|
+
#
|
|
79
|
+
# # rubocop:disable DevDoc/Rails/NoErrorsAddInController
|
|
80
|
+
# # -- outcome of User#verify_code? (attempt verification, not record validity)
|
|
81
|
+
#
|
|
82
|
+
# ## Relationship with DevDoc/Rails/NoCollectionIdsWriterInController
|
|
83
|
+
# Same placement family (the model owns the write AND its guards),
|
|
84
|
+
# complementary signals: that cop catches association writers that
|
|
85
|
+
# bypass validations silently; this cop catches validity rules
|
|
86
|
+
# hand-copied into the controller where the model never sees them.
|
|
87
|
+
#
|
|
88
|
+
# NOTE: Blindness, for reviewers: a `?`-suffixed verification method
|
|
89
|
+
# in the guard is NOT exempted (over-flag on purpose — prefer a false
|
|
90
|
+
# positive to a false negative; silence with the disable above); a
|
|
91
|
+
# data rule hidden inside a policy-referencing guard IS wrongly
|
|
92
|
+
# exempted; `errors[:key] = value` and `send(:add, ...)` spellings are
|
|
93
|
+
# not detected; and a branch guarded by `case/when` gets no guard
|
|
94
|
+
# analysis (flagged — justify or restructure).
|
|
95
|
+
#
|
|
96
|
+
# @example
|
|
97
|
+
# # bad — hand-written presence validation
|
|
98
|
+
# return @article.errors.add(:title, :blank) if title.blank?
|
|
99
|
+
#
|
|
100
|
+
# # bad — import-row rules belong on a form-object PORO
|
|
101
|
+
# return @member.errors.add(:base, 'Each entry needs an email') if email.blank?
|
|
102
|
+
#
|
|
103
|
+
# # good (auto-exempt) — delivering a policy decision
|
|
104
|
+
# if policy(@chat).initiation_blocked_for_admin?
|
|
105
|
+
# @chat.errors.add(:base, 'Admins cannot initiate this chat')
|
|
106
|
+
# end
|
|
107
|
+
#
|
|
108
|
+
# # good (auto-exempt) — delivering a verification outcome
|
|
109
|
+
# if @user.validate_and_consume_otp!(params[:otp_attempt])
|
|
110
|
+
# @user.enable_two_factor
|
|
111
|
+
# else
|
|
112
|
+
# @user.errors.add(:base, 'Invalid OTP code')
|
|
113
|
+
# end
|
|
114
|
+
class NoErrorsAddInController < Base
|
|
115
|
+
MSG = 'Errors written onto the model from the controller — the rule belongs in a model ' \
|
|
116
|
+
'validation (or form-object PORO) whose failures the save renders for every caller. ' \
|
|
117
|
+
'If this line only delivers an outcome from elsewhere, disable with a comment naming ' \
|
|
118
|
+
'where the check lives.'.freeze
|
|
119
|
+
|
|
120
|
+
RESTRICT_ON_SEND = %i[add].freeze
|
|
121
|
+
|
|
122
|
+
# A `!`-method in a guard is the verify idiom (returns success or
|
|
123
|
+
# failure without raising). `!` itself (`:not`) must not match.
|
|
124
|
+
BANG_METHOD = /\w!\z/
|
|
125
|
+
|
|
126
|
+
def on_send(node)
|
|
127
|
+
return unless errors_receiver?(node.receiver)
|
|
128
|
+
return if delivering_outcome?(node)
|
|
129
|
+
|
|
130
|
+
add_offense(node.loc.selector)
|
|
131
|
+
end
|
|
132
|
+
# Safe navigation (`record&.errors&.add(...)`) parses as csend
|
|
133
|
+
# nodes, which on_send does not receive — alias it so `&.` is
|
|
134
|
+
# caught too.
|
|
135
|
+
alias on_csend on_send
|
|
136
|
+
|
|
137
|
+
private
|
|
138
|
+
|
|
139
|
+
# `record.errors.add(...)` on any receiver shape (ivar, local,
|
|
140
|
+
# chain); the `errors` read itself may be safe-navigated, so csend
|
|
141
|
+
# receivers count.
|
|
142
|
+
def errors_receiver?(receiver)
|
|
143
|
+
%i[send csend].include?(receiver&.type) && receiver.method_name == :errors
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# The three observed delivery shapes (see the docstring): the check
|
|
147
|
+
# lives elsewhere and this line renders its outcome.
|
|
148
|
+
def delivering_outcome?(node)
|
|
149
|
+
node.each_ancestor(:resbody).any? || guarded_by_external_check?(node)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def guarded_by_external_check?(node)
|
|
153
|
+
if_node = node.each_ancestor(:if).first
|
|
154
|
+
return false unless if_node
|
|
155
|
+
|
|
156
|
+
condition = if_node.condition
|
|
157
|
+
references_policy?(condition) || invokes_bang_method?(condition)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def references_policy?(condition)
|
|
161
|
+
send_nodes(condition).any? { |send_node| send_node.method_name == :policy }
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def invokes_bang_method?(condition)
|
|
165
|
+
send_nodes(condition).any? do |send_node|
|
|
166
|
+
BANG_METHOD.match?(send_node.method_name.to_s)
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def send_nodes(root)
|
|
171
|
+
[root, *root.each_descendant(:send)].select(&:send_type?)
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
module RuboCop
|
|
2
|
+
module Cop
|
|
3
|
+
module DevDoc
|
|
4
|
+
module Rails
|
|
5
|
+
# A record finder in a controller must not be fed by a form-submitted
|
|
6
|
+
# attribute (`params[:form][:attribute]`, `params.dig(:form, :attribute)`,
|
|
7
|
+
# `params.require(:form)[:attribute]`): assign the id to the model and
|
|
8
|
+
# let its validations decide.
|
|
9
|
+
#
|
|
10
|
+
# ## Rationale
|
|
11
|
+
# A route param (`params[:id]`, `params[:post_id]`) is resource
|
|
12
|
+
# identity from the URL — a missing record means a bad URL, and the
|
|
13
|
+
# finder's `RecordNotFound` → 404 is the correct response. A
|
|
14
|
+
# form-submitted attribute is a different kind of input entirely: an
|
|
15
|
+
# unpicked select is the FIRST state a user can reach, so "blank" is an
|
|
16
|
+
# expected form state, not a bad URL. Feeding it to `find` converts
|
|
17
|
+
# "the user hasn't chosen yet" into a 404 — which JSON-UI frontends
|
|
18
|
+
# render as a generic error ("Not accessible") for every 4xx — and
|
|
19
|
+
# `find_by` trades the crash for a nil-guard + hand-written
|
|
20
|
+
# `errors.add` in the controller, which is validation living outside
|
|
21
|
+
# the model (see the controller best-practice doc: let model
|
|
22
|
+
# validations handle business rules through the normal save flow).
|
|
23
|
+
#
|
|
24
|
+
# The deeper failure mode: the lookup exists because the flow passes
|
|
25
|
+
# the resolved record as a method argument instead of assigning the
|
|
26
|
+
# foreign key to the model — and an argument cannot be validated. Under
|
|
27
|
+
# assignment, there is no line of code where a blank pick can become a
|
|
28
|
+
# 4xx: blank → nil → presence validation; foreign id → same-account
|
|
29
|
+
# validation; both render through the standard model-errors channel.
|
|
30
|
+
#
|
|
31
|
+
# Measured on the raw signal (finders whose arguments reference nested
|
|
32
|
+
# params) across two consuming codebases: every hit triaged as either
|
|
33
|
+
# this violation or a deliberate lookup carried below.
|
|
34
|
+
#
|
|
35
|
+
# ❌ Blank pick raises RecordNotFound — a 404 for an expected form state
|
|
36
|
+
# def create
|
|
37
|
+
# category = current_user.categories.find(params[:article][:category_id])
|
|
38
|
+
# @article.publish_to!(category)
|
|
39
|
+
# end
|
|
40
|
+
#
|
|
41
|
+
# ✔️ The controller assigns; the model validates; the responder renders
|
|
42
|
+
# def create
|
|
43
|
+
# @article.category_id = params.dig(:article, :category_id)
|
|
44
|
+
# @article.publish!
|
|
45
|
+
# end
|
|
46
|
+
#
|
|
47
|
+
# ## Exception
|
|
48
|
+
# A lookup where nil is a VALID outcome (resolve-or-create flows: no
|
|
49
|
+
# session yet → create one) is deliberate — silence it with a
|
|
50
|
+
# `%<marker>s` comment on the flagged line or the contiguous block
|
|
51
|
+
# directly above, stating why the resolution must not become an
|
|
52
|
+
# assignment + validation.
|
|
53
|
+
#
|
|
54
|
+
# ## Relationship with DevDoc/Auth/UnscopedFindJustification
|
|
55
|
+
# Different rules on adjacent shapes: that cop flags a bare CONSTANT
|
|
56
|
+
# receiver resolving a client-controlled id with no scoping (an auth
|
|
57
|
+
# reach question); this cop flags any receiver fed by a NESTED form
|
|
58
|
+
# param (a placement question). A constant-receiver form-param find
|
|
59
|
+
# can rightly trip both.
|
|
60
|
+
#
|
|
61
|
+
# NOTE: The nesting walk counts consecutive accessor hops
|
|
62
|
+
# (`[]`/`dig`/`fetch`/`require`/`expect`) above `params`. An id
|
|
63
|
+
# copied into a local first IS detected — a read of a local/instance
|
|
64
|
+
# variable whose every same-method assignment sources a nested form
|
|
65
|
+
# param (`attribute_id = params[:form][:attribute_id]`;
|
|
66
|
+
# `Model.find(attribute_id)`); op-assignments (`||=`, `&&=`, `+=`,
|
|
67
|
+
# including one sourced from a form param) count as NOT sourced, so
|
|
68
|
+
# any op-assignment of the name silences the analysis. Still not
|
|
69
|
+
# detected, for reviewers: a laundering hop BETWEEN accessors
|
|
70
|
+
# (`params[:form].presence&.dig(:attribute_id)`), a local reassigned
|
|
71
|
+
# from a safe source anywhere in the method (the cop stays silent
|
|
72
|
+
# rather than guess which assignment the finder sees), an ivar
|
|
73
|
+
# assigned in a different method (e.g. a `before_action`), and
|
|
74
|
+
# multiple-assignment spellings (`a, b = params[:form][:x], y`).
|
|
75
|
+
#
|
|
76
|
+
# @example
|
|
77
|
+
# # bad — form-submitted attribute resolved by a finder
|
|
78
|
+
# category = current_user.categories.find(params[:article][:category_id])
|
|
79
|
+
# session = Chat.find_by(id: params.dig(:chat_message, :session_id))
|
|
80
|
+
#
|
|
81
|
+
# # bad — the laundering spelling, same offense one line earlier
|
|
82
|
+
# category_id = params.dig(:article, :category_id)
|
|
83
|
+
# category = current_user.categories.find(category_id)
|
|
84
|
+
#
|
|
85
|
+
# # good — route params stay finder-fed; a bad URL SHOULD 404
|
|
86
|
+
# @article = current_user.articles.find(params[:article_id])
|
|
87
|
+
# @comment = @article.comments.find_by(id: params[:comment_id])
|
|
88
|
+
#
|
|
89
|
+
# # good — assignment + model validation
|
|
90
|
+
# @article.category_id = params.dig(:article, :category_id)
|
|
91
|
+
# @article.save
|
|
92
|
+
#
|
|
93
|
+
# # good — deliberate resolve-or-create lookup, reason stated
|
|
94
|
+
# # form-lookup: absent session is valid — nil means create a new one
|
|
95
|
+
# session = Chat.find_by(id: params[:chat_message][:session_id]) || Chat.create!
|
|
96
|
+
class NoFormParamLookupInController < Base
|
|
97
|
+
include Auth::AdjacentJustification
|
|
98
|
+
|
|
99
|
+
MSG = '`%<method>s` resolves a form-submitted attribute (`params[form][attribute]`) — ' \
|
|
100
|
+
'a blank or unknown pick becomes RecordNotFound, a 404 for an expected form state, ' \
|
|
101
|
+
'and the validity check migrates into the controller. Assign the id to the model ' \
|
|
102
|
+
'and validate it there; for a deliberate lookup state why in a "%<marker>s" comment.'.freeze
|
|
103
|
+
|
|
104
|
+
# One hop above `params` may itself be nested when it names several
|
|
105
|
+
# keys: `params.dig(:form, :attribute)` is top-level spelling with
|
|
106
|
+
# nested reach. `fetch` is excluded from this rule — its second
|
|
107
|
+
# argument is a default value, not a nested key.
|
|
108
|
+
MULTI_KEY_ACCESSORS = %i[dig].freeze
|
|
109
|
+
# Chained accessors counted as hops above a bare `params`; two or
|
|
110
|
+
# more hops means the value lives under a form hash, not at the
|
|
111
|
+
# route-param top level.
|
|
112
|
+
ACCESSOR_HOPS = %i([] dig fetch require expect).freeze
|
|
113
|
+
# Variable reads/assignments tracked by the laundering analysis.
|
|
114
|
+
VARIABLE_READS = %i[lvar ivar].freeze
|
|
115
|
+
VARIABLE_ASSIGNMENTS = %i[lvasgn ivasgn].freeze
|
|
116
|
+
|
|
117
|
+
# FinderMethods is configuration-driven, so the name filter lives in
|
|
118
|
+
# on_send rather than RESTRICT_ON_SEND.
|
|
119
|
+
def on_send(node)
|
|
120
|
+
return unless finder_methods.include?(node.method_name)
|
|
121
|
+
return unless node.arguments.any? { |argument| references_form_param?(argument) }
|
|
122
|
+
return if adjacent_justification?(node)
|
|
123
|
+
|
|
124
|
+
add_offense(node.loc.selector, message: offense_message(node))
|
|
125
|
+
end
|
|
126
|
+
alias on_csend on_send
|
|
127
|
+
|
|
128
|
+
private
|
|
129
|
+
|
|
130
|
+
def offense_message(node)
|
|
131
|
+
format(MSG, method: node.method_name, marker: justification_marker)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def references_form_param?(search_root)
|
|
135
|
+
references_nested_form_param?(search_root) || laundered_form_param?(search_root)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def references_nested_form_param?(search_root)
|
|
139
|
+
search_root.each_descendant(:send) do |descendant|
|
|
140
|
+
return true if bare_params?(descendant) && nested_access?(descendant)
|
|
141
|
+
end
|
|
142
|
+
false
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# The laundering spelling: the id is copied into a local/instance
|
|
146
|
+
# variable on one line and the finder is fed the variable on the
|
|
147
|
+
# next (`attribute_id = params[:form][:attribute_id]` …
|
|
148
|
+
# `Model.find(attribute_id)`). A read counts only when EVERY
|
|
149
|
+
# assignment to that name in the same method sources a nested form
|
|
150
|
+
# param — one safe reassignment anywhere makes the final value
|
|
151
|
+
# unknowable without order analysis, so the cop stays silent
|
|
152
|
+
# (under-flagging by design).
|
|
153
|
+
def laundered_form_param?(search_root)
|
|
154
|
+
method_scope = search_root.each_ancestor(:def, :defs).first
|
|
155
|
+
return false if method_scope.nil?
|
|
156
|
+
|
|
157
|
+
assignments = method_scope.each_descendant(*VARIABLE_ASSIGNMENTS).to_a
|
|
158
|
+
variable_reads(search_root).any? do |read|
|
|
159
|
+
assigned_only_from_form_params?(read.children.first, assignments)
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# True when the name is assigned at least once in the method and
|
|
164
|
+
# every one of its assignments sources a nested form param. An
|
|
165
|
+
# op-assignment (`x ||= y`, `x += n`) parses as an op-asgn node
|
|
166
|
+
# wrapping a VALUELESS lvasgn/ivasgn — `children.last` is the name
|
|
167
|
+
# Symbol, not a value — and counts as NOT sourced: its rhs is a
|
|
168
|
+
# reassignment from somewhere else, which per the contract above
|
|
169
|
+
# silences the analysis (and a form-param-sourced `||=` is that
|
|
170
|
+
# same blindness, not a crash).
|
|
171
|
+
def assigned_only_from_form_params?(name, assignments)
|
|
172
|
+
name_assignments = assignments.select { |assignment| assignment.children.first == name }
|
|
173
|
+
name_assignments.any? && name_assignments.all? { |assignment| form_param_sourced?(assignment) }
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def form_param_sourced?(assignment)
|
|
177
|
+
rhs = assignment.children.last
|
|
178
|
+
rhs.is_a?(RuboCop::AST::Node) && references_nested_form_param?(rhs)
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def variable_reads(search_root)
|
|
182
|
+
[search_root, *search_root.each_descendant(*VARIABLE_READS)]
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def bare_params?(node)
|
|
186
|
+
node.method_name == :params && node.receiver.nil?
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# True when the value is reached through a form hash: two or more
|
|
190
|
+
# chained accessor hops (`params[:form][:attribute]`,
|
|
191
|
+
# `params.require(:form)[:attribute]`), or a single `dig` naming
|
|
192
|
+
# several keys.
|
|
193
|
+
def nested_access?(params_node)
|
|
194
|
+
first_hop = params_node.parent
|
|
195
|
+
return false unless accessor_call?(first_hop)
|
|
196
|
+
|
|
197
|
+
return true if multi_key_dig?(first_hop)
|
|
198
|
+
|
|
199
|
+
accessor_hops(first_hop) >= 2
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def accessor_call?(node)
|
|
203
|
+
node&.send_type? && ACCESSOR_HOPS.include?(node.method_name)
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# `dig` is the one accessor whose extra ARGUMENTS nest: a single
|
|
207
|
+
# `params.dig(:form, :attribute)` hop reaches as deep as
|
|
208
|
+
# `params[:form][:attribute]`.
|
|
209
|
+
def multi_key_dig?(node)
|
|
210
|
+
node.method_name == :dig && node.arguments.count >= 2
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# Chained accessor calls above the given one, stopping at the first
|
|
214
|
+
# non-accessor parent.
|
|
215
|
+
def accessor_hops(node)
|
|
216
|
+
hops = 1
|
|
217
|
+
current = node.parent
|
|
218
|
+
while accessor_call?(current)
|
|
219
|
+
hops += 1
|
|
220
|
+
current = current.parent
|
|
221
|
+
end
|
|
222
|
+
hops
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def finder_methods
|
|
226
|
+
@finder_methods ||= Array(cop_config.fetch('FinderMethods', %w[find find_by find_by!])).map(&:to_sym)
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def justification_marker
|
|
230
|
+
cop_config.fetch('JustificationMarker', 'form-lookup:')
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
end
|