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.
@@ -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 `save(validate: false)` skip validations and callbacks, which hides
11
- # data integrity issues rather than surfacing them.
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
- # `save(validate: false)`), with per-method actionable messages instead
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: 'Avoid `update_column`; it bypasses validations. Use `save!` instead.',
58
- update_columns: 'Avoid `update_columns`; it bypasses validations. Use `save!` instead.',
59
- update_attribute: 'Avoid `update_attribute`; it bypasses validations (the singular is not ' \
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: 'Avoid `update_all`; it bypasses validations. Use `save!` in a loop instead.',
62
- insert: 'Avoid `insert`; it bypasses validations. Use `create!` instead, ' \
63
- 'or `# rubocop:disable` with a reason if a raw insert is intentional.',
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: 'Avoid `insert_all`; it bypasses validations. Use `create!` in a loop, ' \
67
- 'or `# rubocop:disable` with a reason if bulk-insert is intentional.',
68
- upsert: 'Avoid `upsert`; it bypasses validations. Use `create!`/`update!` instead, ' \
69
- 'or `# rubocop:disable` with a reason if a raw upsert is intentional.',
70
- upsert_all: 'Avoid `upsert_all`; it bypasses validations. Use `create!`/`update!` in a loop, ' \
71
- 'or `# rubocop:disable` with a reason if bulk-upsert is intentional.',
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
- 'Iterate with `save!`, or `# rubocop:disable` with a reason if a bulk timestamp bump ' \
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
- 'or `# rubocop:disable` with a reason if bulk-delete is intentional.'
126
+ "or #{PROTOCOL} if bulk-delete is intentional."
95
127
  }.freeze
96
128
 
97
- SAVE_MSG = 'Avoid `save(validate: false)`; it bypasses validations. Use `save!` instead.'.freeze
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 + %i[save]).freeze
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 node.method?(:save)
115
- return unless save_with_validate_false?(node)
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
- def save_with_validate_false?(node)
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