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
|
@@ -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
|