rubocop-dev_doc 0.16.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.
Files changed (27) hide show
  1. checksums.yaml +4 -4
  2. data/config/default.yml +312 -60
  3. data/config/obsoletion.yml +7 -3
  4. data/lib/dev_doc/i18n/tasks.rb +68 -0
  5. data/lib/dev_doc/railtie.rb +19 -0
  6. data/lib/dev_doc/test/lints/cross_tenant_canary_sweep.rb +2 -0
  7. data/lib/dev_doc/test/lints/domain_class_base.rb +138 -0
  8. data/lib/dev_doc/test/lints/http_driven_controller_tests.rb +2 -0
  9. data/lib/dev_doc/test/pseudo_i18n_crawler.rb +5 -0
  10. data/lib/rubocop/cop/dev_doc/i18n/hardcoded_text.rb +243 -0
  11. data/lib/rubocop/cop/dev_doc/i18n/localizable_props.rb +69 -19
  12. data/lib/rubocop/cop/dev_doc/i18n/require_translation.rb +13 -17
  13. data/lib/rubocop/cop/dev_doc/i18n/text_shape.rb +94 -0
  14. data/lib/rubocop/cop/dev_doc/rails/application_model_base.rb +56 -0
  15. data/lib/rubocop/cop/dev_doc/rails/avoid_bypassing_validation.rb +81 -26
  16. data/lib/rubocop/cop/dev_doc/rails/no_errors_add_in_controller.rb +177 -0
  17. data/lib/rubocop/cop/dev_doc/rails/no_form_param_lookup_in_controller.rb +236 -0
  18. data/lib/rubocop/cop/dev_doc/style/in_memory_association_find.rb +162 -0
  19. data/lib/rubocop/cop/dev_doc/style/no_silent_rescue.rb +269 -0
  20. data/lib/rubocop/cop/dev_doc/style/prefer_allowlist_symbol_param.rb +193 -0
  21. data/lib/rubocop/cop/dev_doc/style/use_g_log.rb +133 -0
  22. data/lib/rubocop/cop/dev_doc/test/prefer_find_sole_by.rb +146 -0
  23. data/lib/rubocop/cop/dev_doc/test/test_name_special_characters.rb +108 -0
  24. data/lib/rubocop/cop/dev_doc/view/prefer_prop_over_name.rb +11 -1
  25. data/lib/rubocop/dev_doc/version.rb +1 -1
  26. metadata +16 -3
  27. data/lib/rubocop/cop/dev_doc/i18n/report_text.rb +0 -76
