rubocop-dev_doc 0.5.0.beta1 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/config/default.yml +19 -139
- data/lib/rubocop/cop/dev_doc/auth/current_user_branching.rb +47 -4
- data/lib/rubocop/cop/dev_doc/migration/avoid_column_default.rb +53 -2
- data/lib/rubocop/cop/dev_doc/migration/avoid_vague_column_names.rb +7 -1
- data/lib/rubocop/cop/dev_doc/rails/avoid_ordering_by_id.rb +167 -0
- data/lib/rubocop/cop/dev_doc/rails/avoid_rails_callbacks.rb +14 -4
- data/lib/rubocop/cop/dev_doc/rails/avoid_raw_sql.rb +227 -0
- data/lib/rubocop/cop/dev_doc/style/prefer_public_send.rb +86 -0
- data/lib/rubocop/cop/dev_doc/style/tap_block_ignores_value.rb +123 -0
- data/lib/rubocop/cop/dev_doc/test/avoid_unit_test.rb +24 -0
- data/lib/rubocop/dev_doc/version.rb +1 -1
- metadata +13 -7
- data/lib/rubocop/cop/dev_doc/i18n/report_text.rb +0 -112
- data/lib/rubocop/cop/dev_doc/i18n/require_translation.rb +0 -116
- data/lib/rubocop/cop/dev_doc/i18n/translation_key_prefix.rb +0 -89
- data/lib/rubocop/cop/dev_doc/i18n/unverified_translation.rb +0 -106
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
module RuboCop
|
|
2
|
+
module Cop
|
|
3
|
+
module DevDoc
|
|
4
|
+
module Rails
|
|
5
|
+
# Avoid raw SQL in query methods and `connection.execute`.
|
|
6
|
+
#
|
|
7
|
+
# ## Rationale
|
|
8
|
+
# Raw SQL bypasses Rails' adapter abstraction, hides typos in
|
|
9
|
+
# column and table names (a misspelled column silently returns
|
|
10
|
+
# nothing instead of raising `NameError`), is non-reversible in
|
|
11
|
+
# migrations, and ships no audit trail for why raw SQL was chosen
|
|
12
|
+
# over the Rails idiom. Every raw-SQL site should be a deliberate,
|
|
13
|
+
# reviewable choice — not a default.
|
|
14
|
+
#
|
|
15
|
+
# Prefer, in order:
|
|
16
|
+
#
|
|
17
|
+
# - The hash/array form: `where(status: "active")`,
|
|
18
|
+
# `where(status: ["active", "pending"])`.
|
|
19
|
+
# - Parameterized fragments: `where("col = ?", val)`,
|
|
20
|
+
# `where("col = :sym", sym: val)`. The `?` / `:name` placeholder
|
|
21
|
+
# binds the value safely and the column name is the only
|
|
22
|
+
# user-controlled surface.
|
|
23
|
+
# - Arel nodes for database-specific constructs that the hash
|
|
24
|
+
# form can't express.
|
|
25
|
+
#
|
|
26
|
+
# ❌ Raw SQL — typo in `statu` returns nothing, silently
|
|
27
|
+
# User.where("statu = 'active'")
|
|
28
|
+
#
|
|
29
|
+
# ✔️ Hash form — typo raises `ArgumentError` at the query layer
|
|
30
|
+
# User.where(status: "active")
|
|
31
|
+
#
|
|
32
|
+
# ✔️ Parameterized fragment — value bound, column name spelled
|
|
33
|
+
# User.where("status = ?", status)
|
|
34
|
+
#
|
|
35
|
+
# ## Scope
|
|
36
|
+
# The cop covers any string argument to:
|
|
37
|
+
#
|
|
38
|
+
# - **Execution methods** — `execute`, `exec_query`, `exec_update`,
|
|
39
|
+
# `exec_delete`, `exec_insert`, `find_by_sql`, `select_rows`,
|
|
40
|
+
# `select_values`, `select_one`, `select_all`, `update_sql`,
|
|
41
|
+
# `insert_sql`, `delete_sql`, `update_all`, `delete_all`.
|
|
42
|
+
# - **Query fragment methods** — `where`, `having`, `order`,
|
|
43
|
+
# `reorder`, `joins`, `select`, `group`, `from`, `lock`, `pluck`.
|
|
44
|
+
# - **`Arel.sql`** — the explicit opt-in to raw SQL (see below).
|
|
45
|
+
#
|
|
46
|
+
# Applies everywhere — application code AND migrations. A
|
|
47
|
+
# migration's `execute "UPDATE ..."` is the same smell as an
|
|
48
|
+
# app code `where("...")`; both bypass the adapter and offer no
|
|
49
|
+
# audit trail.
|
|
50
|
+
#
|
|
51
|
+
# ## `Arel.sql`
|
|
52
|
+
# Always flagged. `Arel.sql` exists specifically to bypass Rails'
|
|
53
|
+
# raw-SQL protection — reaching for it should require
|
|
54
|
+
# justification, not be a reflex. If a fragment is genuinely
|
|
55
|
+
# needed, disable inline with a reason; otherwise express it as
|
|
56
|
+
# an Arel AST node or a parameterized query.
|
|
57
|
+
#
|
|
58
|
+
# ## Counter updates (`update_all("col = col + 1")`)
|
|
59
|
+
# For **single records**, use `Model.increment_counter(:col, id)`
|
|
60
|
+
# or `Model.update_counters(id, col: 1)` — both generate
|
|
61
|
+
# parameterized SQL and surface typos in the column name. Neither
|
|
62
|
+
# is flagged by `DevDoc/Migration/AvoidBypassingValidation` (they
|
|
63
|
+
# are the Rails-blessed atomic-counter primitives).
|
|
64
|
+
#
|
|
65
|
+
# For **bulk counter updates**, the only clean option is
|
|
66
|
+
# `find_each { |m| Model.increment_counter(:col, m.id) }` — N
|
|
67
|
+
# queries, slow on large tables, but free of the
|
|
68
|
+
# validation-bypass smell. If the N-query cost is genuinely
|
|
69
|
+
# unacceptable, `update_all("col = col + 1")` requires disabling
|
|
70
|
+
# BOTH this cop AND `DevDoc/Migration/AvoidBypassingValidation`
|
|
71
|
+
# with reasons — the friction is the audit trail (locking
|
|
72
|
+
# implications, idempotency on re-runs, and the like are worth
|
|
73
|
+
# a second look).
|
|
74
|
+
#
|
|
75
|
+
# ❌ Single-record counter update via raw SQL
|
|
76
|
+
# User.where(id: id).update_all("login_count = login_count + 1")
|
|
77
|
+
#
|
|
78
|
+
# ✔️ Parameterized — generates safe SQL
|
|
79
|
+
# User.increment_counter(:login_count, id)
|
|
80
|
+
#
|
|
81
|
+
# ## Exception
|
|
82
|
+
# Genuine raw-SQL needs — database-specific DDL
|
|
83
|
+
# (`CREATE INDEX CONCURRENTLY`, `ALTER TYPE ... ADD VALUE`),
|
|
84
|
+
# complex joins that don't fit Arel, analytic queries with CTEs
|
|
85
|
+
# — go through an inline disable comment with a brief reason.
|
|
86
|
+
# The friction is the feature: every raw-SQL site ends up in
|
|
87
|
+
# code review with an articulated justification.
|
|
88
|
+
#
|
|
89
|
+
# ## Migration backfills are NOT an exception
|
|
90
|
+
# A common AI-agent reflex is to justify
|
|
91
|
+
# `execute "UPDATE users SET role = 0 ..."` with "models can
|
|
92
|
+
# drift, so raw SQL is safer." That justification is rejected
|
|
93
|
+
# here. The data-integrity cost of bypassing validations and
|
|
94
|
+
# callbacks outweighs the (rare, usually catchable) risk of a
|
|
95
|
+
# migration failing because a model changed.
|
|
96
|
+
#
|
|
97
|
+
# The correct backfill pattern goes through the model with
|
|
98
|
+
# `save!` — `DevDoc/Migration/AvoidBypassingValidation`
|
|
99
|
+
# enforces this, and `best_practices/backend/en/02_migration.md`
|
|
100
|
+
# shows the shape:
|
|
101
|
+
#
|
|
102
|
+
# ✔️ Model through the migration — validations run, callbacks fire
|
|
103
|
+
# User.where(role: nil).find_each do |user|
|
|
104
|
+
# user.role = 0
|
|
105
|
+
# user.save!
|
|
106
|
+
# end
|
|
107
|
+
#
|
|
108
|
+
# If existing rows would fail validation, fix the data first
|
|
109
|
+
# rather than bypassing validation — that's the signal, not an
|
|
110
|
+
# obstacle.
|
|
111
|
+
#
|
|
112
|
+
# NOTE: Parameterized fragments are detected by the presence of
|
|
113
|
+
# a `?` or `:name` placeholder plus at least one additional
|
|
114
|
+
# argument. A `?` appearing in literal SQL that isn't a
|
|
115
|
+
# placeholder (`where("name LIKE '%?%'")`) is a residual false
|
|
116
|
+
# negative; inline-disable if it surfaces.
|
|
117
|
+
#
|
|
118
|
+
# NOTE: Interpolated strings (`where("col = '#{val}'")`) are
|
|
119
|
+
# ALWAYS flagged — they cannot be parameterized by construction
|
|
120
|
+
# and are a SQL-injection risk. Use the `?` placeholder form.
|
|
121
|
+
#
|
|
122
|
+
# NOTE: Method calls returning strings (`where(some_helper)`)
|
|
123
|
+
# are not flagged — the cop only inspects literal strings.
|
|
124
|
+
# Helper methods that wrap raw SQL should themselves carry the
|
|
125
|
+
# disable.
|
|
126
|
+
#
|
|
127
|
+
# @example
|
|
128
|
+
# # bad — raw SQL
|
|
129
|
+
# execute "UPDATE users SET role = 0 WHERE role IS NULL"
|
|
130
|
+
# User.where("status = 'active'")
|
|
131
|
+
# User.where("status = '#{status}'")
|
|
132
|
+
# User.order("created_at")
|
|
133
|
+
# User.joins("INNER JOIN items ON items.user_id = users.id")
|
|
134
|
+
# User.update_all("login_count = login_count + 1")
|
|
135
|
+
# User.select("DISTINCT status")
|
|
136
|
+
# Arel.sql("NULLS LAST")
|
|
137
|
+
#
|
|
138
|
+
# # good — hash / array / symbol form
|
|
139
|
+
# User.where(status: "active")
|
|
140
|
+
# User.where(status: ["active", "pending"])
|
|
141
|
+
# User.order(:created_at)
|
|
142
|
+
# User.joins(:items)
|
|
143
|
+
# User.update_all(status: "active")
|
|
144
|
+
# User.select(:status)
|
|
145
|
+
#
|
|
146
|
+
# # good — parameterized fragment
|
|
147
|
+
# User.where("status = ?", status)
|
|
148
|
+
# User.where("status = :sym", sym: status)
|
|
149
|
+
# User.find_by_sql("SELECT * FROM users WHERE id = ?", id)
|
|
150
|
+
#
|
|
151
|
+
# # good — counter update on a single record
|
|
152
|
+
# User.increment_counter(:login_count, id)
|
|
153
|
+
class AvoidRawSql < Base
|
|
154
|
+
MSG = 'Avoid raw SQL — prefer the hash/array form, a ' \
|
|
155
|
+
'parameterized fragment, or an Arel node. Disable with ' \
|
|
156
|
+
'a reason when raw SQL is genuinely required.'.freeze
|
|
157
|
+
|
|
158
|
+
EXECUTION_METHODS = %i[
|
|
159
|
+
execute exec_query exec_update exec_delete exec_insert
|
|
160
|
+
find_by_sql select_rows select_values select_one select_all
|
|
161
|
+
update_sql insert_sql delete_sql update_all delete_all
|
|
162
|
+
].freeze
|
|
163
|
+
|
|
164
|
+
FRAGMENT_METHODS = %i[
|
|
165
|
+
where having order reorder joins select group from lock pluck
|
|
166
|
+
].freeze
|
|
167
|
+
|
|
168
|
+
SQL_METHODS = (EXECUTION_METHODS + FRAGMENT_METHODS).freeze
|
|
169
|
+
|
|
170
|
+
RESTRICT_ON_SEND = (SQL_METHODS + [:sql]).freeze
|
|
171
|
+
|
|
172
|
+
def on_send(node)
|
|
173
|
+
if arel_sql?(node)
|
|
174
|
+
check_arel_sql(node)
|
|
175
|
+
elsif SQL_METHODS.include?(node.method_name)
|
|
176
|
+
check_sql_method(node)
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
private
|
|
181
|
+
|
|
182
|
+
# `Arel.sql("...")` — the explicit opt-in to raw SQL. The
|
|
183
|
+
# receiver must be the `Arel` constant so unrelated
|
|
184
|
+
# `obj.sql(...)` calls don't false-positive.
|
|
185
|
+
def arel_sql?(node)
|
|
186
|
+
node.method_name == :sql &&
|
|
187
|
+
node.receiver&.const_type? &&
|
|
188
|
+
node.receiver.const_name == 'Arel'
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# Arel.sql is always flagged (no parameterized exemption) —
|
|
192
|
+
# reaching for it is itself the smell being audited.
|
|
193
|
+
def check_arel_sql(node)
|
|
194
|
+
arg = node.first_argument
|
|
195
|
+
return unless arg && string?(arg)
|
|
196
|
+
|
|
197
|
+
add_offense(arg)
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def check_sql_method(node)
|
|
201
|
+
arg = node.first_argument
|
|
202
|
+
return unless arg && string?(arg)
|
|
203
|
+
return if parameterized?(node, arg)
|
|
204
|
+
|
|
205
|
+
add_offense(arg)
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def string?(arg)
|
|
209
|
+
arg.str_type? || arg.dstr_type?
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# A literal string (`str`) is parameterized when it contains a
|
|
213
|
+
# `?` or `:name` placeholder AND there's at least one more
|
|
214
|
+
# argument to bind. An interpolated string (`dstr`) is NEVER
|
|
215
|
+
# parameterized — the value is concatenated in, not bound.
|
|
216
|
+
def parameterized?(send_node, string_arg)
|
|
217
|
+
return false if string_arg.dstr_type?
|
|
218
|
+
return false unless send_node.arguments.count > 1
|
|
219
|
+
|
|
220
|
+
value = string_arg.value
|
|
221
|
+
value.include?('?') || value.match?(/:[a-z_][a-z0-9_]*/i)
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
module RuboCop
|
|
2
|
+
module Cop
|
|
3
|
+
module DevDoc
|
|
4
|
+
module Style
|
|
5
|
+
# Prefer `public_send` over `send` when dispatching dynamically.
|
|
6
|
+
#
|
|
7
|
+
# ## Rationale
|
|
8
|
+
# `public_send` respects Ruby's method visibility. When the dispatched
|
|
9
|
+
# name should resolve to a public API method (the common case for
|
|
10
|
+
# generic validators, form helpers, attribute readers, etc.), using
|
|
11
|
+
# `public_send`:
|
|
12
|
+
#
|
|
13
|
+
# - **Surfaces typos / misconfig immediately.** A name that drifts to
|
|
14
|
+
# a private internal raises `NoMethodError` instead of silently
|
|
15
|
+
# invoking it.
|
|
16
|
+
# - **Future-proofs the call site.** If the target method later moves
|
|
17
|
+
# to private (refactor, contract narrowing), `public_send` raises
|
|
18
|
+
# on the next run; `send` keeps silently working — potentially
|
|
19
|
+
# masking a real bug.
|
|
20
|
+
# - **Documents intent.** A reader of `public_send` knows the author
|
|
21
|
+
# meant a public-API call. `send` leaves it ambiguous whether
|
|
22
|
+
# private access was wanted.
|
|
23
|
+
#
|
|
24
|
+
# ## Relationship to AvoidSend
|
|
25
|
+
# `DevDoc/Style/AvoidSend` flags *dynamic* dispatch (both `send` and
|
|
26
|
+
# `public_send`) as risky. This cop is orthogonal: it flags `send`
|
|
27
|
+
# specifically, including the cases AvoidSend exempts (literal-symbol
|
|
28
|
+
# args, prefix-string args). The friction asymmetry is intentional —
|
|
29
|
+
# `send` is the deeper exception, so it costs an extra disable:
|
|
30
|
+
#
|
|
31
|
+
# obj.public_send(method_name) # AvoidSend: 1 disable
|
|
32
|
+
# obj.public_send(:foo) # clean
|
|
33
|
+
# obj.send(method_name) # AvoidSend + this cop: 2 disables
|
|
34
|
+
# obj.send(:foo) # this cop only: 1 disable
|
|
35
|
+
#
|
|
36
|
+
# ## When `send` IS the right choice
|
|
37
|
+
# Reach for `send` only when you have an articulable reason to bypass
|
|
38
|
+
# visibility — and leave a comment so the next reader knows it's
|
|
39
|
+
# deliberate:
|
|
40
|
+
#
|
|
41
|
+
# - **Tests** calling a private helper:
|
|
42
|
+
#
|
|
43
|
+
# # rubocop:disable DevDoc/Style/PreferPublicSend -- unit-testing private method
|
|
44
|
+
# assert_equal 5, calculator.send(:internal_carry)
|
|
45
|
+
# # rubocop:enable DevDoc/Style/PreferPublicSend
|
|
46
|
+
#
|
|
47
|
+
# - **Framework / introspection** code that intentionally calls
|
|
48
|
+
# private callbacks (e.g. `record.send(:_run_save_callbacks)`).
|
|
49
|
+
#
|
|
50
|
+
# ## Receiver-less `send` is exempt
|
|
51
|
+
# `send(:foo)` (no explicit receiver) calls a method on `self` and is
|
|
52
|
+
# commonly used inside a class to dispatch to its own private methods.
|
|
53
|
+
# Rewriting to `public_send` would either change semantics (the
|
|
54
|
+
# method must become public) or fail. Same exemption as `AvoidSend`.
|
|
55
|
+
#
|
|
56
|
+
# @example
|
|
57
|
+
# # bad — prefer public_send
|
|
58
|
+
# record.send(attribute)
|
|
59
|
+
# instance.send(:public_method)
|
|
60
|
+
# obj.send("export_#{x}")
|
|
61
|
+
#
|
|
62
|
+
# # good — visibility-respecting dispatch
|
|
63
|
+
# record.public_send(attribute)
|
|
64
|
+
# instance.public_send(:public_method)
|
|
65
|
+
# obj.public_send("export_#{x}")
|
|
66
|
+
#
|
|
67
|
+
# # good — receiver-less send (calling self's own method)
|
|
68
|
+
# send(:internal_helper)
|
|
69
|
+
class PreferPublicSend < Base
|
|
70
|
+
MSG = 'Prefer `public_send` over `send` — it respects method ' \
|
|
71
|
+
'visibility, surfacing typos/misconfig that would otherwise ' \
|
|
72
|
+
'silently invoke a private method. Disable with a reason ' \
|
|
73
|
+
'when bypassing visibility is intentional.'.freeze
|
|
74
|
+
|
|
75
|
+
RESTRICT_ON_SEND = %i[send].freeze
|
|
76
|
+
|
|
77
|
+
def on_send(node)
|
|
78
|
+
return if node.receiver.nil?
|
|
79
|
+
|
|
80
|
+
add_offense(node.loc.selector)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
module RuboCop
|
|
2
|
+
module Cop
|
|
3
|
+
module DevDoc
|
|
4
|
+
module Style
|
|
5
|
+
# Flag `Object#tap` whose block ignores the yielded value.
|
|
6
|
+
#
|
|
7
|
+
# ## Rationale
|
|
8
|
+
# `tap` exists to **operate on the yielded receiver and return it** —
|
|
9
|
+
# `obj.tap { |x| configure(x) }`. The yielded value is the whole
|
|
10
|
+
# point of the method.
|
|
11
|
+
#
|
|
12
|
+
# When the block **ignores** the yielded value, `tap` is no longer
|
|
13
|
+
# doing that job. It's being used purely as a temp-variable-elision
|
|
14
|
+
# trick for "run a side effect, then return the receiver":
|
|
15
|
+
#
|
|
16
|
+
# ❌ Block never references the yielded value — `tap` is eliding a temp var
|
|
17
|
+
# read_value.tap { do_side_effect }
|
|
18
|
+
#
|
|
19
|
+
# This costs readability for no benefit:
|
|
20
|
+
#
|
|
21
|
+
# - **It hides the return value in the receiver position.** `tap`'s
|
|
22
|
+
# "returns self" behaviour is its *less obvious* property. A reader
|
|
23
|
+
# has to stop and recall that the expression returns the receiver,
|
|
24
|
+
# not the block's value. The explicit form puts the return value on
|
|
25
|
+
# its own line where "what does this return" is literal.
|
|
26
|
+
# - **Footgun magnet.** Return-value semantics here are subtle — it's
|
|
27
|
+
# easy to nearby-edit the block into something whose value you think
|
|
28
|
+
# matters, or to confuse it with the broken `expr; side_effect`
|
|
29
|
+
# sequence (which returns the side effect's value, not `expr`'s).
|
|
30
|
+
# Code whose contract is "return this value" reads and edits more
|
|
31
|
+
# safely when the return is explicit.
|
|
32
|
+
# - **Equal stakes → prefer the duller form.** Both forms are correct
|
|
33
|
+
# and neither is faster. With no functional difference, the more
|
|
34
|
+
# obvious one wins in a shared codebase.
|
|
35
|
+
#
|
|
36
|
+
# ✔️ Explicit intent, return value on its own line
|
|
37
|
+
# result = read_value
|
|
38
|
+
# do_side_effect
|
|
39
|
+
# result
|
|
40
|
+
#
|
|
41
|
+
# ## When `tap` IS the right choice
|
|
42
|
+
# Reserve `tap` for its canonical use — when the block references the
|
|
43
|
+
# yielded value:
|
|
44
|
+
#
|
|
45
|
+
# ✔️ Block uses the yielded value — this is what `tap` is for
|
|
46
|
+
# Model.new.tap { |m| m.role = :admin; m.save }
|
|
47
|
+
# record.tap { |r| logger.debug(r.to_sql) }
|
|
48
|
+
#
|
|
49
|
+
# ## Exception
|
|
50
|
+
# Genuine intentional uses (e.g. a logging side effect that reads
|
|
51
|
+
# clearer in tap position) go through an inline `# rubocop:disable`
|
|
52
|
+
# with a reason. The friction is the feature — it forces the choice
|
|
53
|
+
# to be articulated and reviewed.
|
|
54
|
+
#
|
|
55
|
+
# NOTE: Numbered-param (`_1`) and implicit-`it` (Ruby 3.4+) forms are
|
|
56
|
+
# not flagged — by construction, those forms reference the yielded
|
|
57
|
+
# value, which is the canonical use of `tap`. Block-pass (`&:sym`) is
|
|
58
|
+
# likewise not flagged; it isn't a block node.
|
|
59
|
+
#
|
|
60
|
+
# NOTE: If a nested block *shadows* the outer arg name
|
|
61
|
+
# (`tap { |v| items.each { |v| v.foo } }`), the inner reference is
|
|
62
|
+
# treated as covering the outer — a rare residual false negative.
|
|
63
|
+
# Inline-disable if it ever matters.
|
|
64
|
+
#
|
|
65
|
+
# @example
|
|
66
|
+
# # bad — block ignores the yielded value
|
|
67
|
+
# read_value.tap { do_side_effect }
|
|
68
|
+
# record.tap { |r| log_event }
|
|
69
|
+
# value.tap { }
|
|
70
|
+
#
|
|
71
|
+
# # good — block references the yielded value
|
|
72
|
+
# Model.new.tap { |m| m.role = :admin; m.save }
|
|
73
|
+
# record.tap { |r| logger.debug(r.to_sql) }
|
|
74
|
+
#
|
|
75
|
+
# # good — a reference inside a nested block still counts
|
|
76
|
+
# items.tap { |i| [1, 2].each { i << _1 } }
|
|
77
|
+
#
|
|
78
|
+
# # good — preferred explicit form when the value is unused
|
|
79
|
+
# result = read_value
|
|
80
|
+
# do_side_effect
|
|
81
|
+
# result
|
|
82
|
+
class TapBlockIgnoresValue < Base
|
|
83
|
+
MSG = '`tap` is for operating on the yielded value — when the ' \
|
|
84
|
+
'block ignores it, prefer an explicit assign-and-return. ' \
|
|
85
|
+
'Disable with a reason when the side effect reads clearer ' \
|
|
86
|
+
'in `tap` position.'.freeze
|
|
87
|
+
|
|
88
|
+
def on_block(node)
|
|
89
|
+
return unless node.send_node.method?(:tap)
|
|
90
|
+
return if block_uses_yielded_value?(node)
|
|
91
|
+
|
|
92
|
+
add_offense(node.send_node.loc.selector)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
private
|
|
96
|
+
|
|
97
|
+
def block_uses_yielded_value?(node)
|
|
98
|
+
args = node.arguments
|
|
99
|
+
return false unless args
|
|
100
|
+
return false if args.children.empty? # no param declared → value ignored
|
|
101
|
+
|
|
102
|
+
# `tap` yields one value; multi-arg / destructuring is unusual.
|
|
103
|
+
# Be conservative — don't flag what we can't confidently call
|
|
104
|
+
# "ignoring the value".
|
|
105
|
+
return true if args.children.length >= 2
|
|
106
|
+
|
|
107
|
+
arg = args.children.first
|
|
108
|
+
return true unless arg.respond_to?(:name)
|
|
109
|
+
|
|
110
|
+
return false unless node.body # empty body with declared arg → flag
|
|
111
|
+
|
|
112
|
+
# Walk from the block node (not `body`): `each_descendant`
|
|
113
|
+
# excludes the receiver, so a body that *is* an lvar
|
|
114
|
+
# (`tap { |v| v }`) would be missed otherwise. Nested blocks
|
|
115
|
+
# are walked too, so a reference inside one counts as "used".
|
|
116
|
+
# The shadowing edge case is documented above as a NOTE.
|
|
117
|
+
node.each_descendant(:lvar).any? { |n| n.children.first == arg.name }
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
@@ -13,6 +13,18 @@ module RuboCop
|
|
|
13
13
|
# controller test (very rare) — e.g. a search-ranking detail the
|
|
14
14
|
# controller never exposes.
|
|
15
15
|
#
|
|
16
|
+
# The danger a unit test hides: it can stay green while the feature is
|
|
17
|
+
# broken in production. It proves a method works in isolation — NOT that
|
|
18
|
+
# the real request path calls that method, in the right order, inside the
|
|
19
|
+
# transaction it needs. A model method can be flawless in a unit test
|
|
20
|
+
# while the controller calls the wrong method, skips it, or runs it
|
|
21
|
+
# outside its transaction; the unit test stays green and the feature is
|
|
22
|
+
# broken. Controller tests fail when the *wiring* is wrong — which is
|
|
23
|
+
# where regressions actually live. So a passing unit test is not evidence
|
|
24
|
+
# the feature works; it is false confidence about production. Reach for
|
|
25
|
+
# one only when you are sure a controller test genuinely cannot reach the
|
|
26
|
+
# path, not because it is quicker to write.
|
|
27
|
+
#
|
|
16
28
|
# This cop flags only the literal `< ActiveSupport::TestCase`
|
|
17
29
|
# superclass. The blessed blackbox bases —
|
|
18
30
|
# `ActionDispatch::IntegrationTest`, `Glib::IntegrationTest`,
|
|
@@ -20,6 +32,18 @@ module RuboCop
|
|
|
20
32
|
# though they inherit from `ActiveSupport::TestCase` transitively.
|
|
21
33
|
#
|
|
22
34
|
# ## Escape hatch
|
|
35
|
+
# Before reaching for a unit test, assume a controller test IS possible
|
|
36
|
+
# and look harder — that conclusion is almost always premature. Behaviour
|
|
37
|
+
# that feels inherently unit-level is usually reachable end-to-end:
|
|
38
|
+
# - Transaction rollback / "a failure mid-request": inject the failure
|
|
39
|
+
# at a class-method chokepoint the gem/service calls (stub it to
|
|
40
|
+
# raise), drive the real request, and assert the observable rollback
|
|
41
|
+
# (e.g. `assert_no_difference` on the record count). Even atomicity,
|
|
42
|
+
# which feels inherently unit-level, is reachable this way.
|
|
43
|
+
# - "The controller wraps it in a transaction so I can't isolate the
|
|
44
|
+
# model's own": you usually don't need to — assert the *observable*
|
|
45
|
+
# guarantee through the real path; that is what matters in production.
|
|
46
|
+
#
|
|
23
47
|
# When a unit test is genuinely necessary, suppress with a reason that
|
|
24
48
|
# explains why a controller test can't cover the path. That reason IS the
|
|
25
49
|
# required justification — keep it specific and reviewable:
|
metadata
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: rubocop-dev_doc
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.5.0
|
|
4
|
+
version: 0.5.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- dev-doc contributors
|
|
8
|
+
autorequire:
|
|
8
9
|
bindir: bin
|
|
9
10
|
cert_chain: []
|
|
10
|
-
date:
|
|
11
|
+
date: 2026-06-27 00:00:00.000000000 Z
|
|
11
12
|
dependencies:
|
|
12
13
|
- !ruby/object:Gem::Dependency
|
|
13
14
|
name: activesupport
|
|
@@ -79,6 +80,8 @@ dependencies:
|
|
|
79
80
|
- - ">="
|
|
80
81
|
- !ruby/object:Gem::Version
|
|
81
82
|
version: '2.0'
|
|
83
|
+
description:
|
|
84
|
+
email:
|
|
82
85
|
executables: []
|
|
83
86
|
extensions: []
|
|
84
87
|
extra_rdoc_files: []
|
|
@@ -91,10 +94,6 @@ files:
|
|
|
91
94
|
- lib/rubocop-dev_doc.rb
|
|
92
95
|
- lib/rubocop/cop/dev_doc/auth/current_user_branching.rb
|
|
93
96
|
- lib/rubocop/cop/dev_doc/auth/load_resource_current_user_guard.rb
|
|
94
|
-
- lib/rubocop/cop/dev_doc/i18n/report_text.rb
|
|
95
|
-
- lib/rubocop/cop/dev_doc/i18n/require_translation.rb
|
|
96
|
-
- lib/rubocop/cop/dev_doc/i18n/translation_key_prefix.rb
|
|
97
|
-
- lib/rubocop/cop/dev_doc/i18n/unverified_translation.rb
|
|
98
97
|
- lib/rubocop/cop/dev_doc/migration/amount_column_in_cents.rb
|
|
99
98
|
- lib/rubocop/cop/dev_doc/migration/avoid_bypassing_validation.rb
|
|
100
99
|
- lib/rubocop/cop/dev_doc/migration/avoid_column_default.rb
|
|
@@ -109,7 +108,9 @@ files:
|
|
|
109
108
|
- lib/rubocop/cop/dev_doc/migration/require_timestamps.rb
|
|
110
109
|
- lib/rubocop/cop/dev_doc/rails/application_record_transaction.rb
|
|
111
110
|
- lib/rubocop/cop/dev_doc/rails/avoid_lifecycle_method_override.rb
|
|
111
|
+
- lib/rubocop/cop/dev_doc/rails/avoid_ordering_by_id.rb
|
|
112
112
|
- lib/rubocop/cop/dev_doc/rails/avoid_rails_callbacks.rb
|
|
113
|
+
- lib/rubocop/cop/dev_doc/rails/avoid_raw_sql.rb
|
|
113
114
|
- lib/rubocop/cop/dev_doc/rails/bang_save_in_transaction.rb
|
|
114
115
|
- lib/rubocop/cop/dev_doc/rails/enum_column_not_null.rb
|
|
115
116
|
- lib/rubocop/cop/dev_doc/rails/enum_must_be_symbolized.rb
|
|
@@ -125,19 +126,23 @@ files:
|
|
|
125
126
|
- lib/rubocop/cop/dev_doc/style/avoid_send.rb
|
|
126
127
|
- lib/rubocop/cop/dev_doc/style/minimize_variable_scope.rb
|
|
127
128
|
- lib/rubocop/cop/dev_doc/style/no_unscoped_method_definitions.rb
|
|
129
|
+
- lib/rubocop/cop/dev_doc/style/prefer_public_send.rb
|
|
128
130
|
- lib/rubocop/cop/dev_doc/style/repeated_bracket_read.rb
|
|
129
131
|
- lib/rubocop/cop/dev_doc/style/repeated_safe_navigation_receiver.rb
|
|
130
132
|
- lib/rubocop/cop/dev_doc/style/string_symbol_comparison.rb
|
|
133
|
+
- lib/rubocop/cop/dev_doc/style/tap_block_ignores_value.rb
|
|
131
134
|
- lib/rubocop/cop/dev_doc/test/avoid_glib_travel_freeze.rb
|
|
132
135
|
- lib/rubocop/cop/dev_doc/test/avoid_unit_test.rb
|
|
133
136
|
- lib/rubocop/cop/dev_doc/test/response_assert_equal.rb
|
|
134
137
|
- lib/rubocop/dev_doc.rb
|
|
135
138
|
- lib/rubocop/dev_doc/plugin.rb
|
|
136
139
|
- lib/rubocop/dev_doc/version.rb
|
|
140
|
+
homepage:
|
|
137
141
|
licenses: []
|
|
138
142
|
metadata:
|
|
139
143
|
default_lint_roller_plugin: RuboCop::DevDoc::Plugin
|
|
140
144
|
rubygems_mfa_required: 'true'
|
|
145
|
+
post_install_message:
|
|
141
146
|
rdoc_options: []
|
|
142
147
|
require_paths:
|
|
143
148
|
- lib
|
|
@@ -152,7 +157,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
152
157
|
- !ruby/object:Gem::Version
|
|
153
158
|
version: '0'
|
|
154
159
|
requirements: []
|
|
155
|
-
rubygems_version: 4.
|
|
160
|
+
rubygems_version: 3.4.6
|
|
161
|
+
signing_key:
|
|
156
162
|
specification_version: 4
|
|
157
163
|
summary: RuboCop cops enforcing dev-doc best practices
|
|
158
164
|
test_files: []
|
|
@@ -1,112 +0,0 @@
|
|
|
1
|
-
module RuboCop
|
|
2
|
-
module Cop
|
|
3
|
-
module DevDoc
|
|
4
|
-
module I18n
|
|
5
|
-
# Report every user-facing text in a glib JSON-UI text prop — both
|
|
6
|
-
# hardcoded strings and already-localized `t(...)` calls.
|
|
7
|
-
#
|
|
8
|
-
# ## Rationale
|
|
9
|
-
# `DevDoc/I18n/RequireTranslation` flags only *hardcoded* strings; it
|
|
10
|
-
# stays silent once a value is localized. This cop is the opposite: it
|
|
11
|
-
# fires on **every** text value, localized or not, so you can sweep a
|
|
12
|
-
# codebase and collect the full list of user-facing strings (e.g. to
|
|
13
|
-
# seed a translation catalog or audit coverage).
|
|
14
|
-
#
|
|
15
|
-
# It is a tooling aid, not a lint — **disabled by default** and runs at
|
|
16
|
-
# `info` severity. Run it during a localization pass; it is not meant
|
|
17
|
-
# for every commit.
|
|
18
|
-
#
|
|
19
|
-
# Both the hardcoded form (`view.p text: 'Welcome'`) and the localized
|
|
20
|
-
# form (`view.p text: t('home.welcome')`) are reported. Blank/whitespace
|
|
21
|
-
# strings and pure dynamic values (`user.name`) carry no static text and
|
|
22
|
-
# are skipped — see `DevDoc/I18n/UnverifiedTranslation` for those.
|
|
23
|
-
#
|
|
24
|
-
# The watched method names and localizable keys are configurable via
|
|
25
|
-
# `WatchedMethods:` and `LocalizableKeys:`.
|
|
26
|
-
#
|
|
27
|
-
# 📋 Reported — hardcoded text
|
|
28
|
-
# view.p text: 'Welcome'
|
|
29
|
-
#
|
|
30
|
-
# 📋 Reported — localized text
|
|
31
|
-
# view.p text: t('home.welcome')
|
|
32
|
-
#
|
|
33
|
-
# @example
|
|
34
|
-
# # info (hardcoded text)
|
|
35
|
-
# view.p text: 'Welcome'
|
|
36
|
-
#
|
|
37
|
-
# # info (localized text — still reported)
|
|
38
|
-
# view.p text: t('home.welcome')
|
|
39
|
-
#
|
|
40
|
-
# # info (interpolated string)
|
|
41
|
-
# view.p text: "Hi #{name}"
|
|
42
|
-
#
|
|
43
|
-
# # ignored (blank — no text)
|
|
44
|
-
# view.fields_text label: ''
|
|
45
|
-
#
|
|
46
|
-
# # ignored (pure dynamic — no static text)
|
|
47
|
-
# view.p text: user.name
|
|
48
|
-
class ReportText < Base
|
|
49
|
-
MSG = 'Text for `%<key>s:`: review/collect this for localization.'.freeze
|
|
50
|
-
|
|
51
|
-
DEFAULT_WATCHED_METHODS = %w[
|
|
52
|
-
h1 h2 h3 h4 h5 p label markdown
|
|
53
|
-
fields_text fields_number fields_select fields_password
|
|
54
|
-
fields_textarea fields_check fields_checkGroup fields_chipGroup
|
|
55
|
-
fields_timeZone fields_radioGroup fields_date fields_datetime
|
|
56
|
-
].freeze
|
|
57
|
-
|
|
58
|
-
DEFAULT_LOCALIZABLE_KEYS = %w[
|
|
59
|
-
title subtitle subsubtitle label placeholder text
|
|
60
|
-
].freeze
|
|
61
|
-
|
|
62
|
-
TRANSLATION_METHODS = %i[t translate].freeze
|
|
63
|
-
|
|
64
|
-
def on_send(node)
|
|
65
|
-
return unless watched_methods.include?(node.method_name.to_s)
|
|
66
|
-
|
|
67
|
-
node.arguments.each do |arg|
|
|
68
|
-
next unless arg.hash_type?
|
|
69
|
-
|
|
70
|
-
arg.pairs.each { |pair| check_pair(pair) }
|
|
71
|
-
end
|
|
72
|
-
end
|
|
73
|
-
alias on_csend on_send
|
|
74
|
-
|
|
75
|
-
private
|
|
76
|
-
|
|
77
|
-
def check_pair(pair)
|
|
78
|
-
key = pair.key
|
|
79
|
-
return unless key.sym_type?
|
|
80
|
-
return unless localizable_keys.include?(key.value.to_s)
|
|
81
|
-
return unless text?(pair.value)
|
|
82
|
-
|
|
83
|
-
add_offense(pair.value, message: format(MSG, key: key.value))
|
|
84
|
-
end
|
|
85
|
-
|
|
86
|
-
# Any value that carries static user-facing text: a non-blank string
|
|
87
|
-
# literal, an interpolated string, or a translation call. Blank strings
|
|
88
|
-
# and pure dynamic values (`user.name`) carry no text and are skipped.
|
|
89
|
-
def text?(node)
|
|
90
|
-
return true if node.dstr_type?
|
|
91
|
-
return !node.value.strip.empty? if node.str_type?
|
|
92
|
-
|
|
93
|
-
translation_call?(node)
|
|
94
|
-
end
|
|
95
|
-
|
|
96
|
-
def translation_call?(node)
|
|
97
|
-
(node.send_type? || node.csend_type?) &&
|
|
98
|
-
TRANSLATION_METHODS.include?(node.method_name)
|
|
99
|
-
end
|
|
100
|
-
|
|
101
|
-
def watched_methods
|
|
102
|
-
cop_config.fetch('WatchedMethods', DEFAULT_WATCHED_METHODS)
|
|
103
|
-
end
|
|
104
|
-
|
|
105
|
-
def localizable_keys
|
|
106
|
-
cop_config.fetch('LocalizableKeys', DEFAULT_LOCALIZABLE_KEYS)
|
|
107
|
-
end
|
|
108
|
-
end
|
|
109
|
-
end
|
|
110
|
-
end
|
|
111
|
-
end
|
|
112
|
-
end
|