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.
@@ -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:
@@ -1,5 +1,5 @@
1
1
  module RuboCop
2
2
  module DevDoc
3
- VERSION = "0.5.0.beta1".freeze
3
+ VERSION = "0.5.0".freeze
4
4
  end
5
5
  end
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.beta1
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: 1980-01-02 00:00:00.000000000 Z
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.0.6
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