@@ -0,0 +1,162 @@
1
+ module RuboCop
2
+ module Cop
3
+ module DevDoc
4
+ module Style
5
+ # Flag block-form `find`/`detect` on an ActiveRecord association
6
+ # declared in the same file, and demand the fork: refactor to
7
+ # `find_by`, or keep the in-memory view and justify it with a
8
+ # disable-reason.
9
+ #
10
+ # ## Rationale
11
+ # `Relation#find` with a block returns straight into
12
+ # `Enumerable#find` over the association's loaded target —
13
+ # persisted rows PLUS records built in memory and not yet saved.
14
+ # `find_by` is `where(...).take`: a SQL query that has never seen
15
+ # those records — and on an unsaved owner the association's DB
16
+ # scope is empty outright, so `find_by` returns nil while the
17
+ # loaded target holds the caller's submitted selection (`build`,
18
+ # nested attributes, a `*_ids=` assignment on a new record). A
19
+ # validator refactored to `find_by` stops seeing what was
20
+ # submitted, and the very check it exists to make silently
21
+ # passes. (On a persisted owner a `*_ids=` assignment writes the
22
+ # join rows immediately, inside a transaction, so both spellings
23
+ # agree there; the block form's edge is the unsaved population.)
24
+ # Code whose in-memory semantics are load-bearing but unexplained
25
+ # is one well-meaning `find_by` refactor away from a bug its suite
26
+ # does not pin, so the cop's job is to force that explanation to
27
+ # exist.
28
+ #
29
+ # ## Exception — the justified in-memory search
30
+ # Where the in-memory view is required (validators over unsaved
31
+ # records, nested-attribute flows), keep the block form and
32
+ # disable this cop inline, with the reason stated at the disable:
33
+ # why the loaded target — not the database — is the correct thing
34
+ # to search here. The disable-with-reason is the sanctioned exit
35
+ # (the same doctrine as the sibling doctrine cops); no magic
36
+ # comment keyword is recognized, because a keyword can be typed
37
+ # without the understanding.
38
+ #
39
+ # ## Relationship with DevDoc/Rails/NoBlockPredicateOnRelation
40
+ # That cop fires on PROVEN relation chains (`.where(...)`,
41
+ # scopes) and deliberately exempts bare association reads — its
42
+ # dogfooding history names "validators that must see unsaved
43
+ # in-memory records" as the exempted population. This cop is the
44
+ # complement: bare association reads proven by a same-file
45
+ # `has_many` / `has_and_belongs_to_many` declaration,
46
+ # `find`/`detect` only, about correctness justification rather
47
+ # than SQL push-down. The two never fire on the same node.
48
+ #
49
+ # NOTE: non-offenses, by design: an `lvar` shadowing the
50
+ # association's name (`tags = load_tags; tags.find { }` with a
51
+ # `has_many :tags` in the file); a same-file `def` receiver (a
52
+ # scope-shaped method is not a declaration); a `.where`-chained
53
+ # receiver (NoBlockPredicateOnRelation's territory);
54
+ # `has_one`/`belongs_to` names (singular associations do not
55
+ # enumerate); chained receivers such as
56
+ # `association.reload.find { }`; receivers on other objects
57
+ # (`other.posts.find { }`); and receivers in files that do not
58
+ # declare the association — a controller's
59
+ # `@post.comments.find { }` is a stated blind spot, since the
60
+ # declaring class is invisible outside its file. Safe-navigation
61
+ # receivers (`association&.find { }`) fire: same in-memory
62
+ # semantics. `&:sym` block-passes and numbered-block spellings
63
+ # fire like block literals; other block-passes
64
+ # (`&->(tag) { ... }`, `&method(:tag)`) are not detected — the
65
+ # same documented hole NoBlockPredicateOnRelation carries.
66
+ #
67
+ # @example
68
+ # # bad (declaration and search in one file)
69
+ # class Post < ApplicationRecord
70
+ # has_many :comments
71
+ #
72
+ # def foreign_comment?
73
+ # comments.find { |comment| comment.post_id != id }
74
+ # end
75
+ # end
76
+ #
77
+ # # good — the database side
78
+ # comments.find_by(post_id: id)
79
+ #
80
+ # # good — the justified in-memory side
81
+ # # The post is unsaved, so the submitted comments exist only in
82
+ # # the loaded target; find_by on an unsaved owner's association
83
+ # # is empty and would let a foreign comment through.
84
+ # # rubocop:disable DevDoc/Style/InMemoryAssociationFind
85
+ # comments.find { |comment| comment.post_id != id }
86
+ # # rubocop:enable DevDoc/Style/InMemoryAssociationFind
87
+ class InMemoryAssociationFind < Base
88
+ MSG = 'Block-form `%<method>s` on association `%<association>s` searches the loaded ' \
89
+ 'association, including unsaved in-memory children. Use `find_by` unless that ' \
90
+ 'in-memory view is required; if it is, keep the block form and disable this ' \
91
+ 'cop with that reason.'.freeze
92
+
93
+ RESTRICT_ON_SEND = %i[find detect].freeze
94
+
95
+ # A bare association read: `comments` or `self.comments`.
96
+ # Anything else — an lvar, an ivar, a constant, another object's
97
+ # call, a chained call — captures nothing and the send is left
98
+ # alone. The safe-navigation spelling reaches here through
99
+ # `on_csend`: `comments&.find` dispatches on_csend with a plain
100
+ # `(send nil? :comments)` receiver.
101
+ def_node_matcher :bare_receiver_name, <<~PATTERN
102
+ {(send nil? $_) (send (self) $_)}
103
+ PATTERN
104
+
105
+ # The name a `has_many` / `has_and_belongs_to_many` macro declares;
106
+ # the trailing `...` carries the scope lambda and options hash.
107
+ def_node_matcher :declared_association_name, <<~PATTERN
108
+ (send nil? {:has_many :has_and_belongs_to_many} $({sym str} _) ...)
109
+ PATTERN
110
+
111
+ def on_new_investigation
112
+ super
113
+ @association_names = nil
114
+ end
115
+
116
+ def on_send(node)
117
+ return unless node.block_literal? || symbol_block_pass?(node)
118
+ return if node.receiver.nil?
119
+
120
+ name = bare_receiver_name(unwrap_begin(node.receiver))
121
+ return unless name
122
+ return unless association_names.include?(name)
123
+
124
+ add_offense(node, message: format(MSG, method: node.method_name, association: name))
125
+ end
126
+
127
+ alias on_csend on_send
128
+
129
+ private
130
+
131
+ # A paren group's value is its LAST child (`(a; b).find { }`
132
+ # searches `b`), so unwrap to that before matching — same
133
+ # doctrine as NoBlockPredicateOnRelation.
134
+ def unwrap_begin(node)
135
+ node = node.children.last while node&.begin_type?
136
+ node
137
+ end
138
+
139
+ # `find(&:sym)` searches the loaded target exactly like the literal
140
+ # block form — one character shorter, so it must not be a dodge.
141
+ def symbol_block_pass?(node)
142
+ last = node.last_argument
143
+ last&.block_pass_type? && last.children.first&.sym_type?
144
+ end
145
+
146
+ # Every collection association name declared in this file, wherever
147
+ # the macro sits — class body, `included do` block in a concern.
148
+ def association_names
149
+ @association_names ||= Set.new.tap do |names|
150
+ next unless processed_source.ast
151
+
152
+ processed_source.ast.each_node(:send) do |node|
153
+ name = declared_association_name(node)
154
+ names << name.value.to_sym if name
155
+ end
156
+ end
157
+ end
158
+ end
159
+ end
160
+ end
161
+ end
162
+ end
@@ -0,0 +1,269 @@
1
+ module RuboCop
2
+ module Cop
3
+ module DevDoc
4
+ module Style
5
+ # Flag a block-form `rescue` whose body swallows the failure without
6
+ # a trace: no re-raise, no reporting call, no stated justification.
7
+ #
8
+ # ## Rationale
9
+ # A rescue that neither re-raises nor reports makes production
10
+ # failures invisible. The code keeps running on a fallback as if
11
+ # nothing happened, nothing reaches the logs or the error monitor,
12
+ # and the first observable symptom is wrong behavior or wrong data —
13
+ # usually far from the cause, which makes the swallow self-concealing:
14
+ # the very breakage that should have been seen is the one thing no
15
+ # one can see. The shape ships easily because it starts as "this must
16
+ # never break the page": a display helper degrading on a malformed
17
+ # file, an optional integration returning a fallback. Degrading is
18
+ # fine; degrading UNRECORDED is not — the report is one line beside
19
+ # the fallback.
20
+ #
21
+ # The broad spellings are the aggravating case: a bare `rescue` or
22
+ # `rescue StandardError` catches programming errors (NoMethodError,
23
+ # TypeError) together with the expected environmental failure, so a
24
+ # bug in the very code being protected is silenced too. A narrow,
25
+ # named exception class shrinks that blast radius but still owes the
26
+ # same three exits.
27
+ #
28
+ # ## What an offense means
29
+ # An offense does NOT assert the swallow is a defect. The cop cannot
30
+ # see whether a deliberate degradation is correct for the call site —
31
+ # it asserts the swallow is UNDOCUMENTED. A rescue that degrades on
32
+ # purpose is a violation until the reason is stated in the marker
33
+ # comment; the marker is the intended resolution for deliberate
34
+ # ones, not a workaround. On adoption, expect a one-time
35
+ # justification sweep over existing rescues — writing each reason
36
+ # honestly IS the audit.
37
+ #
38
+ # ## Remedy
39
+ # The blessed shape is report-then-degrade — one reporting line,
40
+ # then the fallback:
41
+ #
42
+ # ❌ the failure vanishes
43
+ # def verified_suffix
44
+ # stamp = read_stamp
45
+ # "verified #{stamp.fetch('checked_at')}"
46
+ # rescue StandardError
47
+ # nil
48
+ # end
49
+ #
50
+ # ✔️ report, then degrade — the page still renders, the failure
51
+ # still lands where someone will read it
52
+ # def verified_suffix
53
+ # stamp = read_stamp
54
+ # "verified #{stamp.fetch('checked_at')}"
55
+ # rescue StandardError => e
56
+ # logger.error("stamp unreadable, suffix suppressed: #{e.class} #{e.message}")
57
+ # nil
58
+ # end
59
+ #
60
+ # ✔️ re-raise (after any partial handling) — the caller decides
61
+ #
62
+ # ✔️ deliberate degradation, reason stated for review
63
+ # def verified_suffix
64
+ # stamp = read_stamp
65
+ # "verified #{stamp.fetch('checked_at')}"
66
+ # rescue StandardError
67
+ # # silent-rescue: cosmetic suffix only; the stamp file is
68
+ # # machine-regenerated and a stale suffix must not break the page.
69
+ # nil
70
+ # end
71
+ #
72
+ # ## Returning the failure as data counts as surfacing it
73
+ # A body whose return VALUE carries the bound exception — an error
74
+ # payload (`Result.new(status: 502, body: { error: e.class.to_s })`),
75
+ # a rendered 4xx (`render json: { error: e.message }, status:
76
+ # :bad_request`), an error column persisted for a status page — is
77
+ # not silent: the failure leaves the method with the caller, who
78
+ # decides what to do with it, exactly as with a re-raise. The cop
79
+ # reads this structurally: the body's final expression (or any
80
+ # explicit `return`) references the bound exception variable.
81
+ # Blind spots of that reading, for reviewers: the reference counts
82
+ # even on a branch the run did not take, and a generic payload
83
+ # built WITHOUT the exception ("failed") still flags.
84
+ #
85
+ # ## Relationship with `Lint/SuppressedException` (stock)
86
+ # This cop is a strict superset at the stock cop's default settings
87
+ # (`AllowComments: true`, `AllowNil: true`): everything it flags —
88
+ # the empty, uncommented rescue — this cop flags too, and it also
89
+ # catches the `nil`, literal and silent-action bodies those settings
90
+ # allow, requiring a named marker phrase instead of any comment.
91
+ # Disable `Lint/SuppressedException` when enabling this one to avoid
92
+ # double-flagging the empty-body corner.
93
+ #
94
+ # Modifier rescues (`value rescue fallback`) are deliberately out of
95
+ # scope: the modifier form has no room to report, so every instance
96
+ # is categorically silent, and `Style/RescueModifier` already flags
97
+ # all of them on form alone. Keep that cop enabled; this one adds
98
+ # nothing there.
99
+ #
100
+ # ## Configuration
101
+ # `JustificationMarker` (default: `silent-rescue:`) — the phrase the
102
+ # adjacent comment must contain: on the `rescue` line itself
103
+ # (trailing), in the contiguous comment block ending on the line
104
+ # directly above the `rescue` keyword, or in the body's leading
105
+ # comment block directly below it (measured adopters put the reason
106
+ # there; unlike the auth marker cops both sides of the `rescue`
107
+ # keyword are accepted — the marker still has to name the phrase
108
+ # deliberately, whichever placement reads best).
109
+ #
110
+ # `ReportingMethods` (default: `log`, `warn`, `error`, `errors`,
111
+ # `exception`, `fatal`, `critical`, `notify`, `notice`, `report`,
112
+ # `capture`, `track`, `instrument`, `monitor`, `alert`,
113
+ # `breadcrumb`, `handle`) — matched as whole name TOKENS, not
114
+ # substrings: a method counts when, after stripping a trailing
115
+ # `?`/`!`/`=`, any of its `_`-separated segments equals one of these
116
+ # words. Receiver-agnostic, so any logger or error-monitor API
117
+ # counts (`logger.error(e)`, `Sentry.capture_exception(e)`,
118
+ # `NewRelic::Agent.notice_error(e)`), a delegating handler whose
119
+ # name says what it does counts (`handle_api_error(e)`), and a body
120
+ # surfacing the failure through the record counts
121
+ # (`record.errors.add(:base, ...)` — the user sees it). A name that
122
+ # merely CONTAINS a verb does not: `login_path`, `tracking_number`,
123
+ # `warning?` are not reporting. Add your project's in-house
124
+ # reporting verbs here.
125
+ #
126
+ # NOTE: The walk judges the body syntactically: any matching call
127
+ # anywhere in the body counts as reporting, even one an if-condition
128
+ # hides, and a failure merely stashed (`@last_error = e`) does not.
129
+ # Reviewers judge whether what gets reported is the right failure.
130
+ # Retry-only bodies (`rescue; retry; end`) are flagged — an
131
+ # unreported retry loop is the classic forever-silent failure. An
132
+ # empty-bodied clause's below-rescue marker also sits directly above
133
+ # the NEXT clause's `rescue`, so one marker justifies both — a single
134
+ # statement covering adjacent clauses, accepted as intended. Only
135
+ # `app/` and `lib/` are inspected.
136
+ #
137
+ # @example
138
+ # # bad — fallback, no trace
139
+ # begin
140
+ # sync_prices
141
+ # rescue StandardError
142
+ # []
143
+ # end
144
+ #
145
+ # # good — report, then degrade
146
+ # begin
147
+ # sync_prices
148
+ # rescue StandardError => e
149
+ # logger.error("price sync failed: #{e.message}")
150
+ # []
151
+ # end
152
+ #
153
+ # # good — deliberate, stated
154
+ # begin
155
+ # sync_prices
156
+ # rescue StandardError
157
+ # # silent-rescue: prices refresh hourly; a failed refresh keeps
158
+ # # the list from the last successful run.
159
+ # []
160
+ # end
161
+ class NoSilentRescue < Base
162
+ include Auth::AdjacentJustification
163
+ include RescueNode
164
+
165
+ MSG = 'This rescue swallows the failure without a trace — report it (a logger/error-monitor ' \
166
+ 'call), re-raise it, or state the reason in a "%<marker>s" comment next to the rescue.'.freeze
167
+
168
+ DEFAULT_REPORTING_METHODS = %w[
169
+ log warn error errors exception fatal critical notify notice report capture track
170
+ instrument monitor alert breadcrumb handle
171
+ ].freeze
172
+
173
+ PROPAGATING_METHODS = %i[raise fail throw].freeze
174
+
175
+ def on_resbody(node)
176
+ return if rescue_modifier?(node)
177
+ return if reports_or_propagates?(node.body)
178
+ return if surfaces_failure?(node)
179
+ return if adjacent_justification?(node) || leading_body_marker?(node)
180
+
181
+ add_offense(node.loc.keyword, message: format(MSG, marker: justification_marker))
182
+ end
183
+
184
+ private
185
+
186
+ # Any reporting or propagating call in the body — including the
187
+ # body itself when it is that call — makes the rescue audible.
188
+ # An empty body reports nothing by construction.
189
+ def reports_or_propagates?(body)
190
+ return false if body.nil?
191
+
192
+ body.each_node(:send, :csend).any? { |call| reporting_call?(call) }
193
+ end
194
+
195
+ # The marker also counts in the body's own leading comment block —
196
+ # the contiguous comment-only lines directly below the `rescue`
197
+ # keyword, where adopters naturally state the reason. A comment
198
+ # after the first body statement is not adjacent to the rescue.
199
+ def leading_body_marker?(node)
200
+ marker = normalized_marker
201
+ comments_by_line = processed_source.comments.group_by { |comment| comment.location.line }
202
+ line = node.loc.keyword.line + 1
203
+ while (line_comments = comments_by_line[line]) && comment_only_line?(line)
204
+ return true if contains_marker?(line_comments, marker)
205
+
206
+ line += 1
207
+ end
208
+
209
+ false
210
+ end
211
+
212
+ # The String-downcasing the shared concern applies on its own
213
+ # path; a Regexp marker passes through for `contains_marker?`.
214
+ def normalized_marker
215
+ marker = justification_marker
216
+ marker.is_a?(String) ? marker.downcase : marker
217
+ end
218
+
219
+ # A body whose return value carries the bound exception hands the
220
+ # failure to the caller as data (error payload, rendered 4xx,
221
+ # persisted error column) — surfaced, not swallowed.
222
+ def surfaces_failure?(node)
223
+ exception_variable = node.exception_variable
224
+ return false unless exception_variable
225
+
226
+ body = node.body
227
+ return false if body.nil?
228
+
229
+ name = exception_variable.name
230
+ references_exception?(final_expression(body), name) ||
231
+ body.each_descendant(:return).any? do |returned|
232
+ references_exception?(returned.children.first, name)
233
+ end
234
+ end
235
+
236
+ def final_expression(body)
237
+ body.begin_type? ? body.children.last : body
238
+ end
239
+
240
+ def references_exception?(node, name)
241
+ return false unless node
242
+
243
+ node.each_node(:lvar).any? { |lvar| lvar.children.first == name }
244
+ end
245
+
246
+ # Token match, not substring: `login_path` must not read as
247
+ # reporting. `warning?`/`save!`/`level=` lose their suffix before
248
+ # splitting.
249
+ def reporting_call?(call)
250
+ name = call.method_name
251
+ return true if PROPAGATING_METHODS.include?(name)
252
+
253
+ tokens = name.to_s.sub(/[?!=]\z/, '').split('_')
254
+ reporting_methods.any? { |verb| tokens.include?(verb) }
255
+ end
256
+
257
+ def reporting_methods
258
+ @reporting_methods ||=
259
+ Array(cop_config.fetch('ReportingMethods', DEFAULT_REPORTING_METHODS))
260
+ end
261
+
262
+ def justification_marker
263
+ cop_config.fetch('JustificationMarker', 'silent-rescue:')
264
+ end
265
+ end
266
+ end
267
+ end
268
+ end
269
+ end
@@ -0,0 +1,193 @@
1
+ module RuboCop
2
+ module Cop
3
+ module DevDoc
4
+ module Style
5
+ # Flag a hand-rolled allow-list lookup — a `find`/`detect` block that
6
+ # compares each entry of a fixed list (bare or `.to_s`'d) against a
7
+ # request param — where the boundary helper already owns the decision.
8
+ #
9
+ # ## Rationale
10
+ # `glib_allowlist_symbol_param(value, ALLOWED, default:)` exists so the
11
+ # missing/junk/blank-value decision lives in one reviewed place. The
12
+ # hand-rolled `LIST.find { |entry| entry.to_s == params[:x].to_s }`
13
+ # re-derives the helper's internals at the call site: the
14
+ # degrade-to-default policy, the nil/blank fallthrough and the
15
+ # string-space (symbol-DoS-safe) comparison all get re-implemented by
16
+ # hand, and the next reader cannot tell an intentional degrade from an
17
+ # oversight. A real instance of exactly this shape slipped through
18
+ # review carrying a paragraph of commentary explaining what the
19
+ # helper's name already encodes.
20
+ #
21
+ # ## Relationship with the boundary cop pair
22
+ # Third of the trio over the same boundary doctrine.
23
+ # `DevDoc/Style/StringSymbolComparison` polices comparing without
24
+ # converting (always-false `==` against a symbol literal);
25
+ # `DevDoc/Style/AvoidSymbolizingBoundaryInput` polices converting
26
+ # without validating (`.to_sym` on params). This cop polices the third
27
+ # shape: validating by hand instead of through the shared helper. All
28
+ # three share the same sanctioned exit.
29
+ #
30
+ # ## Exception — the swap is a behavior change on mutating requests
31
+ # On GET/HEAD the helper degrades unrecognised values to `default`,
32
+ # exactly like the hand-roll. On a mutating request it raises
33
+ # `UnrecognizedValue` on a present-but-unrecognised value — the
34
+ # intended doctrine for values the UI wrote (a mismatch means a
35
+ # forgotten allow-list entry or a broken client and must surface).
36
+ # Where a silent degrade is deliberate — an endpoint anyone can
37
+ # POST to, where junk values are expected traffic — pass the
38
+ # helper's `raise_on_unrecognized: false` (glib-web 7.0.2+):
39
+ # unrecognised values then degrade to `default` on every method,
40
+ # matching the hand-roll's degrade-vs-raise behavior. (The return
41
+ # type follows the remedy's own rule either way: the helper always
42
+ # symbolizes the matched entry, including entries from string
43
+ # lists.)
44
+ #
45
+ # ## Sources detected
46
+ # - `params[…]`, `params.dig(…)`, `params.fetch(…)`, `params.require(…)`
47
+ # - `request.headers[…]`
48
+ #
49
+ # `ENV[…]` is deliberately excluded, as in
50
+ # `DevDoc/Style/AvoidSymbolizingBoundaryInput`: operator-controlled,
51
+ # not attacker-reachable, and boot-time config code has no controller
52
+ # helper to call.
53
+ #
54
+ # NOTE: only the direct `find`/`detect` block form is detected, and
55
+ # only over a developer-controlled list: the body must be a single
56
+ # `==` comparison (parentheses around it are fine; multi-statement
57
+ # bodies, modifier-`if`/`rescue` bodies and `!=` are not), and the
58
+ # iterated collection must not itself be boundary-sourced —
59
+ # `params[:ids].find { … }` is a lookup inside client data, not an
60
+ # allow-list, and the helper's `.to_sym` must never run over client
61
+ # entries. A `Hash#find` over pairs (`PRESETS.find { |key, _config| … }`)
62
+ # fires too; its remedy passes `.keys` and returns the key, not the
63
+ # `[key, value]` pair. Membership predicates over the same comparison
64
+ # (`any?`, `include?`), the numbered-block and `it` spellings (`_1`,
65
+ # Ruby 3.4's `it`), a param already flowed into a local or helper
66
+ # (`report_params[:x]`), a `params.permit(…)[]` read (not a detected
67
+ # source, as in the sibling cops), and a laundered list receiver
68
+ # (`params[:ids].map(&:to_i).find { … }`) are beyond static reach —
69
+ # review catches those.
70
+ #
71
+ # @example
72
+ # # bad
73
+ # preset = PRESETS.keys.find { |key| key.to_s == params[:preset].to_s }
74
+ # mode = MODES.find { |mode| params[:mode].to_s == mode.to_s }
75
+ # status = STATUSES.detect { |status| status == params[:status] }
76
+ #
77
+ # # good
78
+ # preset = glib_allowlist_symbol_param(params[:preset], PRESETS.keys, default: DEFAULT_PRESET)
79
+ # mode = glib_allowlist_symbol_param(params[:mode], MODES, default: :active)
80
+ # status = glib_allowlist_symbol_param(params[:status], STATUSES, default: :draft)
81
+ class PreferAllowlistSymbolParam < Base
82
+ MSG = 'Hand-rolled allow-list lookup against `%<source>s`; use ' \
83
+ '`glib_allowlist_symbol_param(value, ALLOWED, default:)` instead.'.freeze
84
+
85
+ # String-preserving transforms that may sit between the boundary
86
+ # read and the comparison — same laundering-hop doctrine as
87
+ # AvoidSymbolizingBoundaryInput.
88
+ TRANSFORM_HOPS = %i[presence to_s strip downcase upcase squish].freeze
89
+
90
+ def_node_matcher :params_access?, <<~PATTERN
91
+ (send (send nil? :params) {:[] :dig :fetch :require} ...)
92
+ PATTERN
93
+
94
+ def_node_matcher :request_headers_access?, <<~PATTERN
95
+ (send (send _ :headers) :[] _)
96
+ PATTERN
97
+
98
+ def on_block(node)
99
+ return unless %i[find detect].include?(node.send_node.method_name)
100
+ # A boundary-sourced list is a lookup inside client data, not an
101
+ # allow-list (see NOTE): the helper's `.to_sym` must never run
102
+ # over client-controlled entries.
103
+ return if boundary_source_in(node.send_node.receiver)
104
+
105
+ source = boundary_lookup(node)
106
+ return unless source
107
+
108
+ add_offense(node, message: format(MSG, source: source.source))
109
+ end
110
+
111
+ private
112
+
113
+ # The boundary access in the block's `==` comparison, whichever side
114
+ # holds it; nil when the body is not a single comparison or neither
115
+ # side reads the boundary.
116
+ def boundary_lookup(block)
117
+ comparison = block_comparison(block.body)
118
+ return unless comparison
119
+
120
+ names = block_arg_names(block)
121
+ lookup_source(comparison.receiver, comparison.first_argument, names) ||
122
+ lookup_source(comparison.first_argument, comparison.receiver, names)
123
+ end
124
+
125
+ # The block's `==` comparison, or nil for any other body shape.
126
+ # Parentheses mint a single-child `begin` around the comparison;
127
+ # those are unwrapped so `(entry.to_s == params[:x].to_s)` still
128
+ # matches, while multi-statement bodies keep their multi-child
129
+ # begin and fall out here.
130
+ def block_comparison(body)
131
+ body = body.children.first while single_child_begin?(body)
132
+ body if body&.send_type? && body.method?(:==)
133
+ end
134
+
135
+ def single_child_begin?(node)
136
+ node&.begin_type? && node.children.one?
137
+ end
138
+
139
+ # The compared block arguments (`|key|`); destructured arguments
140
+ # have no single name and contribute none.
141
+ def block_arg_names(block)
142
+ block.arguments.filter_map { |arg| arg.name if arg.arg_type? }
143
+ end
144
+
145
+ # The boundary access behind `other_operand`, when the OTHER side is
146
+ # the allow-list entry. Returns nil otherwise.
147
+ def lookup_source(entry_operand, other_operand, names)
148
+ return unless entry_operand?(entry_operand, names)
149
+
150
+ boundary_source_in(other_operand)
151
+ end
152
+
153
+ # The allow-list entry side: a bare block argument (`|status|`) or
154
+ # its string form (`|key| key.to_s`, `key&.to_s`). Anything richer —
155
+ # an attribute of the iterated record (`u.email`) or another method
156
+ # call on it — is a lookup-by-value, not an allow-list, and is left
157
+ # alone.
158
+ def entry_operand?(operand, names)
159
+ return true if operand.lvar_type? && names.include?(operand.name)
160
+
161
+ to_s_on_entry?(operand) && names.include?(operand.receiver.name)
162
+ end
163
+
164
+ # The entry's own `.to_s` — the helper's own comparison shape,
165
+ # whatever the list holds.
166
+ def to_s_on_entry?(operand)
167
+ (operand.send_type? || operand.csend_type?) && operand.method?(:to_s) &&
168
+ operand.receiver&.lvar_type?
169
+ end
170
+
171
+ # The boundary source at the bottom of the receiver chain, walking
172
+ # through laundering transforms (mirrors
173
+ # AvoidSymbolizingBoundaryInput#boundary_source_in). Returns nil for
174
+ # anything else (locals, ivars, helper-method results) so those
175
+ # don't false-positive.
176
+ def boundary_source_in(node)
177
+ while node
178
+ return node if boundary_source?(node)
179
+ return nil unless node.send_type? || node.csend_type?
180
+ return nil unless TRANSFORM_HOPS.include?(node.method_name)
181
+
182
+ node = node.receiver
183
+ end
184
+ end
185
+
186
+ def boundary_source?(node)
187
+ params_access?(node) || request_headers_access?(node)
188
+ end
189
+ end
190
+ end
191
+ end
192
+ end
193
+ end